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; +}