From 80f1402f5cf2677a0969588e2130ccc7210cf3f1 Mon Sep 17 00:00:00 2001 From: michal Date: Fri, 18 Sep 2026 12:40:00 +0200 Subject: [PATCH] feat: better workart --- .../build-compilation-dependencies/SKILL.md | 2 + .claude/skills/thread-safety-itc/SKILL.md | 21 + .../src/examples/AudioFile/AudioFile.tsx | 18 + apps/fabric-example/ios/Podfile.lock | 4 +- .../react-native-audio-api/RNAudioAPI.podspec | 2 +- .../com/swmansion/audioapi/AudioAPIModule.kt | 4 +- .../system/notification/ArtworkLoader.kt | 165 +++++++ .../notification/NotificationRegistry.kt | 31 +- .../notification/PlaybackNotification.kt | 233 +++++---- .../common/cpp/clangd/CMakeLists.txt | 8 +- .../ios/audioapi/ios/AudioAPIModule.mm | 47 +- .../ios/system/notification/ArtworkLoader.h | 42 ++ .../ios/system/notification/ArtworkLoader.mm | 327 +++++++++++++ .../NotificationQueueAssertions.h | 13 + .../notification/NotificationRegistry.h | 19 +- .../notification/NotificationRegistry.mm | 95 ++-- .../notification/PlaybackNotification.h | 13 +- .../notification/PlaybackNotification.mm | 443 +++++++++++------- 18 files changed, 1143 insertions(+), 344 deletions(-) create mode 100644 packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/ArtworkLoader.kt create mode 100644 packages/react-native-audio-api/ios/audioapi/ios/system/notification/ArtworkLoader.h create mode 100644 packages/react-native-audio-api/ios/audioapi/ios/system/notification/ArtworkLoader.mm create mode 100644 packages/react-native-audio-api/ios/audioapi/ios/system/notification/NotificationQueueAssertions.h diff --git a/.claude/skills/build-compilation-dependencies/SKILL.md b/.claude/skills/build-compilation-dependencies/SKILL.md index d7cc4202f..cd364ca37 100644 --- a/.claude/skills/build-compilation-dependencies/SKILL.md +++ b/.claude/skills/build-compilation-dependencies/SKILL.md @@ -151,6 +151,8 @@ Downloaded artifacts land in: - 16KB page size alignment enabled for Android 15+ - **Extension API (prefab)**: single public C++ header ``; prefab publishes transitive headers needed to compile it (`prepareAudioApiHeadersForPrefabs`); `fix-prefab.gradle` ensures the `.so` is in prefab metadata. Contract: `EXTENSION_API.md` - CMake exposes `COMMON_CPP_DIR` and `ANDROID_CPP_DIR` as **PUBLIC** include dirs so prefab consumers resolve `` +- **React Native's transitive Kotlin/Java deps are usable without declaring them.** `ReactAndroid` declares several libraries with `api(...)` rather than `implementation(...)`, so they reach us through the existing `implementation "com.facebook.react:react-native:+"`. Fresco is the notable one (`com.facebook.fresco:fresco` — 3.2.0 on RN 0.76, 3.7.0 on RN 0.87), used by `system/notification/ArtworkLoader.kt`. Check the RN version's `ReactAndroid/build.gradle.kts` before adding a dependency that RN may already expose; adding it explicitly only risks a version conflict. +- **Verify any such API at the RN floor, not just the checked-in version.** `android/build.gradle` asserts RN minor >= 76, so a Kotlin API that exists in `node_modules` today may not exist at 0.76. Fresco's API surface happens to be identical across 3.2.0-3.7.0, but the third `ResizeOptions` constructor parameter was renamed (`maxBitmapSize` -> `maxBitmapDimension`), so positional arguments are required. Sources for a given version are fetchable from `raw.githubusercontent.com/facebook/{react-native,fresco}/v/...`. For full per-line analysis see [build-details.md](build-details.md#android-androidcmakeliststxt-root--detailed-analysis). diff --git a/.claude/skills/thread-safety-itc/SKILL.md b/.claude/skills/thread-safety-itc/SKILL.md index 83a029480..5e2219461 100644 --- a/.claude/skills/thread-safety-itc/SKILL.md +++ b/.claude/skills/thread-safety-itc/SKILL.md @@ -221,8 +221,29 @@ suspend.then→suspended, event→suspended`. --- +## Android Notification Subsystem: JS-Queue Confinement + +Kotlin rather than C++, but the same discipline. `AudioAPIModule.showNotification` → +`MediaSessionManager` → `NotificationRegistry.showNotification` → `BaseNotification.show()`/`hide()` +runs entirely on the **JS thread**; nothing on that path hops threads. `MediaSessionCompat` adopts +the looper of whichever thread constructed it, so a session created in `initializeIfNeeded()` is +JS-thread-affine too. + +Anything arriving asynchronously (artwork loads) must therefore be delivered back with +`reactContext.runOnJSQueueThread { ... }`, **not** `runOnUiQueueThread`. `runOnJSQueueThread` always +posts and never runs inline, so a callback cannot re-enter an update already in progress — which in +turn makes plain field assignment safe and removes any need for a lock. + +Async results also need a **generation counter** (`PlaybackNotification.artworkGeneration`), bumped +on every new request and in `hide()`. Cancelling cannot recall a result that has already been posted +to the queue, so the callback compares the generation it captured and drops a stale one. Without it, +a superseded load overwrites newer artwork or repaints a dismissed notification. + +--- + ## Common Mistakes +- **Hopping to the UI thread from a notification callback** — `MediaSessionCompat` is bound to the JS queue thread that built it; use `runOnJSQueueThread`. Two `setMetadata` writers on different threads each read-modify-write the session's metadata and clobber each other. Keep one writer that builds from the notification's own fields. - **Reading `node_->field_` in a getter** when that field is written by the audio thread → use shadow state or atomics. - **Calling `node_->method()` directly from a setter** → always schedule via `scheduleAudioEvent`. - **Not clearing callback IDs in the HostObject destructor** → node keeps firing into a GC'd JSI function; call `assignOnXCallbackId(0)` from each event HostObject layer on teardown diff --git a/apps/common-app/src/examples/AudioFile/AudioFile.tsx b/apps/common-app/src/examples/AudioFile/AudioFile.tsx index 6e88994bc..2ef1cbbdc 100644 --- a/apps/common-app/src/examples/AudioFile/AudioFile.tsx +++ b/apps/common-app/src/examples/AudioFile/AudioFile.tsx @@ -71,7 +71,9 @@ const AudioFile: FC = () => { await PlaybackNotificationManager.show({ title: 'Audio File', artist: 'Software Mansion', + artwork: 'https://wallpaperaccess.com/full/2658793.jpg', album: 'Audio API', + androidSmallIcon: 'logo', duration: duration, state: 'paused', speed: 1.0, @@ -138,6 +140,20 @@ const AudioFile: FC = () => { } ); + const nextTrackListener = PlaybackNotificationManager.addEventListener( + 'playbackNotificationNextTrack', + () => { + console.log('Next track event received from notification'); + } + ); + + const previousTrackListener = PlaybackNotificationManager.addEventListener( + 'playbackNotificationPreviousTrack', + () => { + console.log('Previous track event received from notification'); + } + ); + // Keep interruption handling through AudioManager const interruptionSubscription = AudioManager.addSystemEventListener( 'interruption', @@ -184,6 +200,8 @@ const AudioFile: FC = () => { seekToListener.remove(); interruptionSubscription?.remove(); duckListener.remove(); + nextTrackListener.remove(); + previousTrackListener.remove(); }; }, [isPlaying, wasPlaying]); diff --git a/apps/fabric-example/ios/Podfile.lock b/apps/fabric-example/ios/Podfile.lock index 3a40a05a1..f2dd442dd 100644 --- a/apps/fabric-example/ios/Podfile.lock +++ b/apps/fabric-example/ios/Podfile.lock @@ -2504,7 +2504,7 @@ SPEC CHECKSUMS: ReactCodegen: af9d73dcac8951d8a47920882b803da2986e3da7 ReactCommon: 5f5a302748322015f2e3334869cdd9b4d0395846 ReactNativeDependencies: ec5530432d8191b38e278117d498aaff05ea9d6f - RNAudioAPI: 5fe6257beb4b9d735cb0916630067b6d2f57b22c + RNAudioAPI: f6c9a71fcae2d5505202ea369082539e1902b8f1 RNAudioWorklets: e763c48efcbd47d25f61eb5bb49a72d8205b5f4e RNGestureHandler: f09eca0b47053142f7543f5c71d183f215ba5d81 RNReanimated: eabb9b5c492b8e1f16f5523c354ab3a8770c4102 @@ -2516,4 +2516,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: 7a9375c6de5b95bc2125d07cf736baa649d6ba8e -COCOAPODS: 1.16.2 +COCOAPODS: 1.17.0 diff --git a/packages/react-native-audio-api/RNAudioAPI.podspec b/packages/react-native-audio-api/RNAudioAPI.podspec index fbbb3da83..df0b764cd 100644 --- a/packages/react-native-audio-api/RNAudioAPI.podspec +++ b/packages/react-native-audio-api/RNAudioAPI.podspec @@ -56,7 +56,7 @@ Pod::Spec.new do |s| end end - s.ios.frameworks = 'Accelerate', 'AVFoundation', 'AudioToolbox', 'MediaPlayer' + s.ios.frameworks = 'Accelerate', 'AVFoundation', 'AudioToolbox', 'CoreGraphics', 'ImageIO', 'MediaPlayer', 'UIKit' s.prepare_command = <<-CMD chmod +x scripts/download-prebuilt-binaries.sh diff --git a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/AudioAPIModule.kt b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/AudioAPIModule.kt index 5411f005d..8eee8b129 100644 --- a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/AudioAPIModule.kt +++ b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/AudioAPIModule.kt @@ -189,9 +189,9 @@ class AudioAPIModule( options: ReadableMap?, promise: Promise?, ) { + val result = Arguments.createMap() try { if (type == null || key == null) { - val result = Arguments.createMap() result.putBoolean("success", false) result.putString("error", "Type and key are required") promise?.resolve(result) @@ -200,11 +200,9 @@ class AudioAPIModule( MediaSessionManager.showNotification(type, key, options) - val result = Arguments.createMap() result.putBoolean("success", true) promise?.resolve(result) } catch (e: Exception) { - val result = Arguments.createMap() result.putBoolean("success", false) result.putString("error", e.message ?: "Unknown error") promise?.resolve(result) diff --git a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/ArtworkLoader.kt b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/ArtworkLoader.kt new file mode 100644 index 000000000..4f5d883ea --- /dev/null +++ b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/ArtworkLoader.kt @@ -0,0 +1,165 @@ +package com.swmansion.audioapi.system.notification + +import android.graphics.Bitmap +import android.net.Uri +import android.os.Handler +import android.os.Looper +import android.util.Log +import com.facebook.common.executors.CallerThreadExecutor +import com.facebook.common.references.CloseableReference +import com.facebook.datasource.DataSource +import com.facebook.drawee.backends.pipeline.Fresco +import com.facebook.imagepipeline.common.ImageDecodeOptions +import com.facebook.imagepipeline.common.ResizeOptions +import com.facebook.imagepipeline.core.ImagePipeline +import com.facebook.imagepipeline.datasource.BaseBitmapDataSubscriber +import com.facebook.imagepipeline.image.CloseableImage +import com.facebook.imagepipeline.request.ImageRequestBuilder +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.modules.fresco.FrescoModule +import java.lang.ref.WeakReference +import java.util.concurrent.atomic.AtomicBoolean + +private typealias ImageDataSource = DataSource> + +/** + * Loads media notification artwork through React Native's Fresco image pipeline. + */ +class ArtworkLoader( + private val reactContext: WeakReference, +) { + /** Abandons a load. Idempotent, non-blocking, and safe to call from any thread. */ + fun interface Handle { + fun cancel() + } + + companion object { + private const val TAG = "ArtworkLoader" + + /** Deadline covering connect, download and decode together. */ + private const val ARTWORK_FETCH_TIMEOUT_MS = 10_000L + } + + private val mainHandler = Handler(Looper.getMainLooper()) + + /** + * Latches once Fresco has been found to be unusable, so that a host app without it does not pay + * for a failing module lookup on every metadata update. + */ + @Volatile + private var isPipelineUnavailable = false + + /** + * Fetches [uri], decoded no smaller than [targetSizePx] per side. + */ + fun load( + uri: Uri, + targetSizePx: Int, + onResult: (Bitmap?) -> Unit, + ): Handle { + val imagePipeline = imagePipelineOrNull() ?: return deliverNothing(onResult) + + val request = + ImageRequestBuilder + .newBuilderWithSource(uri) + .setResizeOptions(ResizeOptions(targetSizePx, targetSizePx)) + // forcing first frame of animated image + .setImageDecodeOptions( + ImageDecodeOptions + .newBuilder() + .setForceStaticImage(true) + .build(), + ).build() + + return Fetch(imagePipeline.fetchDecodedImage(request, null), uri, onResult).also { it.start() } + } + + private fun deliverNothing(onResult: (Bitmap?) -> Unit): Handle { + postToNativeModulesQueue { onResult(null) } + return Handle {} + } + + private fun postToNativeModulesQueue(action: () -> Unit) { + reactContext.get()?.runOnNativeModulesQueueThread { action() } + } + + /** + * Returns React Native's shared image pipeline, or null when Fresco is unavailable. + * + * `Fresco.initialize` must never be called here: [FrescoModule] owns it and supplies the pipeline + * configuration mounted image views depend on, and a second call rebuilds the pipeline and orphans + * the previous one with its caches. The module is a lazily created TurboModule, so requesting it + * from the React context runs that initialization along the same path mounting an image would. + */ + private fun imagePipelineOrNull(): ImagePipeline? { + if (isPipelineUnavailable) return null + + if (!Fresco.hasBeenInitialized()) { + val context = reactContext.get() ?: return null + try { + context.getNativeModule(FrescoModule::class.java) + } catch (e: Exception) { + Log.w(TAG, "Could not obtain FrescoModule: ${e.message}") + } + + if (!Fresco.hasBeenInitialized()) { + isPipelineUnavailable = true + Log.w(TAG, "Fresco is not initialized; notification artwork will not be loaded") + return null + } + } + + return Fresco.getImagePipeline() + } + + /** + * A single in-flight fetch. Success, failure, timeout and cancellation race each other, and + * [hasDelivered] makes the outcome exactly-once. + */ + private inner class Fetch( + private val dataSource: ImageDataSource, + private val uri: Uri, + private val onResult: (Bitmap?) -> Unit, + ) : Handle { + private val hasDelivered = AtomicBoolean(false) + + private val onTimeout = + Runnable { + Log.w(TAG, "Artwork fetch for $uri exceeded $ARTWORK_FETCH_TIMEOUT_MS ms, cancelling") + settle(null, notify = true) + } + + fun start() { + mainHandler.postDelayed(onTimeout, ARTWORK_FETCH_TIMEOUT_MS) + dataSource.subscribe( + object : BaseBitmapDataSubscriber() { + override fun onNewResultImpl(bitmap: Bitmap?) { + settle(bitmap?.copy(Bitmap.Config.ARGB_8888, false), notify = true) + } + + override fun onFailureImpl(failedDataSource: ImageDataSource) { + Log.w(TAG, "Failed to load artwork from $uri: ${failedDataSource.failureCause?.message}") + settle(null, notify = true) + } + }, + CallerThreadExecutor.getInstance(), + ) + } + + // do not notify, so canceled request do not override current artwork + override fun cancel() = settle(null, notify = false) + + private fun settle( + bitmap: Bitmap?, + notify: Boolean, + ) { + if (!hasDelivered.compareAndSet(false, true)) return + mainHandler.removeCallbacks(onTimeout) + // Closing aborts the fetch; closing an already finished source is harmless. + dataSource.close() + if (notify) { + postToNativeModulesQueue { onResult(bitmap) } + } + } + } +} diff --git a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/NotificationRegistry.kt b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/NotificationRegistry.kt index 0ab29555d..528cddf7b 100644 --- a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/NotificationRegistry.kt +++ b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/NotificationRegistry.kt @@ -1,5 +1,6 @@ package com.swmansion.audioapi.system.notification +import android.annotation.SuppressLint import android.app.Notification import android.util.Log import androidx.annotation.RequiresPermission @@ -8,6 +9,15 @@ import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReadableMap import com.swmansion.audioapi.system.ForegroundServiceManager import java.lang.ref.WeakReference +import java.util.concurrent.ConcurrentHashMap + +/** + * Re-posts a notification whose content changed after [BaseNotification.show] returned, such as + * artwork that finished loading asynchronously. + */ +fun interface NotificationRedisplay { + fun redisplay(notification: Notification) +} /** * Central notification registry that manages multiple notification instances. @@ -20,14 +30,14 @@ class NotificationRegistry( companion object { private const val TAG = "NotificationRegistry" - // Store last built notifications for foreground service access - private val builtNotifications = mutableMapOf() + // Written from the NativeModules queue thread, read by the foreground service on the main thread. + private val builtNotifications = ConcurrentHashMap() fun getBuiltNotification(notificationId: Int): Notification? = builtNotifications[notificationId] } - private val notifications = mutableMapOf() - private val activeNotifications = mutableMapOf() + private val notifications = HashMap() + private val activeNotifications = HashMap() /** * Show or update a notification. @@ -123,7 +133,8 @@ class NotificationRegistry( audioAPIModule, PlaybackNotification.ID, "audio_playback", - ) + ArtworkLoader(reactContext), + ) { redisplayIfActive(key, it) } } "recording" -> { @@ -183,6 +194,16 @@ class NotificationRegistry( Log.d(TAG, "Cleaned up all notifications") } + @SuppressLint("MissingPermission") + private fun redisplayIfActive( + key: String, + notification: Notification, + ) { + if (!isNotificationActive(key)) return + val notificationId = notifications[key]?.getNotificationId() ?: return + displayNotification(notificationId, notification) + } + @RequiresPermission(android.Manifest.permission.POST_NOTIFICATIONS) private fun displayNotification( id: Int, diff --git a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/PlaybackNotification.kt b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/PlaybackNotification.kt index 2b3d7c3dd..6a91c426c 100644 --- a/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/PlaybackNotification.kt +++ b/packages/react-native-audio-api/android/src/main/java/com/swmansion/audioapi/system/notification/PlaybackNotification.kt @@ -5,24 +5,24 @@ import android.app.PendingIntent import android.content.Context import android.content.Intent import android.graphics.Bitmap -import android.graphics.BitmapFactory -import android.graphics.drawable.BitmapDrawable +import android.net.Uri import android.os.Build import android.support.v4.media.MediaMetadataCompat import android.support.v4.media.session.MediaSessionCompat import android.support.v4.media.session.PlaybackStateCompat import android.view.KeyEvent import androidx.core.app.NotificationCompat +import androidx.core.net.toUri import androidx.media.app.NotificationCompat.MediaStyle import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReadableMap import com.facebook.react.bridge.ReadableType +import com.facebook.react.views.imagehelper.ResourceDrawableIdHelper import com.swmansion.audioapi.AudioAPIModule import com.swmansion.audioapi.R import com.swmansion.audioapi.system.AudioEvent -import java.io.IOException +import java.io.File import java.lang.ref.WeakReference -import java.net.URL /** * PlaybackNotification @@ -33,12 +33,18 @@ import java.net.URL * - Integrates with Android MediaSession for lock screen controls * - Is persistent and cannot be swiped away when playing * - Notifies its dismissal via PlaybackNotificationReceiver + * + * Every mutable field below is confined to the React NativeModules queue thread. + * Artwork arrives asynchronously and is therefore delivered back onto the + * same queue rather than onto the JS or main thread. */ class PlaybackNotification( private val reactContext: WeakReference, private val audioAPIModule: WeakReference, private val notificationId: Int, private val channelId: String, + private val artworkLoader: ArtworkLoader, + private val notificationRedisplay: NotificationRedisplay, ) : BaseNotification { companion object { const val MEDIA_BUTTON = "playback_notification_media_button" @@ -48,6 +54,7 @@ class PlaybackNotification( // Must match kDefaultSkipIntervalSeconds on iOS. const val DEFAULT_SKIP_INTERVAL_SECONDS = 15 + private const val ARTWORK_TARGET_SIZE_PX = 512 } private var skipIntervalSeconds: Int = DEFAULT_SKIP_INTERVAL_SECONDS @@ -71,7 +78,17 @@ class PlaybackNotification( private var speed: Float = 1.0F private var playbackStateVal: Int = PlaybackStateCompat.STATE_PAUSED - private var artworkThread: Thread? = null + private var artworkRequest: ArtworkLoader.Handle? = null + + /** The artwork currently shown or in flight; the key that de-duplicates repeated updates. */ + private var displayedArtworkUri: Uri? = null + + /** + * Incremented by every new artwork request and by [hide]. + * + * A load captures the generation it started with and its result is dropped if that no longer matches. + */ + private var artworkGeneration: Long = 0 private fun initializeIfNeeded() { if (isInitialized) return @@ -182,10 +199,11 @@ class PlaybackNotification( override fun hide() { if (!isInitialized) return - if (artworkThread != null && artworkThread!!.isAlive) { - artworkThread!!.interrupt() - } - artworkThread = null + // Invalidates any load already on its way back to this queue; see artworkGeneration. + artworkGeneration++ + artworkRequest?.cancel() + artworkRequest = null + displayedArtworkUri = null mediaSession?.isActive = false mediaSession?.release() @@ -240,63 +258,32 @@ class PlaybackNotification( if (info.hasKey("control") && info.hasKey("enabled")) { enableControl(info.getString("control"), info.getBoolean("enabled")) + return } - val md = MediaMetadataCompat.Builder() - if (info.hasKey("title")) title = info.getString("title") if (info.hasKey("artist")) artist = info.getString("artist") if (info.hasKey("album")) album = info.getString("album") if (info.hasKey("duration")) duration = (info.getDouble("duration") * 1000).toLong() - md.putString(MediaMetadataCompat.METADATA_KEY_TITLE, title) - md.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, artist) - md.putString(MediaMetadataCompat.METADATA_KEY_ALBUM, album) - md.putLong(MediaMetadataCompat.METADATA_KEY_DURATION, duration) - - notificationBuilder?.setContentTitle(title) - notificationBuilder?.setContentText(artist) - notificationBuilder?.setContentInfo(album) - - if (info.hasKey("artwork")) { - if (artworkThread != null && artworkThread!!.isAlive) { - artworkThread!!.interrupt() + if (info.hasKey("androidSmallIcon")) { + val smallIcon = resolveSmallIconResource(info) + if (smallIcon != 0) { + notificationBuilder?.setSmallIcon(smallIcon) } + } - var localArtwork = false - val artworkUri = - if (info.getType("artwork") == ReadableType.Map) { - localArtwork = true - info.getMap("artwork")?.getString("uri") - } else { - info.getString("artwork") - } - - if (artworkUri != null) { - artworkThread = - Thread { - try { - val bitmap = loadArtwork(artworkUri, localArtwork) - if (bitmap != null) { - artwork = bitmap - val context = reactContext.get() - context?.runOnUiQueueThread { - notificationBuilder?.setLargeIcon(bitmap) - - val currentMetadata = mediaSession?.controller?.metadata - val newBuilder = MediaMetadataCompat.Builder(currentMetadata ?: MediaMetadataCompat.Builder().build()) - mediaSession?.setMetadata(newBuilder.putBitmap(MediaMetadataCompat.METADATA_KEY_ART, bitmap).build()) - - // Trigger update - val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as android.app.NotificationManager - notificationManager.notify(notificationId, buildNotification()) - } - } - } catch (ex: Exception) { - ex.printStackTrace() - } + if (info.hasKey("artwork")) { + val artworkUri = resolveArtworkUri(info) + if (artworkUri != null && artworkUri != displayedArtworkUri) { + artworkRequest?.cancel() + displayedArtworkUri = artworkUri + + val generation = ++artworkGeneration + artworkRequest = + artworkLoader.load(artworkUri, ARTWORK_TARGET_SIZE_PX) { bitmap -> + onArtworkLoaded(generation, bitmap) } - artworkThread!!.start() } } @@ -325,14 +312,47 @@ class PlaybackNotification( updatePlaybackState(playbackStateVal) - if (artwork != null) { - md.putBitmap(MediaMetadataCompat.METADATA_KEY_ART, artwork) - } - mediaSession?.setMetadata(md.build()) + publishMetadata() updateNotificationsActions() } + private fun publishMetadata() { + val session = mediaSession ?: return + + session.setMetadata( + MediaMetadataCompat + .Builder() + .putString(MediaMetadataCompat.METADATA_KEY_TITLE, title) + .putString(MediaMetadataCompat.METADATA_KEY_ARTIST, artist) + .putString(MediaMetadataCompat.METADATA_KEY_ALBUM, album) + .putLong(MediaMetadataCompat.METADATA_KEY_DURATION, duration) + .apply { artwork?.let { putBitmap(MediaMetadataCompat.METADATA_KEY_ART, it) } } + .build(), + ) + } + + private fun onArtworkLoaded( + generation: Long, + bitmap: Bitmap?, + ) { + if (generation != artworkGeneration) return + artworkRequest = null + + if (bitmap == null) { + // Clearing the key lets the same artwork be retried after a transient failure. + displayedArtworkUri = null + return + } + + artwork = bitmap + + val builder = notificationBuilder ?: return + builder.setLargeIcon(bitmap) + publishMetadata() + notificationRedisplay.redisplay(builder.build()) + } + private fun enableControl( name: String?, enabled: Boolean, @@ -413,14 +433,14 @@ class PlaybackNotification( if (hasControl(PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS)) { notificationBuilder?.addAction( - createAction("previousTrack", "Previous track", android.R.drawable.ic_media_previous, PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS), + createAction("previousTrack", "Previous track", PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS), ) actionsList.add(index++) } if (hasControl(PlaybackStateCompat.ACTION_REWIND)) { notificationBuilder?.addAction( - createAction("skip_backward", "Skip Backward", skipBackwardIcon(), PlaybackStateCompat.ACTION_REWIND), + createAction("skip_backward", "Skip Backward", PlaybackStateCompat.ACTION_REWIND), ) actionsList.add(index++) } @@ -428,32 +448,32 @@ class PlaybackNotification( if (isPlaying) { if (hasControl(PlaybackStateCompat.ACTION_PAUSE)) { notificationBuilder?.addAction( - createAction("pause", "Pause", android.R.drawable.ic_media_pause, PlaybackStateCompat.ACTION_PAUSE), + createAction("pause", "Pause", PlaybackStateCompat.ACTION_PAUSE), ) actionsList.add(index++) } else if (hasControl(PlaybackStateCompat.ACTION_STOP)) { notificationBuilder?.addAction( - createAction("stop", "Stop", R.drawable.stop, PlaybackStateCompat.ACTION_STOP), + createAction("stop", "Stop", PlaybackStateCompat.ACTION_STOP), ) actionsList.add(index++) } } else { if (hasControl(PlaybackStateCompat.ACTION_PLAY)) { - notificationBuilder?.addAction(createAction("play", "Play", android.R.drawable.ic_media_play, PlaybackStateCompat.ACTION_PLAY)) + notificationBuilder?.addAction(createAction("play", "Play", PlaybackStateCompat.ACTION_PLAY)) actionsList.add(index++) } } if (hasControl(PlaybackStateCompat.ACTION_FAST_FORWARD)) { notificationBuilder?.addAction( - createAction("skip_forward", "Skip Forward", skipForwardIcon(), PlaybackStateCompat.ACTION_FAST_FORWARD), + createAction("skip_forward", "Skip Forward", PlaybackStateCompat.ACTION_FAST_FORWARD), ) actionsList.add(index++) } if (hasControl(PlaybackStateCompat.ACTION_SKIP_TO_NEXT)) { notificationBuilder?.addAction( - createAction("nextTrack", "Next track", android.R.drawable.ic_media_next, PlaybackStateCompat.ACTION_SKIP_TO_NEXT), + createAction("nextTrack", "Next track", PlaybackStateCompat.ACTION_SKIP_TO_NEXT), ) actionsList.add(index++) } @@ -470,7 +490,6 @@ class PlaybackNotification( private fun createAction( name: String, title: String, - icon: Int, mediaAction: Long, ): NotificationCompat.Action { val context = reactContext.get()!! @@ -501,41 +520,67 @@ class PlaybackNotification( PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, ) } - return NotificationCompat.Action(icon, title, pendingIntent) + return NotificationCompat.Action(actionIcon(name), title, pendingIntent) } + /** + * Icon drawn on the notification's own action button. + * + * Only Android 12 and below show these. From Android 13 the system builds media controls from the + * PlaybackState and draws its own glyphs for the standard transport actions; the only app-supplied + * icons that survive there are the ones attached to the custom actions in + * [updatePlaybackActionState]. This mapping becomes dead once minSdk reaches 33. + */ + private fun actionIcon(name: String): Int = + when (name) { + "play" -> android.R.drawable.ic_media_play + "pause" -> android.R.drawable.ic_media_pause + "stop" -> R.drawable.stop + "nextTrack" -> android.R.drawable.ic_media_next + "previousTrack" -> android.R.drawable.ic_media_previous + "skip_forward" -> skipForwardIcon() + "skip_backward" -> skipBackwardIcon() + else -> 0 + } + private fun hasControl(control: Long): Boolean = (controls and control) == control - private fun loadArtwork( - url: String, - local: Boolean, - ): Bitmap? { + private fun resolveSmallIconResource(info: ReadableMap): Int { + val context = reactContext.get() ?: return 0 + + val name = + if (info.getType("androidSmallIcon") == ReadableType.Map) { + info.getMap("androidSmallIcon")?.getString("uri") + } else { + info.getString("androidSmallIcon") + } + if (name.isNullOrEmpty()) return 0 + + return ResourceDrawableIdHelper + .getResourceDrawableId(context, name) + } + + private fun resolveArtworkUri(info: ReadableMap): Uri? { val context = reactContext.get() ?: return null - return try { - if (local && !url.startsWith("http")) { - val helper = - com.facebook.react.views.imagehelper.ResourceDrawableIdHelper - .getInstance() - val drawable = helper.getResourceDrawable(context, url) - if (drawable is BitmapDrawable) { - drawable.bitmap - } else { - BitmapFactory.decodeFile(url) - } + val isBundledAsset = info.getType("artwork") == ReadableType.Map + val source = + if (isBundledAsset) { + info.getMap("artwork")?.getString("uri") } else { - val connection = URL(url).openConnection() - connection.connect() - val inputStream = connection.getInputStream() - val bitmap = BitmapFactory.decodeStream(inputStream) - inputStream.close() - bitmap + info.getString("artwork") } - } catch (e: IOException) { - null - } catch (e: Exception) { - null + if (source.isNullOrEmpty()) return null + + if (isBundledAsset && !source.startsWith("http")) { + // Yields Uri.EMPTY, never null, when the drawable cannot be resolved. + val drawableUri = + ResourceDrawableIdHelper + .getResourceDrawableUri(context, source) + if (drawableUri != Uri.EMPTY) return drawableUri } + + return if (source.startsWith("/")) Uri.fromFile(File(source)) else source.toUri() } private fun createNotificationChannel() { diff --git a/packages/react-native-audio-api/common/cpp/clangd/CMakeLists.txt b/packages/react-native-audio-api/common/cpp/clangd/CMakeLists.txt index 96fc1b904..533b418b0 100644 --- a/packages/react-native-audio-api/common/cpp/clangd/CMakeLists.txt +++ b/packages/react-native-audio-api/common/cpp/clangd/CMakeLists.txt @@ -135,6 +135,12 @@ if(APPLE) OUTPUT_STRIP_TRAILING_WHITESPACE ERROR_QUIET ) + if(IOS_SDK_PATH) + get_filename_component(IOS_SDKS_DIR "${IOS_SDK_PATH}" DIRECTORY) + if(IS_DIRECTORY "${IOS_SDKS_DIR}/iPhoneSimulator.sdk") + set(IOS_SDK_PATH "${IOS_SDKS_DIR}/iPhoneSimulator.sdk") + endif() + endif() if(IOS_SDK_PATH) target_compile_options(rnaudioapi_cursor_ios PRIVATE -isysroot "${IOS_SDK_PATH}") target_compile_options(rnaudioapi_cursor_ios_objc PRIVATE -isysroot "${IOS_SDK_PATH}") @@ -190,4 +196,4 @@ if(APPLE) if(IOS_SDK_PATH) target_compile_options(rnaudioapi_cursor_ios_headers PRIVATE -isysroot "${IOS_SDK_PATH}") endif() -endif() \ No newline at end of file +endif() diff --git a/packages/react-native-audio-api/ios/audioapi/ios/AudioAPIModule.mm b/packages/react-native-audio-api/ios/audioapi/ios/AudioAPIModule.mm index 2365ca1a8..69e4d6eb7 100644 --- a/packages/react-native-audio-api/ios/audioapi/ios/AudioAPIModule.mm +++ b/packages/react-native-audio-api/ios/audioapi/ios/AudioAPIModule.mm @@ -253,42 +253,41 @@ - (dispatch_queue_t)methodQueue showNotification : (NSString *)type key : (NSString *)key options : (NSDictionary *) options resolve : (RCTPromiseResolveBlock)resolve reject : (RCTPromiseRejectBlock)reject) { - dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ - BOOL success = [self.notificationRegistry showNotificationWithType:type - key:key - options:options]; - - if (success) { - resolve(@{@"success" : @true}); - } else { - resolve(@{@"success" : @false, @"error" : @"Failed to show notification"}); - } - }); + [self.notificationRegistry + showNotificationWithType:type + key:key + options:options + completion:^(BOOL success) { + if (success) { + resolve(@{@"success" : @true}); + } else { + resolve(@{@"success" : @false, @"error" : @"Failed to show notification"}); + } + }]; } RCT_EXPORT_METHOD( hideNotification : (NSString *)key resolve : (RCTPromiseResolveBlock) resolve reject : (RCTPromiseRejectBlock)reject) { - dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ - BOOL success = [self.notificationRegistry hideNotificationWithKey:key]; - - if (success) { - resolve(@{@"success" : @true}); - } else { - resolve(@{@"success" : @false, @"error" : @"Failed to hide notification"}); - } - }); + [self.notificationRegistry + hideNotificationWithKey:key + completion:^(BOOL success) { + if (success) { + resolve(@{@"success" : @true}); + } else { + resolve(@{@"success" : @false, @"error" : @"Failed to hide notification"}); + } + }]; } RCT_EXPORT_METHOD( isNotificationActive : (NSString *)key resolve : (RCTPromiseResolveBlock) resolve reject : (RCTPromiseRejectBlock)reject) { - dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ - BOOL isActive = [self.notificationRegistry isNotificationActiveWithKey:key]; - resolve(@(isActive)); - }); + [self.notificationRegistry + isNotificationActiveWithKey:key + completion:^(BOOL isActive) { resolve(@(isActive)); }]; } #ifdef RCT_NEW_ARCH_ENABLED diff --git a/packages/react-native-audio-api/ios/audioapi/ios/system/notification/ArtworkLoader.h b/packages/react-native-audio-api/ios/audioapi/ios/system/notification/ArtworkLoader.h new file mode 100644 index 000000000..b506123fe --- /dev/null +++ b/packages/react-native-audio-api/ios/audioapi/ios/system/notification/ArtworkLoader.h @@ -0,0 +1,42 @@ +#pragma once + +#import +#import + +NS_ASSUME_NONNULL_BEGIN + +/** + * Abandons a load. Must be called on the main queue. + */ +@protocol ArtworkRequest + +- (void)cancel; + +@end + +/** + * Receives the outcome of a load: the decoded image, or nil on failure or timeout. + */ +using ArtworkLoadCompletion = void (^)(UIImage *_Nullable image); + +/** + * Fetches and decodes media notification artwork, bounded in size and in time. Every completion is + * delivered on the main queue, which owns the callers' artwork state. + * + * One instance is shared by every notification so that they share its URL session and its caches. + */ +@interface ArtworkLoader : NSObject + +/** + * Fetches @c url, decoded so that its longer side is at most @c maximumSizeInPixels. + */ +- (id)loadArtworkFromURL:(NSURL *)url + maximumSizeInPixels:(NSInteger)maximumSizeInPixels + completion:(ArtworkLoadCompletion)completion; + +/** Cancels every load in flight and empties the decoded-image cache. */ +- (void)cleanup; + +@end + +NS_ASSUME_NONNULL_END diff --git a/packages/react-native-audio-api/ios/audioapi/ios/system/notification/ArtworkLoader.mm b/packages/react-native-audio-api/ios/audioapi/ios/system/notification/ArtworkLoader.mm new file mode 100644 index 000000000..35fd55da9 --- /dev/null +++ b/packages/react-native-audio-api/ios/audioapi/ios/system/notification/ArtworkLoader.mm @@ -0,0 +1,327 @@ +#import +#import +#import + +static const NSTimeInterval kArtworkFetchTimeoutSeconds = 10.0; + +/** + * Ceiling on a downloaded body before it is decoded. Checked once the transfer has completed: + * aborting mid-stream would need a delegate-based session, and the deadline already bounds the + * transfer in time. + */ +static const NSUInteger kArtworkMaximumDownloadBytes = 16UL * 1024 * 1024; +static const NSUInteger kArtworkMemoryCacheCapacityBytes = 16UL * 1024 * 1024; +static const NSUInteger kArtworkDiskCacheCapacityBytes = 64UL * 1024 * 1024; + +@interface ArtworkLoader () + +@property (nonatomic, readonly) dispatch_queue_t decodeQueue; +@property (nonatomic, readonly) NSURLSession *session; +@property (nonatomic, readonly) NSCache *imageCache; + +@end + +#pragma mark - Bounded decoding + +static NSDictionary *ArtworkThumbnailOptions(NSInteger maximumSizeInPixels) +{ + return @{ + // Embedded thumbnails are typically far too small to display. + (id)kCGImageSourceCreateThumbnailFromImageAlways : @YES, + // Bakes the EXIF orientation into the pixels, so the result is upright. + (id)kCGImageSourceCreateThumbnailWithTransform : @YES, + // Decodes now, inside the deadline, rather than lazily on whichever thread first draws it. + (id)kCGImageSourceShouldCacheImmediately : @YES, + (id)kCGImageSourceThumbnailMaxPixelSize : @(maximumSizeInPixels), + }; +} + +/** Decodes @c source at no more than @c maximumSizeInPixels on its longer side, then releases it. */ +static UIImage *_Nullable ArtworkImageFromSource( + CGImageSourceRef _Nullable source, + NSInteger maximumSizeInPixels) +{ + if (source == NULL) { + return nil; + } + + NSDictionary *options = ArtworkThumbnailOptions(maximumSizeInPixels); + CGImageRef thumbnail = + CGImageSourceCreateThumbnailAtIndex(source, 0, (__bridge CFDictionaryRef)options); + CFRelease(source); + if (thumbnail == NULL) { + return nil; + } + + UIImage *image = [UIImage imageWithCGImage:thumbnail scale:1.0 orientation:UIImageOrientationUp]; + CGImageRelease(thumbnail); + return image; +} + +static UIImage *_Nullable ArtworkImageFromData(NSData *data, NSInteger maximumSizeInPixels) +{ + return ArtworkImageFromSource( + CGImageSourceCreateWithData((__bridge CFDataRef)data, NULL), maximumSizeInPixels); +} + +/** Reads through ImageIO rather than NSData, so a large file is never held whole in memory. */ +static UIImage *_Nullable ArtworkImageFromFileURL(NSURL *url, NSInteger maximumSizeInPixels) +{ + return ArtworkImageFromSource( + CGImageSourceCreateWithURL((__bridge CFURLRef)url, NULL), maximumSizeInPixels); +} + +#pragma mark - ArtworkFetch + +/** + * A single load in flight. Success, failure, the deadline and cancellation all race to reach + * @c settleWithImage:notify:, and only the first arrival is delivered. + * + * Blocks capture the fetch strongly: the deadline bounds its lifetime, so nothing can leak. + */ +@interface ArtworkFetch : NSObject + +- (instancetype)initWithLoader:(ArtworkLoader *)loader + url:(NSURL *)url + maximumSizeInPixels:(NSInteger)maximumSizeInPixels + completion:(ArtworkLoadCompletion)completion; + +- (void)start; + +@end + +@implementation ArtworkFetch { + ArtworkLoader *_loader; + NSURL *_url; + NSString *_cacheKey; + NSInteger _maximumSizeInPixels; + + // Confined to the main queue. + ArtworkLoadCompletion _completion; + NSURLSessionDataTask *_downloadTask; + dispatch_block_t _timeoutBlock; + BOOL _hasSettled; +} + +- (instancetype)initWithLoader:(ArtworkLoader *)loader + url:(NSURL *)url + maximumSizeInPixels:(NSInteger)maximumSizeInPixels + completion:(ArtworkLoadCompletion)completion +{ + if (self = [super init]) { + _loader = loader; + _url = url; + // An image decoded to a different ceiling is not interchangeable, so the ceiling is in the key. + _cacheKey = + [NSString stringWithFormat:@"%@|%ld", url.absoluteString, (long)maximumSizeInPixels]; + _maximumSizeInPixels = maximumSizeInPixels; + _completion = [completion copy]; + _hasSettled = NO; + } + + return self; +} + +- (void)start +{ + AUDIOAPI_ASSERT_ON_QUEUE(dispatch_get_main_queue()); + if (_hasSettled) { + return; + } + + UIImage *cached = [_loader.imageCache objectForKey:_cacheKey]; + if (cached != nil) { + [self settleWithImage:cached notify:YES]; + return; + } + + [self armDeadline]; + + if (_url.isFileURL) { + [self decodeOnDecodeQueue:^{ + return ArtworkImageFromFileURL(self->_url, self->_maximumSizeInPixels); + }]; + return; + } + + [self startDownload]; +} + +- (void)armDeadline +{ + _timeoutBlock = dispatch_block_create(DISPATCH_BLOCK_INHERIT_QOS_CLASS, ^{ + NSLog( + @"[ArtworkLoader] Artwork fetch timed out after %.0fs: %@", + kArtworkFetchTimeoutSeconds, + self->_url); + [self settleWithImage:nil notify:YES]; + }); + + dispatch_after( + dispatch_time(DISPATCH_TIME_NOW, (int64_t)(kArtworkFetchTimeoutSeconds * NSEC_PER_SEC)), + dispatch_get_main_queue(), + _timeoutBlock); +} + +- (void)startDownload +{ + _downloadTask = + [_loader.session dataTaskWithURL:_url + completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) { + // Arrives on the session's delegate queue, so hop before touching any confined state. + dispatch_async(dispatch_get_main_queue(), ^{ + [self handleDownloadedData:data response:response error:error]; + }); + }]; + [_downloadTask resume]; +} + +- (void)handleDownloadedData:(NSData *)data + response:(NSURLResponse *)response + error:(NSError *)error +{ + if (_hasSettled) { + // A task that was cancelled or that lost to the deadline, reporting in late. + return; + } + + if (error != nil) { + NSLog( + @"[ArtworkLoader] Failed to download artwork from %@: %@", + _url, + error.localizedDescription); + [self settleWithImage:nil notify:YES]; + return; + } + + if ([response isKindOfClass:[NSHTTPURLResponse class]]) { + NSInteger statusCode = ((NSHTTPURLResponse *)response).statusCode; + if (statusCode < 200 || statusCode > 299) { + NSLog(@"[ArtworkLoader] Artwork request returned HTTP %ld: %@", (long)statusCode, _url); + [self settleWithImage:nil notify:YES]; + return; + } + } + + if (data.length == 0 || data.length > kArtworkMaximumDownloadBytes) { + NSLog( + @"[ArtworkLoader] Rejecting artwork body of %lu bytes: %@", + (unsigned long)data.length, + _url); + [self settleWithImage:nil notify:YES]; + return; + } + + [self decodeOnDecodeQueue:^{ return ArtworkImageFromData(data, self->_maximumSizeInPixels); }]; +} + +/** + * Runs @c decode off the main queue and settles with its result. The deadline can fire + * meanwhile; ImageIO cannot be interrupted, so the decode finishes and its result is dropped. + */ +- (void)decodeOnDecodeQueue:(UIImage *_Nullable (^)(void))decode +{ + dispatch_async(_loader.decodeQueue, ^{ + UIImage *image = decode(); + if (image == nil) { + NSLog(@"[ArtworkLoader] Failed to decode artwork from %@", self->_url); + } + + dispatch_async(dispatch_get_main_queue(), ^{ [self settleWithImage:image notify:YES]; }); + }); +} + +/** The single exit; only the first arrival is delivered. */ +- (void)settleWithImage:(UIImage *_Nullable)image notify:(BOOL)notify +{ + AUDIOAPI_ASSERT_ON_QUEUE(dispatch_get_main_queue()); + if (_hasSettled) { + return; + } + _hasSettled = YES; + + if (_timeoutBlock != nil) { + dispatch_block_cancel(_timeoutBlock); + _timeoutBlock = nil; + } + [_downloadTask cancel]; + _downloadTask = nil; + + if (image != nil) { + NSUInteger cost = (NSUInteger)(image.size.width * image.size.height * 4); + [_loader.imageCache setObject:image forKey:_cacheKey cost:cost]; + } + + ArtworkLoadCompletion completion = _completion; + // Released now, so a fetch that outlives its caller does not keep the caller's captures alive. + _completion = nil; + + if (notify && completion != nil) { + completion(image); + } +} + +- (void)cancel +{ + [self settleWithImage:nil notify:NO]; +} + +@end + +#pragma mark - ArtworkLoader + +@implementation ArtworkLoader + +- (instancetype)init +{ + if (self = [super init]) { + // Serial, so concurrent loads cannot each hold a decode buffer at the same time. + _decodeQueue = dispatch_queue_create( + "com.swmansion.audioapi.artworkDecode", + dispatch_queue_attr_make_with_qos_class(DISPATCH_QUEUE_SERIAL, QOS_CLASS_UTILITY, 0)); + + NSURLSessionConfiguration *configuration = + [NSURLSessionConfiguration defaultSessionConfiguration]; + configuration.timeoutIntervalForRequest = kArtworkFetchTimeoutSeconds; + configuration.timeoutIntervalForResource = kArtworkFetchTimeoutSeconds; + configuration.requestCachePolicy = NSURLRequestUseProtocolCachePolicy; + // Waiting for connectivity would outlive the deadline. + configuration.waitsForConnectivity = NO; + // A private cache, so artwork and the host app's responses cannot evict each other. + configuration.URLCache = + [[NSURLCache alloc] initWithMemoryCapacity:kArtworkMemoryCacheCapacityBytes + diskCapacity:kArtworkDiskCacheCapacityBytes + diskPath:@"com.swmansion.audioapi.artwork"]; + _session = [NSURLSession sessionWithConfiguration:configuration]; + + _imageCache = [[NSCache alloc] init]; + _imageCache.totalCostLimit = kArtworkMemoryCacheCapacityBytes; + } + + return self; +} + +- (id)loadArtworkFromURL:(NSURL *)url + maximumSizeInPixels:(NSInteger)maximumSizeInPixels + completion:(ArtworkLoadCompletion)completion +{ + ArtworkFetch *fetch = [[ArtworkFetch alloc] initWithLoader:self + url:url + maximumSizeInPixels:maximumSizeInPixels + completion:completion]; + + // Dispatched rather than started inline, so even a cache hit calls back a turn later and callers + // are never re-entered from inside their own update. + dispatch_async(dispatch_get_main_queue(), ^{ [fetch start]; }); + + return fetch; +} + +- (void)cleanup +{ + // Every task in flight fails with NSURLErrorCancelled and settles as a failure on the main queue. + [_session invalidateAndCancel]; + [_imageCache removeAllObjects]; +} + +@end diff --git a/packages/react-native-audio-api/ios/audioapi/ios/system/notification/NotificationQueueAssertions.h b/packages/react-native-audio-api/ios/audioapi/ios/system/notification/NotificationQueueAssertions.h new file mode 100644 index 000000000..bbcf08c32 --- /dev/null +++ b/packages/react-native-audio-api/ios/audioapi/ios/system/notification/NotificationQueueAssertions.h @@ -0,0 +1,13 @@ +#pragma once + +#import + +/** + * Traps in debug builds when the caller is not on `queue`. Notification state has exactly one owner + * queue instead of a lock, and this makes that ownership visible at each entry point. + */ +#if defined(DEBUG) && DEBUG +#define AUDIOAPI_ASSERT_ON_QUEUE(queue) dispatch_assert_queue(queue) +#else +#define AUDIOAPI_ASSERT_ON_QUEUE(queue) ((void)0) +#endif diff --git a/packages/react-native-audio-api/ios/audioapi/ios/system/notification/NotificationRegistry.h b/packages/react-native-audio-api/ios/audioapi/ios/system/notification/NotificationRegistry.h index 398f1d58e..2403571c4 100644 --- a/packages/react-native-audio-api/ios/audioapi/ios/system/notification/NotificationRegistry.h +++ b/packages/react-native-audio-api/ios/audioapi/ios/system/notification/NotificationRegistry.h @@ -10,6 +10,8 @@ * * Central manager for all notification types. * Manages registration, lifecycle, and routing of notification implementations. + * + * Every method below hops onto the main queue, which owns the notifications, and answers from there. */ @interface NotificationRegistry : NSObject @@ -22,28 +24,29 @@ * @param type The notification type identifier * @param key The notification key * @param options Options for showing the notification - * @return YES if successful, NO otherwise + * @param completion Receives YES if successful, NO otherwise */ -- (BOOL)showNotificationWithType:(NSString *)type +- (void)showNotificationWithType:(NSString *)type key:(NSString *)key - options:(NSDictionary *)options; + options:(NSDictionary *)options + completion:(void (^)(BOOL success))completion; /** * Hide a notification. * @param key The notification key - * @return YES if successful, NO otherwise + * @param completion Receives YES if successful, NO otherwise */ -- (BOOL)hideNotificationWithKey:(NSString *)key; +- (void)hideNotificationWithKey:(NSString *)key completion:(void (^)(BOOL success))completion; /** * Check if a notification is active. * @param key The notification key - * @return YES if active, NO otherwise + * @param completion Receives YES if active, NO otherwise */ -- (BOOL)isNotificationActiveWithKey:(NSString *)key; +- (void)isNotificationActiveWithKey:(NSString *)key completion:(void (^)(BOOL isActive))completion; /** - * Clean up all notifications. + * Clean up all notifications. Blocks until the main queue has run it. */ - (void)cleanup; diff --git a/packages/react-native-audio-api/ios/audioapi/ios/system/notification/NotificationRegistry.mm b/packages/react-native-audio-api/ios/audioapi/ios/system/notification/NotificationRegistry.mm index 652a6262d..791e4a7c2 100644 --- a/packages/react-native-audio-api/ios/audioapi/ios/system/notification/NotificationRegistry.mm +++ b/packages/react-native-audio-api/ios/audioapi/ios/system/notification/NotificationRegistry.mm @@ -1,9 +1,12 @@ #import +#import +#import #import #import @implementation NotificationRegistry { NSMutableDictionary> *_notifications; + ArtworkLoader *_artworkLoader; } - (instancetype)initWithAudioAPIModule:(AudioAPIModule *)audioAPIModule @@ -11,17 +14,56 @@ - (instancetype)initWithAudioAPIModule:(AudioAPIModule *)audioAPIModule if (self = [super init]) { self.audioAPIModule = audioAPIModule; _notifications = [[NSMutableDictionary alloc] init]; - - NSLog(@"[NotificationRegistry] Initialized"); + _artworkLoader = [[ArtworkLoader alloc] init]; } return self; } -- (BOOL)showNotificationWithType:(NSString *)type +- (void)showNotificationWithType:(NSString *)type key:(NSString *)key options:(NSDictionary *)options + completion:(void (^)(BOOL success))completion +{ + dispatch_async(dispatch_get_main_queue(), ^{ + completion([self showNotificationOnMainQueueWithType:type key:key options:options]); + }); +} + +- (void)hideNotificationWithKey:(NSString *)key completion:(void (^)(BOOL success))completion +{ + dispatch_async( + dispatch_get_main_queue(), ^{ completion([self hideNotificationOnMainQueueWithKey:key]); }); +} + +- (void)isNotificationActiveWithKey:(NSString *)key completion:(void (^)(BOOL isActive))completion +{ + dispatch_async(dispatch_get_main_queue(), ^{ + id notification = self->_notifications[key]; + completion(notification != nil && [notification isActive]); + }); +} + +- (void)cleanup +{ + // Synchronous, so teardown cannot race work already queued. + if ([NSThread isMainThread]) { + [self cleanupOnMainQueue]; + } else { + dispatch_sync(dispatch_get_main_queue(), ^{ [self cleanupOnMainQueue]; }); + } + + [_artworkLoader cleanup]; +} + +#pragma mark - Private Methods + +- (BOOL)showNotificationOnMainQueueWithType:(NSString *)type + key:(NSString *)key + options:(NSDictionary *)options { + AUDIOAPI_ASSERT_ON_QUEUE(dispatch_get_main_queue()); + if (!key) { NSLog(@"[NotificationRegistry] Invalid key"); return false; @@ -30,7 +72,6 @@ - (BOOL)showNotificationWithType:(NSString *)type id notification = _notifications[key]; bool created = false; - // Create if doesn't exist if (!notification) { if (!type) { NSLog(@"[NotificationRegistry] Type required for new notification: %@", key); @@ -45,33 +86,22 @@ - (BOOL)showNotificationWithType:(NSString *)type } _notifications[key] = notification; - NSLog(@"[NotificationRegistry] Created notification type '%@' with key '%@'", type, key); created = true; } - // Initialize if first time showing - if (![notification isActive]) { - if (![notification initializeWithOptions:options]) { - NSLog(@"[NotificationRegistry] Failed to initialize notification: %@", key); - return false; - } - } - BOOL success = [notification showWithOptions:options]; - if (created) { - if (success) { - NSLog(@"[NotificationRegistry] Showed notification: %@", key); - } else { - NSLog(@"[NotificationRegistry] Failed to show notification: %@", key); - } + if (created && !success) { + NSLog(@"[NotificationRegistry] Failed to show notification: %@", key); } return success; } -- (BOOL)hideNotificationWithKey:(NSString *)key +- (BOOL)hideNotificationOnMainQueueWithKey:(NSString *)key { + AUDIOAPI_ASSERT_ON_QUEUE(dispatch_get_main_queue()); + id notification = _notifications[key]; if (!notification) { @@ -81,31 +111,17 @@ - (BOOL)hideNotificationWithKey:(NSString *)key BOOL success = [notification hide]; - if (success) { - NSLog(@"[NotificationRegistry] Hid notification: %@", key); - } else { + if (!success) { NSLog(@"[NotificationRegistry] Failed to hide notification: %@", key); } return success; } -- (BOOL)isNotificationActiveWithKey:(NSString *)key +- (void)cleanupOnMainQueue { - id notification = _notifications[key]; + AUDIOAPI_ASSERT_ON_QUEUE(dispatch_get_main_queue()); - if (!notification) { - return false; - } - - return [notification isActive]; -} - -- (void)cleanup -{ - NSLog(@"[NotificationRegistry] Cleaning up all notifications"); - - // Clean up all notifications for (id notification in [_notifications allValues]) { [notification cleanup]; } @@ -113,12 +129,11 @@ - (void)cleanup [_notifications removeAllObjects]; } -#pragma mark - Private Methods - - (id)createNotificationForType:(NSString *)type { if ([type isEqualToString:@"playback"]) { - return [[PlaybackNotification alloc] initWithAudioAPIModule:self.audioAPIModule]; + return [[PlaybackNotification alloc] initWithAudioAPIModule:self.audioAPIModule + artworkLoader:_artworkLoader]; } // Future: Add more notification types here // else if ([type isEqualToString:@"recording"]) { diff --git a/packages/react-native-audio-api/ios/audioapi/ios/system/notification/PlaybackNotification.h b/packages/react-native-audio-api/ios/audioapi/ios/system/notification/PlaybackNotification.h index efa049211..504835e2d 100644 --- a/packages/react-native-audio-api/ios/audioapi/ios/system/notification/PlaybackNotification.h +++ b/packages/react-native-audio-api/ios/audioapi/ios/system/notification/PlaybackNotification.h @@ -2,6 +2,7 @@ #import #import +#import #import @class AudioAPIModule; @@ -14,14 +15,14 @@ * * Note: On iOS, this only manages metadata. Notification visibility is controlled * by the AudioContext state (active audio session shows controls). + * + * Every method must be called on the main queue, which owns all of its state. The artwork loader + * must deliver there as well. */ @interface PlaybackNotification : NSObject -@property (nonatomic, weak) AudioAPIModule *audioAPIModule; -@property (nonatomic, weak) MPNowPlayingInfoCenter *playingInfoCenter; -@property (nonatomic, copy) NSString *artworkUrl; -@property (nonatomic, assign) BOOL isActive; - -- (instancetype)initWithAudioAPIModule:(AudioAPIModule *)audioAPIModule; +- (instancetype)initWithAudioAPIModule:(AudioAPIModule *)audioAPIModule + artworkLoader:(ArtworkLoader *)artworkLoader NS_DESIGNATED_INITIALIZER; +- (instancetype)init NS_UNAVAILABLE; @end diff --git a/packages/react-native-audio-api/ios/audioapi/ios/system/notification/PlaybackNotification.mm b/packages/react-native-audio-api/ios/audioapi/ios/system/notification/PlaybackNotification.mm index 1293ef4f5..afb66b119 100644 --- a/packages/react-native-audio-api/ios/audioapi/ios/system/notification/PlaybackNotification.mm +++ b/packages/react-native-audio-api/ios/audioapi/ios/system/notification/PlaybackNotification.mm @@ -1,36 +1,111 @@ #import +#import #import -#define NOW_PLAYING_INFO_KEYS \ - @{ \ - @"title" : MPMediaItemPropertyTitle, \ - @"artist" : MPMediaItemPropertyArtist, \ - @"album" : MPMediaItemPropertyAlbumTitle, \ - @"duration" : MPMediaItemPropertyPlaybackDuration, \ - @"elapsedTime" : MPNowPlayingInfoPropertyElapsedPlaybackTime, \ - @"speed" : MPNowPlayingInfoPropertyPlaybackRate, \ - @"artwork" : MPMediaItemPropertyArtwork, \ - @"isLiveStream" : MPNowPlayingInfoPropertyIsLiveStream \ - } - // Must match PlaybackNotification.DEFAULT_SKIP_INTERVAL_SECONDS on Android. static const NSInteger kDefaultSkipIntervalSeconds = 15; +static const NSInteger kArtworkMinimumSizeInPixels = 512; +static const NSInteger kArtworkMaximumSizeInPixels = 1024; + +#pragma mark - Artwork presentation + +/** Lock screen artwork tracks the portrait width, i.e. the screen's short edge. */ +static NSInteger ArtworkSizeInPixelsForMainScreen() +{ + UIScreen *screen = [UIScreen mainScreen]; + CGFloat shortEdgePoints = MIN(screen.bounds.size.width, screen.bounds.size.height); + auto pixels = (NSInteger)(shortEdgePoints * screen.scale); + return MIN(MAX(pixels, kArtworkMinimumSizeInPixels), kArtworkMaximumSizeInPixels); +} + +static UIImage *ArtworkImageScaledToFit(UIImage *image, CGSize requestedSize) +{ + CGSize sourceSize = image.size; + if (requestedSize.width <= 0 || requestedSize.height <= 0 || sourceSize.width <= 0 || + sourceSize.height <= 0) { + return image; + } + + CGFloat scale = + MIN(requestedSize.width / sourceSize.width, requestedSize.height / sourceSize.height); + // Upscaling would cost memory without adding detail. + if (scale >= 1.0) { + return image; + } + + CGSize targetSize = CGSizeMake(round(sourceSize.width * scale), round(sourceSize.height * scale)); + + UIGraphicsImageRendererFormat *format = [UIGraphicsImageRendererFormat preferredFormat]; + // Keeps the decoded image's scale, so its points remain its pixels. + format.scale = 1.0; + format.opaque = NO; + + UIGraphicsImageRenderer *renderer = [[UIGraphicsImageRenderer alloc] initWithSize:targetSize + format:format]; + return [renderer imageWithActions:^(UIGraphicsImageRendererContext *rendererContext) { + [image drawInRect:CGRectMake(0, 0, targetSize.width, targetSize.height)]; + }]; +} + +/** Wraps a decoded image as artwork that honours the size the system asks for. */ +static MPMediaItemArtwork *ArtworkForImage(UIImage *image) +{ + // The handler runs on an arbitrary thread, so it captures the image and nothing of the + // notification's state. + return [[MPMediaItemArtwork alloc] initWithBoundsSize:image.size + requestHandler:^UIImage *(CGSize requestedSize) { + return ArtworkImageScaledToFit(image, requestedSize); + }]; +} + @implementation PlaybackNotification { + __weak AudioAPIModule *_audioAPIModule; + ArtworkLoader *_artworkLoader; + BOOL _isInitialized; - NSMutableDictionary *_currentInfo; + BOOL _isActive; NSInteger _skipInterval; + + // Metadata, published as a whole by -publishNowPlayingInfo. + NSString *_title; + NSString *_artist; + NSString *_album; + NSTimeInterval _duration; + NSTimeInterval _elapsedTime; + double _speed; + BOOL _isLiveStream; + BOOL _isPlaying; + MPMediaItemArtwork *_artwork; + + NSInteger _artworkMaxPixels; + + id _artworkRequest; + + /** The artwork currently shown or in flight; the key that de-duplicates repeated updates. */ + NSURL *_displayedArtworkURL; + + /** + * Incremented by every new artwork request and by -hide. A load captures the generation it + * started with and its result is dropped if that no longer matches; cancellation alone cannot + * catch a completion already enqueued when its request was superseded. + */ + uint64_t _artworkGeneration; } - (instancetype)initWithAudioAPIModule:(AudioAPIModule *)audioAPIModule + artworkLoader:(ArtworkLoader *)artworkLoader { + AUDIOAPI_ASSERT_ON_QUEUE(dispatch_get_main_queue()); + if (self = [super init]) { - self.audioAPIModule = audioAPIModule; - self.playingInfoCenter = [MPNowPlayingInfoCenter defaultCenter]; + _audioAPIModule = audioAPIModule; + _artworkLoader = artworkLoader; _isInitialized = false; _isActive = false; - _currentInfo = [[NSMutableDictionary alloc] init]; _skipInterval = kDefaultSkipIntervalSeconds; + _speed = 1.0; + _artworkMaxPixels = ArtworkSizeInPixelsForMainScreen(); } return self; @@ -40,16 +115,14 @@ - (instancetype)initWithAudioAPIModule:(AudioAPIModule *)audioAPIModule - (BOOL)initializeWithOptions:(NSDictionary *)options { + AUDIOAPI_ASSERT_ON_QUEUE(dispatch_get_main_queue()); + if (_isInitialized) { return true; } - // Enable remote control events - dispatch_async(dispatch_get_main_queue(), ^{ - [[UIApplication sharedApplication] beginReceivingRemoteControlEvents]; - }); + [[UIApplication sharedApplication] beginReceivingRemoteControlEvents]; - // Enable default remote commands [self enableRemoteCommand:@"play" enabled:true]; [self enableRemoteCommand:@"pause" enabled:true]; [self enableRemoteCommand:@"nextTrack" enabled:true]; @@ -64,41 +137,59 @@ - (BOOL)initializeWithOptions:(NSDictionary *)options - (BOOL)showWithOptions:(NSDictionary *)options { + AUDIOAPI_ASSERT_ON_QUEUE(dispatch_get_main_queue()); + [self updateSkipIntervalFromOptions:options]; - if (!_isInitialized) { - if (![self initializeWithOptions:options]) { - return false; - } + if (![self initializeWithOptions:options]) { + return false; } - // Handle control enable/disable if (options[@"control"] && options[@"enabled"]) { NSString *control = options[@"control"]; BOOL enabled = [options[@"enabled"] boolValue]; [self enableControl:control enabled:enabled]; - // If it's a control update, we can return early or continue // Continuing lets us update metadata if provided mixed with controls } - // Update the now playing info - [self updateNowPlayingInfo:options]; - _isActive = true; + [self updateMetadataFromOptions:options]; + [self publishNowPlayingInfo]; + return true; } - (BOOL)hide { + AUDIOAPI_ASSERT_ON_QUEUE(dispatch_get_main_queue()); + if (!_isActive) { return true; } - // Clear now playing info - self.playingInfoCenter.nowPlayingInfo = nil; - self.artworkUrl = nil; - [_currentInfo removeAllObjects]; + // Invalidates any load already on its way back to this queue; see _artworkGeneration. + _artworkGeneration++; + [_artworkRequest cancel]; + _artworkRequest = nil; + _displayedArtworkURL = nil; + _artwork = nil; + + _title = nil; + _artist = nil; + _album = nil; + _duration = 0; + _elapsedTime = 0; + _speed = 1.0; + _isLiveStream = NO; + _isPlaying = NO; + + MPNowPlayingInfoCenter *center = [MPNowPlayingInfoCenter defaultCenter]; + center.nowPlayingInfo = nil; + // On iOS clearing nowPlayingInfo is what dismisses the entry; see -publishNowPlayingInfo. +#if TARGET_OS_MACCATALYST + center.playbackState = MPNowPlayingPlaybackStateStopped; +#endif _isActive = false; @@ -107,12 +198,12 @@ - (BOOL)hide - (void)cleanup { - // Hide if active + AUDIOAPI_ASSERT_ON_QUEUE(dispatch_get_main_queue()); + if (_isActive) { [self hide]; } - // Disable all remote commands MPRemoteCommandCenter *remoteCenter = [MPRemoteCommandCenter sharedCommandCenter]; [remoteCenter.playCommand removeTarget:self]; [remoteCenter.pauseCommand removeTarget:self]; @@ -125,16 +216,14 @@ - (void)cleanup [remoteCenter.seekBackwardCommand removeTarget:self]; [remoteCenter.changePlaybackPositionCommand removeTarget:self]; - // Disable remote control events - dispatch_async(dispatch_get_main_queue(), ^{ - [[UIApplication sharedApplication] endReceivingRemoteControlEvents]; - }); + [[UIApplication sharedApplication] endReceivingRemoteControlEvents]; _isInitialized = false; } - (BOOL)isActive { + AUDIOAPI_ASSERT_ON_QUEUE(dispatch_get_main_queue()); return _isActive; } @@ -143,7 +232,7 @@ - (NSString *)getNotificationType return @"playback"; } -#pragma mark - Private Methods +#pragma mark - Metadata - (void)updateSkipIntervalFromOptions:(NSDictionary *)options { @@ -156,139 +245,172 @@ - (void)updateSkipIntervalFromOptions:(NSDictionary *)options [self applySkipIntervals]; } -- (void)applySkipIntervals +/** Applies whichever keys this update carries, leaving every other field as it was. */ +- (void)updateMetadataFromOptions:(NSDictionary *)options { - MPRemoteCommandCenter *remoteCenter = [MPRemoteCommandCenter sharedCommandCenter]; - remoteCenter.skipForwardCommand.preferredIntervals = @[ @(_skipInterval) ]; - remoteCenter.skipBackwardCommand.preferredIntervals = @[ @(_skipInterval) ]; + if (!options) { + return; + } + + if (options[@"title"] != nullptr) { + _title = options[@"title"]; + } + if (options[@"artist"] != nullptr) { + _artist = options[@"artist"]; + } + if (options[@"album"] != nullptr) { + _album = options[@"album"]; + } + if (options[@"duration"] != nullptr) { + _duration = [options[@"duration"] doubleValue]; + } + if (options[@"elapsedTime"] != nullptr) { + _elapsedTime = [options[@"elapsedTime"] doubleValue]; + } + if (options[@"speed"] != nullptr) { + _speed = [options[@"speed"] doubleValue]; + } + if (options[@"isLiveStream"] != nullptr) { + _isLiveStream = [options[@"isLiveStream"] boolValue]; + } + // Sending isEqualToString: to a non-string from JavaScript would raise, not return NO. + if ([options[@"state"] isKindOfClass:[NSString class]]) { + _isPlaying = [options[@"state"] isEqualToString:@"playing"]; + } + + if (options[@"artwork"] != nullptr) { + NSURL *artworkURL = [self resolveArtworkURL:options[@"artwork"]]; + // An unresolvable value leaves the displayed artwork alone, like an omitted key would. + if (artworkURL != nil) { + [self requestArtworkForURL:artworkURL]; + } + } } -- (void)updateNowPlayingInfo:(NSDictionary *)info +/** + * The single writer of MPNowPlayingInfoCenter. The dictionary is rebuilt from this object's + * fields rather than read back from the center, so a dismissed entry can never be rebuilt out of a + * surviving key. + */ +- (void)publishNowPlayingInfo { - if (!info) { + if (!_isActive) { return; } - // Get existing now playing info or create new one - NSMutableDictionary *nowPlayingInfo = [self.playingInfoCenter.nowPlayingInfo mutableCopy]; - if (!nowPlayingInfo) { - nowPlayingInfo = [[NSMutableDictionary alloc] init]; + NSMutableDictionary *info = [[NSMutableDictionary alloc] init]; + if (_title != nil) { + info[MPMediaItemPropertyTitle] = _title; + } + if (_artist != nil) { + info[MPMediaItemPropertyArtist] = _artist; + } + if (_album != nil) { + info[MPMediaItemPropertyAlbumTitle] = _album; + } + if (_artwork != nil) { + info[MPMediaItemPropertyArtwork] = _artwork; } + info[MPMediaItemPropertyPlaybackDuration] = @(_duration); + info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = @(_elapsedTime); + // The system advances its scrubber by this rate, so a paused item must report zero. + info[MPNowPlayingInfoPropertyPlaybackRate] = @(_isPlaying ? _speed : 0.0); + info[MPNowPlayingInfoPropertyIsLiveStream] = @(_isLiveStream); - // Map keys from our API to MPNowPlayingInfoCenter keys - NSDictionary *keyMap = NOW_PLAYING_INFO_KEYS; + MPNowPlayingInfoCenter *center = [MPNowPlayingInfoCenter defaultCenter]; + center.nowPlayingInfo = info; + // On iOS the playback state is inferred from the audio session and the rate above; setting it + // requires a private entitlement and is refused with an "Ignoring setPlaybackState" log. +#if TARGET_OS_MACCATALYST + center.playbackState = + _isPlaying ? MPNowPlayingPlaybackStatePlaying : MPNowPlayingPlaybackStatePaused; +#endif +} - // Only update the keys that are provided in this update - for (NSString *key in info) { - NSString *mpKey = keyMap[key]; - if (mpKey) { - // Handle artwork specially - don't set it directly to nowPlayingInfo - if ([key isEqualToString:@"artwork"]) { - _currentInfo[key] = info[key]; - } else { - nowPlayingInfo[mpKey] = info[key]; - _currentInfo[key] = info[key]; - } - } - } +#pragma mark - Artwork - self.playingInfoCenter.nowPlayingInfo = nowPlayingInfo; +/** + * Resolves the artwork value sent from JavaScript, a string or a map carrying a `uri`, to a URL the + * loader can fetch, or nil when it names nothing reachable. + */ +- (NSURL *)resolveArtworkURL:(id)source +{ + NSString *value = nil; + if ([source isKindOfClass:[NSString class]]) { + value = source; + } else if ([source isKindOfClass:[NSDictionary class]]) { + value = ((NSDictionary *)source)[@"uri"]; + } - // Handle playback state - NSString *state = _currentInfo[@"state"]; - MPNowPlayingPlaybackState playbackState = MPNowPlayingPlaybackStatePaused; + if (![value isKindOfClass:[NSString class]] || value.length == 0) { + return nil; + } - if (state) { - if ([state isEqualToString:@"playing"]) { - playbackState = MPNowPlayingPlaybackStatePlaying; - } else if ([state isEqualToString:@"paused"]) { - playbackState = MPNowPlayingPlaybackStatePaused; - } else { - playbackState = MPNowPlayingPlaybackStatePaused; - } + if ([value hasPrefix:@"http://"] || [value hasPrefix:@"https://"] || + [value hasPrefix:@"file://"]) { + return [NSURL URLWithString:value]; } - self.playingInfoCenter.playbackState = playbackState; + if ([value hasPrefix:@"/"]) { + return [NSURL fileURLWithPath:value]; + } - // Handle artwork - NSString *artworkUrl = [self getArtworkUrl:_currentInfo[@"artwork"]]; - [self updateArtworkIfNeeded:artworkUrl]; + // A bare name refers to a resource bundled with the host app. + NSString *path = [[NSBundle mainBundle] pathForResource:value ofType:nil]; + return path != nil ? [NSURL fileURLWithPath:path] : nil; } -- (NSString *)getArtworkUrl:(id)artwork +- (void)requestArtworkForURL:(NSURL *)url { - if (!artwork) { - return nil; - } - - // Handle both string and dictionary formats - if ([artwork isKindOfClass:[NSString class]]) { - return artwork; - } else if ([artwork isKindOfClass:[NSDictionary class]]) { - return artwork[@"uri"]; + // Covers art still loading as well as art displayed, so a per-second elapsedTime update does not + // restart the same download on every tick. + if ([url.absoluteString isEqualToString:_displayedArtworkURL.absoluteString]) { + return; } - return nil; + [_artworkRequest cancel]; + _displayedArtworkURL = url; + + uint64_t generation = ++_artworkGeneration; + // The fetch owns this block: a strong capture would keep a torn-down notification alive until the + // deadline and let it publish afterwards. + __weak PlaybackNotification *weakSelf = self; + _artworkRequest = [_artworkLoader loadArtworkFromURL:url + maximumSizeInPixels:_artworkMaxPixels + completion:^(UIImage *image) { + [weakSelf applyLoadedArtworkImage:image + forGeneration:generation]; + }]; } -- (void)updateArtworkIfNeeded:(NSString *)artworkUrl +/** Applies artwork that finished loading; a stale @c generation is discarded. */ +- (void)applyLoadedArtworkImage:(UIImage *)image forGeneration:(uint64_t)generation { - if (!artworkUrl) { + AUDIOAPI_ASSERT_ON_QUEUE(dispatch_get_main_queue()); + + if (generation != _artworkGeneration) { return; } + _artworkRequest = nil; - MPNowPlayingInfoCenter *center = [MPNowPlayingInfoCenter defaultCenter]; - if ([artworkUrl isEqualToString:self.artworkUrl] && - center.nowPlayingInfo[MPMediaItemPropertyArtwork] != nil) { + if (image == nil) { + // Clearing the key lets the same address be retried after a transient failure. Artwork already + // published is left in place: a failed load is not an instruction to remove art. + _displayedArtworkURL = nil; return; } - self.artworkUrl = artworkUrl; - - // Load artwork asynchronously - dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_BACKGROUND, 0), ^{ - NSURL *url = nil; - NSData *imageData = nil; - UIImage *image = nil; - - @try { - if ([artworkUrl hasPrefix:@"http://"] || [artworkUrl hasPrefix:@"https://"]) { - // Remote URL - url = [NSURL URLWithString:artworkUrl]; - imageData = [NSData dataWithContentsOfURL:url]; - } else { - // Local file - try as resource or file path - NSString *imagePath = [[NSBundle mainBundle] pathForResource:artworkUrl ofType:nil]; - if (imagePath) { - imageData = [NSData dataWithContentsOfFile:imagePath]; - } else { - // Try as absolute path - imageData = [NSData dataWithContentsOfFile:artworkUrl]; - } - } - - if (imageData) { - image = [UIImage imageWithData:imageData]; - } - } @catch (NSException *exception) { - // Failed to load artwork - } + _artwork = ArtworkForImage(image); + [self publishNowPlayingInfo]; +} - if (image) { - MPMediaItemArtwork *artwork = [[MPMediaItemArtwork alloc] - initWithBoundsSize:image.size - requestHandler:^UIImage *_Nonnull(CGSize size) { return image; }]; - - dispatch_async(dispatch_get_main_queue(), ^{ - NSMutableDictionary *nowPlayingInfo = [center.nowPlayingInfo mutableCopy]; - if (!nowPlayingInfo) { - nowPlayingInfo = [[NSMutableDictionary alloc] init]; - } - nowPlayingInfo[MPMediaItemPropertyArtwork] = artwork; - center.nowPlayingInfo = nowPlayingInfo; - }); - } - }); +#pragma mark - Remote Commands + +- (void)applySkipIntervals +{ + MPRemoteCommandCenter *remoteCenter = [MPRemoteCommandCenter sharedCommandCenter]; + remoteCenter.skipForwardCommand.preferredIntervals = @[ @(_skipInterval) ]; + remoteCenter.skipBackwardCommand.preferredIntervals = @[ @(_skipInterval) ]; } - (void)enableControl:(NSString *)control enabled:(BOOL)enabled @@ -309,6 +431,10 @@ - (void)enableControl:(NSString *)control enabled:(BOOL)enabled - (void)enableRemoteCommand:(NSString *)name enabled:(BOOL)enabled { + if ([name isEqualToString:@"skipForward"] || [name isEqualToString:@"skipBackward"]) { + [self applySkipIntervals]; + } + MPRemoteCommandCenter *remoteCenter = [MPRemoteCommandCenter sharedCommandCenter]; if ([name isEqualToString:@"play"]) { @@ -326,12 +452,10 @@ - (void)enableRemoteCommand:(NSString *)name enabled:(BOOL)enabled withSelector:@selector(onPreviousTrack:) enabled:enabled]; } else if ([name isEqualToString:@"skipForward"]) { - [self applySkipIntervals]; [self enableCommand:remoteCenter.skipForwardCommand withSelector:@selector(onSkipForward:) enabled:enabled]; } else if ([name isEqualToString:@"skipBackward"]) { - [self applySkipIntervals]; [self enableCommand:remoteCenter.skipBackwardCommand withSelector:@selector(onSkipBackward:) enabled:enabled]; @@ -363,36 +487,35 @@ - (void)enableCommand:(MPRemoteCommand *)command withSelector:(SEL)selector enab - (MPRemoteCommandHandlerStatus)onPlay:(MPRemoteCommandEvent *)event { - [self.audioAPIModule invokeHandlerWithEventName:audioapi::AudioEvent::PLAYBACK_NOTIFICATION_PLAY - payload:audioapi::EmptyPayload{}]; + [_audioAPIModule invokeHandlerWithEventName:audioapi::AudioEvent::PLAYBACK_NOTIFICATION_PLAY + payload:audioapi::EmptyPayload{}]; return MPRemoteCommandHandlerStatusSuccess; } - (MPRemoteCommandHandlerStatus)onPause:(MPRemoteCommandEvent *)event { - [self.audioAPIModule invokeHandlerWithEventName:audioapi::AudioEvent::PLAYBACK_NOTIFICATION_PAUSE - payload:audioapi::EmptyPayload{}]; + [_audioAPIModule invokeHandlerWithEventName:audioapi::AudioEvent::PLAYBACK_NOTIFICATION_PAUSE + payload:audioapi::EmptyPayload{}]; return MPRemoteCommandHandlerStatusSuccess; } - (MPRemoteCommandHandlerStatus)onStop:(MPRemoteCommandEvent *)event { - [self.audioAPIModule invokeHandlerWithEventName:audioapi::AudioEvent::PLAYBACK_NOTIFICATION_STOP - payload:audioapi::EmptyPayload{}]; + [_audioAPIModule invokeHandlerWithEventName:audioapi::AudioEvent::PLAYBACK_NOTIFICATION_STOP + payload:audioapi::EmptyPayload{}]; return MPRemoteCommandHandlerStatusSuccess; } - (MPRemoteCommandHandlerStatus)onNextTrack:(MPRemoteCommandEvent *)event { - [self.audioAPIModule - invokeHandlerWithEventName:audioapi::AudioEvent::PLAYBACK_NOTIFICATION_NEXT_TRACK - payload:audioapi::EmptyPayload{}]; + [_audioAPIModule invokeHandlerWithEventName:audioapi::AudioEvent::PLAYBACK_NOTIFICATION_NEXT_TRACK + payload:audioapi::EmptyPayload{}]; return MPRemoteCommandHandlerStatusSuccess; } - (MPRemoteCommandHandlerStatus)onPreviousTrack:(MPRemoteCommandEvent *)event { - [self.audioAPIModule + [_audioAPIModule invokeHandlerWithEventName:audioapi::AudioEvent::PLAYBACK_NOTIFICATION_PREVIOUS_TRACK payload:audioapi::EmptyPayload{}]; return MPRemoteCommandHandlerStatusSuccess; @@ -400,7 +523,7 @@ - (MPRemoteCommandHandlerStatus)onPreviousTrack:(MPRemoteCommandEvent *)event - (MPRemoteCommandHandlerStatus)onSeekForward:(MPRemoteCommandEvent *)event { - [self.audioAPIModule + [_audioAPIModule invokeHandlerWithEventName:audioapi::AudioEvent::PLAYBACK_NOTIFICATION_SEEK_FORWARD payload:audioapi::EmptyPayload{}]; return MPRemoteCommandHandlerStatusSuccess; @@ -408,7 +531,7 @@ - (MPRemoteCommandHandlerStatus)onSeekForward:(MPRemoteCommandEvent *)event - (MPRemoteCommandHandlerStatus)onSeekBackward:(MPRemoteCommandEvent *)event { - [self.audioAPIModule + [_audioAPIModule invokeHandlerWithEventName:audioapi::AudioEvent::PLAYBACK_NOTIFICATION_SEEK_BACKWARD payload:audioapi::EmptyPayload{}]; return MPRemoteCommandHandlerStatusSuccess; @@ -416,7 +539,7 @@ - (MPRemoteCommandHandlerStatus)onSeekBackward:(MPRemoteCommandEvent *)event - (MPRemoteCommandHandlerStatus)onSkipForward:(MPSkipIntervalCommandEvent *)event { - [self.audioAPIModule + [_audioAPIModule invokeHandlerWithEventName:audioapi::AudioEvent::PLAYBACK_NOTIFICATION_SKIP_FORWARD payload:audioapi::DoubleValuePayload{.value = event.interval}]; return MPRemoteCommandHandlerStatusSuccess; @@ -424,7 +547,7 @@ - (MPRemoteCommandHandlerStatus)onSkipForward:(MPSkipIntervalCommandEvent *)even - (MPRemoteCommandHandlerStatus)onSkipBackward:(MPSkipIntervalCommandEvent *)event { - [self.audioAPIModule + [_audioAPIModule invokeHandlerWithEventName:audioapi::AudioEvent::PLAYBACK_NOTIFICATION_SKIP_BACKWARD payload:audioapi::DoubleValuePayload{.value = event.interval}]; return MPRemoteCommandHandlerStatusSuccess; @@ -433,7 +556,7 @@ - (MPRemoteCommandHandlerStatus)onSkipBackward:(MPSkipIntervalCommandEvent *)eve - (MPRemoteCommandHandlerStatus)onChangePlaybackPosition: (MPChangePlaybackPositionCommandEvent *)event { - [self.audioAPIModule + [_audioAPIModule invokeHandlerWithEventName:audioapi::AudioEvent::PLAYBACK_NOTIFICATION_SEEK_TO payload:audioapi::DoubleValuePayload{.value = event.positionTime}]; return MPRemoteCommandHandlerStatusSuccess;