mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-12 05:10:52 +02:00
preliminary support for astra desktop
This commit is contained in:
@@ -29,6 +29,12 @@
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
"expo-asset",
|
||||
[
|
||||
"expo-camera",
|
||||
{
|
||||
"cameraPermission": "Allow Astra to scan desktop pairing QR codes."
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-splash-screen",
|
||||
{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
|
||||
</manifest>
|
||||
+120
@@ -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<String, Map<String, Any?>>()
|
||||
|
||||
override fun definition() = ModuleDefinition {
|
||||
Name("AstraDesktopDiscovery")
|
||||
|
||||
Events("onDesktopRemoteFound", "onDesktopRemoteLost")
|
||||
|
||||
AsyncFunction("start").Coroutine<Unit> {
|
||||
withContext(Dispatchers.Main) { startDiscovery() }
|
||||
}
|
||||
|
||||
AsyncFunction("stop").Coroutine<Unit> {
|
||||
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 }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"platforms": ["android"],
|
||||
"android": {
|
||||
"modules": ["expo.modules.astradesktopdiscovery.AstraDesktopDiscoveryModule"]
|
||||
}
|
||||
}
|
||||
@@ -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<AstraDesktopDiscoveryEvents> {
|
||||
start(): Promise<void>;
|
||||
stop(): Promise<void>;
|
||||
getCached(): AstraDesktopDiscoveryItem[];
|
||||
}
|
||||
|
||||
const native = requireOptionalNativeModule<AstraDesktopDiscoveryModuleType>('AstraDesktopDiscovery');
|
||||
|
||||
export const AstraDesktopDiscovery = native ?? {
|
||||
addListener: () => ({ remove: () => {} }),
|
||||
removeAllListeners: () => {},
|
||||
start: async () => {},
|
||||
stop: async () => {},
|
||||
getCached: () => [],
|
||||
};
|
||||
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<application>
|
||||
<receiver
|
||||
android:name="expo.modules.astradesktopremotesession.AstraDesktopRemoteSessionReceiver"
|
||||
android:exported="false" />
|
||||
</application>
|
||||
</manifest>
|
||||
+334
@@ -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<String, Any>) {
|
||||
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<String, Any>("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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"platforms": ["android"],
|
||||
"android": {
|
||||
"modules": ["expo.modules.astradesktopremotesession.AstraDesktopRemoteSessionModule"]
|
||||
}
|
||||
}
|
||||
@@ -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<AstraDesktopRemoteSessionEvents> {
|
||||
setNowPlaying(state: AstraDesktopRemoteSessionState): void;
|
||||
clear(): void;
|
||||
}
|
||||
|
||||
const native = requireOptionalNativeModule<AstraDesktopRemoteSessionModuleType>('AstraDesktopRemoteSession');
|
||||
|
||||
export const AstraDesktopRemoteSession = native ?? {
|
||||
addListener: () => ({ remove: () => {} }),
|
||||
removeAllListeners: () => {},
|
||||
setNowPlaying: () => {},
|
||||
clear: () => {},
|
||||
};
|
||||
Generated
+76
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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 (
|
||||
<Screen>
|
||||
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={styles.content}>
|
||||
@@ -202,6 +215,24 @@ export default function SettingsScreen() {
|
||||
<Ionicons name="chevron-forward" size={18} color={colors.textTertiary} />
|
||||
</Pressable>
|
||||
|
||||
<Text variant="label" color={colors.textTertiary} style={[styles.sectionLabel, styles.sectionSpacing]}>
|
||||
EXPERIMENTAL
|
||||
</Text>
|
||||
<Pressable
|
||||
style={styles.option}
|
||||
onPress={() => router.push('/desktop-remote' as never)}
|
||||
accessibilityRole="button"
|
||||
>
|
||||
<Ionicons name="phone-portrait-outline" size={20} color={colors.textSecondary} />
|
||||
<View style={styles.optionText}>
|
||||
<Text variant="body">Desktop Remote</Text>
|
||||
<Text variant="caption" color={colors.textSecondary} style={styles.optionDescription}>
|
||||
{desktopRemoteSubtitle}
|
||||
</Text>
|
||||
</View>
|
||||
<Ionicons name="chevron-forward" size={18} color={colors.textTertiary} />
|
||||
</Pressable>
|
||||
|
||||
<Text
|
||||
variant="label"
|
||||
color={colors.textTertiary}
|
||||
|
||||
@@ -23,8 +23,14 @@ import { useEQStore } from '@/stores/eqStore';
|
||||
import { useAudioSettingsStore } from '@/stores/audioSettingsStore';
|
||||
import { useRemoteSourcesStore } from '@/stores/remoteSourcesStore';
|
||||
import { useLastFmSettingsStore } from '@/stores/lastFmSettingsStore';
|
||||
import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore';
|
||||
import { useNormalizationSync } from '@/audio/useNormalizationSync';
|
||||
import { useLastFmScrobbler } from '@/audio/useLastFmScrobbler';
|
||||
import {
|
||||
clearDesktopRemoteMediaSession,
|
||||
setDesktopRemoteMediaSession,
|
||||
subscribeDesktopRemoteMediaSessionCommands,
|
||||
} from '@/services/desktopRemoteMediaSession';
|
||||
import { colors } from '@/theme';
|
||||
|
||||
// Anchor the root stack at the tabs so a deep link straight to a top-level route (the
|
||||
@@ -61,6 +67,46 @@ function LastFmScrobbler() {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Mirrors Desktop Remote now-playing into a separate Android MediaSession. */
|
||||
function DesktopRemoteMediaSessionSync() {
|
||||
const connection = useDesktopRemoteStore((s) => 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() {
|
||||
<ScopeLifecycle />
|
||||
<NormalizationSync />
|
||||
<LastFmScrobbler />
|
||||
<DesktopRemoteMediaSessionSync />
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
|
||||
@@ -0,0 +1,983 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
TextInput,
|
||||
useWindowDimensions,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { Image } from 'expo-image';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { AstraLogo } from '@/components/AstraLogo';
|
||||
import { MarqueeText } from '@/components/MarqueeText';
|
||||
import { Screen } from '@/components/Screen';
|
||||
import { SeekBar } from '@/components/SeekBar';
|
||||
import { Text } from '@/components/Text';
|
||||
import { colors, radius, spacing } from '@/theme';
|
||||
import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore';
|
||||
import type { DesktopRemoteDiscoveredDesktop } from '@/types/desktopRemote';
|
||||
|
||||
const MAX_CONTENT_WIDTH = 408;
|
||||
const CONTENT_SIDE_PADDING = spacing.lg;
|
||||
const NARROW_CONTENT_SIDE_PADDING = spacing.md;
|
||||
const MEDIA_AREA_MIN = 220;
|
||||
const COMPACT_MEDIA_AREA_MIN = 128;
|
||||
const MEDIA_AREA_MAX = 360;
|
||||
const ART_SIZE_MAX = 340;
|
||||
const HEADER_HEIGHT = 32;
|
||||
const CONTENT_TOP_PADDING = spacing.sm;
|
||||
const CONTENT_BOTTOM_PADDING = spacing.lg;
|
||||
const MEDIA_TOP_MARGIN = spacing.lg;
|
||||
const MEDIA_BOTTOM_GAP = spacing.xl;
|
||||
const TRACK_INFO_ESTIMATE = 96;
|
||||
const SEEK_BLOCK_ESTIMATE = 54;
|
||||
const PLAY_BUTTON_SIZE = 68;
|
||||
const SKIP_ICON_SIZE = 32;
|
||||
const PLAY_ICON_SIZE = 34;
|
||||
const TRANSPORT_TOP_MARGIN = spacing.lg;
|
||||
const SUB_BUTTON_SIZE = 40;
|
||||
const SUB_ICON_SIZE = 20;
|
||||
const SUB_TOP_MARGIN = spacing.lg;
|
||||
const MIN_FLOATING_SPACE = spacing.sm;
|
||||
|
||||
interface RemoteLayout {
|
||||
contentPadding: number;
|
||||
contentWidth: number;
|
||||
artSize: number;
|
||||
mediaTopMargin: number;
|
||||
mediaBottomGap: number;
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function getRemoteLayout(windowWidth: number, availableHeight: number): RemoteLayout {
|
||||
const contentPadding =
|
||||
windowWidth < 360 ? NARROW_CONTENT_SIDE_PADDING : CONTENT_SIDE_PADDING;
|
||||
const contentWidth = Math.max(0, Math.min(windowWidth - contentPadding * 2, MAX_CONTENT_WIDTH));
|
||||
const mediaMax = Math.min(contentWidth, MEDIA_AREA_MAX);
|
||||
const mediaFloor = availableHeight < 620 ? COMPACT_MEDIA_AREA_MIN : MEDIA_AREA_MIN;
|
||||
const mediaMin = Math.min(mediaMax, mediaFloor);
|
||||
const mediaTopMargin = availableHeight < 680 ? spacing.md : MEDIA_TOP_MARGIN;
|
||||
const mediaBottomGap = availableHeight < 680 ? spacing.lg : MEDIA_BOTTOM_GAP;
|
||||
const fixedHeight =
|
||||
CONTENT_TOP_PADDING +
|
||||
CONTENT_BOTTOM_PADDING +
|
||||
HEADER_HEIGHT +
|
||||
mediaTopMargin +
|
||||
mediaBottomGap +
|
||||
TRACK_INFO_ESTIMATE +
|
||||
SEEK_BLOCK_ESTIMATE +
|
||||
TRANSPORT_TOP_MARGIN +
|
||||
PLAY_BUTTON_SIZE +
|
||||
SUB_TOP_MARGIN +
|
||||
SUB_BUTTON_SIZE +
|
||||
MIN_FLOATING_SPACE;
|
||||
const heightBoundMedia = availableHeight - fixedHeight;
|
||||
const fitAwareMediaMin = Math.min(mediaMin, Math.max(96, heightBoundMedia));
|
||||
const artSize = Math.min(
|
||||
Math.round(clamp(heightBoundMedia, fitAwareMediaMin, mediaMax)),
|
||||
ART_SIZE_MAX
|
||||
);
|
||||
return {
|
||||
contentPadding,
|
||||
contentWidth,
|
||||
artSize,
|
||||
mediaTopMargin,
|
||||
mediaBottomGap,
|
||||
};
|
||||
}
|
||||
|
||||
function formatPairingCountdown(expiresAt: number, now = Date.now()): string {
|
||||
const seconds = Math.max(0, Math.ceil((expiresAt - now) / 1000));
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
return `${minutes}:${String(seconds % 60).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function hostFromBaseUrl(baseUrl: string): string {
|
||||
try {
|
||||
return new URL(baseUrl).host;
|
||||
} catch {
|
||||
return baseUrl;
|
||||
}
|
||||
}
|
||||
|
||||
function connectionLabel(state: ReturnType<typeof useDesktopRemoteStore.getState>['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 (
|
||||
<Pressable
|
||||
style={[styles.discoveredRow, disabled && styles.buttonDisabled]}
|
||||
onPress={() => onPair(desktop)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<View style={styles.discoveredIcon}>
|
||||
<Ionicons name="desktop-outline" size={20} color={colors.accent} />
|
||||
</View>
|
||||
<View style={styles.discoveredText}>
|
||||
<Text variant="body" numberOfLines={1}>
|
||||
{desktop.name}
|
||||
</Text>
|
||||
<Text variant="caption" color={colors.textTertiary} numberOfLines={1}>
|
||||
{hostFromBaseUrl(desktop.baseUrl)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.discoveredAction}>
|
||||
<Text variant="label" color={colors.accent}>
|
||||
Pair
|
||||
</Text>
|
||||
<Ionicons name="keypad-outline" size={17} color={colors.accent} />
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
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 = () => (
|
||||
<ScrollView
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={styles.content}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
<View style={styles.hero}>
|
||||
<Ionicons name="phone-portrait-outline" size={30} color={colors.accent} />
|
||||
<View style={styles.heroText}>
|
||||
<Text variant="title" style={styles.heading}>
|
||||
Desktop Remote
|
||||
</Text>
|
||||
<Text variant="body" color={colors.textSecondary}>
|
||||
Pair this phone with Astra Desktop to control playback over your LAN.
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.card}>
|
||||
<View style={styles.cardHeader}>
|
||||
<Text variant="body">Nearby desktops</Text>
|
||||
{discoveryRunning || isBusy ? <ActivityIndicator color={colors.accent} /> : null}
|
||||
</View>
|
||||
<Text variant="caption" color={colors.textSecondary} style={styles.cardCopy}>
|
||||
Tap a discovered desktop, then enter the PIN shown in Astra Desktop.
|
||||
</Text>
|
||||
{discoveryAvailable ? (
|
||||
discovered.length > 0 ? (
|
||||
<View style={styles.discoveredList}>
|
||||
{discovered.map((desktop) => (
|
||||
<DiscoveredDesktopRow
|
||||
key={desktop.endpointUuid || desktop.baseUrl}
|
||||
desktop={desktop}
|
||||
onPair={pairDiscovered}
|
||||
disabled={isBusy || pinPairingActive}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
) : (
|
||||
<Text variant="caption" color={colors.textSecondary} style={styles.cardCopy}>
|
||||
Discovery is running. Use QR or manual pairing if this desktop does not appear.
|
||||
</Text>
|
||||
)
|
||||
) : (
|
||||
<Text variant="caption" color={colors.textSecondary} style={styles.cardCopy}>
|
||||
Android LAN discovery is not available in this build. Use QR or manual pairing.
|
||||
</Text>
|
||||
)}
|
||||
{pinPairing ? (
|
||||
<View style={styles.pinPanel}>
|
||||
<View style={styles.pinPanelHeader}>
|
||||
<View>
|
||||
<Text variant="body">{pinPairing.desktopName || 'Astra Desktop'}</Text>
|
||||
<Text variant="caption" color={colors.textSecondary}>
|
||||
{hostFromBaseUrl(pinPairing.baseUrl)}
|
||||
</Text>
|
||||
</View>
|
||||
<Text variant="mono" color={colors.accentText}>
|
||||
{pinCountdown}
|
||||
</Text>
|
||||
</View>
|
||||
<TextInput
|
||||
style={[styles.input, styles.pinInput]}
|
||||
value={pinInput}
|
||||
onChangeText={updatePinInput}
|
||||
placeholder="000000"
|
||||
placeholderTextColor={colors.textTertiary}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType="number-pad"
|
||||
maxLength={6}
|
||||
textContentType="oneTimeCode"
|
||||
/>
|
||||
<Pressable
|
||||
style={[
|
||||
styles.primaryButton,
|
||||
(normalizedPinInput.length !== 6 || !pinPairingActive) && styles.buttonDisabled,
|
||||
]}
|
||||
disabled={normalizedPinInput.length !== 6 || isBusy || !pinPairingActive}
|
||||
onPress={submitPin}
|
||||
>
|
||||
<Ionicons name="checkmark" size={18} color={colors.accentTextStrong} />
|
||||
<Text variant="body" color={colors.accentTextStrong}>
|
||||
Confirm PIN
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
<View style={styles.card}>
|
||||
<View style={styles.cardHeader}>
|
||||
<Text variant="body">Pair with QR</Text>
|
||||
{isBusy ? <ActivityIndicator color={colors.accent} /> : null}
|
||||
</View>
|
||||
<Text variant="caption" color={colors.textSecondary} style={styles.cardCopy}>
|
||||
Open Astra Desktop settings, enable Phone Remote, then scan or paste the pairing link.
|
||||
</Text>
|
||||
<View style={styles.actionRow}>
|
||||
<Pressable style={styles.primaryButton} onPress={() => router.push('/desktop-remote/scan' as never)}>
|
||||
<Ionicons name="scan" size={18} color={colors.accentTextStrong} />
|
||||
<Text variant="body" color={colors.accentTextStrong}>
|
||||
Scan QR
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={pairingLink}
|
||||
onChangeText={setPairingLink}
|
||||
placeholder="Paste pairing link"
|
||||
placeholderTextColor={colors.textTertiary}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType="url"
|
||||
/>
|
||||
<Pressable
|
||||
style={[styles.secondaryButton, !pairingLink.trim() && styles.buttonDisabled]}
|
||||
disabled={!pairingLink.trim()}
|
||||
onPress={() => void pairFromInput(pairingLink)}
|
||||
>
|
||||
<Text variant="body" color={pairingLink.trim() ? colors.textPrimary : colors.textTertiary}>
|
||||
Pair from link
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<View style={styles.card}>
|
||||
<View style={styles.cardHeader}>
|
||||
<Text variant="body">Manual fallback</Text>
|
||||
</View>
|
||||
<Text variant="caption" color={colors.textSecondary} style={styles.cardCopy}>
|
||||
Enter the desktop URL and pairing code from Astra Desktop.
|
||||
</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={manualBaseUrl}
|
||||
onChangeText={setManualBaseUrl}
|
||||
placeholder="http://desktop-ip:38402"
|
||||
placeholderTextColor={colors.textTertiary}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType="url"
|
||||
/>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={manualTicket}
|
||||
onChangeText={setManualTicket}
|
||||
placeholder="Pairing code"
|
||||
placeholderTextColor={colors.textTertiary}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
<Pressable
|
||||
style={[
|
||||
styles.secondaryButton,
|
||||
(!manualBaseUrl.trim() || !manualTicket.trim()) && styles.buttonDisabled,
|
||||
]}
|
||||
disabled={!manualBaseUrl.trim() || !manualTicket.trim()}
|
||||
onPress={() => void pairManual(manualBaseUrl, manualTicket)}
|
||||
>
|
||||
<Text
|
||||
variant="body"
|
||||
color={manualBaseUrl.trim() && manualTicket.trim() ? colors.textPrimary : colors.textTertiary}
|
||||
>
|
||||
Pair manually
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{pinPairing ? (
|
||||
<View style={styles.statusBox}>
|
||||
<Text variant="body">Enter the PIN shown on desktop</Text>
|
||||
<Text variant="mono" color={colors.accentText}>
|
||||
{pinCountdown}
|
||||
</Text>
|
||||
</View>
|
||||
) : pairing ? (
|
||||
<View style={styles.statusBox}>
|
||||
<Text variant="body">Waiting for desktop approval</Text>
|
||||
<Text variant="mono" color={colors.accentText}>
|
||||
{countdown}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{errorMessage ? (
|
||||
<Text variant="caption" color={colors.warning} style={styles.feedback}>
|
||||
{errorMessage}
|
||||
</Text>
|
||||
) : null}
|
||||
</ScrollView>
|
||||
);
|
||||
|
||||
const renderController = () => (
|
||||
<View
|
||||
style={[
|
||||
styles.remoteContent,
|
||||
{
|
||||
paddingHorizontal: remoteLayout.contentPadding,
|
||||
paddingBottom: insets.bottom + CONTENT_BOTTOM_PADDING,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View style={[styles.remoteShell, { width: remoteLayout.contentWidth }]}>
|
||||
<View style={styles.remoteNowHeader}>
|
||||
<Pressable style={styles.headerBtn} onPress={() => router.back()} hitSlop={12}>
|
||||
<Ionicons name="chevron-down" size={26} color={colors.textSecondary} />
|
||||
</Pressable>
|
||||
<View style={styles.headerMid}>
|
||||
<Text variant="caption" style={styles.eyebrow}>
|
||||
PLAYING FROM
|
||||
</Text>
|
||||
<Text variant="label" numberOfLines={1} style={styles.source}>
|
||||
{remoteSource}
|
||||
</Text>
|
||||
</View>
|
||||
<Pressable
|
||||
style={styles.headerBtn}
|
||||
onPress={() => void reconnect()}
|
||||
hitSlop={12}
|
||||
accessibilityLabel="Reconnect to desktop"
|
||||
>
|
||||
<Ionicons name="refresh" size={20} color={colors.textSecondary} />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{currentTrack ? (
|
||||
<View style={styles.remotePlayer}>
|
||||
<View
|
||||
style={[
|
||||
styles.middleStack,
|
||||
{
|
||||
marginTop: remoteLayout.mediaTopMargin,
|
||||
marginBottom: remoteLayout.mediaBottomGap,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
styles.artCard,
|
||||
{
|
||||
width: remoteLayout.artSize,
|
||||
height: remoteLayout.artSize,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{art ? (
|
||||
<Image
|
||||
key={currentTrack.id}
|
||||
source={{ uri: art }}
|
||||
style={styles.artImage}
|
||||
contentFit="cover"
|
||||
/>
|
||||
) : (
|
||||
<AstraLogo size={Math.round(remoteLayout.artSize * 0.4)} />
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.spacer} />
|
||||
|
||||
<View style={styles.playerControls}>
|
||||
<View style={styles.trackInfo}>
|
||||
<View style={styles.trackTextStack}>
|
||||
<MarqueeText
|
||||
variant="heading"
|
||||
containerStyle={styles.trackTitle}
|
||||
style={styles.trackTitleText}
|
||||
>
|
||||
{currentTrack.title}
|
||||
</MarqueeText>
|
||||
<MarqueeText variant="body" style={styles.artist}>
|
||||
{currentTrack.artist || currentTrack.album || remoteSource}
|
||||
</MarqueeText>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<SeekBar
|
||||
currentTime={snapshot?.currentTime ?? 0}
|
||||
duration={snapshot?.duration ?? 0}
|
||||
trackKey={currentTrack.id}
|
||||
onSeek={(seconds) => void sendControl('seek', seconds)}
|
||||
/>
|
||||
|
||||
<View style={styles.transport}>
|
||||
<Pressable
|
||||
hitSlop={10}
|
||||
style={styles.transportSideBtn}
|
||||
onPress={() => void reconnect()}
|
||||
accessibilityLabel="Reconnect"
|
||||
>
|
||||
<Ionicons name="refresh" size={SUB_ICON_SIZE + 2} color={colors.textTertiary} />
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={() => void sendControl('previous')}
|
||||
hitSlop={12}
|
||||
style={styles.transportMainBtn}
|
||||
accessibilityLabel="Previous"
|
||||
>
|
||||
<Ionicons name="play-skip-back" size={SKIP_ICON_SIZE} color={colors.textPrimary} />
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={() => void sendControl(isPlaying ? 'pause' : 'play')}
|
||||
hitSlop={12}
|
||||
style={[styles.playButton, { backgroundColor: accent }]}
|
||||
accessibilityLabel={isPlaying ? 'Pause desktop' : 'Play desktop'}
|
||||
>
|
||||
<Ionicons
|
||||
name={snapshot?.playbackState === 'loading' ? 'ellipsis-horizontal' : isPlaying ? 'pause' : 'play'}
|
||||
size={PLAY_ICON_SIZE}
|
||||
color={colors.bgPrimary}
|
||||
/>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={() => void sendControl('next')}
|
||||
hitSlop={12}
|
||||
style={styles.transportMainBtn}
|
||||
accessibilityLabel="Next"
|
||||
>
|
||||
<Ionicons name="play-skip-forward" size={SKIP_ICON_SIZE} color={colors.textPrimary} />
|
||||
</Pressable>
|
||||
<Pressable
|
||||
hitSlop={10}
|
||||
style={styles.transportSideBtn}
|
||||
onPress={() => void sendControl('toggle-favorite')}
|
||||
accessibilityLabel={currentTrack.isFavorite ? 'Remove from favorites' : 'Add to favorites'}
|
||||
accessibilityState={{ selected: currentTrack.isFavorite }}
|
||||
>
|
||||
<Ionicons
|
||||
name={currentTrack.isFavorite ? 'heart' : 'heart-outline'}
|
||||
size={SUB_ICON_SIZE + 4}
|
||||
color={currentTrack.isFavorite ? colors.accent : colors.textTertiary}
|
||||
/>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<View style={styles.subRow}>
|
||||
<View style={styles.statusPill}>
|
||||
<View
|
||||
style={[
|
||||
styles.statusDot,
|
||||
{ backgroundColor: connectionState === 'connected' ? accent : colors.warning },
|
||||
]}
|
||||
/>
|
||||
<Text variant="label" color={colors.textSecondary}>
|
||||
{connectionLabel(connectionState)}
|
||||
</Text>
|
||||
</View>
|
||||
<Text variant="caption" color={colors.textTertiary} numberOfLines={1} style={styles.remoteDetail}>
|
||||
{remoteDetail}
|
||||
</Text>
|
||||
<Pressable
|
||||
hitSlop={10}
|
||||
style={styles.subBtn}
|
||||
onPress={confirmForget}
|
||||
accessibilityLabel="Forget desktop"
|
||||
>
|
||||
<Ionicons name="trash-outline" size={SUB_ICON_SIZE + 2} color={colors.warning} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.remoteEmpty}>
|
||||
<AstraLogo size={72} />
|
||||
<Text variant="heading" style={styles.emptyTitle}>
|
||||
Nothing playing
|
||||
</Text>
|
||||
<Text variant="body" color={colors.textSecondary} style={styles.centered}>
|
||||
{statusText}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{errorMessage ? (
|
||||
<Text variant="caption" color={colors.warning} style={styles.remoteFeedback}>
|
||||
{errorMessage}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
|
||||
const showController = Boolean(connection && connectionState !== 'unpaired');
|
||||
|
||||
return (
|
||||
<Screen padded={!showController}>
|
||||
<KeyboardAvoidingView
|
||||
style={styles.flex}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
>
|
||||
{!initialized ? (
|
||||
<View style={styles.loading}>
|
||||
<ActivityIndicator color={colors.accent} />
|
||||
</View>
|
||||
) : showController ? (
|
||||
renderController()
|
||||
) : (
|
||||
<>
|
||||
<View style={styles.topBar}>
|
||||
<Pressable style={styles.back} onPress={() => router.back()} hitSlop={8}>
|
||||
<Ionicons name="chevron-back" size={22} color={colors.textSecondary} />
|
||||
<Text variant="body" color={colors.textSecondary}>
|
||||
Settings
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
{renderSetup()}
|
||||
</>
|
||||
)}
|
||||
</KeyboardAvoidingView>
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
|
||||
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',
|
||||
},
|
||||
});
|
||||
@@ -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 (
|
||||
<Screen>
|
||||
<View style={styles.header}>
|
||||
<Pressable style={styles.back} onPress={() => router.back()} hitSlop={8}>
|
||||
<Ionicons name="chevron-back" size={22} color={colors.textSecondary} />
|
||||
<Text variant="body" color={colors.textSecondary}>
|
||||
Desktop Remote
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<Text variant="title" style={styles.heading}>
|
||||
Scan pairing QR
|
||||
</Text>
|
||||
|
||||
{!permission ? (
|
||||
<View style={styles.center}>
|
||||
<ActivityIndicator color={colors.accent} />
|
||||
</View>
|
||||
) : !permission.granted ? (
|
||||
<View style={styles.permissionCard}>
|
||||
<Ionicons name="camera-outline" size={28} color={colors.accent} />
|
||||
<Text variant="body">Camera access is needed to scan the desktop pairing QR.</Text>
|
||||
<Pressable style={styles.primaryButton} onPress={() => void requestPermission()}>
|
||||
<Text variant="body" color={colors.accentTextStrong}>
|
||||
Allow camera
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.scannerFrame}>
|
||||
<CameraView
|
||||
style={styles.camera}
|
||||
facing="back"
|
||||
barcodeScannerSettings={{ barcodeTypes: ['qr'] }}
|
||||
onBarcodeScanned={locked ? undefined : onScanned}
|
||||
/>
|
||||
<View pointerEvents="none" style={styles.scanBox} />
|
||||
{locked ? (
|
||||
<View style={styles.locked}>
|
||||
<ActivityIndicator color={colors.accentTextStrong} />
|
||||
<Text variant="body" color={colors.accentTextStrong}>
|
||||
Pairing...
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
)}
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
@@ -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<T>(
|
||||
baseUrl: string,
|
||||
path: string,
|
||||
options: JsonRequestOptions = {}
|
||||
): Promise<T> {
|
||||
const headers: Record<string, string> = {
|
||||
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<string, unknown>;
|
||||
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<DesktopRemoteIdentity | null> {
|
||||
try {
|
||||
const payload = await fetchJson<unknown>(baseUrl, '/v1/identity');
|
||||
return normalizeIdentity(payload);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function claimDesktopRemotePairingTicket(
|
||||
baseUrl: string,
|
||||
ticket: string,
|
||||
deviceName: string = defaultDesktopRemoteDeviceName()
|
||||
): Promise<DesktopRemotePairingClaim> {
|
||||
const payload = await fetchJson<Record<string, unknown>>(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<DesktopRemotePinPairingRequest> {
|
||||
const payload = await fetchJson<Record<string, unknown>>(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<DesktopRemotePairingStatus> {
|
||||
const payload = await fetchJson<Record<string, unknown>>(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<DesktopRemotePairingStatus> {
|
||||
const payload = await fetchJson<Record<string, unknown>>(
|
||||
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<DesktopRemoteNowPlayingSnapshot> {
|
||||
return fetchJson<DesktopRemoteNowPlayingSnapshot>(
|
||||
baseUrl,
|
||||
`/v1/now-playing${inlineArtwork ? '?inlineArtwork=1' : ''}`,
|
||||
{ token }
|
||||
);
|
||||
}
|
||||
|
||||
export async function sendDesktopRemoteControl(
|
||||
baseUrl: string,
|
||||
token: string,
|
||||
command: DesktopRemoteControlCommand,
|
||||
time?: number
|
||||
): Promise<void> {
|
||||
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();
|
||||
};
|
||||
}
|
||||
@@ -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<DesktopRemoteConnection | null> {
|
||||
const raw = await SecureStore.getItemAsync(CONNECTION_KEY);
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Partial<DesktopRemoteConnection>;
|
||||
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<void> {
|
||||
await SecureStore.setItemAsync(CONNECTION_KEY, JSON.stringify(connection));
|
||||
}
|
||||
|
||||
export async function getDesktopRemoteToken(): Promise<string | null> {
|
||||
const token = await SecureStore.getItemAsync(TOKEN_KEY);
|
||||
return token && token.trim() ? token.trim() : null;
|
||||
}
|
||||
|
||||
export async function setDesktopRemoteToken(token: string): Promise<void> {
|
||||
await SecureStore.setItemAsync(TOKEN_KEY, token);
|
||||
}
|
||||
|
||||
export async function clearDesktopRemotePairing(): Promise<void> {
|
||||
await Promise.all([
|
||||
SecureStore.deleteItemAsync(CONNECTION_KEY),
|
||||
SecureStore.deleteItemAsync(TOKEN_KEY),
|
||||
]);
|
||||
}
|
||||
@@ -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<DiscoveryEvents> {
|
||||
start(): Promise<void>;
|
||||
stop(): Promise<void>;
|
||||
getCached(): DesktopRemoteDiscoveredDesktop[];
|
||||
}
|
||||
|
||||
const native = requireOptionalNativeModule<AstraDesktopDiscoveryModuleType>('AstraDesktopDiscovery');
|
||||
|
||||
export const desktopRemoteDiscoveryAvailable = Platform.OS === 'android' && native != null;
|
||||
|
||||
export const AstraDesktopDiscovery = native ?? {
|
||||
addListener: () => ({ remove: () => {} }),
|
||||
removeAllListeners: () => {},
|
||||
start: async () => {},
|
||||
stop: async () => {},
|
||||
getCached: () => [],
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<void>;
|
||||
startDiscovery: () => Promise<void>;
|
||||
stopDiscovery: () => Promise<void>;
|
||||
requestPinPairing: (baseUrl: string) => Promise<void>;
|
||||
confirmPinPairing: (pin: string) => Promise<void>;
|
||||
pairFromInput: (input: string) => Promise<void>;
|
||||
pairManual: (baseUrl: string, ticket: string) => Promise<void>;
|
||||
connect: () => Promise<boolean>;
|
||||
reconnect: () => Promise<void>;
|
||||
disconnect: () => void;
|
||||
forget: () => Promise<void>;
|
||||
sendControl: (command: DesktopRemoteControlCommand, time?: number) => Promise<void>;
|
||||
}
|
||||
|
||||
let pairingPollTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let snapshotPollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | 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<DesktopRemoteConnection> {
|
||||
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<DesktopRemoteStore>((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) });
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user