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:
@@ -41,3 +41,12 @@ example
|
|||||||
# generated native folders
|
# generated native folders
|
||||||
/ios
|
/ios
|
||||||
/android
|
/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
|
||||||
|
|||||||
@@ -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>
|
||||||
+162
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
+26
@@ -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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
+90
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+50
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+30
@@ -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()
|
||||||
|
}
|
||||||
+77
@@ -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');
|
||||||
@@ -40,8 +40,8 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "expo start",
|
"start": "expo start",
|
||||||
"android": "expo start --android",
|
"android": "expo run:android",
|
||||||
"ios": "expo start --ios",
|
"ios": "expo run:ios",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"lint": "expo lint"
|
"lint": "expo lint"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,7 +6,10 @@ export default function TabsLayout() {
|
|||||||
<NativeTabs
|
<NativeTabs
|
||||||
backgroundColor={colors.bg}
|
backgroundColor={colors.bg}
|
||||||
indicatorColor={colors.surface2}
|
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}>
|
tintColor={colors.accent}>
|
||||||
<NativeTabs.Trigger name="index">
|
<NativeTabs.Trigger name="index">
|
||||||
<NativeTabs.Trigger.Label>Feed</NativeTabs.Trigger.Label>
|
<NativeTabs.Trigger.Label>Feed</NativeTabs.Trigger.Label>
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import { router } from 'expo-router';
|
import { router } from 'expo-router';
|
||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { FlatList, RefreshControl, StyleSheet, Text, View } from 'react-native';
|
import { FlatList, RefreshControl, StyleSheet, Text, View } from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
import { sortFeed, type FeedItemDTO, type ProfileDTO } from '@musicshare/shared';
|
import { sortFeed, type FeedItemDTO, type ProfileDTO } from '@musicshare/shared';
|
||||||
import { NowPlayingCard } from '@/components/NowPlayingCard';
|
import { NowPlayingCard } from '@/components/NowPlayingCard';
|
||||||
|
import { SharingControls } from '@/components/SharingControls';
|
||||||
import { Body, Button, Card, Title } from '@/components/ui';
|
import { Body, Button, Card, Title } from '@/components/ui';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { showError } from '@/lib/errors';
|
import { showError } from '@/lib/errors';
|
||||||
|
import { shouldOfferSetup, useReader } from '@/lib/nowPlayingReader';
|
||||||
import { useRealtime } from '@/lib/useRealtime';
|
import { useRealtime } from '@/lib/useRealtime';
|
||||||
import { useSession } from '@/lib/session';
|
import { useSession } from '@/lib/session';
|
||||||
import { colors, space } from '@/lib/theme';
|
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 { data: mine, mutate: mutateMine } = useSWR<ProfileDTO>(me ? `/users/${me.username}` : null);
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
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) => {
|
useRealtime(true, (msg) => {
|
||||||
if (msg.type !== 'now_playing') return;
|
if (msg.type !== 'now_playing') return;
|
||||||
if (msg.data.user.id === me?.id) {
|
if (msg.data.user.id === me?.id) {
|
||||||
@@ -61,6 +73,13 @@ export default function FeedScreen() {
|
|||||||
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={refresh} tintColor={colors.muted} />}
|
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={refresh} tintColor={colors.muted} />}
|
||||||
ListHeaderComponent={
|
ListHeaderComponent={
|
||||||
<View style={styles.header}>
|
<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 && (
|
{mine?.nowPlaying && (
|
||||||
<View style={{ gap: space.sm }}>
|
<View style={{ gap: space.sm }}>
|
||||||
<Text style={styles.sectionLabel}>YOU</Text>
|
<Text style={styles.sectionLabel}>YOU</Text>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import * as Linking from 'expo-linking';
|
import * as Linking from 'expo-linking';
|
||||||
import * as WebBrowser from 'expo-web-browser';
|
import * as WebBrowser from 'expo-web-browser';
|
||||||
import { Image } from 'expo-image';
|
import { Image } from 'expo-image';
|
||||||
|
import { router } from 'expo-router';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Alert, Pressable, StyleSheet, Text, View } from 'react-native';
|
import { Alert, Pressable, StyleSheet, Text, View } from 'react-native';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
@@ -13,9 +14,11 @@ import {
|
|||||||
type TrackSearchResultDTO,
|
type TrackSearchResultDTO,
|
||||||
} from '@musicshare/shared';
|
} from '@musicshare/shared';
|
||||||
import { Screen } from '@/components/Screen';
|
import { Screen } from '@/components/Screen';
|
||||||
|
import { SharingControls } from '@/components/SharingControls';
|
||||||
import { Body, Button, Card, Chip, Field, Heading, Row, Title } from '@/components/ui';
|
import { Body, Button, Card, Chip, Field, Heading, Row, Title } from '@/components/ui';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { showError } from '@/lib/errors';
|
import { showError } from '@/lib/errors';
|
||||||
|
import { openReaderSettings, useReader } from '@/lib/nowPlayingReader';
|
||||||
import { useDebounced } from '@/lib/useDebounced';
|
import { useDebounced } from '@/lib/useDebounced';
|
||||||
import { useSession } from '@/lib/session';
|
import { useSession } from '@/lib/session';
|
||||||
import { colors, radius, space } from '@/lib/theme';
|
import { colors, radius, space } from '@/lib/theme';
|
||||||
@@ -25,7 +28,9 @@ export default function SettingsScreen() {
|
|||||||
<Screen>
|
<Screen>
|
||||||
<Title>Settings</Title>
|
<Title>Settings</Title>
|
||||||
<ProfileSection />
|
<ProfileSection />
|
||||||
|
<SharingControls />
|
||||||
<PreferredService />
|
<PreferredService />
|
||||||
|
<ThisPhone />
|
||||||
<Connections />
|
<Connections />
|
||||||
<FeaturedPicker />
|
<FeaturedPicker />
|
||||||
<Account />
|
<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() {
|
function Connections() {
|
||||||
const { data: connections = [], mutate } = useSWR<ConnectionDTO[]>('/connections');
|
const { data: connections = [], mutate } = useSWR<ConnectionDTO[]>('/connections');
|
||||||
const byProvider = new Map(connections.map((c) => [c.provider, c]));
|
const byProvider = new Map(connections.map((c) => [c.provider, c]));
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { AppState, StyleSheet, View } from 'react-native';
|
|||||||
import { SWRConfig } from 'swr';
|
import { SWRConfig } from 'swr';
|
||||||
import { Body, Button, Title } from '@/components/ui';
|
import { Body, Button, Title } from '@/components/ui';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
|
import { syncDevice } from '@/lib/nowPlayingReader';
|
||||||
import { SessionProvider, useSession } from '@/lib/session';
|
import { SessionProvider, useSession } from '@/lib/session';
|
||||||
import { colors, space } from '@/lib/theme';
|
import { colors, space } from '@/lib/theme';
|
||||||
|
|
||||||
@@ -35,6 +36,11 @@ function AppNavigator() {
|
|||||||
if (status !== 'loading') SplashScreen.hideAsync();
|
if (status !== 'loading') SplashScreen.hideAsync();
|
||||||
}, [status]);
|
}, [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 === 'loading') return null;
|
||||||
if (status === 'offline') {
|
if (status === 'offline') {
|
||||||
return (
|
return (
|
||||||
@@ -69,6 +75,7 @@ function AppNavigator() {
|
|||||||
<Stack.Protected guard={signedIn}>
|
<Stack.Protected guard={signedIn}>
|
||||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||||
<Stack.Screen name="u/[username]" options={{ title: '' }} />
|
<Stack.Screen name="u/[username]" options={{ title: '' }} />
|
||||||
|
<Stack.Screen name="share-setup" options={{ headerShown: false, presentation: 'modal' }} />
|
||||||
</Stack.Protected>
|
</Stack.Protected>
|
||||||
<Stack.Protected guard={!signedIn}>
|
<Stack.Protected guard={!signedIn}>
|
||||||
<Stack.Screen name="login" options={{ headerShown: false }} />
|
<Stack.Screen name="login" options={{ headerShown: false }} />
|
||||||
|
|||||||
@@ -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} />}
|
{np.isPlaying && <View style={styles.dot} />}
|
||||||
<Text style={[styles.status, np.isPlaying && { color: colors.live }]} numberOfLines={1}>
|
<Text style={[styles.status, np.isPlaying && { color: colors.live }]} numberOfLines={1}>
|
||||||
{np.isPlaying ? 'Listening now' : `Played ${timeAgo(np.updatedAt)}`}
|
{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>
|
</Text>
|
||||||
{onTogglePin && (
|
{onTogglePin && (
|
||||||
<Pressable
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,6 +2,7 @@ import * as SecureStore from 'expo-secure-store';
|
|||||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||||
import { ApiError, type AuthResponse, type MeDTO } from '@musicshare/shared';
|
import { ApiError, type AuthResponse, type MeDTO } from '@musicshare/shared';
|
||||||
import { api, setApiToken } from './api';
|
import { api, setApiToken } from './api';
|
||||||
|
import { unregisterDevice } from './nowPlayingReader';
|
||||||
|
|
||||||
const TOKEN_KEY = 'musicshare.session';
|
const TOKEN_KEY = 'musicshare.session';
|
||||||
|
|
||||||
@@ -59,6 +60,7 @@ export function SessionProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const signOut = useCallback(async () => {
|
const signOut = useCallback(async () => {
|
||||||
|
await unregisterDevice().catch(() => {});
|
||||||
await api.logout().catch(() => {});
|
await api.logout().catch(() => {});
|
||||||
await SecureStore.deleteItemAsync(TOKEN_KEY);
|
await SecureStore.deleteItemAsync(TOKEN_KEY);
|
||||||
setApiToken(null);
|
setApiToken(null);
|
||||||
|
|||||||
@@ -8,13 +8,14 @@
|
|||||||
],
|
],
|
||||||
"@/assets/*": [
|
"@/assets/*": [
|
||||||
"./assets/*"
|
"./assets/*"
|
||||||
|
],
|
||||||
|
"@/modules/*": [
|
||||||
|
"./modules/*"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"**/*.ts",
|
"**/*.ts",
|
||||||
"**/*.tsx",
|
"**/*.tsx"
|
||||||
".expo/types/**/*.ts",
|
|
||||||
"expo-env.d.ts"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user