From 76a5183dfb1563a2cec47dfe4b7f28e861c5a9d5 Mon Sep 17 00:00:00 2001
From: Boof2015 <75185879+Boof2015@users.noreply.github.com>
Date: Wed, 1 Jul 2026 00:43:51 -0400
Subject: [PATCH] preliminary support for astra desktop
---
app.json | 6 +
.../android/build.gradle | 18 +
.../android/src/main/AndroidManifest.xml | 5 +
.../AstraDesktopDiscoveryModule.kt | 120 +++
.../expo-module.config.json | 6 +
modules/astra-desktop-discovery/index.ts | 33 +
.../android/build.gradle | 22 +
.../android/src/main/AndroidManifest.xml | 9 +
.../AstraDesktopRemoteSessionModule.kt | 334 ++++++
.../expo-module.config.json | 6 +
modules/astra-desktop-remote-session/index.ts | 43 +
package-lock.json | 76 ++
package.json | 2 +
src/app/(tabs)/settings.tsx | 31 +
src/app/_layout.tsx | 47 +
src/app/desktop-remote.tsx | 983 ++++++++++++++++++
src/app/desktop-remote/scan.tsx | 146 +++
src/services/desktopRemoteClient.ts | 325 ++++++
src/services/desktopRemoteCredentials.ts | 56 +
src/services/desktopRemoteDiscovery.ts | 26 +
src/services/desktopRemoteMediaSession.ts | 42 +
src/services/desktopRemotePairing.test.mts | 46 +
src/services/desktopRemotePairing.ts | 55 +
src/stores/desktopRemoteStore.ts | 622 +++++++++++
src/types/desktopRemote.ts | 86 ++
25 files changed, 3145 insertions(+)
create mode 100644 modules/astra-desktop-discovery/android/build.gradle
create mode 100644 modules/astra-desktop-discovery/android/src/main/AndroidManifest.xml
create mode 100644 modules/astra-desktop-discovery/android/src/main/java/expo/modules/astradesktopdiscovery/AstraDesktopDiscoveryModule.kt
create mode 100644 modules/astra-desktop-discovery/expo-module.config.json
create mode 100644 modules/astra-desktop-discovery/index.ts
create mode 100644 modules/astra-desktop-remote-session/android/build.gradle
create mode 100644 modules/astra-desktop-remote-session/android/src/main/AndroidManifest.xml
create mode 100644 modules/astra-desktop-remote-session/android/src/main/java/expo/modules/astradesktopremotesession/AstraDesktopRemoteSessionModule.kt
create mode 100644 modules/astra-desktop-remote-session/expo-module.config.json
create mode 100644 modules/astra-desktop-remote-session/index.ts
create mode 100644 src/app/desktop-remote.tsx
create mode 100644 src/app/desktop-remote/scan.tsx
create mode 100644 src/services/desktopRemoteClient.ts
create mode 100644 src/services/desktopRemoteCredentials.ts
create mode 100644 src/services/desktopRemoteDiscovery.ts
create mode 100644 src/services/desktopRemoteMediaSession.ts
create mode 100644 src/services/desktopRemotePairing.test.mts
create mode 100644 src/services/desktopRemotePairing.ts
create mode 100644 src/stores/desktopRemoteStore.ts
create mode 100644 src/types/desktopRemote.ts
diff --git a/app.json b/app.json
index 2f19f65..a0a45f2 100644
--- a/app.json
+++ b/app.json
@@ -29,6 +29,12 @@
"plugins": [
"expo-router",
"expo-asset",
+ [
+ "expo-camera",
+ {
+ "cameraPermission": "Allow Astra to scan desktop pairing QR codes."
+ }
+ ],
[
"expo-splash-screen",
{
diff --git a/modules/astra-desktop-discovery/android/build.gradle b/modules/astra-desktop-discovery/android/build.gradle
new file mode 100644
index 0000000..f1bbec5
--- /dev/null
+++ b/modules/astra-desktop-discovery/android/build.gradle
@@ -0,0 +1,18 @@
+plugins {
+ id 'com.android.library'
+ id 'expo-module-gradle-plugin'
+}
+
+group = 'expo.modules.astradesktopdiscovery'
+version = '0.1.0'
+
+android {
+ namespace "expo.modules.astradesktopdiscovery"
+ defaultConfig {
+ versionCode 1
+ versionName "0.1.0"
+ }
+ lintOptions {
+ abortOnError false
+ }
+}
diff --git a/modules/astra-desktop-discovery/android/src/main/AndroidManifest.xml b/modules/astra-desktop-discovery/android/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..8da0371
--- /dev/null
+++ b/modules/astra-desktop-discovery/android/src/main/AndroidManifest.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/modules/astra-desktop-discovery/android/src/main/java/expo/modules/astradesktopdiscovery/AstraDesktopDiscoveryModule.kt b/modules/astra-desktop-discovery/android/src/main/java/expo/modules/astradesktopdiscovery/AstraDesktopDiscoveryModule.kt
new file mode 100644
index 0000000..9f9a145
--- /dev/null
+++ b/modules/astra-desktop-discovery/android/src/main/java/expo/modules/astradesktopdiscovery/AstraDesktopDiscoveryModule.kt
@@ -0,0 +1,120 @@
+package expo.modules.astradesktopdiscovery
+
+import android.content.Context
+import android.net.nsd.NsdManager
+import android.net.nsd.NsdServiceInfo
+import android.os.Build
+import expo.modules.kotlin.exception.Exceptions
+import expo.modules.kotlin.functions.Coroutine
+import expo.modules.kotlin.modules.Module
+import expo.modules.kotlin.modules.ModuleDefinition
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import java.net.Inet4Address
+import java.util.concurrent.ConcurrentHashMap
+
+class AstraDesktopDiscoveryModule : Module() {
+ private val serviceType = "_astra-remote._tcp."
+ private var discoveryListener: NsdManager.DiscoveryListener? = null
+ private val cached = ConcurrentHashMap>()
+
+ override fun definition() = ModuleDefinition {
+ Name("AstraDesktopDiscovery")
+
+ Events("onDesktopRemoteFound", "onDesktopRemoteLost")
+
+ AsyncFunction("start").Coroutine {
+ withContext(Dispatchers.Main) { startDiscovery() }
+ }
+
+ AsyncFunction("stop").Coroutine {
+ withContext(Dispatchers.Main) { stopDiscovery() }
+ }
+
+ Function("getCached") {
+ cached.values.toList()
+ }
+ }
+
+ private fun requireContext(): Context =
+ appContext.reactContext ?: throw Exceptions.ReactContextLost()
+
+ private fun nsdManager(): NsdManager =
+ requireContext().getSystemService(Context.NSD_SERVICE) as NsdManager
+
+ private fun startDiscovery() {
+ if (discoveryListener != null) return
+
+ val listener = object : NsdManager.DiscoveryListener {
+ override fun onDiscoveryStarted(regType: String) = Unit
+ override fun onDiscoveryStopped(serviceType: String) = Unit
+ override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) {
+ stopDiscovery()
+ }
+ override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) {
+ stopDiscovery()
+ }
+ override fun onServiceFound(serviceInfo: NsdServiceInfo) {
+ if (serviceInfo.serviceType != serviceType) return
+ resolve(serviceInfo)
+ }
+ override fun onServiceLost(serviceInfo: NsdServiceInfo) {
+ cached.remove(serviceInfo.serviceName)
+ sendEvent("onDesktopRemoteLost", mapOf("name" to serviceInfo.serviceName))
+ }
+ }
+
+ discoveryListener = listener
+ nsdManager().discoverServices(serviceType, NsdManager.PROTOCOL_DNS_SD, listener)
+ }
+
+ private fun stopDiscovery() {
+ val listener = discoveryListener ?: return
+ discoveryListener = null
+ try {
+ nsdManager().stopServiceDiscovery(listener)
+ } catch (_: Throwable) {
+ // Android can throw if discovery already stopped. Treat stop as idempotent.
+ }
+ }
+
+ private fun resolve(serviceInfo: NsdServiceInfo) {
+ try {
+ nsdManager().resolveService(serviceInfo, object : NsdManager.ResolveListener {
+ override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) = Unit
+
+ override fun onServiceResolved(resolved: NsdServiceInfo) {
+ val host = resolved.host
+ val address = host?.hostAddress ?: return
+ if (host !is Inet4Address) return
+ val port = resolved.port
+ if (port <= 0) return
+
+ val endpointUuid = txt(resolved, "endpoint_uuid")
+ val desktopName = txt(resolved, "name")
+ val protocolVersion = txt(resolved, "protocol_version")?.toIntOrNull() ?: 1
+ val payload = mapOf(
+ "endpointUuid" to endpointUuid,
+ "desktopName" to desktopName,
+ "protocolVersion" to protocolVersion,
+ "name" to (desktopName ?: resolved.serviceName),
+ "baseUrl" to "http://$address:$port",
+ "address" to address,
+ "port" to port,
+ "lastSeenAt" to System.currentTimeMillis(),
+ )
+ cached[resolved.serviceName] = payload
+ sendEvent("onDesktopRemoteFound", payload)
+ }
+ })
+ } catch (_: Throwable) {
+ // Resolve races are normal while browsing; ignore and wait for the next mDNS packet.
+ }
+ }
+
+ private fun txt(serviceInfo: NsdServiceInfo, key: String): String? {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) return null
+ val value = serviceInfo.attributes[key] ?: return null
+ return value.toString(Charsets.UTF_8).trim().ifEmpty { null }
+ }
+}
diff --git a/modules/astra-desktop-discovery/expo-module.config.json b/modules/astra-desktop-discovery/expo-module.config.json
new file mode 100644
index 0000000..dbf7436
--- /dev/null
+++ b/modules/astra-desktop-discovery/expo-module.config.json
@@ -0,0 +1,6 @@
+{
+ "platforms": ["android"],
+ "android": {
+ "modules": ["expo.modules.astradesktopdiscovery.AstraDesktopDiscoveryModule"]
+ }
+}
diff --git a/modules/astra-desktop-discovery/index.ts b/modules/astra-desktop-discovery/index.ts
new file mode 100644
index 0000000..d360739
--- /dev/null
+++ b/modules/astra-desktop-discovery/index.ts
@@ -0,0 +1,33 @@
+import { requireOptionalNativeModule, type NativeModule } from 'expo-modules-core';
+
+export interface AstraDesktopDiscoveryItem {
+ endpointUuid: string | null;
+ desktopName: string | null;
+ protocolVersion: number;
+ name: string;
+ baseUrl: string;
+ address: string;
+ port: number;
+ lastSeenAt: number;
+}
+
+type AstraDesktopDiscoveryEvents = {
+ onDesktopRemoteFound: (desktop: AstraDesktopDiscoveryItem) => void;
+ onDesktopRemoteLost: (event: { name: string }) => void;
+};
+
+declare class AstraDesktopDiscoveryModuleType extends NativeModule {
+ start(): Promise;
+ stop(): Promise;
+ getCached(): AstraDesktopDiscoveryItem[];
+}
+
+const native = requireOptionalNativeModule('AstraDesktopDiscovery');
+
+export const AstraDesktopDiscovery = native ?? {
+ addListener: () => ({ remove: () => {} }),
+ removeAllListeners: () => {},
+ start: async () => {},
+ stop: async () => {},
+ getCached: () => [],
+};
diff --git a/modules/astra-desktop-remote-session/android/build.gradle b/modules/astra-desktop-remote-session/android/build.gradle
new file mode 100644
index 0000000..a923999
--- /dev/null
+++ b/modules/astra-desktop-remote-session/android/build.gradle
@@ -0,0 +1,22 @@
+plugins {
+ id 'com.android.library'
+ id 'expo-module-gradle-plugin'
+}
+
+group = 'expo.modules.astradesktopremotesession'
+version = '0.1.0'
+
+android {
+ namespace "expo.modules.astradesktopremotesession"
+ defaultConfig {
+ versionCode 1
+ versionName "0.1.0"
+ }
+ lintOptions {
+ abortOnError false
+ }
+}
+
+dependencies {
+ implementation "androidx.media:media:1.6.0"
+}
diff --git a/modules/astra-desktop-remote-session/android/src/main/AndroidManifest.xml b/modules/astra-desktop-remote-session/android/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..f3bbc38
--- /dev/null
+++ b/modules/astra-desktop-remote-session/android/src/main/AndroidManifest.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
diff --git a/modules/astra-desktop-remote-session/android/src/main/java/expo/modules/astradesktopremotesession/AstraDesktopRemoteSessionModule.kt b/modules/astra-desktop-remote-session/android/src/main/java/expo/modules/astradesktopremotesession/AstraDesktopRemoteSessionModule.kt
new file mode 100644
index 0000000..049f8c5
--- /dev/null
+++ b/modules/astra-desktop-remote-session/android/src/main/java/expo/modules/astradesktopremotesession/AstraDesktopRemoteSessionModule.kt
@@ -0,0 +1,334 @@
+package expo.modules.astradesktopremotesession
+
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import android.os.Build
+import android.os.SystemClock
+import android.support.v4.media.MediaMetadataCompat
+import android.support.v4.media.session.MediaSessionCompat
+import android.support.v4.media.session.PlaybackStateCompat
+import android.util.Base64
+import androidx.core.app.NotificationCompat
+import androidx.media.app.NotificationCompat.MediaStyle
+import expo.modules.kotlin.exception.Exceptions
+import expo.modules.kotlin.modules.Module
+import expo.modules.kotlin.modules.ModuleDefinition
+import expo.modules.kotlin.records.Field
+import expo.modules.kotlin.records.Record
+import kotlin.math.max
+import kotlin.math.roundToLong
+
+private const val CHANNEL_ID = "astra_desktop_remote"
+private const val NOTIFICATION_ID = 384021
+private const val ACTION_PREFIX = "expo.modules.astradesktopremotesession.action."
+private const val ACTION_PLAY = ACTION_PREFIX + "PLAY"
+private const val ACTION_PAUSE = ACTION_PREFIX + "PAUSE"
+private const val ACTION_TOGGLE_PLAY = ACTION_PREFIX + "TOGGLE_PLAY"
+private const val ACTION_PREVIOUS = ACTION_PREFIX + "PREVIOUS"
+private const val ACTION_NEXT = ACTION_PREFIX + "NEXT"
+private const val ACTION_TOGGLE_FAVORITE = ACTION_PREFIX + "TOGGLE_FAVORITE"
+private const val ACTION_STOP = ACTION_PREFIX + "STOP"
+private const val MAX_ART_EDGE = 512
+
+class AstraDesktopRemoteSessionState : Record {
+ @Field
+ val title: String? = null
+
+ @Field
+ val artist: String? = null
+
+ @Field
+ val album: String? = null
+
+ @Field
+ val desktopName: String? = null
+
+ @Field
+ val artworkDataUrl: String? = null
+
+ @Field
+ val playbackState: String = "stopped"
+
+ @Field
+ val hasTrack: Boolean = false
+
+ @Field
+ val duration: Double? = null
+
+ @Field
+ val position: Double? = null
+
+ @Field
+ val updatedAt: Double? = null
+
+ @Field
+ val isFavorite: Boolean = false
+}
+
+class AstraDesktopRemoteSessionModule : Module() {
+ override fun definition() = ModuleDefinition {
+ Name("AstraDesktopRemoteSession")
+
+ Events("onDesktopRemoteCommand")
+
+ OnCreate {
+ AstraDesktopRemoteSessionController.bind(this@AstraDesktopRemoteSessionModule)
+ }
+
+ OnDestroy {
+ AstraDesktopRemoteSessionController.unbind(this@AstraDesktopRemoteSessionModule)
+ }
+
+ Function("setNowPlaying") { state: AstraDesktopRemoteSessionState ->
+ AstraDesktopRemoteSessionController.setNowPlaying(requireContext(), state)
+ }
+
+ Function("clear") {
+ AstraDesktopRemoteSessionController.clear(requireContext())
+ }
+ }
+
+ fun emitCommand(payload: Map) {
+ sendEvent("onDesktopRemoteCommand", payload)
+ }
+
+ private fun requireContext(): Context =
+ appContext.reactContext ?: throw Exceptions.ReactContextLost()
+}
+
+class AstraDesktopRemoteSessionReceiver : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ when (intent.action) {
+ ACTION_PLAY -> AstraDesktopRemoteSessionController.emitCommand("play")
+ ACTION_PAUSE -> AstraDesktopRemoteSessionController.emitCommand("pause")
+ ACTION_TOGGLE_PLAY -> AstraDesktopRemoteSessionController.emitCommand("toggle-play")
+ ACTION_PREVIOUS -> AstraDesktopRemoteSessionController.emitCommand("previous")
+ ACTION_NEXT -> AstraDesktopRemoteSessionController.emitCommand("next")
+ ACTION_TOGGLE_FAVORITE -> AstraDesktopRemoteSessionController.emitCommand("toggle-favorite")
+ ACTION_STOP -> {
+ AstraDesktopRemoteSessionController.emitCommand("pause")
+ AstraDesktopRemoteSessionController.clear(context)
+ }
+ }
+ }
+}
+
+object AstraDesktopRemoteSessionController {
+ private var module: AstraDesktopRemoteSessionModule? = null
+ private var mediaSession: MediaSessionCompat? = null
+ private var lastState: AstraDesktopRemoteSessionState? = null
+
+ fun bind(module: AstraDesktopRemoteSessionModule) {
+ this.module = module
+ }
+
+ fun unbind(module: AstraDesktopRemoteSessionModule) {
+ if (this.module === module) this.module = null
+ }
+
+ fun setNowPlaying(context: Context, state: AstraDesktopRemoteSessionState) {
+ if (!state.hasTrack) {
+ clear(context)
+ return
+ }
+ lastState = state
+ val session = ensureSession(context)
+ session.setMetadata(buildMetadata(state))
+ session.setPlaybackState(buildPlaybackState(state))
+ session.isActive = true
+ showNotification(context, session, state)
+ }
+
+ fun clear(context: Context) {
+ lastState = null
+ mediaSession?.apply {
+ isActive = false
+ setPlaybackState(
+ PlaybackStateCompat.Builder()
+ .setState(PlaybackStateCompat.STATE_STOPPED, 0L, 0f)
+ .build()
+ )
+ }
+ notificationManager(context).cancel(NOTIFICATION_ID)
+ }
+
+ fun emitCommand(command: String, position: Double? = null) {
+ val payload = mutableMapOf("command" to command)
+ if (position != null) payload["position"] = position
+ module?.emitCommand(payload)
+ }
+
+ private fun ensureSession(context: Context): MediaSessionCompat {
+ mediaSession?.let { return it }
+ val session = MediaSessionCompat(context.applicationContext, "AstraDesktopRemote").apply {
+ setCallback(object : MediaSessionCompat.Callback() {
+ override fun onPlay() = emitCommand("play")
+ override fun onPause() = emitCommand("pause")
+ override fun onSkipToNext() = emitCommand("next")
+ override fun onSkipToPrevious() = emitCommand("previous")
+ override fun onStop() {
+ emitCommand("pause")
+ clear(context)
+ }
+ override fun onSeekTo(pos: Long) = emitCommand("seek", pos / 1000.0)
+ })
+ }
+ mediaSession = session
+ return session
+ }
+
+ private fun buildMetadata(state: AstraDesktopRemoteSessionState): MediaMetadataCompat {
+ val builder = MediaMetadataCompat.Builder()
+ .putString(MediaMetadataCompat.METADATA_KEY_TITLE, state.title?.ifBlank { "Unknown track" } ?: "Unknown track")
+ .putString(MediaMetadataCompat.METADATA_KEY_ARTIST, state.artist?.ifBlank { "" } ?: "")
+ .putString(MediaMetadataCompat.METADATA_KEY_ALBUM, state.album?.ifBlank { state.desktopName ?: "Astra Desktop" } ?: state.desktopName ?: "Astra Desktop")
+ .putLong(MediaMetadataCompat.METADATA_KEY_DURATION, secondsToMs(state.duration))
+ decodeDataUrlBitmap(state.artworkDataUrl)?.let { bitmap ->
+ builder.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, bitmap)
+ builder.putBitmap(MediaMetadataCompat.METADATA_KEY_ART, bitmap)
+ }
+ return builder.build()
+ }
+
+ private fun buildPlaybackState(state: AstraDesktopRemoteSessionState): PlaybackStateCompat {
+ val playbackState = when (state.playbackState) {
+ "playing" -> PlaybackStateCompat.STATE_PLAYING
+ "paused" -> PlaybackStateCompat.STATE_PAUSED
+ "loading" -> PlaybackStateCompat.STATE_BUFFERING
+ else -> PlaybackStateCompat.STATE_STOPPED
+ }
+ val speed = if (state.playbackState == "playing") 1f else 0f
+ return PlaybackStateCompat.Builder()
+ .setActions(
+ PlaybackStateCompat.ACTION_PLAY or
+ PlaybackStateCompat.ACTION_PAUSE or
+ PlaybackStateCompat.ACTION_PLAY_PAUSE or
+ PlaybackStateCompat.ACTION_SKIP_TO_NEXT or
+ PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or
+ PlaybackStateCompat.ACTION_SEEK_TO or
+ PlaybackStateCompat.ACTION_STOP
+ )
+ .setState(playbackState, currentPositionMs(state), speed, SystemClock.elapsedRealtime())
+ .build()
+ }
+
+ private fun showNotification(
+ context: Context,
+ session: MediaSessionCompat,
+ state: AstraDesktopRemoteSessionState
+ ) {
+ createChannel(context)
+ val isPlaying = state.playbackState == "playing"
+ val playPauseAction = if (isPlaying) {
+ NotificationCompat.Action(
+ android.R.drawable.ic_media_pause,
+ "Pause",
+ actionIntent(context, ACTION_PAUSE, 2)
+ )
+ } else {
+ NotificationCompat.Action(
+ android.R.drawable.ic_media_play,
+ "Play",
+ actionIntent(context, ACTION_PLAY, 2)
+ )
+ }
+ val largeIcon = decodeDataUrlBitmap(state.artworkDataUrl)
+ val notification = NotificationCompat.Builder(context, CHANNEL_ID)
+ .setSmallIcon(android.R.drawable.ic_media_play)
+ .setContentTitle(state.title?.ifBlank { "Unknown track" } ?: "Unknown track")
+ .setContentText(state.artist?.ifBlank { state.desktopName } ?: state.desktopName ?: "Astra Desktop")
+ .setSubText(state.desktopName ?: "Desktop Remote")
+ .setLargeIcon(largeIcon)
+ .setContentIntent(launchIntent(context))
+ .setDeleteIntent(actionIntent(context, ACTION_STOP, 6))
+ .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
+ .setOngoing(isPlaying)
+ .setOnlyAlertOnce(true)
+ .addAction(android.R.drawable.ic_media_previous, "Previous", actionIntent(context, ACTION_PREVIOUS, 1))
+ .addAction(playPauseAction)
+ .addAction(android.R.drawable.ic_media_next, "Next", actionIntent(context, ACTION_NEXT, 3))
+ .addAction(
+ if (state.isFavorite) android.R.drawable.btn_star_big_on else android.R.drawable.btn_star_big_off,
+ "Favorite",
+ actionIntent(context, ACTION_TOGGLE_FAVORITE, 4)
+ )
+ .setStyle(
+ MediaStyle()
+ .setMediaSession(session.sessionToken)
+ .setShowActionsInCompactView(0, 1, 2)
+ )
+ .build()
+ notificationManager(context).notify(NOTIFICATION_ID, notification)
+ }
+
+ private fun createChannel(context: Context) {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
+ val manager = notificationManager(context)
+ val existing = manager.getNotificationChannel(CHANNEL_ID)
+ if (existing != null) return
+ manager.createNotificationChannel(
+ NotificationChannel(
+ CHANNEL_ID,
+ "Desktop Remote",
+ NotificationManager.IMPORTANCE_LOW
+ ).apply {
+ description = "Playback controls for a paired Astra Desktop"
+ }
+ )
+ }
+
+ private fun actionIntent(context: Context, action: String, requestCode: Int): PendingIntent {
+ val flags = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ val intent = Intent(context, AstraDesktopRemoteSessionReceiver::class.java).setAction(action)
+ return PendingIntent.getBroadcast(context, requestCode, intent, flags)
+ }
+
+ private fun launchIntent(context: Context): PendingIntent? {
+ val flags = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ val intent = context.packageManager.getLaunchIntentForPackage(context.packageName) ?: return null
+ return PendingIntent.getActivity(context, 0, intent, flags)
+ }
+
+ private fun notificationManager(context: Context): NotificationManager =
+ context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+
+ private fun secondsToMs(seconds: Double?): Long =
+ max(0.0, seconds ?: 0.0).times(1000.0).roundToLong()
+
+ private fun currentPositionMs(state: AstraDesktopRemoteSessionState): Long {
+ val baseMs = secondsToMs(state.position)
+ if (state.playbackState != "playing") return baseMs
+ val updatedAt = state.updatedAt ?: return baseMs
+ val elapsedMs = max(0.0, System.currentTimeMillis().toDouble() - updatedAt).roundToLong()
+ return baseMs + elapsedMs
+ }
+
+ private fun decodeDataUrlBitmap(value: String?): Bitmap? {
+ val raw = value?.trim().orEmpty()
+ if (!raw.startsWith("data:image/")) return null
+ val comma = raw.indexOf(',')
+ if (comma < 0 || comma >= raw.lastIndex) return null
+ return try {
+ val bytes = Base64.decode(raw.substring(comma + 1), Base64.DEFAULT)
+ val decoded = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return null
+ resizeBitmap(decoded)
+ } catch (_: Throwable) {
+ null
+ }
+ }
+
+ private fun resizeBitmap(bitmap: Bitmap): Bitmap {
+ val edge = max(bitmap.width, bitmap.height)
+ if (edge <= MAX_ART_EDGE) return bitmap
+ val scale = MAX_ART_EDGE.toFloat() / edge.toFloat()
+ val width = max(1, (bitmap.width * scale).roundToLong().toInt())
+ val height = max(1, (bitmap.height * scale).roundToLong().toInt())
+ return Bitmap.createScaledBitmap(bitmap, width, height, true)
+ }
+}
diff --git a/modules/astra-desktop-remote-session/expo-module.config.json b/modules/astra-desktop-remote-session/expo-module.config.json
new file mode 100644
index 0000000..10bc0e3
--- /dev/null
+++ b/modules/astra-desktop-remote-session/expo-module.config.json
@@ -0,0 +1,6 @@
+{
+ "platforms": ["android"],
+ "android": {
+ "modules": ["expo.modules.astradesktopremotesession.AstraDesktopRemoteSessionModule"]
+ }
+}
diff --git a/modules/astra-desktop-remote-session/index.ts b/modules/astra-desktop-remote-session/index.ts
new file mode 100644
index 0000000..f7b3dce
--- /dev/null
+++ b/modules/astra-desktop-remote-session/index.ts
@@ -0,0 +1,43 @@
+import { requireOptionalNativeModule, type NativeModule } from 'expo-modules-core';
+
+export type AstraDesktopRemoteSessionCommand =
+ | { command: 'play' }
+ | { command: 'pause' }
+ | { command: 'toggle-play' }
+ | { command: 'previous' }
+ | { command: 'next' }
+ | { command: 'toggle-favorite' }
+ | { command: 'seek'; position: number }
+ | { command: 'stop' };
+
+export interface AstraDesktopRemoteSessionState {
+ title?: string | null;
+ artist?: string | null;
+ album?: string | null;
+ desktopName?: string | null;
+ artworkDataUrl?: string | null;
+ playbackState: 'stopped' | 'playing' | 'paused' | 'loading';
+ hasTrack: boolean;
+ duration?: number | null;
+ position?: number | null;
+ updatedAt?: number | null;
+ isFavorite?: boolean;
+}
+
+type AstraDesktopRemoteSessionEvents = {
+ onDesktopRemoteCommand: (event: AstraDesktopRemoteSessionCommand) => void;
+};
+
+declare class AstraDesktopRemoteSessionModuleType extends NativeModule {
+ setNowPlaying(state: AstraDesktopRemoteSessionState): void;
+ clear(): void;
+}
+
+const native = requireOptionalNativeModule('AstraDesktopRemoteSession');
+
+export const AstraDesktopRemoteSession = native ?? {
+ addListener: () => ({ remove: () => {} }),
+ removeAllListeners: () => {},
+ setNowPlaying: () => {},
+ clear: () => {},
+};
diff --git a/package-lock.json b/package-lock.json
index 916608e..3f15d1d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -21,6 +21,7 @@
"expo": "~56.0.4",
"expo-asset": "~56.0.14",
"expo-build-properties": "^56.0.19",
+ "expo-camera": "~56.0.8",
"expo-constants": "~56.0.15",
"expo-device": "~56.0.4",
"expo-document-picker": "~56.0.4",
@@ -3220,6 +3221,12 @@
"license": "MIT",
"peer": true
},
+ "node_modules/@types/emscripten": {
+ "version": "1.41.5",
+ "resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.41.5.tgz",
+ "integrity": "sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q==",
+ "license": "MIT"
+ },
"node_modules/@types/encoding-japanese": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/@types/encoding-japanese/-/encoding-japanese-2.2.1.tgz",
@@ -4420,6 +4427,15 @@
"node": "18 || 20 || >=22"
}
},
+ "node_modules/barcode-detector": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/barcode-detector/-/barcode-detector-3.2.0.tgz",
+ "integrity": "sha512-MrT5TT058ptG5YB157pHLfXKVpp0BKEfQBOb8QvzTbatzmLDu85JJ0Gd/sCYwbwdwStJvxsYflrSN6D6E4Ndyw==",
+ "license": "MIT",
+ "dependencies": {
+ "zxing-wasm": "3.1.0"
+ }
+ },
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@@ -6239,6 +6255,26 @@
"node": ">=10"
}
},
+ "node_modules/expo-camera": {
+ "version": "56.0.8",
+ "resolved": "https://registry.npmjs.org/expo-camera/-/expo-camera-56.0.8.tgz",
+ "integrity": "sha512-UDOpUUMisFRmCv1XQV1MJCKGAH2CsIC1Rs6P9Bbc6JLVmbxEKAd5dK68y6cScOdWURxVfJ0PRcjYnSuc8ayyIQ==",
+ "license": "MIT",
+ "dependencies": {
+ "barcode-detector": "^3.0.0"
+ },
+ "peerDependencies": {
+ "expo": "*",
+ "react": "*",
+ "react-native": "*",
+ "react-native-web": "*"
+ },
+ "peerDependenciesMeta": {
+ "react-native-web": {
+ "optional": true
+ }
+ }
+ },
"node_modules/expo-constants": {
"version": "56.0.15",
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-56.0.15.tgz",
@@ -11673,6 +11709,18 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/tagged-tag": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz",
+ "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/terminal-link": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-2.1.1.tgz",
@@ -12607,6 +12655,34 @@
"optional": true
}
}
+ },
+ "node_modules/zxing-wasm": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/zxing-wasm/-/zxing-wasm-3.1.0.tgz",
+ "integrity": "sha512-5+3V1wPRx4gvbeLH2jB7n2cKrYJ1q4i3QgjnBUtrDPeqxJSi6BdzKJg4y6aF6bgW8zfntnYJyrkqFMevDhL2NA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/emscripten": "^1.41.5",
+ "type-fest": "^5.7.0"
+ },
+ "peerDependencies": {
+ "@types/emscripten": ">=1.39.6"
+ }
+ },
+ "node_modules/zxing-wasm/node_modules/type-fest": {
+ "version": "5.7.0",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.7.0.tgz",
+ "integrity": "sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg==",
+ "license": "(MIT OR CC0-1.0)",
+ "dependencies": {
+ "tagged-tag": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
}
}
}
diff --git a/package.json b/package.json
index 6d7790d..833b398 100644
--- a/package.json
+++ b/package.json
@@ -15,6 +15,7 @@
"expo": "~56.0.4",
"expo-asset": "~56.0.14",
"expo-build-properties": "^56.0.19",
+ "expo-camera": "~56.0.8",
"expo-constants": "~56.0.15",
"expo-device": "~56.0.4",
"expo-document-picker": "~56.0.4",
@@ -61,6 +62,7 @@
"ios": "expo run:ios",
"web": "expo start --web",
"lint": "expo lint",
+ "test:desktop-remote": "node --experimental-strip-types --test src/services/desktopRemotePairing.test.mts",
"typecheck": "tsc --noEmit",
"postinstall": "patch-package"
},
diff --git a/src/app/(tabs)/settings.tsx b/src/app/(tabs)/settings.tsx
index d261257..d1a34df 100644
--- a/src/app/(tabs)/settings.tsx
+++ b/src/app/(tabs)/settings.tsx
@@ -1,3 +1,4 @@
+import { useEffect } from 'react';
import { View, Pressable, ScrollView, StyleSheet, Switch } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
@@ -9,6 +10,7 @@ import { useSettingsStore } from '@/stores/settingsStore';
import { useAudioSettingsStore } from '@/stores/audioSettingsStore';
import { useRemoteSourcesStore } from '@/stores/remoteSourcesStore';
import { useLastFmSettingsStore } from '@/stores/lastFmSettingsStore';
+import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore';
import type { ReplayGainMode } from '@/audio/normalization';
import type { ArtistGroupingMode } from '@/library/artistGrouping';
import type { LastFmStatus } from '@/types/lastFm';
@@ -72,6 +74,9 @@ export default function SettingsScreen() {
const router = useRouter();
const remoteSources = useRemoteSourcesStore((s) => s.sources);
const lastFmStatus = useLastFmSettingsStore((s) => s.status);
+ const desktopRemoteConnection = useDesktopRemoteStore((s) => s.connection);
+ const desktopRemoteState = useDesktopRemoteStore((s) => s.connectionState);
+ const initDesktopRemote = useDesktopRemoteStore((s) => s.init);
const groupingMode = useSettingsStore((s) => s.artistGroupingMode);
const setArtistGroupingMode = useSettingsStore((s) => s.setArtistGroupingMode);
@@ -85,6 +90,14 @@ export default function SettingsScreen() {
const setReplayGainEnabled = useAudioSettingsStore((s) => s.setReplayGainEnabled);
const setReplayGainMode = useAudioSettingsStore((s) => s.setReplayGainMode);
+ useEffect(() => {
+ void initDesktopRemote();
+ }, [initDesktopRemote]);
+
+ const desktopRemoteSubtitle = desktopRemoteConnection
+ ? `${desktopRemoteConnection.desktopName ?? 'Astra Desktop'} ยท ${desktopRemoteState === 'connected' ? 'connected' : desktopRemoteState}`
+ : 'Pair with Astra Desktop to control playback from this phone.';
+
return (
@@ -202,6 +215,24 @@ export default function SettingsScreen() {
+
+ EXPERIMENTAL
+
+ router.push('/desktop-remote' as never)}
+ accessibilityRole="button"
+ >
+
+
+ Desktop Remote
+
+ {desktopRemoteSubtitle}
+
+
+
+
+
s.connection);
+ const connectionState = useDesktopRemoteStore((s) => s.connectionState);
+ const snapshot = useDesktopRemoteStore((s) => s.snapshot);
+ const sendControl = useDesktopRemoteStore((s) => s.sendControl);
+
+ useEffect(() => {
+ const subscription = subscribeDesktopRemoteMediaSessionCommands((event) => {
+ if (event.command === 'toggle-play') {
+ const playing = useDesktopRemoteStore.getState().snapshot?.playbackState === 'playing';
+ void useDesktopRemoteStore.getState().sendControl(playing ? 'pause' : 'play');
+ return;
+ }
+ if (event.command === 'stop') {
+ void useDesktopRemoteStore.getState().sendControl('pause');
+ return;
+ }
+ if (event.command === 'seek') {
+ void useDesktopRemoteStore.getState().sendControl('seek', event.position);
+ return;
+ }
+ void useDesktopRemoteStore.getState().sendControl(event.command);
+ });
+ return () => subscription.remove();
+ }, [sendControl]);
+
+ useEffect(() => {
+ if (!connection || connectionState === 'unpaired') {
+ clearDesktopRemoteMediaSession();
+ return;
+ }
+ setDesktopRemoteMediaSession(snapshot, connection);
+ }, [connection, connectionState, snapshot]);
+
+ useEffect(() => clearDesktopRemoteMediaSession, []);
+
+ return null;
+}
+
export default function RootLayout() {
const [fontsLoaded] = useFonts({
Inter_400Regular,
@@ -129,6 +175,7 @@ export default function RootLayout() {
+
['connectionState']): string {
+ switch (state) {
+ case 'connected':
+ return 'Live';
+ case 'connecting':
+ return 'Connecting';
+ case 'reconnecting':
+ return 'Retrying';
+ case 'pinEntry':
+ return 'PIN';
+ case 'pendingApproval':
+ return 'Approval';
+ case 'pairing':
+ return 'Pairing';
+ case 'error':
+ return 'Offline';
+ default:
+ return 'Not paired';
+ }
+}
+
+function DiscoveredDesktopRow({ desktop, onPair, disabled }: {
+ desktop: DesktopRemoteDiscoveredDesktop;
+ onPair: (desktop: DesktopRemoteDiscoveredDesktop) => void;
+ disabled: boolean;
+}) {
+ return (
+ onPair(desktop)}
+ disabled={disabled}
+ >
+
+
+
+
+
+ {desktop.name}
+
+
+ {hostFromBaseUrl(desktop.baseUrl)}
+
+
+
+
+ Pair
+
+
+
+
+ );
+}
+
+export default function DesktopRemoteScreen() {
+ const router = useRouter();
+ const { pair } = useLocalSearchParams<{ pair?: string }>();
+ const insets = useSafeAreaInsets();
+ const { width: windowWidth, height: windowHeight } = useWindowDimensions();
+ const initialized = useDesktopRemoteStore((s) => s.initialized);
+ const connectionState = useDesktopRemoteStore((s) => s.connectionState);
+ const connection = useDesktopRemoteStore((s) => s.connection);
+ const snapshot = useDesktopRemoteStore((s) => s.snapshot);
+ const discovered = useDesktopRemoteStore((s) => s.discovered);
+ const discoveryAvailable = useDesktopRemoteStore((s) => s.discoveryAvailable);
+ const discoveryRunning = useDesktopRemoteStore((s) => s.discoveryRunning);
+ const pairing = useDesktopRemoteStore((s) => s.pairing);
+ const pinPairing = useDesktopRemoteStore((s) => s.pinPairing);
+ const message = useDesktopRemoteStore((s) => s.message);
+ const errorMessage = useDesktopRemoteStore((s) => s.errorMessage);
+ const init = useDesktopRemoteStore((s) => s.init);
+ const startDiscovery = useDesktopRemoteStore((s) => s.startDiscovery);
+ const stopDiscovery = useDesktopRemoteStore((s) => s.stopDiscovery);
+ const requestPinPairing = useDesktopRemoteStore((s) => s.requestPinPairing);
+ const confirmPinPairing = useDesktopRemoteStore((s) => s.confirmPinPairing);
+ const pairFromInput = useDesktopRemoteStore((s) => s.pairFromInput);
+ const pairManual = useDesktopRemoteStore((s) => s.pairManual);
+ const reconnect = useDesktopRemoteStore((s) => s.reconnect);
+ const forget = useDesktopRemoteStore((s) => s.forget);
+ const sendControl = useDesktopRemoteStore((s) => s.sendControl);
+
+ const [pairingLink, setPairingLink] = useState('');
+ const [pinInput, setPinInput] = useState('');
+ const [pinClock, setPinClock] = useState(() => Date.now());
+ const [manualBaseUrl, setManualBaseUrl] = useState('');
+ const [manualTicket, setManualTicket] = useState('');
+
+ useEffect(() => {
+ void init();
+ }, [init]);
+
+ useEffect(() => {
+ void startDiscovery();
+ return () => {
+ void stopDiscovery();
+ };
+ }, [startDiscovery, stopDiscovery]);
+
+ useEffect(() => {
+ if (typeof pair === 'string' && pair.trim()) {
+ void pairFromInput(pair);
+ router.setParams({ pair: undefined });
+ }
+ }, [pair, pairFromInput, router]);
+
+ useEffect(() => {
+ if (!pinPairing) return undefined;
+ const timer = setInterval(() => setPinClock(Date.now()), 1000);
+ return () => clearInterval(timer);
+ }, [pinPairing]);
+
+ const currentTrack = snapshot?.currentTrack ?? null;
+ const isPlaying = snapshot?.playbackState === 'playing';
+ const isBusy = connectionState === 'pairing' || connectionState === 'pendingApproval' || connectionState === 'connecting';
+ const art = currentTrack?.artworkDataUrl ?? null;
+ const accent = snapshot?.visualizerLineColor || colors.accent;
+ const availableHeight = windowHeight - insets.top - insets.bottom;
+ const remoteLayout = getRemoteLayout(windowWidth, availableHeight);
+ const remoteSource = connection?.desktopName ?? 'Astra Desktop';
+ const remoteDetail = snapshot?.outputDeviceLabel?.trim() || (connection ? hostFromBaseUrl(connection.baseUrl) : '');
+ const countdown = pairing ? formatPairingCountdown(pairing.expiresAt) : '';
+ const pinPairingActive = Boolean(pinPairing && pinPairing.expiresAt > pinClock);
+ const pinCountdown = pinPairing ? formatPairingCountdown(pinPairing.expiresAt, pinClock) : '';
+ const normalizedPinInput = pinInput.replace(/\s+/g, '');
+
+ const statusText = useMemo(() => {
+ if (message) return message;
+ if (connection) return hostFromBaseUrl(connection.baseUrl);
+ if (discoveryRunning) return 'Searching for Astra Desktop on this network.';
+ if (!discoveryAvailable) return 'Discovery unavailable on this device; use QR or manual pairing.';
+ return 'Not paired.';
+ }, [connection, discoveryAvailable, discoveryRunning, message]);
+
+ const confirmForget = () => {
+ Alert.alert('Forget desktop?', 'This removes the saved desktop pairing from this phone.', [
+ { text: 'Cancel', style: 'cancel' },
+ { text: 'Forget', style: 'destructive', onPress: () => void forget() },
+ ]);
+ };
+
+ const pairDiscovered = (desktop: DesktopRemoteDiscoveredDesktop) => {
+ setPinInput('');
+ setPinClock(Date.now());
+ void requestPinPairing(desktop.baseUrl);
+ };
+
+ const submitPin = () => {
+ void confirmPinPairing(pinInput);
+ };
+
+ const updatePinInput = (value: string) => {
+ setPinInput(value.replace(/\D/g, '').slice(0, 6));
+ };
+
+ const renderSetup = () => (
+
+
+
+
+
+ Desktop Remote
+
+
+ Pair this phone with Astra Desktop to control playback over your LAN.
+
+
+
+
+
+
+ Nearby desktops
+ {discoveryRunning || isBusy ? : null}
+
+
+ Tap a discovered desktop, then enter the PIN shown in Astra Desktop.
+
+ {discoveryAvailable ? (
+ discovered.length > 0 ? (
+
+ {discovered.map((desktop) => (
+
+ ))}
+
+ ) : (
+
+ Discovery is running. Use QR or manual pairing if this desktop does not appear.
+
+ )
+ ) : (
+
+ Android LAN discovery is not available in this build. Use QR or manual pairing.
+
+ )}
+ {pinPairing ? (
+
+
+
+ {pinPairing.desktopName || 'Astra Desktop'}
+
+ {hostFromBaseUrl(pinPairing.baseUrl)}
+
+
+
+ {pinCountdown}
+
+
+
+
+
+
+ Confirm PIN
+
+
+
+ ) : null}
+
+
+
+
+ Pair with QR
+ {isBusy ? : null}
+
+
+ Open Astra Desktop settings, enable Phone Remote, then scan or paste the pairing link.
+
+
+ router.push('/desktop-remote/scan' as never)}>
+
+
+ Scan QR
+
+
+
+
+ void pairFromInput(pairingLink)}
+ >
+
+ Pair from link
+
+
+
+
+
+
+ Manual fallback
+
+
+ Enter the desktop URL and pairing code from Astra Desktop.
+
+
+
+ void pairManual(manualBaseUrl, manualTicket)}
+ >
+
+ Pair manually
+
+
+
+
+ {pinPairing ? (
+
+ Enter the PIN shown on desktop
+
+ {pinCountdown}
+
+
+ ) : pairing ? (
+
+ Waiting for desktop approval
+
+ {countdown}
+
+
+ ) : null}
+
+ {errorMessage ? (
+
+ {errorMessage}
+
+ ) : null}
+
+ );
+
+ const renderController = () => (
+
+
+
+ router.back()} hitSlop={12}>
+
+
+
+
+ PLAYING FROM
+
+
+ {remoteSource}
+
+
+ void reconnect()}
+ hitSlop={12}
+ accessibilityLabel="Reconnect to desktop"
+ >
+
+
+
+
+ {currentTrack ? (
+
+
+
+ {art ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+
+
+ {currentTrack.title}
+
+
+ {currentTrack.artist || currentTrack.album || remoteSource}
+
+
+
+
+ void sendControl('seek', seconds)}
+ />
+
+
+ void reconnect()}
+ accessibilityLabel="Reconnect"
+ >
+
+
+ void sendControl('previous')}
+ hitSlop={12}
+ style={styles.transportMainBtn}
+ accessibilityLabel="Previous"
+ >
+
+
+ void sendControl(isPlaying ? 'pause' : 'play')}
+ hitSlop={12}
+ style={[styles.playButton, { backgroundColor: accent }]}
+ accessibilityLabel={isPlaying ? 'Pause desktop' : 'Play desktop'}
+ >
+
+
+ void sendControl('next')}
+ hitSlop={12}
+ style={styles.transportMainBtn}
+ accessibilityLabel="Next"
+ >
+
+
+ void sendControl('toggle-favorite')}
+ accessibilityLabel={currentTrack.isFavorite ? 'Remove from favorites' : 'Add to favorites'}
+ accessibilityState={{ selected: currentTrack.isFavorite }}
+ >
+
+
+
+
+
+
+
+
+ {connectionLabel(connectionState)}
+
+
+
+ {remoteDetail}
+
+
+
+
+
+
+
+ ) : (
+
+
+
+ Nothing playing
+
+
+ {statusText}
+
+
+ )}
+
+
+ {errorMessage ? (
+
+ {errorMessage}
+
+ ) : null}
+
+ );
+
+ const showController = Boolean(connection && connectionState !== 'unpaired');
+
+ return (
+
+
+ {!initialized ? (
+
+
+
+ ) : showController ? (
+ renderController()
+ ) : (
+ <>
+
+ router.back()} hitSlop={8}>
+
+
+ Settings
+
+
+
+ {renderSetup()}
+ >
+ )}
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ flex: {
+ flex: 1,
+ },
+ topBar: {
+ marginTop: spacing.md,
+ marginBottom: spacing.sm,
+ },
+ back: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 2,
+ },
+ content: {
+ paddingBottom: spacing.xxl,
+ gap: spacing.md,
+ },
+ loading: {
+ flex: 1,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ hero: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: spacing.md,
+ marginTop: spacing.lg,
+ marginBottom: spacing.sm,
+ },
+ heroText: {
+ flex: 1,
+ },
+ heading: {
+ marginBottom: spacing.xs,
+ },
+ card: {
+ borderRadius: radius.md,
+ borderWidth: StyleSheet.hairlineWidth,
+ borderColor: colors.glassBorder,
+ backgroundColor: colors.glassBg,
+ padding: spacing.lg,
+ gap: spacing.md,
+ },
+ cardHeader: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ },
+ cardCopy: {
+ lineHeight: 19,
+ },
+ actionRow: {
+ flexDirection: 'row',
+ },
+ primaryButton: {
+ minHeight: 44,
+ borderRadius: radius.sm,
+ backgroundColor: colors.accent,
+ paddingHorizontal: spacing.lg,
+ alignItems: 'center',
+ justifyContent: 'center',
+ flexDirection: 'row',
+ gap: spacing.sm,
+ },
+ secondaryButton: {
+ minHeight: 44,
+ borderRadius: radius.sm,
+ borderWidth: StyleSheet.hairlineWidth,
+ borderColor: colors.glassBorder,
+ backgroundColor: colors.bgTertiary,
+ paddingHorizontal: spacing.md,
+ alignItems: 'center',
+ justifyContent: 'center',
+ flexDirection: 'row',
+ gap: spacing.sm,
+ },
+ buttonDisabled: {
+ opacity: 0.55,
+ },
+ input: {
+ minHeight: 46,
+ borderRadius: radius.sm,
+ borderWidth: StyleSheet.hairlineWidth,
+ borderColor: colors.glassBorder,
+ backgroundColor: colors.bgSecondary,
+ paddingHorizontal: spacing.md,
+ color: colors.textPrimary,
+ fontSize: 15,
+ },
+ discoveredList: {
+ gap: spacing.sm,
+ },
+ discoveredRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: spacing.md,
+ minHeight: 54,
+ },
+ discoveredIcon: {
+ width: 38,
+ height: 38,
+ borderRadius: radius.sm,
+ backgroundColor: colors.bgTertiary,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ discoveredText: {
+ flex: 1,
+ },
+ discoveredAction: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: spacing.xs,
+ },
+ pinPanel: {
+ borderRadius: radius.sm,
+ borderWidth: StyleSheet.hairlineWidth,
+ borderColor: colors.glassBorder,
+ backgroundColor: colors.bgSecondary,
+ padding: spacing.md,
+ gap: spacing.md,
+ },
+ pinPanelHeader: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ gap: spacing.md,
+ },
+ pinInput: {
+ textAlign: 'center',
+ fontSize: 24,
+ fontWeight: '700',
+ },
+ statusBox: {
+ borderRadius: radius.md,
+ backgroundColor: colors.bgTertiary,
+ padding: spacing.lg,
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ },
+ feedback: {
+ lineHeight: 18,
+ },
+ remoteContent: {
+ flex: 1,
+ alignItems: 'center',
+ paddingTop: CONTENT_TOP_PADDING,
+ },
+ remoteShell: {
+ flex: 1,
+ },
+ remoteNowHeader: {
+ height: HEADER_HEIGHT,
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ },
+ headerBtn: {
+ width: 32,
+ height: 32,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ headerMid: {
+ flex: 1,
+ alignItems: 'center',
+ },
+ eyebrow: {
+ color: colors.textTertiary,
+ letterSpacing: 0,
+ fontSize: 10,
+ },
+ source: {
+ color: colors.textSecondary,
+ marginTop: 1,
+ },
+ statusPill: {
+ height: 30,
+ borderRadius: radius.pill,
+ borderWidth: StyleSheet.hairlineWidth,
+ borderColor: colors.glassBorder,
+ paddingHorizontal: spacing.sm,
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: spacing.xs,
+ },
+ statusDot: {
+ width: 7,
+ height: 7,
+ borderRadius: 4,
+ },
+ remotePlayer: {
+ flex: 1,
+ },
+ middleStack: {
+ width: '100%',
+ alignItems: 'center',
+ },
+ artCard: {
+ borderRadius: radius.lg,
+ backgroundColor: colors.bgTertiary,
+ alignItems: 'center',
+ justifyContent: 'center',
+ overflow: 'hidden',
+ },
+ artImage: {
+ width: '100%',
+ height: '100%',
+ },
+ spacer: {
+ flex: 1,
+ minHeight: MIN_FLOATING_SPACE,
+ },
+ playerControls: {
+ width: '100%',
+ },
+ trackInfo: {
+ alignSelf: 'stretch',
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: spacing.md,
+ marginBottom: spacing.md,
+ },
+ trackTextStack: {
+ flex: 1,
+ minWidth: 0,
+ alignItems: 'flex-start',
+ },
+ trackTitle: {
+ alignSelf: 'stretch',
+ },
+ trackTitleText: {
+ textAlign: 'left',
+ },
+ artist: {
+ color: colors.accentText,
+ },
+ transport: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ marginTop: TRANSPORT_TOP_MARGIN,
+ },
+ transportMainBtn: {
+ width: 48,
+ height: 48,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ transportSideBtn: {
+ width: 48,
+ height: 48,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ playButton: {
+ width: PLAY_BUTTON_SIZE,
+ height: PLAY_BUTTON_SIZE,
+ borderRadius: radius.pill,
+ backgroundColor: colors.accent,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ subRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ gap: spacing.md,
+ marginTop: SUB_TOP_MARGIN,
+ paddingHorizontal: spacing.sm,
+ },
+ subBtn: {
+ width: SUB_BUTTON_SIZE,
+ height: SUB_BUTTON_SIZE,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ remoteDetail: {
+ flex: 1,
+ minWidth: 0,
+ },
+ remoteEmpty: {
+ flex: 1,
+ alignItems: 'center',
+ justifyContent: 'center',
+ gap: spacing.md,
+ paddingHorizontal: spacing.xl,
+ },
+ emptyTitle: {
+ textAlign: 'center',
+ },
+ centered: {
+ textAlign: 'center',
+ },
+ remoteFeedback: {
+ alignSelf: 'center',
+ marginTop: spacing.sm,
+ paddingHorizontal: CONTENT_SIDE_PADDING,
+ textAlign: 'center',
+ },
+});
diff --git a/src/app/desktop-remote/scan.tsx b/src/app/desktop-remote/scan.tsx
new file mode 100644
index 0000000..f9a497e
--- /dev/null
+++ b/src/app/desktop-remote/scan.tsx
@@ -0,0 +1,146 @@
+import { useState } from 'react';
+import { ActivityIndicator, Pressable, StyleSheet, View } from 'react-native';
+import { CameraView, useCameraPermissions, type BarcodeScanningResult } from 'expo-camera';
+import { Ionicons } from '@expo/vector-icons';
+import { useRouter } from 'expo-router';
+import { Screen } from '@/components/Screen';
+import { Text } from '@/components/Text';
+import { colors, radius, spacing } from '@/theme';
+import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore';
+
+export default function DesktopRemoteScanScreen() {
+ const router = useRouter();
+ const pairFromInput = useDesktopRemoteStore((s) => s.pairFromInput);
+ const [permission, requestPermission] = useCameraPermissions();
+ const [locked, setLocked] = useState(false);
+
+ const onScanned = (result: BarcodeScanningResult) => {
+ if (locked) return;
+ const data = result.data?.trim();
+ if (!data) return;
+ setLocked(true);
+ void pairFromInput(data).finally(() => {
+ router.replace('/desktop-remote' as never);
+ });
+ };
+
+ return (
+
+
+ router.back()} hitSlop={8}>
+
+
+ Desktop Remote
+
+
+
+
+
+ Scan pairing QR
+
+
+ {!permission ? (
+
+
+
+ ) : !permission.granted ? (
+
+
+ Camera access is needed to scan the desktop pairing QR.
+ void requestPermission()}>
+
+ Allow camera
+
+
+
+ ) : (
+
+
+
+ {locked ? (
+
+
+
+ Pairing...
+
+
+ ) : null}
+
+ )}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ header: {
+ marginTop: spacing.md,
+ marginBottom: spacing.lg,
+ },
+ back: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 2,
+ },
+ heading: {
+ marginBottom: spacing.lg,
+ },
+ center: {
+ flex: 1,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ permissionCard: {
+ borderRadius: radius.md,
+ borderWidth: StyleSheet.hairlineWidth,
+ borderColor: colors.glassBorder,
+ backgroundColor: colors.glassBg,
+ padding: spacing.lg,
+ gap: spacing.md,
+ },
+ primaryButton: {
+ minHeight: 44,
+ borderRadius: radius.sm,
+ backgroundColor: colors.accent,
+ paddingHorizontal: spacing.lg,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ scannerFrame: {
+ flex: 1,
+ borderRadius: radius.md,
+ overflow: 'hidden',
+ backgroundColor: colors.bgSecondary,
+ marginBottom: spacing.xl,
+ },
+ camera: {
+ flex: 1,
+ },
+ scanBox: {
+ position: 'absolute',
+ left: '15%',
+ right: '15%',
+ top: '25%',
+ aspectRatio: 1,
+ borderRadius: radius.md,
+ borderWidth: 2,
+ borderColor: colors.accent,
+ },
+ locked: {
+ position: 'absolute',
+ left: spacing.lg,
+ right: spacing.lg,
+ bottom: spacing.lg,
+ minHeight: 48,
+ borderRadius: radius.sm,
+ backgroundColor: colors.accent,
+ alignItems: 'center',
+ justifyContent: 'center',
+ flexDirection: 'row',
+ gap: spacing.sm,
+ },
+});
diff --git a/src/services/desktopRemoteClient.ts b/src/services/desktopRemoteClient.ts
new file mode 100644
index 0000000..df241c9
--- /dev/null
+++ b/src/services/desktopRemoteClient.ts
@@ -0,0 +1,325 @@
+import * as Device from 'expo-device';
+import type {
+ DesktopRemoteControlCommand,
+ DesktopRemoteIdentity,
+ DesktopRemoteNowPlayingSnapshot,
+ DesktopRemotePairingClaim,
+ DesktopRemotePairingStatus,
+ DesktopRemotePinPairingRequest,
+} from '@/types/desktopRemote';
+export {
+ parseDesktopRemoteManualInput,
+ parseDesktopRemotePairingInput,
+} from './desktopRemotePairing';
+
+const REQUEST_TIMEOUT_MS = 8000;
+
+interface JsonRequestOptions {
+ method?: 'GET' | 'POST';
+ token?: string | null;
+ body?: unknown;
+ timeoutMs?: number;
+ signal?: AbortSignal;
+}
+
+export class DesktopRemoteHttpError extends Error {
+ status: number;
+ payload: unknown;
+
+ constructor(status: number, message: string, payload: unknown) {
+ super(message);
+ this.status = status;
+ this.payload = payload;
+ }
+}
+
+function timeoutSignal(timeoutMs: number, parentSignal?: AbortSignal): AbortSignal {
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
+
+ const abort = () => controller.abort();
+ if (parentSignal) {
+ if (parentSignal.aborted) controller.abort();
+ else parentSignal.addEventListener('abort', abort, { once: true });
+ }
+
+ controller.signal.addEventListener(
+ 'abort',
+ () => {
+ clearTimeout(timer);
+ parentSignal?.removeEventListener('abort', abort);
+ },
+ { once: true }
+ );
+
+ return controller.signal;
+}
+
+async function fetchJson(
+ baseUrl: string,
+ path: string,
+ options: JsonRequestOptions = {}
+): Promise {
+ const headers: Record = {
+ Accept: 'application/json',
+ };
+ let body: string | undefined;
+ if (options.body !== undefined) {
+ headers['Content-Type'] = 'application/json; charset=utf-8';
+ body = JSON.stringify(options.body);
+ }
+ if (options.token) headers.Authorization = `Bearer ${options.token}`;
+
+ const response = await fetch(`${baseUrl}${path}`, {
+ method: options.method ?? (body ? 'POST' : 'GET'),
+ headers,
+ body,
+ cache: 'no-store',
+ signal: timeoutSignal(options.timeoutMs ?? REQUEST_TIMEOUT_MS, options.signal),
+ });
+ const payload = await response.json().catch(() => null);
+ if (!response.ok) {
+ const message =
+ payload && typeof payload === 'object' && 'error' in payload && typeof payload.error === 'string'
+ ? payload.error
+ : `Desktop remote request failed (${response.status}).`;
+ throw new DesktopRemoteHttpError(response.status, message, payload);
+ }
+ return payload as T;
+}
+
+function normalizeIdentity(payload: unknown): DesktopRemoteIdentity | null {
+ if (!payload || typeof payload !== 'object') return null;
+ const candidate = payload as Record;
+ return {
+ endpointUuid: typeof candidate.endpointUuid === 'string' && candidate.endpointUuid.trim()
+ ? candidate.endpointUuid.trim()
+ : null,
+ desktopName: typeof candidate.desktopName === 'string' && candidate.desktopName.trim()
+ ? candidate.desktopName.trim()
+ : null,
+ protocolVersion:
+ typeof candidate.protocolVersion === 'number' && Number.isFinite(candidate.protocolVersion)
+ ? candidate.protocolVersion
+ : 1,
+ };
+}
+
+function clientLabel(): string {
+ if (Device.osName === 'Android') return 'Android Phone';
+ if (Device.osName === 'iOS') return Device.modelName?.includes('iPad') ? 'iPad' : 'iPhone';
+ return 'Astra Mobile';
+}
+
+export function defaultDesktopRemoteDeviceName(): string {
+ const model = Device.modelName?.trim();
+ return model ? `${model} Remote` : 'Astra Mobile Remote';
+}
+
+export async function fetchDesktopRemoteIdentity(baseUrl: string): Promise {
+ try {
+ const payload = await fetchJson(baseUrl, '/v1/identity');
+ return normalizeIdentity(payload);
+ } catch {
+ return null;
+ }
+}
+
+export async function claimDesktopRemotePairingTicket(
+ baseUrl: string,
+ ticket: string,
+ deviceName: string = defaultDesktopRemoteDeviceName()
+): Promise {
+ const payload = await fetchJson>(baseUrl, '/v1/pairing/claim', {
+ method: 'POST',
+ body: {
+ ticket,
+ deviceName,
+ clientLabel: clientLabel(),
+ },
+ });
+ return {
+ requestId: String(payload.requestId ?? ''),
+ pollToken: String(payload.pollToken ?? ''),
+ expiresAt: typeof payload.expiresAt === 'number' ? payload.expiresAt : 0,
+ deviceName: String(payload.deviceName ?? deviceName),
+ clientLabel: String(payload.clientLabel ?? clientLabel()),
+ identity: normalizeIdentity(payload.identity ?? payload),
+ };
+}
+
+export async function requestDesktopRemotePinPairing(
+ baseUrl: string,
+ deviceName: string = defaultDesktopRemoteDeviceName()
+): Promise {
+ const payload = await fetchJson>(baseUrl, '/v1/pairing/pin-request', {
+ method: 'POST',
+ body: {
+ deviceName,
+ clientLabel: clientLabel(),
+ },
+ });
+ return {
+ requestId: String(payload.requestId ?? ''),
+ pollToken: String(payload.pollToken ?? ''),
+ expiresAt: typeof payload.expiresAt === 'number' ? payload.expiresAt : 0,
+ deviceName: String(payload.deviceName ?? deviceName),
+ clientLabel: String(payload.clientLabel ?? clientLabel()),
+ identity: normalizeIdentity(payload.identity ?? payload),
+ };
+}
+
+export async function confirmDesktopRemotePinPairing(
+ baseUrl: string,
+ requestId: string,
+ pin: string
+): Promise {
+ const payload = await fetchJson>(baseUrl, '/v1/pairing/pin-confirm', {
+ method: 'POST',
+ body: {
+ requestId,
+ pin,
+ },
+ });
+ const state = typeof payload.state === 'string' ? payload.state : 'approved';
+ return {
+ state: state as DesktopRemotePairingStatus['state'],
+ expiresAt: typeof payload.expiresAt === 'number' ? payload.expiresAt : 0,
+ token: typeof payload.token === 'string' ? payload.token : undefined,
+ deviceId: typeof payload.deviceId === 'string' ? payload.deviceId : null,
+ identity: normalizeIdentity(payload.identity ?? payload),
+ };
+}
+
+export async function fetchDesktopRemotePairingStatus(
+ baseUrl: string,
+ pollToken: string
+): Promise {
+ const payload = await fetchJson>(
+ baseUrl,
+ `/v1/pairing/status?pollToken=${encodeURIComponent(pollToken)}`
+ );
+ const state = typeof payload.state === 'string' ? payload.state : 'pending';
+ return {
+ state: state as DesktopRemotePairingStatus['state'],
+ expiresAt: typeof payload.expiresAt === 'number' ? payload.expiresAt : 0,
+ token: typeof payload.token === 'string' ? payload.token : undefined,
+ deviceId: typeof payload.deviceId === 'string' ? payload.deviceId : null,
+ identity: normalizeIdentity(payload.identity ?? payload),
+ };
+}
+
+export async function fetchDesktopRemoteNowPlaying(
+ baseUrl: string,
+ token: string,
+ inlineArtwork = false
+): Promise {
+ return fetchJson(
+ baseUrl,
+ `/v1/now-playing${inlineArtwork ? '?inlineArtwork=1' : ''}`,
+ { token }
+ );
+}
+
+export async function sendDesktopRemoteControl(
+ baseUrl: string,
+ token: string,
+ command: DesktopRemoteControlCommand,
+ time?: number
+): Promise {
+ await fetchJson<{ ok: true }>(baseUrl, '/v1/control', {
+ method: 'POST',
+ token,
+ body: command === 'seek' ? { command, time } : { command },
+ });
+}
+
+export type DesktopRemoteSseHandlers = {
+ onSnapshot: (snapshot: DesktopRemoteNowPlayingSnapshot) => void;
+ onUnauthorized: () => void;
+ onDisconnect: () => void;
+ onError?: (error: unknown) => void;
+};
+
+function processSseChunk(
+ buffer: { value: string },
+ chunk: string,
+ onSnapshot: (snapshot: DesktopRemoteNowPlayingSnapshot) => void
+): void {
+ buffer.value += chunk.replace(/\r/g, '');
+ let boundary = buffer.value.indexOf('\n\n');
+ while (boundary !== -1) {
+ const raw = buffer.value.slice(0, boundary);
+ buffer.value = buffer.value.slice(boundary + 2);
+ let eventName = 'message';
+ const data: string[] = [];
+ for (const line of raw.split('\n')) {
+ if (!line || line.startsWith(':')) continue;
+ if (line.startsWith('event:')) {
+ eventName = line.slice(6).trim();
+ continue;
+ }
+ if (line.startsWith('data:')) data.push(line.slice(5).trimStart());
+ }
+ if (eventName === 'now-playing' && data.length > 0) {
+ try {
+ onSnapshot(JSON.parse(data.join('\n')) as DesktopRemoteNowPlayingSnapshot);
+ } catch {
+ // Ignore a malformed event; polling/reconnect will correct the UI.
+ }
+ }
+ boundary = buffer.value.indexOf('\n\n');
+ }
+}
+
+export function startDesktopRemoteEventStream(
+ baseUrl: string,
+ token: string,
+ handlers: DesktopRemoteSseHandlers
+): () => void {
+ const controller = new AbortController();
+ let closed = false;
+
+ void (async () => {
+ try {
+ const response = await fetch(`${baseUrl}/v1/events`, {
+ headers: {
+ Accept: 'text/event-stream',
+ Authorization: `Bearer ${token}`,
+ },
+ cache: 'no-store',
+ signal: controller.signal,
+ });
+ if (response.status === 401) {
+ handlers.onUnauthorized();
+ return;
+ }
+ const body = response.body as unknown as {
+ getReader?: () => {
+ read: () => Promise<{ done: boolean; value?: Uint8Array }>;
+ };
+ } | null;
+ if (!response.ok || !body?.getReader) throw new Error(`SSE unavailable (${response.status})`);
+
+ const reader = body.getReader();
+ const decoder = new TextDecoder();
+ const buffer = { value: '' };
+ while (!closed) {
+ const next = await reader.read();
+ if (next.done) break;
+ if (next.value) processSseChunk(buffer, decoder.decode(next.value, { stream: true }), handlers.onSnapshot);
+ }
+ processSseChunk(buffer, decoder.decode(), handlers.onSnapshot);
+ if (!closed) handlers.onDisconnect();
+ } catch (error) {
+ if (closed || controller.signal.aborted) return;
+ handlers.onError?.(error);
+ handlers.onDisconnect();
+ }
+ })();
+
+ return () => {
+ closed = true;
+ controller.abort();
+ };
+}
diff --git a/src/services/desktopRemoteCredentials.ts b/src/services/desktopRemoteCredentials.ts
new file mode 100644
index 0000000..9348941
--- /dev/null
+++ b/src/services/desktopRemoteCredentials.ts
@@ -0,0 +1,56 @@
+import * as SecureStore from 'expo-secure-store';
+import type { DesktopRemoteConnection } from '@/types/desktopRemote';
+
+const CONNECTION_KEY = 'desktop_remote_connection_v1';
+const TOKEN_KEY = 'desktop_remote_token_v1';
+
+export async function getDesktopRemoteConnection(): Promise {
+ const raw = await SecureStore.getItemAsync(CONNECTION_KEY);
+ if (!raw) return null;
+ try {
+ const parsed = JSON.parse(raw) as Partial;
+ if (!parsed || typeof parsed !== 'object') return null;
+ if (typeof parsed.id !== 'string' || typeof parsed.baseUrl !== 'string') return null;
+ return {
+ id: parsed.id,
+ baseUrl: parsed.baseUrl,
+ endpointUuid: typeof parsed.endpointUuid === 'string' ? parsed.endpointUuid : null,
+ desktopName: typeof parsed.desktopName === 'string' ? parsed.desktopName : null,
+ protocolVersion:
+ typeof parsed.protocolVersion === 'number' && Number.isFinite(parsed.protocolVersion)
+ ? parsed.protocolVersion
+ : 1,
+ deviceId: typeof parsed.deviceId === 'string' ? parsed.deviceId : null,
+ pairedAt:
+ typeof parsed.pairedAt === 'number' && Number.isFinite(parsed.pairedAt)
+ ? parsed.pairedAt
+ : Date.now(),
+ lastConnectedAt:
+ typeof parsed.lastConnectedAt === 'number' && Number.isFinite(parsed.lastConnectedAt)
+ ? parsed.lastConnectedAt
+ : null,
+ };
+ } catch {
+ return null;
+ }
+}
+
+export async function setDesktopRemoteConnection(connection: DesktopRemoteConnection): Promise {
+ await SecureStore.setItemAsync(CONNECTION_KEY, JSON.stringify(connection));
+}
+
+export async function getDesktopRemoteToken(): Promise {
+ const token = await SecureStore.getItemAsync(TOKEN_KEY);
+ return token && token.trim() ? token.trim() : null;
+}
+
+export async function setDesktopRemoteToken(token: string): Promise {
+ await SecureStore.setItemAsync(TOKEN_KEY, token);
+}
+
+export async function clearDesktopRemotePairing(): Promise {
+ await Promise.all([
+ SecureStore.deleteItemAsync(CONNECTION_KEY),
+ SecureStore.deleteItemAsync(TOKEN_KEY),
+ ]);
+}
diff --git a/src/services/desktopRemoteDiscovery.ts b/src/services/desktopRemoteDiscovery.ts
new file mode 100644
index 0000000..391a248
--- /dev/null
+++ b/src/services/desktopRemoteDiscovery.ts
@@ -0,0 +1,26 @@
+import { Platform } from 'react-native';
+import { requireOptionalNativeModule, type NativeModule } from 'expo-modules-core';
+import type { DesktopRemoteDiscoveredDesktop } from '@/types/desktopRemote';
+
+type DiscoveryEvents = {
+ onDesktopRemoteFound: (desktop: DesktopRemoteDiscoveredDesktop) => void;
+ onDesktopRemoteLost: (event: { name: string }) => void;
+};
+
+declare class AstraDesktopDiscoveryModuleType extends NativeModule {
+ start(): Promise;
+ stop(): Promise;
+ getCached(): DesktopRemoteDiscoveredDesktop[];
+}
+
+const native = requireOptionalNativeModule('AstraDesktopDiscovery');
+
+export const desktopRemoteDiscoveryAvailable = Platform.OS === 'android' && native != null;
+
+export const AstraDesktopDiscovery = native ?? {
+ addListener: () => ({ remove: () => {} }),
+ removeAllListeners: () => {},
+ start: async () => {},
+ stop: async () => {},
+ getCached: () => [],
+};
diff --git a/src/services/desktopRemoteMediaSession.ts b/src/services/desktopRemoteMediaSession.ts
new file mode 100644
index 0000000..0f6b2f2
--- /dev/null
+++ b/src/services/desktopRemoteMediaSession.ts
@@ -0,0 +1,42 @@
+import {
+ AstraDesktopRemoteSession,
+ type AstraDesktopRemoteSessionCommand,
+} from '../../modules/astra-desktop-remote-session';
+import type {
+ DesktopRemoteConnection,
+ DesktopRemoteNowPlayingSnapshot,
+} from '@/types/desktopRemote';
+
+export function setDesktopRemoteMediaSession(
+ snapshot: DesktopRemoteNowPlayingSnapshot | null,
+ connection: DesktopRemoteConnection | null
+): void {
+ if (!snapshot?.currentTrack || !connection) {
+ AstraDesktopRemoteSession.clear();
+ return;
+ }
+ const track = snapshot.currentTrack;
+ AstraDesktopRemoteSession.setNowPlaying({
+ title: track.title,
+ artist: track.artist,
+ album: track.album,
+ desktopName: connection.desktopName,
+ artworkDataUrl: track.artworkDataUrl,
+ playbackState: snapshot.playbackState,
+ hasTrack: true,
+ duration: snapshot.duration,
+ position: snapshot.currentTime,
+ updatedAt: snapshot.updatedAt,
+ isFavorite: track.isFavorite,
+ });
+}
+
+export function clearDesktopRemoteMediaSession(): void {
+ AstraDesktopRemoteSession.clear();
+}
+
+export function subscribeDesktopRemoteMediaSessionCommands(
+ handler: (command: AstraDesktopRemoteSessionCommand) => void
+): { remove: () => void } {
+ return AstraDesktopRemoteSession.addListener('onDesktopRemoteCommand', handler);
+}
diff --git a/src/services/desktopRemotePairing.test.mts b/src/services/desktopRemotePairing.test.mts
new file mode 100644
index 0000000..c4ce29a
--- /dev/null
+++ b/src/services/desktopRemotePairing.test.mts
@@ -0,0 +1,46 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import {
+ normalizeDesktopRemotePinInput,
+ parseDesktopRemoteManualInput,
+ parseDesktopRemotePairingInput,
+} from './desktopRemotePairing.ts';
+
+test('parses current PWA pairing URL format', () => {
+ assert.deepEqual(
+ parseDesktopRemotePairingInput('http://192.168.1.20:38402/remote/#pair=abcDEF_1234567890'),
+ {
+ baseUrl: 'http://192.168.1.20:38402',
+ ticket: 'abcDEF_1234567890',
+ }
+ );
+});
+
+test('parses native pairing links without accepting missing base URLs', () => {
+ assert.deepEqual(
+ parseDesktopRemotePairingInput(
+ 'astra://desktop-remote/pair?baseUrl=http%3A%2F%2F10.0.0.8%3A38402&ticket=abcDEF_1234567890'
+ ),
+ {
+ baseUrl: 'http://10.0.0.8:38402',
+ ticket: 'abcDEF_1234567890',
+ }
+ );
+ assert.equal(parseDesktopRemotePairingInput('astra://desktop-remote/pair?ticket=abcDEF_1234567890'), null);
+});
+
+test('manual pairing requires a reachable http base URL and ticket-shaped code', () => {
+ assert.deepEqual(parseDesktopRemoteManualInput('http://desktop.local:38402/remote/', 'abcDEF_1234567890'), {
+ baseUrl: 'http://desktop.local:38402',
+ ticket: 'abcDEF_1234567890',
+ });
+ assert.equal(parseDesktopRemoteManualInput('ftp://desktop.local', 'abcDEF_1234567890'), null);
+ assert.equal(parseDesktopRemoteManualInput('http://desktop.local:38402', 'short'), null);
+});
+
+test('PIN pairing accepts only six digits with optional spacing', () => {
+ assert.equal(normalizeDesktopRemotePinInput('123456'), '123456');
+ assert.equal(normalizeDesktopRemotePinInput('123 456'), '123456');
+ assert.equal(normalizeDesktopRemotePinInput('12345'), null);
+ assert.equal(normalizeDesktopRemotePinInput('12345x'), null);
+});
diff --git a/src/services/desktopRemotePairing.ts b/src/services/desktopRemotePairing.ts
new file mode 100644
index 0000000..88369ed
--- /dev/null
+++ b/src/services/desktopRemotePairing.ts
@@ -0,0 +1,55 @@
+import type { DesktopRemotePairingInput } from '@/types/desktopRemote';
+
+const PAIRING_TICKET_PATTERN = /^[A-Za-z0-9_-]{16,}$/;
+const PAIRING_PIN_PATTERN = /^\d{6}$/;
+
+function normalizeBaseUrl(value: string): string | null {
+ const trimmed = value.trim().replace(/\/+$/, '');
+ if (!trimmed) return null;
+ try {
+ const parsed = new URL(trimmed);
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null;
+ return parsed.origin;
+ } catch {
+ return null;
+ }
+}
+
+function extractPairFromUrl(url: URL): string {
+ const hashParams = new URLSearchParams(url.hash.replace(/^#/, ''));
+ const hashTicket = hashParams.get('pair')?.trim();
+ if (hashTicket) return hashTicket;
+ return url.searchParams.get('pair')?.trim() ?? '';
+}
+
+export function parseDesktopRemotePairingInput(rawInput: string): DesktopRemotePairingInput | null {
+ const input = rawInput.trim();
+ if (!input) return null;
+
+ try {
+ const parsed = new URL(input);
+ if (parsed.protocol === 'astra:') {
+ const baseUrl = normalizeBaseUrl(parsed.searchParams.get('baseUrl') ?? '');
+ const ticket = parsed.searchParams.get('ticket')?.trim() ?? parsed.searchParams.get('pair')?.trim() ?? '';
+ return baseUrl && PAIRING_TICKET_PATTERN.test(ticket) ? { baseUrl, ticket } : null;
+ }
+
+ const ticket = extractPairFromUrl(parsed);
+ const baseUrl = normalizeBaseUrl(parsed.origin);
+ return baseUrl && PAIRING_TICKET_PATTERN.test(ticket) ? { baseUrl, ticket } : null;
+ } catch {
+ return PAIRING_TICKET_PATTERN.test(input) ? { baseUrl: '', ticket: input } : null;
+ }
+}
+
+export function parseDesktopRemoteManualInput(baseUrl: string, ticket: string): DesktopRemotePairingInput | null {
+ const normalizedBaseUrl = normalizeBaseUrl(baseUrl);
+ const normalizedTicket = ticket.trim();
+ if (!normalizedBaseUrl || !PAIRING_TICKET_PATTERN.test(normalizedTicket)) return null;
+ return { baseUrl: normalizedBaseUrl, ticket: normalizedTicket };
+}
+
+export function normalizeDesktopRemotePinInput(pin: string): string | null {
+ const normalizedPin = pin.replace(/\s+/g, '');
+ return PAIRING_PIN_PATTERN.test(normalizedPin) ? normalizedPin : null;
+}
diff --git a/src/stores/desktopRemoteStore.ts b/src/stores/desktopRemoteStore.ts
new file mode 100644
index 0000000..72789d2
--- /dev/null
+++ b/src/stores/desktopRemoteStore.ts
@@ -0,0 +1,622 @@
+import { create } from 'zustand';
+import {
+ AstraDesktopDiscovery,
+ desktopRemoteDiscoveryAvailable,
+} from '@/services/desktopRemoteDiscovery';
+import {
+ DesktopRemoteHttpError,
+ claimDesktopRemotePairingTicket,
+ confirmDesktopRemotePinPairing,
+ defaultDesktopRemoteDeviceName,
+ fetchDesktopRemoteIdentity,
+ fetchDesktopRemoteNowPlaying,
+ fetchDesktopRemotePairingStatus,
+ parseDesktopRemoteManualInput,
+ parseDesktopRemotePairingInput,
+ requestDesktopRemotePinPairing,
+ sendDesktopRemoteControl,
+ startDesktopRemoteEventStream,
+} from '@/services/desktopRemoteClient';
+import { normalizeDesktopRemotePinInput } from '@/services/desktopRemotePairing';
+import {
+ clearDesktopRemotePairing,
+ getDesktopRemoteConnection,
+ getDesktopRemoteToken,
+ setDesktopRemoteConnection,
+ setDesktopRemoteToken,
+} from '@/services/desktopRemoteCredentials';
+import type {
+ DesktopRemoteConnection,
+ DesktopRemoteControlCommand,
+ DesktopRemoteDiscoveredDesktop,
+ DesktopRemoteIdentity,
+ DesktopRemoteNowPlayingSnapshot,
+} from '@/types/desktopRemote';
+
+const PAIR_POLL_INTERVAL_MS = 1500;
+const SNAPSHOT_POLL_INTERVAL_MS = 5000;
+const RECONNECT_DELAY_MS = 2000;
+
+export type DesktopRemoteConnectionState =
+ | 'unpaired'
+ | 'pairing'
+ | 'pinEntry'
+ | 'pendingApproval'
+ | 'connecting'
+ | 'connected'
+ | 'reconnecting'
+ | 'error';
+
+interface PairingAttempt {
+ baseUrl: string;
+ pollToken: string;
+ expiresAt: number;
+}
+
+interface PinPairingAttempt {
+ baseUrl: string;
+ requestId: string;
+ expiresAt: number;
+ desktopName: string | null;
+}
+
+interface DesktopRemoteStore {
+ initialized: boolean;
+ connectionState: DesktopRemoteConnectionState;
+ connection: DesktopRemoteConnection | null;
+ token: string | null;
+ snapshot: DesktopRemoteNowPlayingSnapshot | null;
+ discovered: DesktopRemoteDiscoveredDesktop[];
+ discoveryAvailable: boolean;
+ discoveryRunning: boolean;
+ pairing: PairingAttempt | null;
+ pinPairing: PinPairingAttempt | null;
+ message: string;
+ errorMessage: string;
+
+ init: () => Promise;
+ startDiscovery: () => Promise;
+ stopDiscovery: () => Promise;
+ requestPinPairing: (baseUrl: string) => Promise;
+ confirmPinPairing: (pin: string) => Promise;
+ pairFromInput: (input: string) => Promise;
+ pairManual: (baseUrl: string, ticket: string) => Promise;
+ connect: () => Promise;
+ reconnect: () => Promise;
+ disconnect: () => void;
+ forget: () => Promise;
+ sendControl: (command: DesktopRemoteControlCommand, time?: number) => Promise;
+}
+
+let pairingPollTimer: ReturnType | null = null;
+let snapshotPollTimer: ReturnType | null = null;
+let reconnectTimer: ReturnType | null = null;
+let stopEventStream: (() => void) | null = null;
+let discoverySubscriptions: { remove: () => void }[] = [];
+let inlineArtworkRequestKey: string | null = null;
+
+function clearPairingPoll(): void {
+ if (pairingPollTimer !== null) {
+ clearTimeout(pairingPollTimer);
+ pairingPollTimer = null;
+ }
+}
+
+function clearSnapshotPoll(): void {
+ if (snapshotPollTimer !== null) {
+ clearInterval(snapshotPollTimer);
+ snapshotPollTimer = null;
+ }
+}
+
+function clearReconnect(): void {
+ if (reconnectTimer !== null) {
+ clearTimeout(reconnectTimer);
+ reconnectTimer = null;
+ }
+}
+
+function stopRealtime(): void {
+ stopEventStream?.();
+ stopEventStream = null;
+ clearSnapshotPoll();
+ clearReconnect();
+}
+
+function displayName(identity: DesktopRemoteIdentity | null, baseUrl: string): string {
+ return identity?.desktopName?.trim() || new URL(baseUrl).hostname || 'Astra Desktop';
+}
+
+function stableConnectionId(identity: DesktopRemoteIdentity | null, baseUrl: string): string {
+ return identity?.endpointUuid?.trim() || baseUrl;
+}
+
+function errorMessage(error: unknown): string {
+ if (error instanceof DesktopRemoteHttpError) return error.message;
+ if (error instanceof Error && error.message.trim()) return error.message;
+ return 'Desktop remote request failed.';
+}
+
+function mergeSnapshotArtwork(
+ previous: DesktopRemoteNowPlayingSnapshot | null,
+ next: DesktopRemoteNowPlayingSnapshot
+): DesktopRemoteNowPlayingSnapshot {
+ const previousTrack = previous?.currentTrack ?? null;
+ const nextTrack = next.currentTrack ?? null;
+ if (!previousTrack || !nextTrack) return next;
+ if (previousTrack.id !== nextTrack.id) return next;
+ if (nextTrack.artworkDataUrl || !previousTrack.artworkDataUrl) return next;
+ return {
+ ...next,
+ currentTrack: {
+ ...nextTrack,
+ artworkDataUrl: previousTrack.artworkDataUrl,
+ },
+ };
+}
+
+async function persistConnectedDesktop(
+ baseUrl: string,
+ token: string,
+ deviceId: string | null,
+ identity: DesktopRemoteIdentity | null
+): Promise {
+ const resolvedIdentity = identity ?? (await fetchDesktopRemoteIdentity(baseUrl));
+ const now = Date.now();
+ const connection: DesktopRemoteConnection = {
+ id: stableConnectionId(resolvedIdentity, baseUrl),
+ baseUrl,
+ endpointUuid: resolvedIdentity?.endpointUuid ?? null,
+ desktopName: displayName(resolvedIdentity, baseUrl),
+ protocolVersion: resolvedIdentity?.protocolVersion ?? 1,
+ deviceId,
+ pairedAt: now,
+ lastConnectedAt: now,
+ };
+ await Promise.all([
+ setDesktopRemoteConnection(connection),
+ setDesktopRemoteToken(token),
+ ]);
+ return connection;
+}
+
+export const useDesktopRemoteStore = create((set, get) => {
+ const refreshInlineArtwork = () => {
+ const { connection, token, snapshot } = get();
+ const track = snapshot?.currentTrack ?? null;
+ if (!connection || !token || !track || track.artworkDataUrl) return;
+ const requestKey = `${connection.id}:${track.id}`;
+ if (inlineArtworkRequestKey === requestKey) return;
+ inlineArtworkRequestKey = requestKey;
+ void fetchDesktopRemoteNowPlaying(connection.baseUrl, token, true).then(
+ (inlineSnapshot) => {
+ inlineArtworkRequestKey = null;
+ set((state) => ({
+ snapshot: mergeSnapshotArtwork(state.snapshot, inlineSnapshot),
+ connectionState: 'connected',
+ errorMessage: '',
+ }));
+ },
+ () => {
+ inlineArtworkRequestKey = null;
+ }
+ );
+ };
+
+ const scheduleSnapshotPoll = () => {
+ clearSnapshotPoll();
+ snapshotPollTimer = setInterval(() => {
+ const { connection, token, connectionState } = get();
+ if (!connection || !token || connectionState === 'connecting') return;
+ void fetchDesktopRemoteNowPlaying(connection.baseUrl, token).then(
+ (snapshot) => {
+ set((state) => ({
+ snapshot: mergeSnapshotArtwork(state.snapshot, snapshot),
+ connectionState: 'connected',
+ errorMessage: '',
+ }));
+ refreshInlineArtwork();
+ },
+ (error) => {
+ if (error instanceof DesktopRemoteHttpError && error.status === 401) {
+ void get().forget();
+ set({ errorMessage: 'Desktop pairing was revoked.' });
+ return;
+ }
+ if (get().connectionState === 'connected') {
+ set({ connectionState: 'reconnecting', message: 'Reconnecting to desktop...' });
+ }
+ }
+ );
+ }, SNAPSHOT_POLL_INTERVAL_MS);
+ };
+
+ const scheduleReconnect = () => {
+ clearReconnect();
+ const { connection, token } = get();
+ if (!connection || !token) return;
+ set({ connectionState: 'reconnecting', message: 'Reconnecting to desktop...' });
+ scheduleSnapshotPoll();
+ reconnectTimer = setTimeout(() => {
+ reconnectTimer = null;
+ void get().connect();
+ }, RECONNECT_DELAY_MS);
+ };
+
+ const pollPairingStatus = async () => {
+ const pairing = get().pairing;
+ if (!pairing) return;
+ try {
+ const status = await fetchDesktopRemotePairingStatus(pairing.baseUrl, pairing.pollToken);
+ if (status.state === 'approved' && status.token?.trim()) {
+ clearPairingPoll();
+ const connection = await persistConnectedDesktop(
+ pairing.baseUrl,
+ status.token.trim(),
+ status.deviceId ?? null,
+ status.identity ?? null
+ );
+ set({
+ connection,
+ token: status.token.trim(),
+ pairing: null,
+ pinPairing: null,
+ connectionState: 'connecting',
+ message: 'Paired. Connecting...',
+ errorMessage: '',
+ });
+ void get().connect();
+ return;
+ }
+ if (status.state === 'rejected') {
+ clearPairingPoll();
+ set({
+ pairing: null,
+ connectionState: 'error',
+ message: '',
+ errorMessage: 'Desktop rejected this pairing request.',
+ });
+ return;
+ }
+ if (status.state === 'expired' || status.state === 'consumed') {
+ clearPairingPoll();
+ set({
+ pairing: null,
+ connectionState: 'error',
+ message: '',
+ errorMessage: 'Pairing link expired. Generate a new QR code on desktop.',
+ });
+ return;
+ }
+ set({
+ connectionState: 'pendingApproval',
+ pairing: { ...pairing, expiresAt: status.expiresAt || pairing.expiresAt },
+ message: 'Approve this phone in Astra on desktop.',
+ });
+ pairingPollTimer = setTimeout(() => void pollPairingStatus(), PAIR_POLL_INTERVAL_MS);
+ } catch (error) {
+ clearPairingPoll();
+ set({
+ pairing: null,
+ connectionState: 'error',
+ message: '',
+ errorMessage: errorMessage(error),
+ });
+ }
+ };
+
+ const claimPairing = async (baseUrl: string, ticket: string) => {
+ clearPairingPoll();
+ stopRealtime();
+ set({
+ connectionState: 'pairing',
+ pairing: null,
+ pinPairing: null,
+ snapshot: null,
+ message: 'Starting pairing...',
+ errorMessage: '',
+ });
+ try {
+ const claim = await claimDesktopRemotePairingTicket(
+ baseUrl,
+ ticket,
+ defaultDesktopRemoteDeviceName()
+ );
+ if (!claim.pollToken) throw new Error('Desktop did not return a pairing poll token.');
+ set({
+ connectionState: 'pendingApproval',
+ pairing: {
+ baseUrl,
+ pollToken: claim.pollToken,
+ expiresAt: claim.expiresAt,
+ },
+ message: 'Approve this phone in Astra on desktop.',
+ });
+ await pollPairingStatus();
+ } catch (error) {
+ set({
+ connectionState: 'error',
+ pairing: null,
+ pinPairing: null,
+ message: '',
+ errorMessage: errorMessage(error),
+ });
+ }
+ };
+
+ const requestPinPairing = async (baseUrl: string) => {
+ clearPairingPoll();
+ stopRealtime();
+ set({
+ connectionState: 'pairing',
+ pairing: null,
+ pinPairing: null,
+ snapshot: null,
+ message: 'Requesting PIN from desktop...',
+ errorMessage: '',
+ });
+ try {
+ const request = await requestDesktopRemotePinPairing(baseUrl, defaultDesktopRemoteDeviceName());
+ if (!request.requestId) throw new Error('Desktop did not return a PIN pairing request.');
+ const desktopName = request.identity?.desktopName?.trim() || null;
+ set({
+ connectionState: 'pinEntry',
+ pinPairing: {
+ baseUrl,
+ requestId: request.requestId,
+ expiresAt: request.expiresAt,
+ desktopName,
+ },
+ message: `Enter the PIN shown on ${desktopName || 'Astra Desktop'}.`,
+ errorMessage: '',
+ });
+ } catch (error) {
+ set({
+ connectionState: 'error',
+ pinPairing: null,
+ message: '',
+ errorMessage: errorMessage(error),
+ });
+ }
+ };
+
+ const confirmPinPairing = async (pin: string) => {
+ const normalizedPin = normalizeDesktopRemotePinInput(pin);
+ const attempt = get().pinPairing;
+ if (!attempt) return;
+ if (!normalizedPin) {
+ set({ errorMessage: 'Enter the 6-digit PIN shown on desktop.' });
+ return;
+ }
+ set({ connectionState: 'pairing', message: 'Confirming PIN...', errorMessage: '' });
+ try {
+ const status = await confirmDesktopRemotePinPairing(attempt.baseUrl, attempt.requestId, normalizedPin);
+ if (status.state !== 'approved' || !status.token?.trim()) {
+ throw new Error('Desktop did not approve this PIN pairing.');
+ }
+ const connection = await persistConnectedDesktop(
+ attempt.baseUrl,
+ status.token.trim(),
+ status.deviceId ?? null,
+ status.identity ?? null
+ );
+ set({
+ connection,
+ token: status.token.trim(),
+ pairing: null,
+ pinPairing: null,
+ connectionState: 'connecting',
+ message: 'Paired. Connecting...',
+ errorMessage: '',
+ });
+ void get().connect();
+ } catch (error) {
+ if (error instanceof DesktopRemoteHttpError && error.status === 401) {
+ set({
+ connectionState: 'pinEntry',
+ message: `Enter the PIN shown on ${attempt.desktopName || 'Astra Desktop'}.`,
+ errorMessage: 'Wrong PIN. Try again.',
+ });
+ return;
+ }
+ set({
+ connectionState: 'error',
+ pinPairing: null,
+ message: '',
+ errorMessage: errorMessage(error),
+ });
+ }
+ };
+
+ return {
+ initialized: false,
+ connectionState: 'unpaired',
+ connection: null,
+ token: null,
+ snapshot: null,
+ discovered: [],
+ discoveryAvailable: desktopRemoteDiscoveryAvailable,
+ discoveryRunning: false,
+ pairing: null,
+ pinPairing: null,
+ message: '',
+ errorMessage: '',
+
+ init: async () => {
+ if (get().initialized) return;
+ const [connection, token] = await Promise.all([
+ getDesktopRemoteConnection(),
+ getDesktopRemoteToken(),
+ ]);
+ set({
+ initialized: true,
+ connection,
+ token,
+ connectionState: connection && token ? 'connecting' : 'unpaired',
+ });
+ if (connection && token) void get().connect();
+ },
+
+ startDiscovery: async () => {
+ if (!desktopRemoteDiscoveryAvailable || get().discoveryRunning) return;
+ if (discoverySubscriptions.length === 0) {
+ discoverySubscriptions = [
+ AstraDesktopDiscovery.addListener('onDesktopRemoteFound', (desktop) => {
+ set((state) => {
+ const byKey = new Map(state.discovered.map((item) => [item.endpointUuid || item.baseUrl, item]));
+ byKey.set(desktop.endpointUuid || desktop.baseUrl, desktop);
+ return {
+ discovered: Array.from(byKey.values()).sort((left, right) =>
+ left.name.localeCompare(right.name)
+ ),
+ };
+ });
+ }),
+ AstraDesktopDiscovery.addListener('onDesktopRemoteLost', (event) => {
+ set((state) => ({
+ discovered: state.discovered.filter((item) => item.name !== event.name),
+ }));
+ }),
+ ];
+ }
+ set({ discoveryRunning: true, discovered: AstraDesktopDiscovery.getCached() });
+ await AstraDesktopDiscovery.start();
+ },
+
+ stopDiscovery: async () => {
+ if (!get().discoveryRunning) return;
+ await AstraDesktopDiscovery.stop();
+ set({ discoveryRunning: false });
+ },
+
+ requestPinPairing,
+
+ confirmPinPairing,
+
+ pairFromInput: async (input: string) => {
+ const parsed = parseDesktopRemotePairingInput(input);
+ if (!parsed || !parsed.baseUrl) {
+ set({
+ connectionState: 'error',
+ errorMessage: 'Paste or scan a full desktop pairing link.',
+ });
+ return;
+ }
+ await claimPairing(parsed.baseUrl, parsed.ticket);
+ },
+
+ pairManual: async (baseUrl: string, ticket: string) => {
+ const parsed = parseDesktopRemoteManualInput(baseUrl, ticket);
+ if (!parsed) {
+ set({
+ connectionState: 'error',
+ errorMessage: 'Enter a valid desktop URL and pairing code.',
+ });
+ return;
+ }
+ await claimPairing(parsed.baseUrl, parsed.ticket);
+ },
+
+ connect: async () => {
+ const { connection, token } = get();
+ if (!connection || !token) {
+ set({ connectionState: 'unpaired' });
+ return false;
+ }
+ stopRealtime();
+ set({ connectionState: 'connecting', message: 'Connecting to desktop...', errorMessage: '' });
+ try {
+ const snapshot = await fetchDesktopRemoteNowPlaying(connection.baseUrl, token, true);
+ const nextConnection = { ...connection, lastConnectedAt: Date.now() };
+ await setDesktopRemoteConnection(nextConnection);
+ set({
+ connection: nextConnection,
+ snapshot,
+ connectionState: 'connected',
+ message: '',
+ errorMessage: '',
+ });
+ stopEventStream = startDesktopRemoteEventStream(connection.baseUrl, token, {
+ onSnapshot: (nextSnapshot) => {
+ set((state) => ({
+ snapshot: mergeSnapshotArtwork(state.snapshot, nextSnapshot),
+ connectionState: 'connected',
+ message: '',
+ errorMessage: '',
+ }));
+ refreshInlineArtwork();
+ },
+ onUnauthorized: () => {
+ void get().forget();
+ set({ errorMessage: 'Desktop pairing was revoked.' });
+ },
+ onDisconnect: scheduleReconnect,
+ onError: () => {
+ scheduleSnapshotPoll();
+ },
+ });
+ scheduleSnapshotPoll();
+ return true;
+ } catch (error) {
+ if (error instanceof DesktopRemoteHttpError && error.status === 401) {
+ await get().forget();
+ set({ errorMessage: 'Desktop pairing was revoked.' });
+ return false;
+ }
+ set({
+ connectionState: 'error',
+ message: '',
+ errorMessage: errorMessage(error),
+ });
+ scheduleReconnect();
+ return false;
+ }
+ },
+
+ reconnect: async () => {
+ await get().connect();
+ },
+
+ disconnect: () => {
+ stopRealtime();
+ clearPairingPoll();
+ set({ connectionState: get().connection ? 'error' : 'unpaired', message: '', snapshot: null, pinPairing: null });
+ },
+
+ forget: async () => {
+ stopRealtime();
+ clearPairingPoll();
+ await clearDesktopRemotePairing();
+ set({
+ connectionState: 'unpaired',
+ connection: null,
+ token: null,
+ snapshot: null,
+ pairing: null,
+ pinPairing: null,
+ message: '',
+ });
+ },
+
+ sendControl: async (command, time) => {
+ const { connection, token } = get();
+ if (!connection || !token) return;
+ try {
+ await sendDesktopRemoteControl(connection.baseUrl, token, command, time);
+ set({ errorMessage: '' });
+ if (command === 'seek' && typeof time === 'number') {
+ set((state) => state.snapshot
+ ? { snapshot: { ...state.snapshot, currentTime: time, updatedAt: Date.now() } }
+ : {});
+ }
+ } catch (error) {
+ if (error instanceof DesktopRemoteHttpError && error.status === 401) {
+ await get().forget();
+ set({ errorMessage: 'Desktop pairing was revoked.' });
+ return;
+ }
+ set({ errorMessage: errorMessage(error) });
+ }
+ },
+ };
+});
diff --git a/src/types/desktopRemote.ts b/src/types/desktopRemote.ts
new file mode 100644
index 0000000..852d038
--- /dev/null
+++ b/src/types/desktopRemote.ts
@@ -0,0 +1,86 @@
+export const DESKTOP_REMOTE_PROTOCOL_VERSION = 1;
+
+export type DesktopRemotePlaybackState = 'stopped' | 'playing' | 'paused' | 'loading';
+export type DesktopRemoteControlCommand =
+ | 'play'
+ | 'pause'
+ | 'next'
+ | 'previous'
+ | 'toggle-favorite'
+ | 'seek';
+
+export interface DesktopRemoteIdentity {
+ endpointUuid: string | null;
+ desktopName: string | null;
+ protocolVersion: number;
+}
+
+export interface DesktopRemoteConnection extends DesktopRemoteIdentity {
+ id: string;
+ baseUrl: string;
+ deviceId: string | null;
+ pairedAt: number;
+ lastConnectedAt: number | null;
+}
+
+export interface DesktopRemoteTrackSnapshot {
+ id: string;
+ title: string;
+ artist: string;
+ artists: string[];
+ album: string;
+ albumArtists: string[];
+ isFavorite: boolean;
+ artworkUrl: string | null;
+ artworkDataUrl: string | null;
+}
+
+export interface DesktopRemoteNowPlayingSnapshot {
+ playbackState: DesktopRemotePlaybackState;
+ currentTime: number;
+ duration: number;
+ queueLength: number;
+ outputDeviceLabel: string | null;
+ visualizerLineColor: string;
+ currentTrack: DesktopRemoteTrackSnapshot | null;
+ updatedAt: number;
+}
+
+export interface DesktopRemotePairingClaim {
+ requestId: string;
+ pollToken: string;
+ expiresAt: number;
+ deviceName: string;
+ clientLabel: string;
+ identity: DesktopRemoteIdentity | null;
+}
+
+export type DesktopRemotePinPairingRequest = DesktopRemotePairingClaim;
+
+export type DesktopRemotePairingState =
+ | 'pending'
+ | 'approved'
+ | 'rejected'
+ | 'expired'
+ | 'consumed';
+
+export interface DesktopRemotePairingStatus {
+ state: DesktopRemotePairingState;
+ expiresAt: number;
+ token?: string;
+ deviceId?: string | null;
+ identity?: DesktopRemoteIdentity | null;
+}
+
+export interface DesktopRemotePairingInput {
+ baseUrl: string;
+ ticket: string;
+}
+
+export interface DesktopRemoteDiscoveredDesktop extends DesktopRemoteIdentity {
+ name: string;
+ baseUrl: string;
+ address: string;
+ port: number;
+ lastSeenAt: number;
+}