diff --git a/apps/mobile/.gitignore b/apps/mobile/.gitignore
index 4b00baf..e9ab2b1 100644
--- a/apps/mobile/.gitignore
+++ b/apps/mobile/.gitignore
@@ -41,3 +41,12 @@ example
# generated native folders
/ios
/android
+
+# Generated by `expo prebuild` (Continuous Native Generation); configure via app.json instead
+/android
+/ios
+
+# Gradle output inside local native modules
+/modules/*/android/build
+/modules/*/android/.gradle
+/modules/*/android/.cxx
diff --git a/apps/mobile/metro.config.js b/apps/mobile/metro.config.js
new file mode 100644
index 0000000..409233e
--- /dev/null
+++ b/apps/mobile/metro.config.js
@@ -0,0 +1,20 @@
+// https://docs.expo.dev/guides/customizing-metro/
+const { getDefaultConfig } = require('expo/metro-config');
+
+const config = getDefaultConfig(__dirname);
+
+// Metro watches the whole monorepo. Keep it out of build output (Gradle, Xcode,
+// Next.js, tsup): those folders hold tens of thousands of files, and exhausting
+// the OS file-watch limit makes Metro silently stop picking up code changes.
+const buildOutput = [
+ /[/\\]apps[/\\]mobile[/\\]android[/\\]/,
+ /[/\\]apps[/\\]mobile[/\\]ios[/\\]/,
+ /[/\\]modules[/\\][^/\\]+[/\\]android[/\\]build[/\\]/,
+ /[/\\]apps[/\\]web[/\\]\.next[/\\]/,
+ /[/\\]apps[/\\]api[/\\]dist[/\\]/,
+ /[/\\]\.turbo[/\\]/,
+];
+const existing = config.resolver.blockList;
+config.resolver.blockList = [...(Array.isArray(existing) ? existing : existing ? [existing] : []), ...buildOutput];
+
+module.exports = config;
diff --git a/apps/mobile/modules/now-playing-reader/android/build.gradle b/apps/mobile/modules/now-playing-reader/android/build.gradle
new file mode 100644
index 0000000..c1596d9
--- /dev/null
+++ b/apps/mobile/modules/now-playing-reader/android/build.gradle
@@ -0,0 +1,23 @@
+plugins {
+ id 'com.android.library'
+ id 'expo-module-gradle-plugin'
+}
+
+group = 'tech.chrisedwards.musicshare.nowplaying'
+version = '0.1.0'
+
+android {
+ namespace "tech.chrisedwards.musicshare.nowplaying"
+ defaultConfig {
+ versionCode 1
+ versionName "0.1.0"
+ }
+ lintOptions {
+ abortOnError false
+ }
+}
+
+dependencies {
+ // Delivers reports even if the app process is killed, and retries when the network returns.
+ implementation "androidx.work:work-runtime-ktx:2.10.5"
+}
diff --git a/apps/mobile/modules/now-playing-reader/android/src/main/AndroidManifest.xml b/apps/mobile/modules/now-playing-reader/android/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..8001090
--- /dev/null
+++ b/apps/mobile/modules/now-playing-reader/android/src/main/AndroidManifest.xml
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/mobile/modules/now-playing-reader/android/src/main/java/tech/chrisedwards/musicshare/nowplaying/MediaListenerService.kt b/apps/mobile/modules/now-playing-reader/android/src/main/java/tech/chrisedwards/musicshare/nowplaying/MediaListenerService.kt
new file mode 100644
index 0000000..11cbde0
--- /dev/null
+++ b/apps/mobile/modules/now-playing-reader/android/src/main/java/tech/chrisedwards/musicshare/nowplaying/MediaListenerService.kt
@@ -0,0 +1,162 @@
+package tech.chrisedwards.musicshare.nowplaying
+
+import android.content.ComponentName
+import android.content.Context
+import android.content.pm.PackageManager
+import android.media.MediaMetadata
+import android.media.session.MediaController
+import android.media.session.MediaSessionManager
+import android.media.session.PlaybackState
+import android.os.Handler
+import android.os.Looper
+import android.service.notification.NotificationListenerService
+
+/**
+ * Bound by the system once the user grants notification access. We don't
+ * override onNotificationPosted: notification access is only the key that
+ * unlocks MediaSessionManager.getActiveSessions(), which tells us what music
+ * apps are playing. Changes are debounced (apps publish title and artist in
+ * separate updates), deduplicated, and handed to Reporter.
+ */
+class MediaListenerService : NotificationListenerService() {
+ private val handler = Handler(Looper.getMainLooper())
+ private val callbacks = mutableMapOf()
+ private var sessionManager: MediaSessionManager? = null
+ private var lastReported: Playback? = null
+
+ private val component by lazy { ComponentName(this, MediaListenerService::class.java) }
+ private val prefs by lazy { Prefs(this) }
+
+ private val sessionsChanged = MediaSessionManager.OnActiveSessionsChangedListener { controllers ->
+ track(controllers.orEmpty())
+ }
+ private val evaluateRunnable = Runnable { evaluate() }
+ private val heartbeatRunnable = object : Runnable {
+ override fun run() {
+ // Re-send while playing so the server knows we're still here (it expires silent phones).
+ lastReported?.takeIf { it.state == PlayState.PLAYING }?.let { Reporter.report(this@MediaListenerService, it) }
+ handler.postDelayed(this, HEARTBEAT_MS)
+ }
+ }
+
+ override fun onListenerConnected() {
+ super.onListenerConnected()
+ instance = this
+ val manager = getSystemService(Context.MEDIA_SESSION_SERVICE) as MediaSessionManager
+ sessionManager = manager
+ try {
+ manager.addOnActiveSessionsChangedListener(sessionsChanged, component, handler)
+ track(manager.getActiveSessions(component))
+ } catch (e: SecurityException) {
+ // Access was revoked between binding and now.
+ }
+ handler.postDelayed(heartbeatRunnable, HEARTBEAT_MS)
+ }
+
+ override fun onListenerDisconnected() {
+ instance = null
+ handler.removeCallbacksAndMessages(null)
+ sessionManager?.removeOnActiveSessionsChangedListener(sessionsChanged)
+ untrackAll()
+ NowPlayingBus.emitPlayback(null)
+ super.onListenerDisconnected()
+ }
+
+ /** Called when JS changes configuration (e.g. which apps to share). */
+ internal fun refresh() {
+ val manager = sessionManager ?: return
+ try {
+ track(manager.getActiveSessions(component))
+ } catch (e: SecurityException) {
+ return
+ }
+ }
+
+ private fun track(controllers: List) {
+ untrackAll()
+ for (controller in controllers) {
+ val callback = object : MediaController.Callback() {
+ override fun onPlaybackStateChanged(state: PlaybackState?) = scheduleEvaluate()
+ override fun onMetadataChanged(metadata: MediaMetadata?) = scheduleEvaluate()
+ override fun onSessionDestroyed() = scheduleEvaluate()
+ }
+ controller.registerCallback(callback, handler)
+ callbacks[controller] = callback
+ }
+ scheduleEvaluate()
+ }
+
+ private fun untrackAll() {
+ callbacks.forEach { (controller, callback) -> controller.unregisterCallback(callback) }
+ callbacks.clear()
+ }
+
+ private fun scheduleEvaluate() {
+ handler.removeCallbacks(evaluateRunnable)
+ handler.postDelayed(evaluateRunnable, DEBOUNCE_MS)
+ }
+
+ private fun evaluate() {
+ val allowed = prefs.allowedPackages
+ val candidates = callbacks.keys
+ .filter { it.packageName in allowed }
+ .mapNotNull { snapshot(it) }
+ // Prefer whatever is actually playing; otherwise a paused session in an allowed app.
+ val current = candidates.firstOrNull { it.state == PlayState.PLAYING } ?: candidates.firstOrNull()
+ NowPlayingBus.emitPlayback(current)
+
+ val next = when {
+ current != null -> current
+ // The app we were reporting went away entirely: tell the server it stopped.
+ lastReported != null && lastReported!!.state != PlayState.STOPPED ->
+ Playback.stopped(lastReported!!.packageName, lastReported!!.appName)
+ else -> return
+ }
+ if (next.identity == lastReported?.identity) return
+ lastReported = next
+ if (prefs.isConfigured) Reporter.report(this, next)
+ }
+
+ private fun snapshot(controller: MediaController): Playback? {
+ val metadata = controller.metadata
+ val title = metadata?.text(MediaMetadata.METADATA_KEY_TITLE) ?: metadata?.text(MediaMetadata.METADATA_KEY_DISPLAY_TITLE)
+ val artist = metadata?.text(MediaMetadata.METADATA_KEY_ARTIST)
+ ?: metadata?.text(MediaMetadata.METADATA_KEY_ALBUM_ARTIST)
+ ?: metadata?.text(MediaMetadata.METADATA_KEY_DISPLAY_SUBTITLE)
+ val playbackState = controller.playbackState
+ val state = when (playbackState?.state) {
+ PlaybackState.STATE_PLAYING, PlaybackState.STATE_BUFFERING,
+ PlaybackState.STATE_FAST_FORWARDING, PlaybackState.STATE_REWINDING,
+ PlaybackState.STATE_SKIPPING_TO_NEXT, PlaybackState.STATE_SKIPPING_TO_PREVIOUS,
+ PlaybackState.STATE_CONNECTING -> PlayState.PLAYING
+ PlaybackState.STATE_PAUSED -> PlayState.PAUSED
+ else -> PlayState.STOPPED
+ }
+ if (title.isNullOrBlank() && state != PlayState.STOPPED) return null // between tracks / ads with no metadata
+ return Playback(
+ packageName = controller.packageName,
+ appName = appLabel(controller.packageName),
+ state = state,
+ title = title,
+ artist = artist,
+ album = metadata?.text(MediaMetadata.METADATA_KEY_ALBUM),
+ durationMs = metadata?.getLong(MediaMetadata.METADATA_KEY_DURATION)?.takeIf { it > 0 },
+ positionMs = playbackState?.position?.takeIf { it >= 0 },
+ )
+ }
+
+ private fun MediaMetadata.text(key: String): String? = getString(key)?.trim()?.takeIf { it.isNotEmpty() }
+
+ private fun appLabel(packageName: String): String? = try {
+ packageManager.getApplicationLabel(packageManager.getApplicationInfo(packageName, 0)).toString()
+ } catch (e: PackageManager.NameNotFoundException) {
+ null
+ }
+
+ companion object {
+ private const val DEBOUNCE_MS = 1_500L
+ private const val HEARTBEAT_MS = 4 * 60_000L
+
+ @Volatile internal var instance: MediaListenerService? = null
+ }
+}
diff --git a/apps/mobile/modules/now-playing-reader/android/src/main/java/tech/chrisedwards/musicshare/nowplaying/NowPlayingBus.kt b/apps/mobile/modules/now-playing-reader/android/src/main/java/tech/chrisedwards/musicshare/nowplaying/NowPlayingBus.kt
new file mode 100644
index 0000000..2ad2d6b
--- /dev/null
+++ b/apps/mobile/modules/now-playing-reader/android/src/main/java/tech/chrisedwards/musicshare/nowplaying/NowPlayingBus.kt
@@ -0,0 +1,26 @@
+package tech.chrisedwards.musicshare.nowplaying
+
+/** Bridges the listener service (system-bound, no JS) to the JS module when the app is open. */
+internal object NowPlayingBus {
+ @Volatile var current: Playback? = null
+ private set
+
+ private val listeners = mutableSetOf()
+
+ interface Listener {
+ fun onPlayback(playback: Playback?)
+ fun onStatus(status: String)
+ }
+
+ @Synchronized fun add(listener: Listener) { listeners.add(listener) }
+ @Synchronized fun remove(listener: Listener) { listeners.remove(listener) }
+
+ @Synchronized fun emitPlayback(playback: Playback?) {
+ current = playback
+ listeners.forEach { it.onPlayback(playback) }
+ }
+
+ @Synchronized fun emitStatus(status: String) {
+ listeners.forEach { it.onStatus(status) }
+ }
+}
diff --git a/apps/mobile/modules/now-playing-reader/android/src/main/java/tech/chrisedwards/musicshare/nowplaying/NowPlayingReaderModule.kt b/apps/mobile/modules/now-playing-reader/android/src/main/java/tech/chrisedwards/musicshare/nowplaying/NowPlayingReaderModule.kt
new file mode 100644
index 0000000..0d74507
--- /dev/null
+++ b/apps/mobile/modules/now-playing-reader/android/src/main/java/tech/chrisedwards/musicshare/nowplaying/NowPlayingReaderModule.kt
@@ -0,0 +1,90 @@
+package tech.chrisedwards.musicshare.nowplaying
+
+import android.content.ComponentName
+import android.content.Intent
+import android.os.Build
+import android.provider.Settings
+import androidx.core.app.NotificationManagerCompat
+import expo.modules.kotlin.exception.Exceptions
+import expo.modules.kotlin.modules.Module
+import expo.modules.kotlin.modules.ModuleDefinition
+
+class NowPlayingReaderModule : Module() {
+ private val context get() = appContext.reactContext ?: throw Exceptions.ReactContextLost()
+
+ private val busListener = object : NowPlayingBus.Listener {
+ override fun onPlayback(playback: Playback?) {
+ sendEvent("onPlaybackChange", mapOf("playback" to playback?.toMap()))
+ }
+ override fun onStatus(status: String) {
+ sendEvent("onStatusChange", mapOf("status" to status))
+ }
+ }
+
+ override fun definition() = ModuleDefinition {
+ Name("NowPlayingReader")
+
+ Events("onPlaybackChange", "onStatusChange")
+
+ OnStartObserving { NowPlayingBus.add(busListener) }
+ OnStopObserving { NowPlayingBus.remove(busListener) }
+
+ /** Whether the user has granted notification access (required to see media sessions). */
+ Function("isPermissionGranted") {
+ NotificationManagerCompat.getEnabledListenerPackages(context).contains(context.packageName)
+ }
+
+ /** Opens the system screen with our toggle (directly on Android 11+, the list before that). */
+ Function("openPermissionSettings") {
+ val intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ Intent(Settings.ACTION_NOTIFICATION_LISTENER_DETAIL_SETTINGS).putExtra(
+ Settings.EXTRA_NOTIFICATION_LISTENER_COMPONENT_NAME,
+ ComponentName(context, MediaListenerService::class.java).flattenToString(),
+ )
+ } else {
+ Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS)
+ }
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ try {
+ context.startActivity(intent)
+ } catch (e: android.content.ActivityNotFoundException) {
+ context.startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
+ }
+ }
+
+ /** Where and how to report, and which apps' sessions to share. */
+ Function("configure") { apiUrl: String, deviceToken: String, allowedPackages: List ->
+ val prefs = Prefs(context)
+ val tokenChanged = prefs.deviceToken != deviceToken
+ prefs.apiUrl = apiUrl
+ prefs.deviceToken = deviceToken
+ prefs.allowedPackages = allowedPackages.toSet()
+ if (tokenChanged) prefs.unauthorized = false
+ MediaListenerService.instance?.refresh()
+ }
+
+ /** Re-send what's playing now (after resuming sharing). */
+ Function("resend") {
+ // Use the last snapshot the listener published rather than the service instance,
+ // which isn't reachable from here if the system rebound the service.
+ val prefs = Prefs(context)
+ NowPlayingBus.current?.let { if (prefs.isConfigured) Reporter.report(context, it) }
+ }
+
+ /** Stop reporting (sign out). */
+ Function("clear") {
+ Prefs(context).clear()
+ NowPlayingBus.emitPlayback(null)
+ }
+
+ /** True when the server rejected this device's token; JS should register a new device. */
+ Function("needsReregistration") {
+ Prefs(context).unauthorized
+ }
+
+ /** What the listener currently sees, for showing "Sharing: X in Spotify" in settings. */
+ Function("getCurrent") {
+ NowPlayingBus.current?.toMap()
+ }
+ }
+}
diff --git a/apps/mobile/modules/now-playing-reader/android/src/main/java/tech/chrisedwards/musicshare/nowplaying/Playback.kt b/apps/mobile/modules/now-playing-reader/android/src/main/java/tech/chrisedwards/musicshare/nowplaying/Playback.kt
new file mode 100644
index 0000000..191a0b0
--- /dev/null
+++ b/apps/mobile/modules/now-playing-reader/android/src/main/java/tech/chrisedwards/musicshare/nowplaying/Playback.kt
@@ -0,0 +1,50 @@
+package tech.chrisedwards.musicshare.nowplaying
+
+import org.json.JSONObject
+
+internal enum class PlayState(val wire: String) { PLAYING("playing"), PAUSED("paused"), STOPPED("stopped") }
+
+/** A snapshot of what one app is playing. */
+internal data class Playback(
+ val packageName: String,
+ val appName: String?,
+ val state: PlayState,
+ val title: String?,
+ val artist: String?,
+ val album: String?,
+ val durationMs: Long?,
+ val positionMs: Long?,
+) {
+ /** Fields that matter for "did anything change": not position, which ticks constantly. */
+ val identity: String get() = listOf(packageName, state.wire, title, artist).joinToString("\u0000")
+
+ fun toJson(): JSONObject = JSONObject().apply {
+ put("app", packageName)
+ appName?.let { put("appName", it) }
+ put("state", state.wire)
+ if (state != PlayState.STOPPED && !title.isNullOrBlank()) {
+ put("track", JSONObject().apply {
+ put("title", title)
+ put("artist", artist ?: "")
+ album?.let { put("album", it) }
+ durationMs?.takeIf { it > 0 }?.let { put("durationMs", it) }
+ })
+ }
+ positionMs?.takeIf { it >= 0 }?.let { put("positionMs", it) }
+ }
+
+ fun toMap(): Map = mapOf(
+ "app" to packageName,
+ "appName" to appName,
+ "state" to state.wire,
+ "title" to title,
+ "artist" to artist,
+ "album" to album,
+ "durationMs" to durationMs?.toDouble(),
+ )
+
+ companion object {
+ fun stopped(packageName: String, appName: String?) =
+ Playback(packageName, appName, PlayState.STOPPED, null, null, null, null, null)
+ }
+}
diff --git a/apps/mobile/modules/now-playing-reader/android/src/main/java/tech/chrisedwards/musicshare/nowplaying/Prefs.kt b/apps/mobile/modules/now-playing-reader/android/src/main/java/tech/chrisedwards/musicshare/nowplaying/Prefs.kt
new file mode 100644
index 0000000..6a0cabd
--- /dev/null
+++ b/apps/mobile/modules/now-playing-reader/android/src/main/java/tech/chrisedwards/musicshare/nowplaying/Prefs.kt
@@ -0,0 +1,30 @@
+package tech.chrisedwards.musicshare.nowplaying
+
+import android.content.Context
+
+/** Configuration written by JS (configure) and read by the listener service, which may run without the app UI. */
+internal class Prefs(context: Context) {
+ private val prefs = context.applicationContext.getSharedPreferences("musicshare.now_playing", Context.MODE_PRIVATE)
+
+ var apiUrl: String?
+ get() = prefs.getString("api_url", null)
+ set(value) = prefs.edit().putString("api_url", value).apply()
+
+ var deviceToken: String?
+ get() = prefs.getString("device_token", null)
+ set(value) = prefs.edit().putString("device_token", value).apply()
+
+ var allowedPackages: Set
+ get() = prefs.getStringSet("allowed_packages", emptySet()) ?: emptySet()
+ set(value) = prefs.edit().putStringSet("allowed_packages", value).apply()
+
+ /** Set when the server rejects the token (device revoked); JS re-registers. */
+ var unauthorized: Boolean
+ get() = prefs.getBoolean("unauthorized", false)
+ set(value) = prefs.edit().putBoolean("unauthorized", value).apply()
+
+ val isConfigured: Boolean
+ get() = !apiUrl.isNullOrEmpty() && !deviceToken.isNullOrEmpty() && !unauthorized
+
+ fun clear() = prefs.edit().clear().apply()
+}
diff --git a/apps/mobile/modules/now-playing-reader/android/src/main/java/tech/chrisedwards/musicshare/nowplaying/Reporter.kt b/apps/mobile/modules/now-playing-reader/android/src/main/java/tech/chrisedwards/musicshare/nowplaying/Reporter.kt
new file mode 100644
index 0000000..a10a813
--- /dev/null
+++ b/apps/mobile/modules/now-playing-reader/android/src/main/java/tech/chrisedwards/musicshare/nowplaying/Reporter.kt
@@ -0,0 +1,77 @@
+package tech.chrisedwards.musicshare.nowplaying
+
+import android.content.Context
+import androidx.work.BackoffPolicy
+import androidx.work.Constraints
+import androidx.work.CoroutineWorker
+import androidx.work.ExistingWorkPolicy
+import androidx.work.NetworkType
+import androidx.work.OneTimeWorkRequestBuilder
+import androidx.work.WorkManager
+import androidx.work.WorkerParameters
+import androidx.work.workDataOf
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import java.net.HttpURLConnection
+import java.net.URL
+import java.util.concurrent.TimeUnit
+
+internal object Reporter {
+ private const val WORK_NAME = "musicshare.now_playing.report"
+
+ /** Queue the latest state. REPLACE drops any older unsent report: only the newest state matters. */
+ fun report(context: Context, playback: Playback) {
+ val request = OneTimeWorkRequestBuilder()
+ .setInputData(workDataOf(ReportWorker.KEY_BODY to playback.toJson().toString()))
+ .setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
+ .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 15, TimeUnit.SECONDS)
+ .build()
+ WorkManager.getInstance(context).enqueueUniqueWork(WORK_NAME, ExistingWorkPolicy.REPLACE, request)
+ }
+}
+
+internal class ReportWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
+ override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
+ val prefs = Prefs(applicationContext)
+ val apiUrl = prefs.apiUrl
+ val token = prefs.deviceToken
+ if (!prefs.isConfigured || apiUrl == null || token == null) return@withContext Result.success()
+ val body = inputData.getString(KEY_BODY) ?: return@withContext Result.success()
+
+ val connection = (URL("${apiUrl.trimEnd('/')}/devices/now-playing").openConnection() as HttpURLConnection).apply {
+ requestMethod = "POST"
+ connectTimeout = 10_000
+ readTimeout = 10_000
+ doOutput = true
+ instanceFollowRedirects = false
+ setRequestProperty("Content-Type", "application/json")
+ setRequestProperty("Authorization", "Bearer $token")
+ }
+ try {
+ connection.outputStream.use { it.write(body.toByteArray(Charsets.UTF_8)) }
+ when (val code = connection.responseCode) {
+ in 200..299 -> Result.success()
+ 401 -> {
+ // Device was revoked or the account deleted: stop reporting until JS re-registers.
+ prefs.unauthorized = true
+ NowPlayingBus.emitStatus("unauthorized")
+ Result.failure()
+ }
+ 429, in 500..599 -> Result.retry()
+ else -> {
+ android.util.Log.w(TAG, "report rejected: HTTP $code")
+ Result.failure()
+ }
+ }
+ } catch (e: java.io.IOException) {
+ Result.retry()
+ } finally {
+ connection.disconnect()
+ }
+ }
+
+ companion object {
+ const val KEY_BODY = "body"
+ private const val TAG = "MusicshareNowPlaying"
+ }
+}
diff --git a/apps/mobile/modules/now-playing-reader/android/src/main/res/values/strings.xml b/apps/mobile/modules/now-playing-reader/android/src/main/res/values/strings.xml
new file mode 100644
index 0000000..b751cec
--- /dev/null
+++ b/apps/mobile/modules/now-playing-reader/android/src/main/res/values/strings.xml
@@ -0,0 +1,3 @@
+
+ musicshare: share what you\'re playing
+
diff --git a/apps/mobile/modules/now-playing-reader/expo-module.config.json b/apps/mobile/modules/now-playing-reader/expo-module.config.json
new file mode 100644
index 0000000..af9f764
--- /dev/null
+++ b/apps/mobile/modules/now-playing-reader/expo-module.config.json
@@ -0,0 +1,6 @@
+{
+ "platforms": ["android"],
+ "android": {
+ "modules": ["tech.chrisedwards.musicshare.nowplaying.NowPlayingReaderModule"]
+ }
+}
diff --git a/apps/mobile/modules/now-playing-reader/index.ts b/apps/mobile/modules/now-playing-reader/index.ts
new file mode 100644
index 0000000..ea036a2
--- /dev/null
+++ b/apps/mobile/modules/now-playing-reader/index.ts
@@ -0,0 +1,3 @@
+// Android-only native module; the default export is null where it is unavailable.
+export { default } from './src/NowPlayingReaderModule';
+export * from './src/NowPlayingReader.types';
diff --git a/apps/mobile/modules/now-playing-reader/src/NowPlayingReader.types.ts b/apps/mobile/modules/now-playing-reader/src/NowPlayingReader.types.ts
new file mode 100644
index 0000000..f3d8f60
--- /dev/null
+++ b/apps/mobile/modules/now-playing-reader/src/NowPlayingReader.types.ts
@@ -0,0 +1,14 @@
+export type NativePlayback = {
+ app: string;
+ appName: string | null;
+ state: 'playing' | 'paused' | 'stopped';
+ title: string | null;
+ artist: string | null;
+ album: string | null;
+ durationMs: number | null;
+};
+
+export type NowPlayingReaderModuleEvents = {
+ onPlaybackChange: (event: { playback: NativePlayback | null }) => void;
+ onStatusChange: (event: { status: 'unauthorized' }) => void;
+};
diff --git a/apps/mobile/modules/now-playing-reader/src/NowPlayingReaderModule.ts b/apps/mobile/modules/now-playing-reader/src/NowPlayingReaderModule.ts
new file mode 100644
index 0000000..c8961a0
--- /dev/null
+++ b/apps/mobile/modules/now-playing-reader/src/NowPlayingReaderModule.ts
@@ -0,0 +1,19 @@
+import { NativeModule, requireOptionalNativeModule } from 'expo';
+
+import type { NativePlayback, NowPlayingReaderModuleEvents } from './NowPlayingReader.types';
+
+declare class NowPlayingReaderModule extends NativeModule {
+ isPermissionGranted(): boolean;
+ openPermissionSettings(): void;
+ configure(apiUrl: string, deviceToken: string, allowedPackages: string[]): void;
+ clear(): void;
+ resend(): void;
+ needsReregistration(): boolean;
+ getCurrent(): NativePlayback | null;
+}
+
+/**
+ * Android only. null on iOS, web, and in Expo Go (no custom native code there),
+ * so callers must feature-check.
+ */
+export default requireOptionalNativeModule('NowPlayingReader');
diff --git a/apps/mobile/package.json b/apps/mobile/package.json
index e815d6c..37b4fce 100644
--- a/apps/mobile/package.json
+++ b/apps/mobile/package.json
@@ -40,8 +40,8 @@
},
"scripts": {
"start": "expo start",
- "android": "expo start --android",
- "ios": "expo start --ios",
+ "android": "expo run:android",
+ "ios": "expo run:ios",
"typecheck": "tsc --noEmit",
"lint": "expo lint"
},
diff --git a/apps/mobile/src/app/(tabs)/_layout.tsx b/apps/mobile/src/app/(tabs)/_layout.tsx
index 89cea62..a58f58f 100644
--- a/apps/mobile/src/app/(tabs)/_layout.tsx
+++ b/apps/mobile/src/app/(tabs)/_layout.tsx
@@ -6,7 +6,10 @@ export default function TabsLayout() {
Feed
diff --git a/apps/mobile/src/app/(tabs)/index.tsx b/apps/mobile/src/app/(tabs)/index.tsx
index 77f3dc6..f58b26d 100644
--- a/apps/mobile/src/app/(tabs)/index.tsx
+++ b/apps/mobile/src/app/(tabs)/index.tsx
@@ -1,13 +1,15 @@
import { router } from 'expo-router';
-import { useState } from 'react';
+import { useEffect, useState } from 'react';
import { FlatList, RefreshControl, StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import useSWR from 'swr';
import { sortFeed, type FeedItemDTO, type ProfileDTO } from '@musicshare/shared';
import { NowPlayingCard } from '@/components/NowPlayingCard';
+import { SharingControls } from '@/components/SharingControls';
import { Body, Button, Card, Title } from '@/components/ui';
import { api } from '@/lib/api';
import { showError } from '@/lib/errors';
+import { shouldOfferSetup, useReader } from '@/lib/nowPlayingReader';
import { useRealtime } from '@/lib/useRealtime';
import { useSession } from '@/lib/session';
import { colors, space } from '@/lib/theme';
@@ -19,6 +21,16 @@ export default function FeedScreen() {
const { data: mine, mutate: mutateMine } = useSWR(me ? `/users/${me.username}` : null);
const [refreshing, setRefreshing] = useState(false);
+ const reader = useReader();
+
+ // First run on Android: open the one-tap setup once (deferred so navigation has mounted).
+ useEffect(() => {
+ const timer = setTimeout(() => {
+ shouldOfferSetup().then((offer) => offer && router.push('/share-setup'));
+ }, 400);
+ return () => clearTimeout(timer);
+ }, []);
+
useRealtime(true, (msg) => {
if (msg.type !== 'now_playing') return;
if (msg.data.user.id === me?.id) {
@@ -61,6 +73,13 @@ export default function FeedScreen() {
refreshControl={}
ListHeaderComponent={
+
+ {reader.available && !reader.granted && (
+
+ Share what you’re playing in Spotify, YouTube Music and other apps. One tap, no accounts.
+
+ )}
{mine?.nowPlaying && (
YOU
diff --git a/apps/mobile/src/app/(tabs)/settings.tsx b/apps/mobile/src/app/(tabs)/settings.tsx
index a4ffa9e..c420a5e 100644
--- a/apps/mobile/src/app/(tabs)/settings.tsx
+++ b/apps/mobile/src/app/(tabs)/settings.tsx
@@ -1,6 +1,7 @@
import * as Linking from 'expo-linking';
import * as WebBrowser from 'expo-web-browser';
import { Image } from 'expo-image';
+import { router } from 'expo-router';
import { useState } from 'react';
import { Alert, Pressable, StyleSheet, Text, View } from 'react-native';
import useSWR from 'swr';
@@ -13,9 +14,11 @@ import {
type TrackSearchResultDTO,
} from '@musicshare/shared';
import { Screen } from '@/components/Screen';
+import { SharingControls } from '@/components/SharingControls';
import { Body, Button, Card, Chip, Field, Heading, Row, Title } from '@/components/ui';
import { api } from '@/lib/api';
import { showError } from '@/lib/errors';
+import { openReaderSettings, useReader } from '@/lib/nowPlayingReader';
import { useDebounced } from '@/lib/useDebounced';
import { useSession } from '@/lib/session';
import { colors, radius, space } from '@/lib/theme';
@@ -25,7 +28,9 @@ export default function SettingsScreen() {
Settings
+
+
@@ -80,6 +85,35 @@ function PreferredService() {
);
}
+/** Android: share from any music app on this phone (no-op elsewhere). */
+function ThisPhone() {
+ const { available, granted, playback } = useReader();
+ if (!available) return null;
+ return (
+
+ This phone
+ {granted ? (
+
+ {playback?.title
+ ? `Sharing ${playback.title}${playback.artist ? ` by ${playback.artist}` : ''} from ${playback.appName ?? 'a music app'}.`
+ : 'On. Anything you play in a music app is shared.'}
+
+ ) : (
+
+ Share what you play in Spotify, YouTube Music, Apple Music and other music apps. No accounts needed.
+
+ )}
+
+ );
+}
+
function Connections() {
const { data: connections = [], mutate } = useSWR('/connections');
const byProvider = new Map(connections.map((c) => [c.provider, c]));
diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx
index 91bc46e..3a2c9af 100644
--- a/apps/mobile/src/app/_layout.tsx
+++ b/apps/mobile/src/app/_layout.tsx
@@ -6,6 +6,7 @@ import { AppState, StyleSheet, View } from 'react-native';
import { SWRConfig } from 'swr';
import { Body, Button, Title } from '@/components/ui';
import { api } from '@/lib/api';
+import { syncDevice } from '@/lib/nowPlayingReader';
import { SessionProvider, useSession } from '@/lib/session';
import { colors, space } from '@/lib/theme';
@@ -35,6 +36,11 @@ function AppNavigator() {
if (status !== 'loading') SplashScreen.hideAsync();
}, [status]);
+ // Keep the Android listener pointed at this account (no-op elsewhere).
+ useEffect(() => {
+ if (signedIn) syncDevice().catch(() => {});
+ }, [signedIn]);
+
if (status === 'loading') return null;
if (status === 'offline') {
return (
@@ -69,6 +75,7 @@ function AppNavigator() {
+
diff --git a/apps/mobile/src/app/share-setup.tsx b/apps/mobile/src/app/share-setup.tsx
new file mode 100644
index 0000000..f37a0cb
--- /dev/null
+++ b/apps/mobile/src/app/share-setup.tsx
@@ -0,0 +1,75 @@
+import { router } from 'expo-router';
+import { useEffect, useState } from 'react';
+import { StyleSheet, View } from 'react-native';
+import { Screen } from '@/components/Screen';
+import { Body, Button, Card, Title } from '@/components/ui';
+import { showError } from '@/lib/errors';
+import { markSetupSeen, openReaderSettings, syncDevice, useReader } from '@/lib/nowPlayingReader';
+import { colors, space } from '@/lib/theme';
+
+/**
+ * One-time onboarding on Android. This doubles as the prominent disclosure Google
+ * Play requires before sending users to the notification-access screen.
+ */
+export default function ShareSetupScreen() {
+ const { granted, playback } = useReader();
+ const [synced, setSynced] = useState(false);
+ const finishing = granted && !synced;
+
+ useEffect(() => {
+ markSetupSeen();
+ }, []);
+
+ useEffect(() => {
+ if (!granted) return;
+ syncDevice()
+ .catch((err) => showError(err, "Couldn't finish setup"))
+ .finally(() => setSynced(true));
+ }, [granted]);
+
+ const close = () => (router.canGoBack() ? router.back() : router.replace('/'));
+
+ return (
+
+
+ {granted ? "You're all set" : 'Share what you’re playing'}
+ {granted ? (
+
+ {playback?.title
+ ? `Friends can see you're listening to ${playback.title}${playback.artist ? ` by ${playback.artist}` : ''}.`
+ : 'Play something in Spotify, YouTube Music, Apple Music or any music app and your friends will see it.'}
+
+ ) : (
+
+ Works with Spotify, YouTube Music, Apple Music, TIDAL, Deezer, SoundCloud and more. No accounts to link.
+
+ )}
+
+
+ {!granted && (
+
+ • We read the song, artist and album from music apps, and nothing else.
+ • We never see your notifications, messages or other apps.
+ • Android will call this “notification access”. That’s the only way it lets apps see what’s playing.
+ • Turn it off any time in Settings.
+
+ )}
+
+
+ {granted ? (
+
+ ) : (
+ <>
+
+
+ >
+ )}
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ content: { flexGrow: 1, justifyContent: 'center', gap: space.xl },
+ point: { color: colors.text, fontSize: 14 },
+});
diff --git a/apps/mobile/src/components/NowPlayingCard.tsx b/apps/mobile/src/components/NowPlayingCard.tsx
index 43d00a8..8a16e96 100644
--- a/apps/mobile/src/components/NowPlayingCard.tsx
+++ b/apps/mobile/src/components/NowPlayingCard.tsx
@@ -34,7 +34,7 @@ export function NowPlayingCard({
{np.isPlaying && }
{np.isPlaying ? 'Listening now' : `Played ${timeAgo(np.updatedAt)}`}
- · {SERVICE_LABELS[np.source]}
+ · {np.sourceApp ?? SERVICE_LABELS[np.source]}
{onTogglePin && (
{
+ setBusy(true);
+ try {
+ setMe(await api.setSharing(pause));
+ if (pause === null) resendCurrentPlayback();
+ } catch (err) {
+ showError(err);
+ }
+ setBusy(false);
+ };
+
+ if (compact) {
+ if (!me.sharingPausedUntil) return null;
+ return (
+
+ Sharing is paused {pausedUntilLabel(me.sharingPausedUntil)}.
+
+ );
+ }
+
+ return (
+
+ Sharing
+ {me.sharingPausedUntil ? (
+ <>
+
+ Paused {pausedUntilLabel(me.sharingPausedUntil)}. Friends don’t see what you play, and nothing is added to your history.
+
+
+ );
+}
diff --git a/apps/mobile/src/lib/nowPlayingReader.ts b/apps/mobile/src/lib/nowPlayingReader.ts
new file mode 100644
index 0000000..f942b4e
--- /dev/null
+++ b/apps/mobile/src/lib/nowPlayingReader.ts
@@ -0,0 +1,99 @@
+import * as Device from 'expo-device';
+import * as SecureStore from 'expo-secure-store';
+import { useEffect, useState } from 'react';
+import { AppState } from 'react-native';
+import { ANDROID_MUSIC_APPS } from '@musicshare/shared';
+import NowPlayingReader, { type NativePlayback } from '@/modules/now-playing-reader';
+import { api } from './api';
+import { API_URL } from './config';
+
+const DEVICE_KEY = 'musicshare.device';
+const SETUP_SEEN_KEY = 'musicshare.shareSetupSeen';
+
+/** True on Android builds that include the native module (not iOS, web, or Expo Go). */
+export const readerAvailable = NowPlayingReader !== null;
+
+/** Dev-only escape hatch for testing with apps outside the music allowlist, e.g. "com.android.chrome". */
+const extraApps = String(process.env.EXPO_PUBLIC_EXTRA_MEDIA_APPS ?? '')
+ .split(',')
+ .map((s) => s.trim())
+ .filter(Boolean);
+const allowedApps = [...Object.keys(ANDROID_MUSIC_APPS), ...extraApps];
+
+type StoredDevice = { id: string; token: string };
+
+async function storedDevice(): Promise {
+ const raw = await SecureStore.getItemAsync(DEVICE_KEY);
+ return raw ? (JSON.parse(raw) as StoredDevice) : null;
+}
+
+let syncing: Promise | null = null;
+
+/**
+ * Make sure this phone is registered and the native listener knows where to
+ * report. Cheap to call repeatedly (app start, returning from settings).
+ * Single-flight: several screens react to the same permission change, and
+ * concurrent calls would each register a device and race to store a token.
+ */
+export function syncDevice(): Promise {
+ syncing ??= doSyncDevice().finally(() => {
+ syncing = null;
+ });
+ return syncing;
+}
+
+async function doSyncDevice(): Promise {
+ if (!NowPlayingReader || !NowPlayingReader.isPermissionGranted()) return;
+ let device = await storedDevice();
+ if (!device || NowPlayingReader.needsReregistration()) {
+ const res = await api.registerDevice({ platform: 'android', name: Device.modelName ?? 'Android phone' });
+ device = { id: res.device.id, token: res.token };
+ await SecureStore.setItemAsync(DEVICE_KEY, JSON.stringify(device));
+ }
+ NowPlayingReader.configure(API_URL, device.token, allowedApps);
+}
+
+/** Sign-out: stop reporting and revoke this phone's token (while the session is still valid). */
+export async function unregisterDevice(): Promise {
+ NowPlayingReader?.clear();
+ const device = await storedDevice();
+ if (device) await api.removeDevice(device.id).catch(() => {});
+ await SecureStore.deleteItemAsync(DEVICE_KEY);
+}
+
+export const openReaderSettings = () => NowPlayingReader?.openPermissionSettings();
+
+/** After resuming sharing, re-send what's playing so friends see it immediately. */
+export const resendCurrentPlayback = () => NowPlayingReader?.resend();
+
+export async function shouldOfferSetup(): Promise {
+ if (!NowPlayingReader || NowPlayingReader.isPermissionGranted()) return false;
+ return (await SecureStore.getItemAsync(SETUP_SEEN_KEY)) === null;
+}
+export const markSetupSeen = () => SecureStore.setItemAsync(SETUP_SEEN_KEY, '1');
+
+/** Live permission state and what the listener currently sees. Re-checks when the app returns to the foreground. */
+export function useReader() {
+ const [granted, setGranted] = useState(() => NowPlayingReader?.isPermissionGranted() ?? false);
+ const [playback, setPlayback] = useState(() => NowPlayingReader?.getCurrent() ?? null);
+
+ useEffect(() => {
+ if (!NowPlayingReader) return;
+ const reader = NowPlayingReader;
+ const appState = AppState.addEventListener('change', (state) => {
+ if (state !== 'active') return;
+ const now = reader.isPermissionGranted();
+ setGranted(now);
+ if (now) syncDevice().catch(() => {});
+ });
+ const playbackSub = reader.addListener('onPlaybackChange', (e) => setPlayback(e.playback));
+ const statusSub = reader.addListener('onStatusChange', () => syncDevice().catch(() => {}));
+ return () => {
+ appState.remove();
+ playbackSub.remove();
+ statusSub.remove();
+ };
+ }, []);
+
+ return { available: readerAvailable, granted, playback };
+}
diff --git a/apps/mobile/src/lib/session.tsx b/apps/mobile/src/lib/session.tsx
index 3d575dd..0b7cb6d 100644
--- a/apps/mobile/src/lib/session.tsx
+++ b/apps/mobile/src/lib/session.tsx
@@ -2,6 +2,7 @@ import * as SecureStore from 'expo-secure-store';
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { ApiError, type AuthResponse, type MeDTO } from '@musicshare/shared';
import { api, setApiToken } from './api';
+import { unregisterDevice } from './nowPlayingReader';
const TOKEN_KEY = 'musicshare.session';
@@ -59,6 +60,7 @@ export function SessionProvider({ children }: { children: React.ReactNode }) {
}, []);
const signOut = useCallback(async () => {
+ await unregisterDevice().catch(() => {});
await api.logout().catch(() => {});
await SecureStore.deleteItemAsync(TOKEN_KEY);
setApiToken(null);
diff --git a/apps/mobile/tsconfig.json b/apps/mobile/tsconfig.json
index 2e9a669..c2a9078 100644
--- a/apps/mobile/tsconfig.json
+++ b/apps/mobile/tsconfig.json
@@ -8,13 +8,14 @@
],
"@/assets/*": [
"./assets/*"
+ ],
+ "@/modules/*": [
+ "./modules/*"
]
}
},
"include": [
"**/*.ts",
- "**/*.tsx",
- ".expo/types/**/*.ts",
- "expo-env.d.ts"
+ "**/*.tsx"
]
}