feat(companion): retain audio with native media controls
This commit is contained in:
@@ -11,8 +11,8 @@ android {
|
|||||||
applicationId = "com.archipelago.app"
|
applicationId = "com.archipelago.app"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 55
|
versionCode = 56
|
||||||
versionName = "0.5.35"
|
versionName = "0.5.36"
|
||||||
|
|
||||||
vectorDrawables {
|
vectorDrawables {
|
||||||
useSupportLibrary = true
|
useSupportLibrary = true
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
<!-- Embedded FIPS mesh tunnel (ArchyVpnService) runs as a foreground service. -->
|
<!-- Embedded FIPS mesh tunnel (ArchyVpnService) runs as a foreground service. -->
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
@@ -67,6 +68,12 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".ui.screens.CompanionAudioService"
|
||||||
|
android:exported="false"
|
||||||
|
android:stopWithTask="false"
|
||||||
|
android:foregroundServiceType="mediaPlayback" />
|
||||||
|
|
||||||
<!-- Embedded FIPS mesh node: split-tunnel VpnService (fd00::/8 only),
|
<!-- Embedded FIPS mesh node: split-tunnel VpnService (fd00::/8 only),
|
||||||
configured entirely by scanning the node's pairing QR. -->
|
configured entirely by scanning the node's pairing QR. -->
|
||||||
<service
|
<service
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import androidx.compose.runtime.collectAsState
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||||
import com.archipelago.app.ui.navigation.AppNavHost
|
import com.archipelago.app.ui.navigation.AppNavHost
|
||||||
import com.archipelago.app.ui.screens.releaseKioskWebView
|
import com.archipelago.app.ui.screens.finishKioskActivity
|
||||||
import com.archipelago.app.ui.theme.ArchipelagoTheme
|
import com.archipelago.app.ui.theme.ArchipelagoTheme
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
|
||||||
@@ -56,11 +56,8 @@ class MainActivity : ComponentActivity() {
|
|||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
// Swiped out of recents (or otherwise finished) — let go of the
|
// Keep an authorized playing WebView owned by the media service;
|
||||||
// retained kiosk WebView so the next launch starts clean. Without
|
// discard ordinary dashboard state when the task is finished.
|
||||||
// this the FIPS service keeps the process (and the static WebView)
|
if (isFinishing) finishKioskActivity()
|
||||||
// alive, and "close the app" no longer restarted it. isFinishing
|
|
||||||
// keeps config changes (rotation) on the fast reattach path.
|
|
||||||
if (isFinishing) releaseKioskWebView()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package com.archipelago.app.ui.screens
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.SystemClock
|
||||||
|
import android.webkit.WebView
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.webkit.JavaScriptReplyProxy
|
||||||
|
import androidx.webkit.WebMessageCompat
|
||||||
|
import androidx.webkit.WebViewCompat
|
||||||
|
import androidx.webkit.WebViewFeature
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
|
/** Metadata/control only: the authorized WebView owns the stream and queue. */
|
||||||
|
internal data class CompanionAudioState(
|
||||||
|
val session: String, val sequence: Long, val title: String,
|
||||||
|
val playing: Boolean, val position: Double, val duration: Double,
|
||||||
|
val previous: Boolean, val next: Boolean, val shuffle: Boolean,
|
||||||
|
val shuffled: Boolean, val artwork: String,
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun parse(value: JSONObject): CompanionAudioState {
|
||||||
|
require(value.optInt("version") == 1 && value.getString("action") == "state")
|
||||||
|
val session = value.getString("session")
|
||||||
|
require(session.matches(Regex("[0-9a-f-]{36}")))
|
||||||
|
val sequence = value.getLong("sequence")
|
||||||
|
require(sequence >= 0 && sequence <= 9007199254740991L)
|
||||||
|
val position = value.getDouble("position"); val duration = value.getDouble("duration")
|
||||||
|
require(position.isFinite() && duration.isFinite() && duration in 0.0..604800.0 && position in 0.0..duration)
|
||||||
|
val title = value.getString("title"); require(title.length <= 512)
|
||||||
|
val artwork = value.optString("artwork", "")
|
||||||
|
require(artwork.length <= 90000 && (artwork.isEmpty() || artwork.startsWith("data:image/jpeg;base64,")))
|
||||||
|
return CompanionAudioState(session, sequence, title, value.getBoolean("playing"), position, duration,
|
||||||
|
value.optBoolean("previous"), value.optBoolean("next"), value.optBoolean("shuffle"),
|
||||||
|
value.optBoolean("shuffled"), artwork)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal object CompanionAudioBridge {
|
||||||
|
private val bindings = java.util.WeakHashMap<WebView, Set<String>>()
|
||||||
|
private val retired = linkedSetOf<String>()
|
||||||
|
private var view: WebView? = null
|
||||||
|
private var origin: String? = null
|
||||||
|
private var reply: ((String) -> Unit)? = null
|
||||||
|
var state: CompanionAudioState? = null
|
||||||
|
private set
|
||||||
|
var updatedAt: Long = 0
|
||||||
|
private set
|
||||||
|
fun retains(web: WebView?) = web != null && view === web && state != null
|
||||||
|
fun attach(web: WebView, urls: List<String>) {
|
||||||
|
if (!WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) return
|
||||||
|
val origins = urls.mapNotNull(::cloudVideoOrigin).toSet()
|
||||||
|
if (origins.isEmpty()) return
|
||||||
|
val existing = bindings[web]
|
||||||
|
if (existing != null) {
|
||||||
|
if (existing != origins) { bindings[web] = emptySet(); release(web) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bindings[web] = origins
|
||||||
|
WebViewCompat.addWebMessageListener(web, "ArchipelagoAudio", origins,
|
||||||
|
object : WebViewCompat.WebMessageListener {
|
||||||
|
override fun onPostMessage(web: WebView, message: WebMessageCompat, source: Uri, main: Boolean, proxy: JavaScriptReplyProxy) {
|
||||||
|
if (bindings[web] != origins) return
|
||||||
|
val raw = runCatching { message.data }.getOrNull() ?: return
|
||||||
|
receive(web, raw, source.toString(), main, origins) { proxy.postMessage(it) }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
internal fun receive(web: WebView, raw: String, source: String, main: Boolean,
|
||||||
|
origins: Set<String>, proxy: (String) -> Unit) {
|
||||||
|
if (!cloudVideoSenderAllowed(web.url, source, origins, main)) return
|
||||||
|
if (raw.length > 96000) return
|
||||||
|
val data = runCatching { JSONObject(raw) }.getOrNull() ?: return
|
||||||
|
val session = data.optString("session")
|
||||||
|
if (data.optString("action") == "release") {
|
||||||
|
if (web === view && session == state?.session) terminate()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val incoming = runCatching { CompanionAudioState.parse(data) }.getOrNull() ?: return
|
||||||
|
if (session in retired) return
|
||||||
|
val old = state
|
||||||
|
if (old != null && old.session == session) {
|
||||||
|
if (view !== web || incoming.sequence <= old.sequence) return
|
||||||
|
} else {
|
||||||
|
if (!incoming.playing) return // Do not start a service for idle metadata.
|
||||||
|
if (old != null) { command("pause"); retire(old.session) }
|
||||||
|
}
|
||||||
|
view = web; origin = cloudVideoOrigin(source); reply = proxy
|
||||||
|
state = if (old != null && old.session == session && !data.has("artwork")) incoming.copy(artwork = old.artwork) else incoming; updatedAt = SystemClock.elapsedRealtime()
|
||||||
|
runCatching {
|
||||||
|
val service = CompanionAudioService.instance
|
||||||
|
if (service != null) service.refresh()
|
||||||
|
else ContextCompat.startForegroundService(web.context.applicationContext,
|
||||||
|
Intent(web.context.applicationContext, CompanionAudioService::class.java))
|
||||||
|
}.onFailure {
|
||||||
|
event("error", "Background playback could not start. Reopen the companion and press Play.")
|
||||||
|
command("pause"); terminate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private fun retire(session: String) {
|
||||||
|
retired.add(session)
|
||||||
|
while (retired.size > 64) retired.remove(retired.first())
|
||||||
|
}
|
||||||
|
private fun event(type: String, value: String? = null, position: Double? = null) {
|
||||||
|
val current = state ?: return
|
||||||
|
if (cloudVideoOrigin(view?.url) != origin) { terminate(); return }
|
||||||
|
val message = JSONObject().put("version", 1).put("session", current.session).put("type", type)
|
||||||
|
if (value != null) message.put(if (type == "error") "error" else "command", value)
|
||||||
|
if (position != null) message.put("position", position)
|
||||||
|
runCatching { reply?.invoke(message.toString()) }
|
||||||
|
}
|
||||||
|
fun command(name: String, position: Double? = null) = event("command", name, position)
|
||||||
|
fun release(web: WebView) { if (view === web) { command("stop"); terminate() } }
|
||||||
|
fun terminate() {
|
||||||
|
state?.session?.let(::retire)
|
||||||
|
state = null; view = null; origin = null; reply = null
|
||||||
|
CompanionAudioService.instance?.finishPlayback()
|
||||||
|
releaseDetachedKioskWebView()
|
||||||
|
}
|
||||||
|
fun stop() { command("stop"); terminate() }
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
package com.archipelago.app.ui.screens
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.IntentFilter
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import android.media.AudioManager
|
||||||
|
import android.media.MediaMetadata
|
||||||
|
import android.media.session.MediaSession
|
||||||
|
import android.media.session.PlaybackState
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.IBinder
|
||||||
|
import android.os.Looper
|
||||||
|
import android.os.SystemClock
|
||||||
|
import android.util.Base64
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import com.archipelago.app.MainActivity
|
||||||
|
|
||||||
|
/** Foreground ownership of the existing authenticated WebView player. No stream
|
||||||
|
* URL, auth token or cookie is copied into native playback or notifications. */
|
||||||
|
class CompanionAudioService : Service() {
|
||||||
|
companion object {
|
||||||
|
internal var instance: CompanionAudioService? = null
|
||||||
|
private const val CHANNEL = "companion-audio"
|
||||||
|
private const val NOTIFICATION = 4056
|
||||||
|
}
|
||||||
|
private lateinit var media: MediaSession
|
||||||
|
private val handler = Handler(Looper.getMainLooper())
|
||||||
|
private var lastArtwork = ""
|
||||||
|
private var bitmap: Bitmap? = null
|
||||||
|
private var finishing = false
|
||||||
|
private val noisy = object : BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context?, intent: Intent?) {
|
||||||
|
if (intent?.action == AudioManager.ACTION_AUDIO_BECOMING_NOISY) CompanionAudioBridge.command("pause")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private val watchdog = object : Runnable {
|
||||||
|
override fun run() {
|
||||||
|
val state = CompanionAudioBridge.state ?: return
|
||||||
|
val age = SystemClock.elapsedRealtime() - CompanionAudioBridge.updatedAt
|
||||||
|
if (age > 90000) CompanionAudioBridge.stop()
|
||||||
|
else {
|
||||||
|
if (age > 15000) CompanionAudioBridge.command("sync")
|
||||||
|
handler.postDelayed(this, 5000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate(); instance = this
|
||||||
|
getSystemService(NotificationManager::class.java).createNotificationChannel(
|
||||||
|
NotificationChannel(CHANNEL, "Audio playback", NotificationManager.IMPORTANCE_LOW))
|
||||||
|
media = MediaSession(this, "Archipelago audio")
|
||||||
|
media.setCallback(object : MediaSession.Callback() {
|
||||||
|
override fun onPlay() = CompanionAudioBridge.command("play")
|
||||||
|
override fun onPause() = CompanionAudioBridge.command("pause")
|
||||||
|
override fun onStop() = CompanionAudioBridge.stop()
|
||||||
|
override fun onSkipToNext() { if (CompanionAudioBridge.state?.next == true) CompanionAudioBridge.command("next") }
|
||||||
|
override fun onSkipToPrevious() { if (CompanionAudioBridge.state?.previous == true) CompanionAudioBridge.command("previous") }
|
||||||
|
override fun onSeekTo(pos: Long) {
|
||||||
|
val duration = CompanionAudioBridge.state?.duration ?: return
|
||||||
|
CompanionAudioBridge.command("seek", (pos / 1000.0).coerceIn(0.0, duration))
|
||||||
|
}
|
||||||
|
override fun onCustomAction(action: String, extras: Bundle?) {
|
||||||
|
if (action == "shuffle" && CompanionAudioBridge.state?.shuffle == true) CompanionAudioBridge.command("shuffle")
|
||||||
|
}
|
||||||
|
}, handler)
|
||||||
|
media.setFlags(MediaSession.FLAG_HANDLES_MEDIA_BUTTONS or MediaSession.FLAG_HANDLES_TRANSPORT_CONTROLS)
|
||||||
|
media.setSessionActivity(openPlayer())
|
||||||
|
media.isActive = true
|
||||||
|
ContextCompat.registerReceiver(this, noisy, IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY), ContextCompat.RECEIVER_NOT_EXPORTED)
|
||||||
|
handler.postDelayed(watchdog, 5000)
|
||||||
|
}
|
||||||
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
val state = CompanionAudioBridge.state
|
||||||
|
if (state == null) { finishPlayback(); return START_NOT_STICKY }
|
||||||
|
finishing = false; instance = this
|
||||||
|
if (intent?.action != null && intent.getStringExtra("session") == state.session) {
|
||||||
|
when (intent.action) {
|
||||||
|
"stop" -> CompanionAudioBridge.stop()
|
||||||
|
"play", "pause" -> CompanionAudioBridge.command(intent.action!!)
|
||||||
|
"next" -> if (state.next) CompanionAudioBridge.command("next")
|
||||||
|
"previous" -> if (state.previous) CompanionAudioBridge.command("previous")
|
||||||
|
"shuffle" -> if (state.shuffle) CompanionAudioBridge.command("shuffle")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!finishing) refresh()
|
||||||
|
return START_NOT_STICKY // Never reconstruct an authorized stream after process death.
|
||||||
|
}
|
||||||
|
private fun openPlayer() = PendingIntent.getActivity(this, 0,
|
||||||
|
Intent(this, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP),
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||||
|
private fun action(name: String, label: String, icon: Int, session: String): Notification.Action {
|
||||||
|
val intent = Intent(this, CompanionAudioService::class.java).setAction(name).putExtra("session", session)
|
||||||
|
val pending = PendingIntent.getService(this, name.hashCode(), intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||||
|
return Notification.Action.Builder(icon, label, pending).build()
|
||||||
|
}
|
||||||
|
internal fun refresh() {
|
||||||
|
if (finishing) return
|
||||||
|
val state = CompanionAudioBridge.state ?: return
|
||||||
|
if (state.artwork != lastArtwork) {
|
||||||
|
lastArtwork = state.artwork
|
||||||
|
bitmap = decodeArtwork(state.artwork)
|
||||||
|
}
|
||||||
|
val metadata = MediaMetadata.Builder().putString(MediaMetadata.METADATA_KEY_TITLE, state.title)
|
||||||
|
.putString(MediaMetadata.METADATA_KEY_ARTIST, "Archipelago")
|
||||||
|
.putLong(MediaMetadata.METADATA_KEY_DURATION, (state.duration * 1000).toLong())
|
||||||
|
bitmap?.let { metadata.putBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART, it) }
|
||||||
|
media.setMetadata(metadata.build())
|
||||||
|
var actions = PlaybackState.ACTION_PLAY or PlaybackState.ACTION_PAUSE or PlaybackState.ACTION_PLAY_PAUSE or PlaybackState.ACTION_STOP
|
||||||
|
if (state.duration > 0) actions = actions or PlaybackState.ACTION_SEEK_TO
|
||||||
|
if (state.previous) actions = actions or PlaybackState.ACTION_SKIP_TO_PREVIOUS
|
||||||
|
if (state.next) actions = actions or PlaybackState.ACTION_SKIP_TO_NEXT
|
||||||
|
val playback = PlaybackState.Builder().setActions(actions)
|
||||||
|
.setState(if (state.playing) PlaybackState.STATE_PLAYING else PlaybackState.STATE_PAUSED,
|
||||||
|
(state.position * 1000).toLong(), if (state.playing) 1f else 0f, SystemClock.elapsedRealtime())
|
||||||
|
if (state.shuffle) playback.addCustomAction("shuffle", if (state.shuffled) "Shuffle on" else "Shuffle off", android.R.drawable.ic_menu_rotate)
|
||||||
|
media.setPlaybackState(playback.build())
|
||||||
|
val controls = mutableListOf<Notification.Action>()
|
||||||
|
if (state.previous) controls.add(action("previous", "Previous", android.R.drawable.ic_media_previous, state.session))
|
||||||
|
controls.add(action(if (state.playing) "pause" else "play", if (state.playing) "Pause" else "Play",
|
||||||
|
if (state.playing) android.R.drawable.ic_media_pause else android.R.drawable.ic_media_play, state.session))
|
||||||
|
if (state.next) controls.add(action("next", "Next", android.R.drawable.ic_media_next, state.session))
|
||||||
|
val compact = controls.indices.toList().toIntArray()
|
||||||
|
if (state.shuffle) controls.add(action("shuffle", if (state.shuffled) "Shuffle on" else "Shuffle off", android.R.drawable.ic_menu_rotate, state.session))
|
||||||
|
controls.add(action("stop", "Stop", android.R.drawable.ic_menu_close_clear_cancel, state.session))
|
||||||
|
val notification = Notification.Builder(this, CHANNEL)
|
||||||
|
.setSmallIcon(android.R.drawable.ic_media_play).setContentTitle(state.title).setContentText("Archipelago")
|
||||||
|
.setContentIntent(openPlayer()).setOnlyAlertOnce(true).setOngoing(state.playing)
|
||||||
|
.setVisibility(Notification.VISIBILITY_PUBLIC).setCategory(Notification.CATEGORY_TRANSPORT)
|
||||||
|
.setStyle(Notification.MediaStyle().setMediaSession(media.sessionToken).setShowActionsInCompactView(*compact))
|
||||||
|
.setActions(*controls.toTypedArray())
|
||||||
|
bitmap?.let { notification.setLargeIcon(it) }
|
||||||
|
startForeground(NOTIFICATION, notification.build())
|
||||||
|
}
|
||||||
|
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||||
|
if (CompanionAudioBridge.state?.playing != true) CompanionAudioBridge.stop()
|
||||||
|
super.onTaskRemoved(rootIntent)
|
||||||
|
}
|
||||||
|
internal fun finishPlayback() {
|
||||||
|
if (finishing) return
|
||||||
|
finishing = true
|
||||||
|
if (instance === this) instance = null
|
||||||
|
stopForeground(STOP_FOREGROUND_REMOVE); stopSelf()
|
||||||
|
}
|
||||||
|
override fun onDestroy() {
|
||||||
|
handler.removeCallbacksAndMessages(null)
|
||||||
|
runCatching { unregisterReceiver(noisy) }
|
||||||
|
media.isActive = false; media.release(); bitmap = null
|
||||||
|
if (instance === this) { instance = null; CompanionAudioBridge.stop() }
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun decodeArtwork(data: String): Bitmap? = runCatching {
|
||||||
|
if (!data.startsWith("data:image/jpeg;base64,") || data.length > 90000) return null
|
||||||
|
val bytes = Base64.decode(data.substringAfter(','), Base64.NO_WRAP)
|
||||||
|
if (bytes.size < 4 || bytes[0] != 0xff.toByte() || bytes[1] != 0xd8.toByte() || bytes[2] != 0xff.toByte()) return null
|
||||||
|
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||||
|
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
|
||||||
|
if (bounds.outWidth !in 1..512 || bounds.outHeight !in 1..512) return null
|
||||||
|
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
|
||||||
|
}.getOrNull()
|
||||||
@@ -144,6 +144,29 @@ private fun openExternalUrl(context: android.content.Context, url: String) {
|
|||||||
* this when the task is genuinely finishing. */
|
* this when the task is genuinely finishing. */
|
||||||
fun releaseKioskWebView() = KioskWebView.drop()
|
fun releaseKioskWebView() = KioskWebView.drop()
|
||||||
|
|
||||||
|
/** A playing session is owned by the foreground media service after task close. */
|
||||||
|
fun finishKioskActivity() {
|
||||||
|
val view = KioskWebView.instance ?: return
|
||||||
|
if (!CompanionAudioBridge.retains(view)) { KioskWebView.drop(); return }
|
||||||
|
(view.parent as? ViewGroup)?.removeView(view)
|
||||||
|
KioskWebView.backgroundOwned = true
|
||||||
|
KioskWebView.clearDelegates()
|
||||||
|
view.setOnTouchListener(null)
|
||||||
|
view.setOnApplyWindowInsetsListener(null)
|
||||||
|
view.setDownloadListener(null)
|
||||||
|
view.webChromeClient = null
|
||||||
|
view.webViewClient = object : WebViewClient() {
|
||||||
|
override fun onPageStarted(web: WebView?, url: String?, favicon: Bitmap?) {
|
||||||
|
web?.let { CompanionAudioBridge.release(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(view.context as? android.content.MutableContextWrapper)?.baseContext = view.context.applicationContext
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun releaseDetachedKioskWebView() {
|
||||||
|
if (KioskWebView.backgroundOwned && !CompanionAudioBridge.retains(KioskWebView.instance)) KioskWebView.drop()
|
||||||
|
}
|
||||||
|
|
||||||
/** Restart the app in place: throw away the retained page and relaunch the
|
/** Restart the app in place: throw away the retained page and relaunch the
|
||||||
* task from scratch. The mesh/VPN service is deliberately left running — this
|
* task from scratch. The mesh/VPN service is deliberately left running — this
|
||||||
* is the "give me a clean app" button (hub menu), not a process kill. */
|
* is the "give me a clean app" button (hub menu), not a process kill. */
|
||||||
@@ -294,6 +317,7 @@ private fun isSameHost(url: String, base: String): Boolean {
|
|||||||
data class InAppLaunch(val url: String, val icon: String? = null, val name: String? = null)
|
data class InAppLaunch(val url: String, val icon: String? = null, val name: String? = null)
|
||||||
|
|
||||||
private object KioskWebView {
|
private object KioskWebView {
|
||||||
|
var backgroundOwned = false
|
||||||
var instance: WebView? = null
|
var instance: WebView? = null
|
||||||
var url: String? = null
|
var url: String? = null
|
||||||
|
|
||||||
@@ -306,13 +330,19 @@ private object KioskWebView {
|
|||||||
var onQrStatus: (String, Boolean) -> Unit = { _, _ -> }
|
var onQrStatus: (String, Boolean) -> Unit = { _, _ -> }
|
||||||
var onQrClose: () -> Unit = {}
|
var onQrClose: () -> Unit = {}
|
||||||
|
|
||||||
|
fun clearDelegates() {
|
||||||
|
onRouteOutbound = {}; onOpenInApp = {}; onQrOpen = {}
|
||||||
|
onQrStatus = { _, _ -> }; onQrClose = {}
|
||||||
|
}
|
||||||
fun drop() {
|
fun drop() {
|
||||||
instance?.let {
|
val old = instance
|
||||||
|
instance = null; url = null; backgroundOwned = false
|
||||||
|
clearDelegates()
|
||||||
|
old?.let {
|
||||||
|
CompanionAudioBridge.release(it)
|
||||||
(it.parent as? ViewGroup)?.removeView(it)
|
(it.parent as? ViewGroup)?.removeView(it)
|
||||||
it.destroy()
|
it.destroy()
|
||||||
}
|
}
|
||||||
instance = null
|
|
||||||
url = null
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -901,7 +931,9 @@ fun WebViewScreen(
|
|||||||
// stale closures from the previous visit are replaced.
|
// stale closures from the previous visit are replaced.
|
||||||
if (KioskWebView.url != serverUrl) KioskWebView.drop()
|
if (KioskWebView.url != serverUrl) KioskWebView.drop()
|
||||||
val reused = KioskWebView.instance
|
val reused = KioskWebView.instance
|
||||||
(reused ?: WebView(context)).apply {
|
(reused ?: WebView(android.content.MutableContextWrapper(context))).apply {
|
||||||
|
(this.context as? android.content.MutableContextWrapper)?.baseContext = context
|
||||||
|
KioskWebView.backgroundOwned = false
|
||||||
(parent as? ViewGroup)?.removeView(this)
|
(parent as? ViewGroup)?.removeView(this)
|
||||||
layoutParams = ViewGroup.LayoutParams(
|
layoutParams = ViewGroup.LayoutParams(
|
||||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
@@ -917,6 +949,7 @@ fun WebViewScreen(
|
|||||||
|
|
||||||
applyArchipelagoSettings()
|
applyArchipelagoSettings()
|
||||||
cloudPip.attach(this, listOfNotNull(serverUrl, meshFallbackUrl))
|
cloudPip.attach(this, listOfNotNull(serverUrl, meshFallbackUrl))
|
||||||
|
CompanionAudioBridge.attach(this, listOfNotNull(serverUrl, meshFallbackUrl))
|
||||||
setDownloadListener(downloads)
|
setDownloadListener(downloads)
|
||||||
settings.apply {
|
settings.apply {
|
||||||
setSupportMultipleWindows(true) // enables onCreateWindow for window.open
|
setSupportMultipleWindows(true) // enables onCreateWindow for window.open
|
||||||
@@ -1091,6 +1124,7 @@ fun WebViewScreen(
|
|||||||
|
|
||||||
webViewClient = object : WebViewClient() {
|
webViewClient = object : WebViewClient() {
|
||||||
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
|
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
|
||||||
|
CompanionAudioBridge.release(view ?: return)
|
||||||
cloudPip.reset()
|
cloudPip.reset()
|
||||||
isLoading = true
|
isLoading = true
|
||||||
hasError = false
|
hasError = false
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package com.archipelago.app.ui.screens
|
||||||
|
|
||||||
|
import org.json.JSONObject
|
||||||
|
import org.junit.Assert.*
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.Before
|
||||||
|
import org.robolectric.Shadows
|
||||||
|
import org.robolectric.RuntimeEnvironment
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.Robolectric
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(manifest = Config.NONE, sdk = [28, 35])
|
||||||
|
class CompanionAudioTest {
|
||||||
|
@Before fun compatReceiverPermission() {
|
||||||
|
val app = RuntimeEnvironment.getApplication()
|
||||||
|
// The real merged manifest contributes this AndroidX permission.
|
||||||
|
Shadows.shadowOf(app).grantPermissions(app.packageName + ".DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION")
|
||||||
|
}
|
||||||
|
@Test fun actualBridgeBindsOriginSessionAndSequenceAndReleasesStoppedPlayback() {
|
||||||
|
val app = RuntimeEnvironment.getApplication()
|
||||||
|
val web = android.webkit.WebView(app)
|
||||||
|
web.loadUrl("https://node.test/cloud")
|
||||||
|
val events = mutableListOf<JSONObject>()
|
||||||
|
fun send(message: JSONObject, origin: String = "https://node.test", main: Boolean = true) {
|
||||||
|
CompanionAudioBridge.receive(web, message.toString(), origin, main, setOf("https://node.test")) { events.add(JSONObject(it)) }
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
send(state(), main = false); assertNull(CompanionAudioBridge.state)
|
||||||
|
send(state(), origin = "https://foreign.test"); assertNull(CompanionAudioBridge.state)
|
||||||
|
send(state()); assertTrue(CompanionAudioBridge.retains(web))
|
||||||
|
send(state().put("sequence", 0).put("playing", false)); assertTrue(CompanionAudioBridge.state!!.playing)
|
||||||
|
CompanionAudioBridge.command("seek", 32.0)
|
||||||
|
assertEquals("seek", events.last().getString("command")); assertEquals(32.0, events.last().getDouble("position"), 0.0)
|
||||||
|
send(state().put("sequence", 2).put("playing", false)); assertFalse(CompanionAudioBridge.state!!.playing)
|
||||||
|
CompanionAudioBridge.stop(); assertNull(CompanionAudioBridge.state)
|
||||||
|
assertEquals("stop", events.last().getString("command"))
|
||||||
|
send(state().put("sequence", 3)); assertNull(CompanionAudioBridge.state) // delayed state cannot revive a stopped session
|
||||||
|
} finally { CompanionAudioBridge.release(web); web.destroy() }
|
||||||
|
}
|
||||||
|
@Test fun liveBridgeBuildsForegroundMediaNotificationForSameSession() {
|
||||||
|
val app = RuntimeEnvironment.getApplication()
|
||||||
|
val web = android.webkit.WebView(app); web.loadUrl("https://node.test/cloud")
|
||||||
|
val payload = state().put("session", "22345678-1234-1234-1234-123456789abc")
|
||||||
|
CompanionAudioBridge.receive(web, payload.toString(), "https://node.test", true, setOf("https://node.test")) {}
|
||||||
|
val lifecycle = Robolectric.buildService(CompanionAudioService::class.java).create()
|
||||||
|
try {
|
||||||
|
lifecycle.get().onStartCommand(null, 0, 1)
|
||||||
|
val notification = Shadows.shadowOf(lifecycle.get()).lastForegroundNotification
|
||||||
|
assertNotNull(notification)
|
||||||
|
assertEquals("Current song", notification.extras.getString(android.app.Notification.EXTRA_TITLE))
|
||||||
|
assertEquals(5, notification.actions.size)
|
||||||
|
assertNotNull(notification.extras.getParcelable<android.media.session.MediaSession.Token>(android.app.Notification.EXTRA_MEDIA_SESSION))
|
||||||
|
lifecycle.get().onTaskRemoved(null)
|
||||||
|
assertTrue(CompanionAudioBridge.retains(web))
|
||||||
|
} finally { CompanionAudioBridge.release(web); lifecycle.destroy(); web.destroy() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun state() = JSONObject("""{"version":1,"action":"state","session":"12345678-1234-1234-1234-123456789abc","sequence":1,"title":"Current song","playing":true,"position":10,"duration":120,"previous":true,"next":true,"shuffle":true,"shuffled":false}""")
|
||||||
|
@Test fun malformedOrUnboundedMetadataCannotBecomeNativePlayback() {
|
||||||
|
for ((key, value) in listOf("version" to 2, "session" to "foreign", "sequence" to -1,
|
||||||
|
"position" to -1, "position" to 121, "duration" to 604801, "title" to "x".repeat(513),
|
||||||
|
"artwork" to "https://node.test/protected?token=secret")) {
|
||||||
|
assertTrue("Must reject $key", runCatching { CompanionAudioState.parse(state().put(key, value)) }.isFailure)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@Test fun currentMetadataPreservesPauseSeekAndQueueCapabilities() {
|
||||||
|
val parsed = CompanionAudioState.parse(state().put("playing", false).put("shuffled", true))
|
||||||
|
assertFalse(parsed.playing); assertTrue(parsed.shuffled)
|
||||||
|
assertTrue(parsed.previous && parsed.next && parsed.shuffle)
|
||||||
|
assertEquals(10.0, parsed.position, 0.0)
|
||||||
|
assertEquals(120.0, parsed.duration, 0.0)
|
||||||
|
assertEquals("", parsed.artwork)
|
||||||
|
}
|
||||||
|
@Test fun artworkNeverFetchesProtectedUrlsAndRejectsInvalidBytes() {
|
||||||
|
assertNull(decodeArtwork("https://node.test/protected"))
|
||||||
|
assertNull(decodeArtwork("data:image/jpeg;base64,AAAA"))
|
||||||
|
assertNull(decodeArtwork("data:image/jpeg;base64," + "A".repeat(90000)))
|
||||||
|
}
|
||||||
|
@Test fun serviceRestartWithoutLiveAuthorizedSessionDoesNotResumePlayback() {
|
||||||
|
val lifecycle = Robolectric.buildService(CompanionAudioService::class.java).create()
|
||||||
|
try {
|
||||||
|
assertEquals(android.app.Service.START_NOT_STICKY, lifecycle.get().onStartCommand(null, 0, 1))
|
||||||
|
assertNull(CompanionAudioBridge.state)
|
||||||
|
assertNull(CompanionAudioService.instance)
|
||||||
|
} finally { lifecycle.destroy() }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@ candidate worktree is `/home/archipelago/Projects/archy-session-key` on branch
|
|||||||
`work/post-190-session-key`. Do not use credentials from chat or print private
|
`work/post-190-session-key`. Do not use credentials from chat or print private
|
||||||
node, wallet, session or registry data.
|
node, wallet, session or registry data.
|
||||||
|
|
||||||
|
Latest active checkpoint: see **Companion56 native-audio qualification** at the end. Earlier receipts below are historical and apply only to their named source/artifacts.
|
||||||
|
|
||||||
## Current candidate
|
## Current candidate
|
||||||
|
|
||||||
Recent commits, newest first:
|
Recent commits, newest first:
|
||||||
@@ -289,7 +291,7 @@ No percentage is inferred from test counts.
|
|||||||
| 17 HTTPS apps | In progress | Exact hostname/trust and companion acceptance |
|
| 17 HTTPS apps | In progress | Exact hostname/trust and companion acceptance |
|
||||||
| 18 Firewall/tunnel | In progress | Read-only UI done; settings persistence/reboot/rollback qualification |
|
| 18 Firewall/tunnel | In progress | Read-only UI done; settings persistence/reboot/rollback qualification |
|
||||||
| 19 Media guide/Cloud PiP | Nearly finished | PiP operator-accepted; finish reusable contract and remaining edge cases |
|
| 19 Media guide/Cloud PiP | Nearly finished | PiP operator-accepted; finish reusable contract and remaining edge cases |
|
||||||
| 20 Native background media | In progress | Cloud PiP done; native audio service/controls and device checks remain |
|
| 20 Native background media | In progress | Native service/controls implemented and APK56 delivered; matching LAN HTTP UI qualification and physical device checks remain |
|
||||||
| 21 Public files/folders | Queued, before22 | Shared pricing interface with blue tints, unrelated-node discovery and tiny marker |
|
| 21 Public files/folders | Queued, before22 | Shared pricing interface with blue tints, unrelated-node discovery and tiny marker |
|
||||||
| 22 Mesh file sharing | Queued, final task | Node/computer choice, tree/search picker, retained multi-selection, delivery design and usual paid-file flow |
|
| 22 Mesh file sharing | Queued, final task | Node/computer choice, tree/search picker, retained multi-selection, delivery design and usual paid-file flow |
|
||||||
|
|
||||||
@@ -327,3 +329,50 @@ Task22 now follows task21 at the end. Its detailed scope is in
|
|||||||
`post-1.9.0-work-backlog.md`. This is a recorded requirement/design task, not a
|
`post-1.9.0-work-backlog.md`. This is a recorded requirement/design task, not a
|
||||||
claim that the picker or transport/payment flow has been implemented. Existing
|
claim that the picker or transport/payment flow has been implemented. Existing
|
||||||
public-sharing blue pricing and marker requirements are retained unchanged.
|
public-sharing blue pricing and marker requirements are retained unchanged.
|
||||||
|
|
||||||
|
|
||||||
|
## Companion56 native-audio qualification — 2026-10-07
|
||||||
|
|
||||||
|
- Operator withdrew the accidental screensaver/Play/Skip prompts. No changes
|
||||||
|
were made for them, so nothing required reverting.
|
||||||
|
- Companion0.5.36/build56 is delivered at
|
||||||
|
`http://192.168.63.240/packages/archipelago-companion-0.5.36.apk`.
|
||||||
|
SHA256 `6bee2a2a13231525997946926bb47c473e01b81c03737d22d1bce18af9982c6e`.
|
||||||
|
Canonical signing, clean packaging and HTTP byte verification passed. The
|
||||||
|
standard fleet APK and live backend/catalogs were preserved.
|
||||||
|
- 29 Android tests pass (including native bridge, foreground notification and
|
||||||
|
task-removal retention on SDK28/35). These are automated tests, not phone
|
||||||
|
acceptance. Durable receipt:
|
||||||
|
`~/.local/state/archipelago/release-qualification/companion-056/`.
|
||||||
|
- The native MediaSession/foreground service controls the same retained
|
||||||
|
authenticated WebView player, with queue controls, bounded thumbnail data,
|
||||||
|
session/origin checks and logout/stop cleanup. No protected stream URLs or
|
||||||
|
credentials are sent to Android and no second decoder/payment is started.
|
||||||
|
- Initial complete dashboard qualification passed1440 tests/175files and its
|
||||||
|
production build. That UI was deployed on dev with backup and unchanged
|
||||||
|
backend, catalog/session/intent hashes and container IDs/start times.
|
||||||
|
- Live LAN HTTP browser inspection then found `crypto.randomUUID` absent. The
|
||||||
|
new bridge now generates UUIDv4 using `crypto.getRandomValues`, which that
|
||||||
|
browser supports. A regression covers this exact condition; final full UI
|
||||||
|
suite/build and replacement deployment are underway. Do not ask the operator
|
||||||
|
to accept native audio until the matching corrected Yaya UI is deployed.
|
||||||
|
- Yaya remains at its previous UI until qualification completes. Physical
|
||||||
|
Android background, lock-screen, swipe-away, reconnect and stop acceptance
|
||||||
|
remains required. CloudPiP55 remains operator accepted.
|
||||||
|
- Dedicated IndeeHub agent owns backend changes and the full isolated seven-app
|
||||||
|
fixture. Its first latest suite had2002pass/1failure/5ignored: the new fixture
|
||||||
|
incorrectly expected no directory, although an empty directory is valid.
|
||||||
|
Assertion corrected to require no journal files. Final isolated recheck:
|
||||||
|
**2,003 passed, zero failed, five ignored**, committed as `b9c75b21`. This
|
||||||
|
is not a live IndeeHub delivery; optimized binary and full VM cutover still
|
||||||
|
remain before Yaya activation.
|
||||||
|
|
||||||
|
- First HTTP-fix full UI run:1440 passed, one60-second Home wallet-display
|
||||||
|
timeout and a worker `snapshotSaved` timeout under severe memory/I/O pressure.
|
||||||
|
Focused rerun:all12 Home wallet-cache tests and all6 companion bridge tests
|
||||||
|
passed (18/18). Clean full rerun is underway; retain both failed and passing
|
||||||
|
logs. The initial1440-green full receipt predates the UUID fix.
|
||||||
|
- Extended real V4V browser qualification supports optional simulated Android
|
||||||
|
transport while using the actual authenticated iframe and bundled free demo
|
||||||
|
media. It checks native command routing and JPEG thumbnail delivery; no
|
||||||
|
physical Android result is implied. The new end-to-end run remains pending.
|
||||||
|
|||||||
@@ -7,10 +7,12 @@ reference app; its updated real-node acceptance is tracked in
|
|||||||
[v4v-native-player-20261006.md](v4v-native-player-20261006.md). Local tests are not
|
[v4v-native-player-20261006.md](v4v-native-player-20261006.md). Local tests are not
|
||||||
proof that a particular deployed app or companion version supports every feature.
|
proof that a particular deployed app or companion version supports every feature.
|
||||||
|
|
||||||
Cloud video picture-in-picture (PiP) in the Android companion is a separate open
|
Cloud video PiP is implemented in companion0.5.35/build55; the operator accepted
|
||||||
implementation task. Do not advertise the audio bridge as a video/PiP API. Native
|
the delivered Cloud PiP flow on2026-10-07. Audio and video use separate channels.
|
||||||
fullscreen support alone does not provide Android PiP. A versioned video contract
|
Native background audio is a companion0.5.36/build56 candidate:29 Android tests
|
||||||
and examples must be added here when implementation and device tests pass.
|
and six companion-bridge dashboard tests pass; physical background/lock-screen/task
|
||||||
|
removal acceptance remains required. Do not infer those phone results from
|
||||||
|
browser or Robolectric tests.
|
||||||
|
|
||||||
## Declare the audio integration
|
## Declare the audio integration
|
||||||
|
|
||||||
@@ -152,7 +154,7 @@ those guards merely to make a test pass.
|
|||||||
viewport test cannot prove OS background playback or PiP support. Do not promise
|
viewport test cannot prove OS background playback or PiP support. Do not promise
|
||||||
playback after the operating system kills the process.
|
playback after the operating system kills the process.
|
||||||
|
|
||||||
## Cloud video PiP acceptance scope (not yet implemented)
|
## Cloud video PiP contract and remaining acceptance
|
||||||
|
|
||||||
Start with Cloud's existing authorized video viewer and companion fullscreen host.
|
Start with Cloud's existing authorized video viewer and companion fullscreen host.
|
||||||
Provide an explicit PiP control when supported, with clear unavailable behavior.
|
Provide an explicit PiP control when supported, with clear unavailable behavior.
|
||||||
@@ -165,3 +167,67 @@ logout/session expiry, FIPS disconnect/reconnect, and process recreation. Valida
|
|||||||
on a physical companion with ordinary and FIPS-accessed Cloud videos before APK
|
on a physical companion with ordinary and FIPS-accessed Cloud videos before APK
|
||||||
publication. Document the proven video integration contract for other app authors
|
publication. Document the proven video integration contract for other app authors
|
||||||
only after that implementation is qualified.
|
only after that implementation is qualified.
|
||||||
|
|
||||||
|
|
||||||
|
The dashboard uses the main-frame-only `ArchipelagoCloudVideo` channel, admitted
|
||||||
|
only for the paired node's exact HTTP(S) origins. Its version1 capability check
|
||||||
|
is separate from `archipelago-v1` app audio integration. The Cloud host arms a
|
||||||
|
random request/session ID with video dimensions and playing state, then enters
|
||||||
|
fullscreen on the existing video in the same user gesture before requesting PiP.
|
||||||
|
Native commands and replies carry that session; a different session is ignored.
|
||||||
|
`state` updates playback controls and `release` retires the session. `restored`
|
||||||
|
returns to the same video; closing requests pause/cleanup. No video URL, cookie,
|
||||||
|
bearer token or second player crosses the channel. The entire dashboard must
|
||||||
|
never be used as the PiP surface. This is currently a Cloud-host contract, not
|
||||||
|
permission for arbitrary embedded apps to call native PiP directly.
|
||||||
|
|
||||||
|
## Companion native audio: build56 candidate
|
||||||
|
|
||||||
|
Apps continue using the existing version1 app audio protocol above. They do not
|
||||||
|
need a second Android stream or a separate queue implementation. The dashboard
|
||||||
|
owns a main-frame `ArchipelagoAudio` channel restricted to paired node origins;
|
||||||
|
embedded app frames cannot invoke it directly. The foreground `mediaPlayback`
|
||||||
|
service owns the retained WebView session after the Activity/task closes and
|
||||||
|
exposes an Android MediaSession with playback metadata, play/pause, previous/next,
|
||||||
|
seek, shuffle and Stop. Playback state is confirmed by the existing app player,
|
||||||
|
not optimistically advanced by native controls.
|
||||||
|
|
||||||
|
Dashboard→native messages contain version1, a random session, monotonically
|
||||||
|
increasing sequence, bounded title/position/duration, playback/control flags and
|
||||||
|
optional JPEG thumbnail bytes. Artwork is fetched by the already authenticated
|
||||||
|
page with same-origin credentials only, capped at512KiB, resized to192px and sent
|
||||||
|
as a bounded data URL. Native code does not fetch artwork URLs or receive auth
|
||||||
|
credentials. Cross-origin images without CORS may have no notification thumbnail;
|
||||||
|
missing artwork never blocks playback. Native image decoding bounds dimensions.
|
||||||
|
|
||||||
|
Native→dashboard controls carry the same session. Retired sessions, stale sequence
|
||||||
|
numbers, foreign origins and subframes cannot revive/control playback. A short
|
||||||
|
heartbeat resynchronizes state; if the dashboard stops responding for90seconds,
|
||||||
|
the native owner stops instead of advertising a live session indefinitely.
|
||||||
|
Playback stays in the same authenticated WebView/iframe; app authorization and
|
||||||
|
entitlement checks remain with that player. App removal, frame replacement,
|
||||||
|
logout, navigation/disconnect or explicit Stop release the corresponding session.
|
||||||
|
A non-playing task removed from Recents is released. A playing one is retained;
|
||||||
|
reopening reattaches the existing document, queue and position. A killed process
|
||||||
|
is not automatically restarted into an authenticated stream.
|
||||||
|
|
||||||
|
The implementation uses the platform MediaSession with the existing WebView
|
||||||
|
player, rather than adding another decoder. No boot receiver or new storage
|
||||||
|
permission is involved. Pair this APK with the matching dashboard build: an older
|
||||||
|
dashboard does not send the native audio protocol merely because the APK changed.
|
||||||
|
|
||||||
|
Qualification commands:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd neode-ui
|
||||||
|
./node_modules/.bin/vitest run src/composables/__tests__/useCompanionAudio.test.ts src/composables/__tests__/useAppMediaBridge.test.ts src/components/__tests__/GlobalAudioPlayerExternal.test.ts
|
||||||
|
cd ../Android
|
||||||
|
./gradlew :app:testDebugUnitTest
|
||||||
|
```
|
||||||
|
|
||||||
|
Before marking physical acceptance, use V4V on the matching server: play a track,
|
||||||
|
close its panel, press Home, lock the phone, pause/resume/seek/skip/shuffle from
|
||||||
|
native controls, remove the companion task while playing, reopen into the same
|
||||||
|
queue/position, then Stop. Repeat logout, headset disconnect and network loss.
|
||||||
|
Confirm download, fullscreen and Cloud PiP still work. App force-stop/process
|
||||||
|
kill is a stop condition, not a promise of uninterrupted playback.
|
||||||
|
|||||||
Binary file not shown.
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"versionName": "0.5.35",
|
"versionName": "0.5.36",
|
||||||
"versionCode": 55
|
"versionCode": 56
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,8 +69,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch, nextTick, onBeforeUnmount } from 'vue'
|
import { ref, watch, nextTick, onBeforeUnmount } from 'vue'
|
||||||
import { useAudioPlayer } from '@/composables/useAudioPlayer'
|
import { useAudioPlayer } from '@/composables/useAudioPlayer'
|
||||||
|
import { useCompanionAudio } from '@/composables/useCompanionAudio'
|
||||||
|
|
||||||
const audioPlayer = useAudioPlayer()
|
const audioPlayer = useAudioPlayer()
|
||||||
|
useCompanionAudio(audioPlayer)
|
||||||
const barEl = ref<HTMLElement | null>(null)
|
const barEl = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
// Publish the player's height as a CSS variable so page scroll containers can
|
// Publish the player's height as a CSS variable so page scroll containers can
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { defineComponent, nextTick, ref } from 'vue'
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { useCompanionAudio } from '../useCompanionAudio'
|
||||||
|
import type { useAudioPlayer } from '../useAudioPlayer'
|
||||||
|
|
||||||
|
beforeEach(() => vi.useFakeTimers())
|
||||||
|
afterEach(() => { vi.useRealTimers(); delete (window as any).ArchipelagoAudio })
|
||||||
|
function fixture(install = true) {
|
||||||
|
const messages: any[] = []
|
||||||
|
const bridge: any = { onmessage: null, postMessage: (raw: string) => messages.push(JSON.parse(raw)) }
|
||||||
|
if (install) (window as any).ArchipelagoAudio = bridge
|
||||||
|
const player = {
|
||||||
|
currentSrc: ref<string | null>('https://node.test/private?token=secret'), currentName: ref('A song'),
|
||||||
|
playing: ref(true), currentTime: ref(12), duration: ref(120), externalArtwork: ref(''),
|
||||||
|
externalApp: ref(true), externalShuffle: ref(false), error: ref<string | null>(null),
|
||||||
|
pause: vi.fn(), play: vi.fn(), seek: vi.fn(), nextExternal: vi.fn(), previousExternal: vi.fn(), shuffleExternal: vi.fn(),
|
||||||
|
stop: vi.fn(() => { player.currentSrc.value = null; player.playing.value = false }),
|
||||||
|
}
|
||||||
|
let audio!: ReturnType<typeof useCompanionAudio>
|
||||||
|
const wrapper = mount(defineComponent({ setup() { audio = useCompanionAudio(player as unknown as ReturnType<typeof useAudioPlayer>); return () => null } }))
|
||||||
|
return { messages, bridge, player, wrapper, audio,
|
||||||
|
command: (command: string, extra = {}) => bridge.onmessage({ data: JSON.stringify({ version: 1, type: 'command', session: messages[0]?.session, command, ...extra }) }) }
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('companion native audio control of the existing player', () => {
|
||||||
|
it('sends bounded metadata and queue controls without stream URLs or credentials', async () => {
|
||||||
|
const f = fixture(); await nextTick()
|
||||||
|
expect(f.messages[0]).toMatchObject({ action: 'state', title: 'A song', playing: true, position: 12, duration: 120, next: true, previous: true, shuffle: true })
|
||||||
|
expect(JSON.stringify(f.messages)).not.toContain('secret')
|
||||||
|
expect(JSON.stringify(f.messages)).not.toContain('https:')
|
||||||
|
f.player.currentTime.value = 13; await nextTick(); f.player.currentTime.value = 14; await nextTick()
|
||||||
|
expect(f.messages).toHaveLength(1)
|
||||||
|
await vi.advanceTimersByTimeAsync(1000)
|
||||||
|
expect(f.messages).toHaveLength(2); expect(f.messages[1].position).toBe(14)
|
||||||
|
f.wrapper.unmount(); expect(f.messages[f.messages.length - 1].action).toBe('release')
|
||||||
|
})
|
||||||
|
it('starts unique valid sessions on LAN HTTP without crypto.randomUUID', async () => {
|
||||||
|
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'randomUUID')
|
||||||
|
Object.defineProperty(crypto, 'randomUUID', { configurable: true, value: undefined })
|
||||||
|
const f = fixture()
|
||||||
|
try {
|
||||||
|
await nextTick()
|
||||||
|
const first = f.messages[0].session
|
||||||
|
expect(first).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/)
|
||||||
|
f.player.currentSrc.value = 'app:second'; await nextTick()
|
||||||
|
expect(f.messages[2].session).not.toBe(first)
|
||||||
|
} finally {
|
||||||
|
f.wrapper.unmount()
|
||||||
|
if (descriptor) Object.defineProperty(crypto, 'randomUUID', descriptor)
|
||||||
|
else Reflect.deleteProperty(crypto, 'randomUUID')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
it('operates the same queue and ignores controls from a stale session', async () => {
|
||||||
|
const f = fixture(); await nextTick()
|
||||||
|
f.command('pause', { session: 'foreign' }); expect(f.player.pause).not.toHaveBeenCalled()
|
||||||
|
f.command('pause'); f.command('next'); f.command('previous'); f.command('shuffle'); f.command('seek', { position: 900 })
|
||||||
|
expect(f.player.pause).toHaveBeenCalledOnce(); expect(f.player.nextExternal).toHaveBeenCalledOnce()
|
||||||
|
expect(f.player.previousExternal).toHaveBeenCalledOnce(); expect(f.player.shuffleExternal).toHaveBeenCalledOnce()
|
||||||
|
expect(f.player.seek).toHaveBeenCalledWith(120)
|
||||||
|
f.command('seek', { position: '5' }); expect(f.player.seek).toHaveBeenCalledTimes(1)
|
||||||
|
f.command('stop'); await nextTick(); expect(f.player.stop).toHaveBeenCalledOnce()
|
||||||
|
expect(f.messages[f.messages.length - 1].action).toBe('release'); f.wrapper.unmount()
|
||||||
|
})
|
||||||
|
it('replaces native session when changing source and releases on logout/player stop', async () => {
|
||||||
|
const f = fixture(); await nextTick(); const first = f.messages[0].session
|
||||||
|
f.player.currentSrc.value = 'app:new'; await nextTick()
|
||||||
|
expect(f.messages[1]).toMatchObject({ action: 'release', session: first })
|
||||||
|
expect(f.messages[2].session).not.toBe(first)
|
||||||
|
f.command('pause', { session: first }); expect(f.player.pause).not.toHaveBeenCalled()
|
||||||
|
f.player.currentSrc.value = null; await nextTick()
|
||||||
|
expect(f.messages[f.messages.length - 1].action).toBe('release'); f.wrapper.unmount()
|
||||||
|
})
|
||||||
|
it('connects a late native bridge and answers synchronization while backgrounded', async () => {
|
||||||
|
const f = fixture(false); await nextTick(); expect(f.messages).toHaveLength(0)
|
||||||
|
;(window as any).ArchipelagoAudio = f.bridge
|
||||||
|
document.dispatchEvent(new Event('visibilitychange'))
|
||||||
|
expect(f.messages).toHaveLength(1)
|
||||||
|
f.command('sync'); expect(f.messages).toHaveLength(2)
|
||||||
|
f.wrapper.unmount(); const count = f.messages.length
|
||||||
|
await vi.advanceTimersByTimeAsync(30000); expect(f.messages).toHaveLength(count)
|
||||||
|
})
|
||||||
|
it('does not revive paused sessions and reports native start failures without leaking details', async () => {
|
||||||
|
const f = fixture(); await nextTick()
|
||||||
|
f.bridge.onmessage({ data: JSON.stringify({ version: 1, session: f.messages[0].session, type: 'error', error: 'sensitive details' }) })
|
||||||
|
expect(f.player.pause).toHaveBeenCalledOnce(); expect(f.player.error.value).toContain('Background playback')
|
||||||
|
expect(f.player.error.value).not.toContain('sensitive')
|
||||||
|
f.player.playing.value = false; await nextTick(); const count = f.messages.length
|
||||||
|
await vi.advanceTimersByTimeAsync(10000); expect(f.messages).toHaveLength(count)
|
||||||
|
f.wrapper.unmount()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import { onBeforeUnmount, watch } from 'vue'
|
||||||
|
import { useAudioPlayer } from './useAudioPlayer'
|
||||||
|
|
||||||
|
type AudioBridge = { postMessage: (message: string) => void; onmessage: ((event: { data: string }) => void) | null }
|
||||||
|
type NativeWindow = Window & { ArchipelagoAudio?: AudioBridge }
|
||||||
|
|
||||||
|
/** LAN HTTP WebViews expose getRandomValues, but not secure-context randomUUID. */
|
||||||
|
function audioSessionId(): string {
|
||||||
|
const bytes = crypto.getRandomValues(new Uint8Array(16))
|
||||||
|
bytes[6] = (bytes[6]! & 0x0f) | 0x40
|
||||||
|
bytes[8] = (bytes[8]! & 0x3f) | 0x80
|
||||||
|
const hex = Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('')
|
||||||
|
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Send a small decoded thumbnail, never a protected URL or authorization data. */
|
||||||
|
async function thumbnail(url: string, signal: AbortSignal): Promise<string> {
|
||||||
|
if (!url) return ''
|
||||||
|
const source = new URL(url, window.location.href)
|
||||||
|
if (source.username || source.password || (source.protocol !== 'https:' && source.origin !== window.location.origin)) return ''
|
||||||
|
const response = await fetch(source.href, { signal, credentials: 'same-origin', referrerPolicy: 'no-referrer' })
|
||||||
|
if (!response.ok || !response.headers.get('content-type')?.startsWith('image/')) return ''
|
||||||
|
const reader = response.body?.getReader()
|
||||||
|
if (!reader) return ''
|
||||||
|
const chunks: Uint8Array[] = []
|
||||||
|
let size = 0
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const chunk = await reader.read()
|
||||||
|
if (chunk.done) break
|
||||||
|
size += chunk.value.byteLength
|
||||||
|
if (size > 512 * 1024) { await reader.cancel(); return '' }
|
||||||
|
chunks.push(chunk.value)
|
||||||
|
}
|
||||||
|
} finally { reader.releaseLock() }
|
||||||
|
const bytes = new Uint8Array(size)
|
||||||
|
let offset = 0
|
||||||
|
for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.length }
|
||||||
|
const picture = await createImageBitmap(new Blob([bytes]), { resizeWidth: 192, resizeHeight: 192, resizeQuality: 'medium' })
|
||||||
|
try {
|
||||||
|
const canvas = document.createElement('canvas'); canvas.width = 192; canvas.height = 192
|
||||||
|
const context = canvas.getContext('2d'); if (!context) return ''
|
||||||
|
context.drawImage(picture, 0, 0, 192, 192)
|
||||||
|
const result = canvas.toDataURL('image/jpeg', 0.75)
|
||||||
|
return result.length <= 90000 ? result : ''
|
||||||
|
} finally { picture.close() }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCompanionAudio(player = useAudioPlayer()) {
|
||||||
|
let bridge: AudioBridge | undefined
|
||||||
|
let session = '', source = '', sequence = 0
|
||||||
|
let artwork = '', sentArtwork = ''
|
||||||
|
let imageRequest: AbortController | null = null
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
let disposed = false
|
||||||
|
let previousHandler: AudioBridge['onmessage'] = null
|
||||||
|
function post(value: object) { try { bridge?.postMessage(JSON.stringify(value)) } catch { /* Lifecycle cleanup can close the document channel. */ } }
|
||||||
|
function release() {
|
||||||
|
if (session) post({ version: 1, action: 'release', session })
|
||||||
|
session = ''; source = ''; artwork = ''; sentArtwork = ''
|
||||||
|
imageRequest?.abort(); imageRequest = null
|
||||||
|
if (timer) clearTimeout(timer)
|
||||||
|
timer = undefined
|
||||||
|
}
|
||||||
|
function receive(event: { data: string }) {
|
||||||
|
let value: any
|
||||||
|
try { value = JSON.parse(event.data) } catch { return }
|
||||||
|
if (value.version !== 1 || !session || value.session !== session) return
|
||||||
|
if (value.type === 'error') {
|
||||||
|
player.pause(); player.error.value = 'Background playback could not start. Reopen the companion and press Play.'
|
||||||
|
release(); return
|
||||||
|
}
|
||||||
|
if (value.type !== 'command') return
|
||||||
|
switch (value.command) {
|
||||||
|
case 'play': if (player.currentSrc.value && !player.playing.value) player.play(player.currentSrc.value, player.currentName.value); break
|
||||||
|
case 'pause': player.pause(); break
|
||||||
|
case 'stop': player.stop(); release(); break
|
||||||
|
case 'seek': if (Number.isFinite(value.position)) player.seek(Math.max(0, Math.min(value.position, player.duration.value))); break
|
||||||
|
case 'next': player.nextExternal(); break
|
||||||
|
case 'previous': player.previousExternal(); break
|
||||||
|
case 'shuffle': player.shuffleExternal(); break
|
||||||
|
case 'sync': publish(); break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function connect() {
|
||||||
|
const candidate = (window as NativeWindow).ArchipelagoAudio
|
||||||
|
if (!candidate) return false
|
||||||
|
if (bridge !== candidate) {
|
||||||
|
release()
|
||||||
|
if (bridge?.onmessage === receive) bridge.onmessage = previousHandler
|
||||||
|
bridge = candidate; previousHandler = candidate.onmessage; candidate.onmessage = receive
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
function publish() {
|
||||||
|
if (disposed || !connect()) return
|
||||||
|
if (!player.currentSrc.value || !player.currentName.value) { release(); return }
|
||||||
|
if (!session || source !== player.currentSrc.value) {
|
||||||
|
if (!player.playing.value) { release(); return }
|
||||||
|
release(); session = audioSessionId(); source = player.currentSrc.value; sequence = 0
|
||||||
|
loadArtwork()
|
||||||
|
}
|
||||||
|
const duration = Number.isFinite(player.duration.value) ? Math.max(0, Math.min(player.duration.value, 604800)) : 0
|
||||||
|
const position = Number.isFinite(player.currentTime.value) ? Math.max(0, Math.min(player.currentTime.value, duration)) : 0
|
||||||
|
const message: Record<string, unknown> = { version: 1, action: 'state', session, sequence: ++sequence,
|
||||||
|
title: player.currentName.value.slice(0, 512), playing: player.playing.value, position, duration,
|
||||||
|
previous: player.externalApp.value, next: player.externalApp.value,
|
||||||
|
shuffle: player.externalApp.value, shuffled: player.externalShuffle.value }
|
||||||
|
if (artwork !== sentArtwork || sequence === 1) { message.artwork = artwork; sentArtwork = artwork }
|
||||||
|
post(message)
|
||||||
|
}
|
||||||
|
function loadArtwork() {
|
||||||
|
imageRequest?.abort(); artwork = ''
|
||||||
|
if (!session || !player.externalArtwork.value) return
|
||||||
|
const target = session; const request = new AbortController(); imageRequest = request
|
||||||
|
void thumbnail(player.externalArtwork.value, request.signal).then(value => {
|
||||||
|
if (!disposed && !request.signal.aborted && target === session) { artwork = value; publish() }
|
||||||
|
}).catch(() => { /* Optional image failure never interrupts music. */ })
|
||||||
|
}
|
||||||
|
// Playback transitions/track changes are immediate; position updates are bounded.
|
||||||
|
watch([player.currentSrc, player.currentName, player.playing, player.externalShuffle], publish, { immediate: true, flush: 'post' })
|
||||||
|
watch([player.currentTime, player.duration], () => {
|
||||||
|
if (!timer) timer = setTimeout(() => { timer = undefined; publish() }, 1000)
|
||||||
|
})
|
||||||
|
watch(player.externalArtwork, () => { loadArtwork(); publish() })
|
||||||
|
const sync = () => publish()
|
||||||
|
window.addEventListener('pageshow', sync)
|
||||||
|
document.addEventListener('visibilitychange', sync)
|
||||||
|
const heartbeat = setInterval(publish, 10000)
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
disposed = true; release(); clearInterval(heartbeat)
|
||||||
|
window.removeEventListener('pageshow', sync); document.removeEventListener('visibilitychange', sync)
|
||||||
|
if (bridge?.onmessage === receive) bridge.onmessage = previousHandler
|
||||||
|
})
|
||||||
|
return { sync }
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
// Authentication store — login, logout, session management
|
// Authentication store — login, logout, session management
|
||||||
|
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref } from 'vue'
|
import { ref, watch } from 'vue'
|
||||||
|
import { useAudioPlayer } from '@/composables/useAudioPlayer'
|
||||||
import { rpcClient } from '../api/rpc-client'
|
import { rpcClient } from '../api/rpc-client'
|
||||||
import { useSyncStore } from './sync'
|
import { useSyncStore } from './sync'
|
||||||
import { useResourcesStore } from './resources'
|
import { useResourcesStore } from './resources'
|
||||||
@@ -9,6 +10,7 @@ import { useResourcesStore } from './resources'
|
|||||||
export const useAuthStore = defineStore('auth', () => {
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
// State
|
// State
|
||||||
const isAuthenticated = ref(localStorage.getItem('neode-auth') === 'true')
|
const isAuthenticated = ref(localStorage.getItem('neode-auth') === 'true')
|
||||||
|
watch(isAuthenticated, authenticated => { if (!authenticated) useAudioPlayer().stop() }, { flush: 'sync' })
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
let sessionValidated = false
|
let sessionValidated = false
|
||||||
@@ -75,6 +77,7 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function logout(): Promise<void> {
|
async function logout(): Promise<void> {
|
||||||
|
useAudioPlayer().stop()
|
||||||
const sync = useSyncStore()
|
const sync = useSyncStore()
|
||||||
try {
|
try {
|
||||||
await rpcClient.logout()
|
await rpcClient.logout()
|
||||||
|
|||||||
@@ -63,6 +63,20 @@ const { chromium, expect } = require(process.env.PLAYWRIGHT_MODULE || '../../neo
|
|||||||
return route.fallback();
|
return route.fallback();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (process.env.QUALIFY_COMPANION_BRIDGE === '1') {
|
||||||
|
if (process.env.QUALIFY_PLAYBACK !== '1') throw Error('Companion bridge qualification requires real playback');
|
||||||
|
await context.addInitScript(() => {
|
||||||
|
if (window !== window.top) return;
|
||||||
|
window.__qualificationAudioBridge = [];
|
||||||
|
window.ArchipelagoAudio = {
|
||||||
|
onmessage: null,
|
||||||
|
postMessage(raw) {
|
||||||
|
window.__qualificationAudioBridge.push(JSON.parse(raw));
|
||||||
|
if (window.__qualificationAudioBridge.length > 300) window.__qualificationAudioBridge.shift();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
page = await context.newPage();
|
page = await context.newPage();
|
||||||
page.on('response', response => { const u = new URL(response.url()); if (u.origin === appOrigin && u.pathname === '/api/auth/nostr/login') proof.login.push(response.status()); });
|
page.on('response', response => { const u = new URL(response.url()); if (u.origin === appOrigin && u.pathname === '/api/auth/nostr/login') proof.login.push(response.status()); });
|
||||||
await page.goto(origin + '/dashboard/discover', { waitUntil: 'domcontentloaded', timeout: 60000 });
|
await page.goto(origin + '/dashboard/discover', { waitUntil: 'domcontentloaded', timeout: 60000 });
|
||||||
|
|||||||
@@ -2,6 +2,15 @@
|
|||||||
// No fixture media, intercepted success, payment or application state fabrication.
|
// No fixture media, intercepted success, payment or application state fabrication.
|
||||||
const { expect } = require(process.env.PLAYWRIGHT_MODULE || '../../neode-ui/node_modules/@playwright/test');
|
const { expect } = require(process.env.PLAYWRIGHT_MODULE || '../../neode-ui/node_modules/@playwright/test');
|
||||||
module.exports = async function qualifyPlayback(page, appOrigin, proof) {
|
module.exports = async function qualifyPlayback(page, appOrigin, proof) {
|
||||||
|
const nativeBridge = process.env.QUALIFY_COMPANION_BRIDGE === '1';
|
||||||
|
async function control(command, button) {
|
||||||
|
if (!nativeBridge) return page.getByRole('button', { name: button, exact: true }).click();
|
||||||
|
await page.evaluate(command => {
|
||||||
|
const state = window.__qualificationAudioBridge?.filter(value => value.action === 'state').at(-1);
|
||||||
|
if (!state?.session || !window.ArchipelagoAudio?.onmessage) throw Error('Companion audio session missing');
|
||||||
|
window.ArchipelagoAudio.onmessage({ data: JSON.stringify({ version: 1, type: 'command', session: state.session, command }) });
|
||||||
|
}, command);
|
||||||
|
}
|
||||||
const app = page.frames().find(frame => frame.url().startsWith(appOrigin + '/'));
|
const app = page.frames().find(frame => frame.url().startsWith(appOrigin + '/'));
|
||||||
if (!app) throw Error('Managed V4V iframe missing after login');
|
if (!app) throw Error('Managed V4V iframe missing after login');
|
||||||
proof.initialBrowse = new URL(app.url()).pathname === '/browse';
|
proof.initialBrowse = new URL(app.url()).pathname === '/browse';
|
||||||
@@ -46,18 +55,18 @@ module.exports = async function qualifyPlayback(page, appOrigin, proof) {
|
|||||||
await expect(bar).toBeVisible();
|
await expect(bar).toBeVisible();
|
||||||
await expect(bar).toContainText(tracks[0].title);
|
await expect(bar).toContainText(tracks[0].title);
|
||||||
await app.waitForFunction(time => window.__qualificationMedia.some(audio => !audio.paused && audio.currentTime > time + 1), initial, { timeout: 15000, polling: 100 });
|
await app.waitForFunction(time => window.__qualificationMedia.some(audio => !audio.paused && audio.currentTime > time + 1), initial, { timeout: 15000, polling: 100 });
|
||||||
await page.getByRole('button', { name: 'Pause audio', exact: true }).click();
|
await control('pause', 'Pause audio');
|
||||||
await app.waitForFunction(() => window.__qualificationMedia.every(audio => audio.paused), undefined, { polling: 100 });
|
await app.waitForFunction(() => window.__qualificationMedia.every(audio => audio.paused), undefined, { polling: 100 });
|
||||||
await page.getByRole('button', { name: 'Play audio', exact: true }).click();
|
await control('play', 'Play audio');
|
||||||
await app.waitForFunction(() => window.__qualificationMedia.some(audio => !audio.paused), undefined, { polling: 100 });
|
await app.waitForFunction(() => window.__qualificationMedia.some(audio => !audio.paused), undefined, { polling: 100 });
|
||||||
await page.getByRole('button', { name: 'Next track', exact: true }).click();
|
await control('next', 'Next track');
|
||||||
await app.waitForFunction(id => window.PulsewireLegacy.getReactBridge().helpers.activePlayerRelease()?.id === id, tracks[1].id, { polling: 100 });
|
await app.waitForFunction(id => window.PulsewireLegacy.getReactBridge().helpers.activePlayerRelease()?.id === id, tracks[1].id, { polling: 100 });
|
||||||
await expect(bar).toContainText(tracks[1].title);
|
await expect(bar).toContainText(tracks[1].title);
|
||||||
// Previous after >3 seconds intentionally restarts the current song in V4V.
|
// Previous after >3 seconds intentionally restarts the current song in V4V.
|
||||||
await app.waitForFunction(() => window.__qualificationMedia.some(audio => !audio.paused && audio.currentTime > 4), undefined, { polling: 100 });
|
await app.waitForFunction(() => window.__qualificationMedia.some(audio => !audio.paused && audio.currentTime > 4), undefined, { polling: 100 });
|
||||||
await page.getByRole('button', { name: 'Previous track', exact: true }).click();
|
await control('previous', 'Previous track');
|
||||||
await app.waitForFunction(() => window.__qualificationMedia.some(audio => !audio.paused && audio.currentTime < 3), undefined, { polling: 100 });
|
await app.waitForFunction(() => window.__qualificationMedia.some(audio => !audio.paused && audio.currentTime < 3), undefined, { polling: 100 });
|
||||||
await page.getByRole('button', { name: 'Shuffle', exact: true }).click();
|
await control('shuffle', 'Shuffle');
|
||||||
await expect(page.getByRole('button', { name: 'Shuffle', exact: true })).toHaveAttribute('aria-pressed', 'true');
|
await expect(page.getByRole('button', { name: 'Shuffle', exact: true })).toHaveAttribute('aria-pressed', 'true');
|
||||||
expect(await app.evaluate(() => window.PulsewireLegacy.getReactBridge().getState().ui.shuffleEnabled)).toBe(true);
|
expect(await app.evaluate(() => window.PulsewireLegacy.getReactBridge().getState().ui.shuffleEnabled)).toBe(true);
|
||||||
const artwork = await app.evaluate(() => window.PulsewireLegacy.getReactBridge().helpers.activePlayerRelease()?.cover || '');
|
const artwork = await app.evaluate(() => window.PulsewireLegacy.getReactBridge().helpers.activePlayerRelease()?.cover || '');
|
||||||
@@ -74,6 +83,15 @@ module.exports = async function qualifyPlayback(page, appOrigin, proof) {
|
|||||||
for (const axis of ['x', 'y', 'width', 'height']) expect(Math.abs(geometry.image[axis] - geometry.bar[axis])).toBeLessThanOrEqual(2);
|
for (const axis of ['x', 'y', 'width', 'height']) expect(Math.abs(geometry.image[axis] - geometry.bar[axis])).toBeLessThanOrEqual(2);
|
||||||
proof.renderedArtwork = true;
|
proof.renderedArtwork = true;
|
||||||
}
|
}
|
||||||
|
if (nativeBridge) {
|
||||||
|
await expect.poll(() => page.evaluate(() => window.__qualificationAudioBridge?.filter(value => value.action === 'state').at(-1)?.title)).toContain(tracks[1].title);
|
||||||
|
const nativeState = await page.evaluate(() => window.__qualificationAudioBridge.filter(value => value.action === 'state').at(-1));
|
||||||
|
expect(nativeState.playing).toBe(true);
|
||||||
|
expect(nativeState.session).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
|
||||||
|
expect(nativeState.shuffled).toBe(true);
|
||||||
|
if (artwork) await expect.poll(() => page.evaluate(() => window.__qualificationAudioBridge.some(value => value.action === 'state' && value.artwork?.startsWith('data:image/jpeg;base64,'))), { timeout: 15000 }).toBe(true);
|
||||||
|
proof.companionBridge = { realBrowserPlayer: true, nativeTransportSimulated: true, controls: ['pause', 'play', 'next', 'previous', 'shuffle'], thumbnail: Boolean(artwork), physicalAndroid: false };
|
||||||
|
}
|
||||||
const barBounds = await bar.boundingBox();
|
const barBounds = await bar.boundingBox();
|
||||||
for (const name of ['Previous track', 'Next track', 'Open playing app']) {
|
for (const name of ['Previous track', 'Next track', 'Open playing app']) {
|
||||||
const control = bar.getByRole('button', { name, exact: true });
|
const control = bar.getByRole('button', { name, exact: true });
|
||||||
|
|||||||
Reference in New Issue
Block a user