Share what's playing in any Android music app

A local Expo module listens to system media sessions (the only way
Android exposes other apps' playback, gated by notification access; we
never read notifications). It filters to known music apps, debounces and
deduplicates changes, sends a heartbeat while playing, and delivers
reports through WorkManager so they survive the app being closed.

- One-screen onboarding that doubles as Google Play's prominent
  disclosure, opening straight to the app's toggle in Android settings
- Only media notifications are pre-ticked on that settings screen
- Device registration is single-flight (several screens react to the
  same permission change) and self-heals if the server revokes a token
- Sign-out revokes the phone's token; resuming sharing re-sends the
  current track immediately
- Pause controls in Settings and on the feed
- Fix invisible tab icons and labels on Android
- Keep Metro out of build output so it doesn't exhaust file watchers

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MzFywujuH9TiuXbr9vjoX8
This commit is contained in:
2026-10-03 17:37:30 -04:00
co-authored by Claude Opus 5.5
parent 23faffc251
commit 15ab815df6
26 changed files with 865 additions and 8 deletions
+9
View File
@@ -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
+20
View File
@@ -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;
@@ -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"
}
@@ -0,0 +1,25 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application>
<!--
Holding notification access is the only way Android lets an app see other apps'
media sessions. We never read notification contents; the service only uses its
binding to call MediaSessionManager.getActiveSessions().
-->
<service
android:name=".MediaListenerService"
android:label="@string/now_playing_listener_label"
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE"
android:exported="false">
<intent-filter>
<action android:name="android.service.notification.NotificationListenerService" />
</intent-filter>
<!-- Only media ("real-time") notifications are relevant, so the settings screen
doesn't pre-tick Conversations etc. We still never read notification contents. -->
<meta-data
android:name="android.service.notification.default_filter_types"
android:value="ongoing" />
</service>
</application>
</manifest>
@@ -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<MediaController, MediaController.Callback>()
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<MediaController>) {
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
}
}
@@ -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<Listener>()
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) }
}
}
@@ -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<String> ->
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()
}
}
}
@@ -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<String, Any?> = 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)
}
}
@@ -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<String>
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()
}
@@ -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<ReportWorker>()
.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"
}
}
@@ -0,0 +1,3 @@
<resources>
<string name="now_playing_listener_label">musicshare: share what you\'re playing</string>
</resources>
@@ -0,0 +1,6 @@
{
"platforms": ["android"],
"android": {
"modules": ["tech.chrisedwards.musicshare.nowplaying.NowPlayingReaderModule"]
}
}
@@ -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';
@@ -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;
};
@@ -0,0 +1,19 @@
import { NativeModule, requireOptionalNativeModule } from 'expo';
import type { NativePlayback, NowPlayingReaderModuleEvents } from './NowPlayingReader.types';
declare class NowPlayingReaderModule extends NativeModule<NowPlayingReaderModuleEvents> {
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<NowPlayingReaderModule>('NowPlayingReader');
+2 -2
View File
@@ -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"
},
+4 -1
View File
@@ -6,7 +6,10 @@ export default function TabsLayout() {
<NativeTabs
backgroundColor={colors.bg}
indicatorColor={colors.surface2}
labelStyle={{ selected: { color: colors.text } }}
// Android defaults icons and labels to black, which vanishes on the dark bar.
iconColor={{ default: colors.muted, selected: colors.text }}
labelStyle={{ default: { color: colors.muted }, selected: { color: colors.text } }}
labelVisibilityMode="labeled"
tintColor={colors.accent}>
<NativeTabs.Trigger name="index">
<NativeTabs.Trigger.Label>Feed</NativeTabs.Trigger.Label>
+20 -1
View File
@@ -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<ProfileDTO>(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={<RefreshControl refreshing={refreshing} onRefresh={refresh} tintColor={colors.muted} />}
ListHeaderComponent={
<View style={styles.header}>
<SharingControls compact />
{reader.available && !reader.granted && (
<Card>
<Body>Share what you’re playing in Spotify, YouTube Music and other apps. One tap, no accounts.</Body>
<Button small variant="primary" title="Turn on" onPress={() => router.push('/share-setup')} style={{ alignSelf: 'flex-start' }} />
</Card>
)}
{mine?.nowPlaying && (
<View style={{ gap: space.sm }}>
<Text style={styles.sectionLabel}>YOU</Text>
+34
View File
@@ -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() {
<Screen>
<Title>Settings</Title>
<ProfileSection />
<SharingControls />
<PreferredService />
<ThisPhone />
<Connections />
<FeaturedPicker />
<Account />
@@ -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 (
<Card>
<Heading>This phone</Heading>
{granted ? (
<Body muted style={styles.small}>
{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.'}
</Body>
) : (
<Body muted style={styles.small}>
Share what you play in Spotify, YouTube Music, Apple Music and other music apps. No accounts needed.
</Body>
)}
<Button
small
variant={granted ? 'secondary' : 'primary'}
title={granted ? 'Turn off in Android settings' : 'Turn on'}
onPress={granted ? openReaderSettings : () => router.push('/share-setup')}
style={{ alignSelf: 'flex-start' }}
/>
</Card>
);
}
function Connections() {
const { data: connections = [], mutate } = useSWR<ConnectionDTO[]>('/connections');
const byProvider = new Map(connections.map((c) => [c.provider, c]));
+7
View File
@@ -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() {
<Stack.Protected guard={signedIn}>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="u/[username]" options={{ title: '' }} />
<Stack.Screen name="share-setup" options={{ headerShown: false, presentation: 'modal' }} />
</Stack.Protected>
<Stack.Protected guard={!signedIn}>
<Stack.Screen name="login" options={{ headerShown: false }} />
+75
View File
@@ -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 (
<Screen edges={['top', 'bottom']} contentContainerStyle={styles.content}>
<View style={{ gap: space.md }}>
<Title>{granted ? "You're all set" : 'Share what you’re playing'}</Title>
{granted ? (
<Body muted>
{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.'}
</Body>
) : (
<Body muted>
Works with Spotify, YouTube Music, Apple Music, TIDAL, Deezer, SoundCloud and more. No accounts to link.
</Body>
)}
</View>
{!granted && (
<Card>
<Body style={styles.point}>• We read the song, artist and album from music apps, and nothing else.</Body>
<Body style={styles.point}>• We never see your notifications, messages or other apps.</Body>
<Body style={styles.point}>• Android will call this “notification access”. That’s the only way it lets apps see what’s playing.</Body>
<Body style={styles.point}>• Turn it off any time in Settings.</Body>
</Card>
)}
<View style={{ gap: space.sm }}>
{granted ? (
<Button variant="primary" title="Done" loading={finishing} onPress={close} />
) : (
<>
<Button variant="primary" title="Turn on" onPress={openReaderSettings} />
<Button title="Not now" onPress={close} />
</>
)}
</View>
</Screen>
);
}
const styles = StyleSheet.create({
content: { flexGrow: 1, justifyContent: 'center', gap: space.xl },
point: { color: colors.text, fontSize: 14 },
});
@@ -34,7 +34,7 @@ export function NowPlayingCard({
{np.isPlaying && <View style={styles.dot} />}
<Text style={[styles.status, np.isPlaying && { color: colors.live }]} numberOfLines={1}>
{np.isPlaying ? 'Listening now' : `Played ${timeAgo(np.updatedAt)}`}
<Text style={styles.via}> · {SERVICE_LABELS[np.source]}</Text>
<Text style={styles.via}> · {np.sourceApp ?? SERVICE_LABELS[np.source]}</Text>
</Text>
{onTogglePin && (
<Pressable
@@ -0,0 +1,60 @@
import { useState } from 'react';
import { pausedUntilLabel } from '@musicshare/shared';
import { api } from '@/lib/api';
import { showError } from '@/lib/errors';
import { resendCurrentPlayback } from '@/lib/nowPlayingReader';
import { useSession } from '@/lib/session';
import { Body, Button, Card, Heading, Row } from './ui';
/** Pause/resume sharing. Compact mode is the banner at the top of the feed. */
export function SharingControls({ compact }: { compact?: boolean }) {
const { me, setMe } = useSession();
const [busy, setBusy] = useState(false);
if (!me) return null;
const set = async (pause: '1h' | '8h' | 'indefinitely' | null) => {
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 (
<Card>
<Body>Sharing is paused {pausedUntilLabel(me.sharingPausedUntil)}.</Body>
<Button small variant="primary" title="Resume" loading={busy} onPress={() => set(null)} style={{ alignSelf: 'flex-start' }} />
</Card>
);
}
return (
<Card>
<Heading>Sharing</Heading>
{me.sharingPausedUntil ? (
<>
<Body muted style={{ fontSize: 13 }}>
Paused {pausedUntilLabel(me.sharingPausedUntil)}. Friends don’t see what you play, and nothing is added to your history.
</Body>
<Button small variant="primary" title="Resume sharing" loading={busy} onPress={() => set(null)} style={{ alignSelf: 'flex-start' }} />
</>
) : (
<>
<Body muted style={{ fontSize: 13 }}>
Friends see what you’re playing. Pause any time.
</Body>
<Row>
<Button small title="1 hour" disabled={busy} onPress={() => set('1h')} />
<Button small title="Until tomorrow" disabled={busy} onPress={() => set('8h')} />
<Button small title="Until I turn it back on" disabled={busy} onPress={() => set('indefinitely')} />
</Row>
</>
)}
</Card>
);
}
+99
View File
@@ -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<StoredDevice | null> {
const raw = await SecureStore.getItemAsync(DEVICE_KEY);
return raw ? (JSON.parse(raw) as StoredDevice) : null;
}
let syncing: Promise<void> | 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<void> {
syncing ??= doSyncDevice().finally(() => {
syncing = null;
});
return syncing;
}
async function doSyncDevice(): Promise<void> {
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<void> {
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<boolean> {
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<NativePlayback | null>(() => 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 };
}
+2
View File
@@ -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);
+4 -3
View File
@@ -8,13 +8,14 @@
],
"@/assets/*": [
"./assets/*"
],
"@/modules/*": [
"./modules/*"
]
}
},
"include": [
"**/*.ts",
"**/*.tsx",
".expo/types/**/*.ts",
"expo-env.d.ts"
"**/*.tsx"
]
}