@craft-native/ios 0.0.143 → 0.0.144

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -178,6 +178,7 @@ Edit `craft.config.json` in your iOS project:
178
178
  "enableBluetooth": true,
179
179
  "enableNFC": true,
180
180
  "enableHealthKit": false,
181
+ "enableAppleMusic": false,
181
182
  "enableScreenCapture": true,
182
183
  "iosVersion": "15.0",
183
184
  "teamId": ""
@@ -188,6 +189,18 @@ The app name is shown as written (`"HQ.training"` is fine); the Xcode
188
189
  project, target and Swift types use a PascalCase identifier made from it
189
190
  (`HQTraining`). A key left out of the file keeps its default.
190
191
 
192
+ Purpose strings in your own words, by Info.plist key (each replaces Craft's
193
+ generic sentence; the keys for frameworks the shell links are always written,
194
+ since App Store Connect checks the binary):
195
+
196
+ ```json
197
+ {
198
+ "usageDescriptions": {
199
+ "NSAppleMusicUsageDescription": "Shows the song playing during a workout and lets you pause or skip it."
200
+ }
201
+ }
202
+ ```
203
+
191
204
  Appearance and navigation:
192
205
 
193
206
  ```json
@@ -520,6 +533,31 @@ window.craft.setKeepAwake(false); // Allow screen dimming
520
533
  const level = await window.craft.getBrightness(); // 0.62
521
534
  await window.craft.setBrightness(0.1); // resolves with what the screen reports
522
535
 
536
+ // Apple Music (enableAppleMusic): what the Music app is playing, and its
537
+ // transport, through MPMusicPlayerController.systemMusicPlayer. Only Apple
538
+ // Music: iOS gives no app another player's controls, so while Spotify or a
539
+ // podcast app plays, the song fields are null and otherAudioPlaying is true.
540
+ // Nothing is read until the person allows media library access, which only
541
+ // requestAuthorization() asks for (call it from their tap, never at launch).
542
+ // Word the prompt with usageDescriptions.NSAppleMusicUsageDescription.
543
+ await window.craft.music.requestAuthorization(); // 'authorized' | 'denied' | 'restricted' | 'notDetermined'
544
+ const now = await window.craft.music.getNowPlaying();
545
+ // { available, authorization, source: 'appleMusic', playbackState: 'playing', isPlaying,
546
+ // itemId, title, artist, album, duration, position, timestamp,
547
+ // artworkDataURL: 'data:image/jpeg;base64,…' (120 px, or null), otherAudioPlaying }
548
+ await window.craft.music.togglePlayPause(); // play() / pause() too; each resolves with the snapshot
549
+ await window.craft.music.skipToNext();
550
+ await window.craft.music.skipToPrevious({ restartAfter: 3 }); // like Music's own button; 0 = always previous
551
+ await window.craft.music.observe(true); // then every change arrives as craftMusicChange
552
+ window.addEventListener('craftMusicChange', (event) => render(event.detail));
553
+ // Or `import { music } from 'craft-native/mobile'`: music.onChange(callback)
554
+ // observes while anyone listens, and off a phone every call answers
555
+ // { available: false } without doing anything.
556
+ // The simulator's Music app has nothing to play: launch with
557
+ // `-CraftFakeMusicPlayer YES` (or `simctl spawn <device> defaults write <bundle id>
558
+ // CraftFakeMusicPlayer -bool YES`) for three made-up songs that play and skip.
559
+ // Simulator builds only; a device always talks to the Music app.
560
+
523
561
  // Speech: short spoken cues that duck the person's music instead of stopping
524
562
  // it, and play with the silent switch on. Always available (no config flag).
525
563
  // Resolves when the utterance ends: true if spoken, false if stopped or
package/dist/cli.js CHANGED
@@ -114,6 +114,7 @@ var DEFAULT_CONFIG = {
114
114
  enablePDFViewer: false,
115
115
  enableAR: false,
116
116
  enableMLKit: false,
117
+ enableAppleMusic: false,
117
118
  iosVersion: "16.0",
118
119
  watchosVersion: "9.0",
119
120
  teamId: "",
@@ -157,7 +158,8 @@ var LINKED_PURPOSES = {
157
158
  NSCalendarsUsageDescription: "your calendar",
158
159
  NSBluetoothAlwaysUsageDescription: "Bluetooth",
159
160
  NSMotionUsageDescription: "motion data",
160
- NSFaceIDUsageDescription: "Face ID"
161
+ NSFaceIDUsageDescription: "Face ID",
162
+ NSAppleMusicUsageDescription: "your music"
161
163
  };
162
164
  function renderUsageDescriptions(config) {
163
165
  const entries = [
@@ -175,9 +177,14 @@ function renderUsageDescriptions(config) {
175
177
  [config.enableLocalNetwork, "NSLocalNetworkUsageDescription", `${config.appName} connects to your own devices on this network.`],
176
178
  [config.enableHealthKit, "NSHealthShareUsageDescription", `${config.appName} reads health data you choose to share.`],
177
179
  [config.enableHealthKit, "NSHealthUpdateUsageDescription", `${config.appName} writes health data only with your permission.`],
178
- [config.enableBiometric, "NSFaceIDUsageDescription", `${config.appName} uses Face ID to protect your account.`]
180
+ [config.enableBiometric, "NSFaceIDUsageDescription", `${config.appName} uses Face ID to protect your account.`],
181
+ [config.enableAppleMusic, "NSAppleMusicUsageDescription", `${config.appName} shows and controls the music playing in the Music app.`]
179
182
  ];
183
+ const own = config.usageDescriptions ?? {};
180
184
  return entries.map(([enabled, key, value]) => {
185
+ const custom = own[key]?.trim();
186
+ if (custom)
187
+ return [key, custom];
181
188
  if (enabled)
182
189
  return [key, value];
183
190
  const linked = LINKED_PURPOSES[key];
@@ -724,6 +731,7 @@ async function init(options) {
724
731
  cpSync(join(TEMPLATES_DIR, "CraftNativeFlatList.swift"), join(output, "Sources", "CraftNativeFlatList.swift"));
725
732
  cpSync(join(TEMPLATES_DIR, "CraftNativeActions.swift"), join(output, "Sources", "CraftNativeActions.swift"));
726
733
  cpSync(join(TEMPLATES_DIR, "CraftHealth.swift"), join(output, "Sources", "CraftHealth.swift"));
734
+ cpSync(join(TEMPLATES_DIR, "CraftMusic.swift"), join(output, "Sources", "CraftMusic.swift"));
727
735
  cpSync(join(TEMPLATES_DIR, "CraftNativeMutation.swift"), join(output, "Sources", "CraftNativeMutation.swift"));
728
736
  cpSync(join(TEMPLATES_DIR, "CraftNativeUI.swift"), join(output, "Sources", "CraftNativeUI.swift"));
729
737
  cpSync(join(TEMPLATES_DIR, "CraftHybrid.swift"), join(output, "Sources", "CraftHybrid.swift"));
package/dist/index.d.ts CHANGED
@@ -60,6 +60,20 @@ export interface CraftConfig {
60
60
  enablePDFViewer?: boolean;
61
61
  enableAR?: boolean;
62
62
  enableMLKit?: boolean;
63
+ /**
64
+ * Show and control what the Music app is playing (`craft.music`, through
65
+ * MPMusicPlayerController.systemMusicPlayer). Only Apple Music: iOS gives
66
+ * no app the controls of another player such as Spotify. iOS asks the
67
+ * person for media library access the first time the page calls
68
+ * `craft.music.requestAuthorization()`, never at launch.
69
+ */
70
+ enableAppleMusic?: boolean;
71
+ /**
72
+ * Purpose strings in the app's own words, by Info.plist key
73
+ * (`{ NSAppleMusicUsageDescription: 'Shows the song playing during a workout.' }`).
74
+ * Each replaces the generic sentence Craft writes for that key.
75
+ */
76
+ usageDescriptions?: Record<string, string>;
63
77
  devServerURL?: string;
64
78
  iosVersion?: string;
65
79
  watchosVersion?: string;
package/dist/index.js CHANGED
@@ -169,6 +169,7 @@ var DEFAULT_CONFIG = {
169
169
  enablePDFViewer: false,
170
170
  enableAR: false,
171
171
  enableMLKit: false,
172
+ enableAppleMusic: false,
172
173
  iosVersion: "16.0",
173
174
  watchosVersion: "9.0",
174
175
  teamId: "",
@@ -212,7 +213,8 @@ var LINKED_PURPOSES = {
212
213
  NSCalendarsUsageDescription: "your calendar",
213
214
  NSBluetoothAlwaysUsageDescription: "Bluetooth",
214
215
  NSMotionUsageDescription: "motion data",
215
- NSFaceIDUsageDescription: "Face ID"
216
+ NSFaceIDUsageDescription: "Face ID",
217
+ NSAppleMusicUsageDescription: "your music"
216
218
  };
217
219
  function renderUsageDescriptions(config) {
218
220
  const entries = [
@@ -230,9 +232,14 @@ function renderUsageDescriptions(config) {
230
232
  [config.enableLocalNetwork, "NSLocalNetworkUsageDescription", `${config.appName} connects to your own devices on this network.`],
231
233
  [config.enableHealthKit, "NSHealthShareUsageDescription", `${config.appName} reads health data you choose to share.`],
232
234
  [config.enableHealthKit, "NSHealthUpdateUsageDescription", `${config.appName} writes health data only with your permission.`],
233
- [config.enableBiometric, "NSFaceIDUsageDescription", `${config.appName} uses Face ID to protect your account.`]
235
+ [config.enableBiometric, "NSFaceIDUsageDescription", `${config.appName} uses Face ID to protect your account.`],
236
+ [config.enableAppleMusic, "NSAppleMusicUsageDescription", `${config.appName} shows and controls the music playing in the Music app.`]
234
237
  ];
238
+ const own = config.usageDescriptions ?? {};
235
239
  return entries.map(([enabled, key, value]) => {
240
+ const custom = own[key]?.trim();
241
+ if (custom)
242
+ return [key, custom];
236
243
  if (enabled)
237
244
  return [key, value];
238
245
  const linked = LINKED_PURPOSES[key];
@@ -779,6 +786,7 @@ async function init(options) {
779
786
  cpSync(join(TEMPLATES_DIR, "CraftNativeFlatList.swift"), join(output, "Sources", "CraftNativeFlatList.swift"));
780
787
  cpSync(join(TEMPLATES_DIR, "CraftNativeActions.swift"), join(output, "Sources", "CraftNativeActions.swift"));
781
788
  cpSync(join(TEMPLATES_DIR, "CraftHealth.swift"), join(output, "Sources", "CraftHealth.swift"));
789
+ cpSync(join(TEMPLATES_DIR, "CraftMusic.swift"), join(output, "Sources", "CraftMusic.swift"));
782
790
  cpSync(join(TEMPLATES_DIR, "CraftNativeMutation.swift"), join(output, "Sources", "CraftNativeMutation.swift"));
783
791
  cpSync(join(TEMPLATES_DIR, "CraftNativeUI.swift"), join(output, "Sources", "CraftNativeUI.swift"));
784
792
  cpSync(join(TEMPLATES_DIR, "CraftHybrid.swift"), join(output, "Sources", "CraftHybrid.swift"));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@craft-native/ios",
3
- "version": "0.0.143",
3
+ "version": "0.0.144",
4
4
  "homepage": "https://github.com/craft-native/craft/tree/main/packages/ios#readme",
5
5
  "repository": {
6
6
  "type": "git",
@@ -1105,6 +1105,8 @@ struct CraftConfig: Codable {
1105
1105
  var enablePDFViewer: Bool = false
1106
1106
  var enableAR: Bool = false
1107
1107
  var enableMLKit: Bool = false
1108
+ /// The Music app's song and transport through `craft.music` (CraftMusic.swift).
1109
+ var enableAppleMusic: Bool = false
1108
1110
  var devServerURL: String? = nil
1109
1111
  var trustedOrigins: [String] = []
1110
1112
  /// The longest the launch splash stays up when the page neither says it is
@@ -2162,6 +2164,43 @@ struct CraftWebView: UIViewRepresentable {
2162
2164
  } else {
2163
2165
  rejectCallback(callbackId, error: "setBrightness was called without the values it needs", code: "INVALID_ARGUMENT")
2164
2166
  }
2167
+ // MARK: - Apple Music (CraftMusic.swift)
2168
+ // Gated on enableAppleMusic. Only musicRequestAuthorization asks
2169
+ // the person anything; the rest answer from what is allowed.
2170
+ case "musicGetAuthorization":
2171
+ if config.enableAppleMusic {
2172
+ DispatchQueue.main.async { self.resolveCallback(callbackId, result: CraftMusic.shared.authorization) }
2173
+ } else {
2174
+ rejectCallback(callbackId, error: "Apple Music is disabled", code: "CAPABILITY_DISABLED")
2175
+ }
2176
+ case "musicRequestAuthorization":
2177
+ if config.enableAppleMusic {
2178
+ DispatchQueue.main.async {
2179
+ CraftMusic.shared.requestAuthorization { [weak self] status in self?.resolveCallback(callbackId, result: status) }
2180
+ }
2181
+ } else {
2182
+ rejectCallback(callbackId, error: "Apple Music is disabled", code: "CAPABILITY_DISABLED")
2183
+ }
2184
+ case "musicGetNowPlaying":
2185
+ if config.enableAppleMusic {
2186
+ DispatchQueue.main.async { self.resolveCallback(callbackId, result: CraftMusic.shared.snapshot()) }
2187
+ } else {
2188
+ rejectCallback(callbackId, error: "Apple Music is disabled", code: "CAPABILITY_DISABLED")
2189
+ }
2190
+ case "musicControl":
2191
+ if !config.enableAppleMusic {
2192
+ rejectCallback(callbackId, error: "Apple Music is disabled", code: "CAPABILITY_DISABLED")
2193
+ } else if let command = body["command"] as? String {
2194
+ musicControl(command, restartAfter: (body["restartAfter"] as? NSNumber)?.doubleValue, callbackId: callbackId)
2195
+ } else {
2196
+ rejectCallback(callbackId, error: "musicControl was called without a command", code: "INVALID_ARGUMENT")
2197
+ }
2198
+ case "musicObserve":
2199
+ if config.enableAppleMusic {
2200
+ musicObserve(body["enabled"] as? Bool ?? true, callbackId: callbackId)
2201
+ } else {
2202
+ rejectCallback(callbackId, error: "Apple Music is disabled", code: "CAPABILITY_DISABLED")
2203
+ }
2165
2204
  // MARK: - Orientation Lock
2166
2205
  case "lockOrientation":
2167
2206
  if config.enableOrientationLock {
@@ -3146,6 +3185,7 @@ struct CraftWebView: UIViewRepresentable {
3146
3185
  deepLinks: \(config.enableDeepLinks),
3147
3186
  flashlight: true,
3148
3187
  brightness: true,
3188
+ music: \(config.enableAppleMusic),
3149
3189
  speech: true,
3150
3190
  network: true,
3151
3191
  deviceInfo: true,
@@ -4684,6 +4724,7 @@ struct CraftWebView: UIViewRepresentable {
4684
4724
  // still open; these wait for the answer instead.
4685
4725
  var personFacing = {
4686
4726
  requestHealthAuthorization: true,
4727
+ musicRequestAuthorization: true,
4687
4728
  requestPermission: true,
4688
4729
  signInWithApple: true,
4689
4730
  dialogAlert: true,
@@ -4881,6 +4922,23 @@ struct CraftWebView: UIViewRepresentable {
4881
4922
  return craft._invoke('getHealthSleepIntervals', {startDate: options.startDate, endDate: options.endDate});
4882
4923
  }
4883
4924
  };
4925
+ // The Music app's song and transport. Nothing is read until
4926
+ // requestAuthorization() has been allowed; observe(true) makes
4927
+ // every change arrive as a craftMusicChange event.
4928
+ craft.music = {
4929
+ getAuthorizationStatus: function() { return craft._invoke('musicGetAuthorization'); },
4930
+ requestAuthorization: function() { return craft._invoke('musicRequestAuthorization'); },
4931
+ getNowPlaying: function() { return craft._invoke('musicGetNowPlaying'); },
4932
+ play: function() { return craft._invoke('musicControl', {command: 'play'}); },
4933
+ pause: function() { return craft._invoke('musicControl', {command: 'pause'}); },
4934
+ togglePlayPause: function() { return craft._invoke('musicControl', {command: 'togglePlayPause'}); },
4935
+ skipToNext: function() { return craft._invoke('musicControl', {command: 'skipToNext'}); },
4936
+ skipToPrevious: function(options) {
4937
+ var restartAfter = options && typeof options.restartAfter === 'number' ? options.restartAfter : null;
4938
+ return craft._invoke('musicControl', {command: 'skipToPrevious', restartAfter: restartAfter});
4939
+ },
4940
+ observe: function(enabled) { return craft._invoke('musicObserve', {enabled: enabled !== false}); }
4941
+ };
4884
4942
  craft.liveActivity = {
4885
4943
  start: function(options) { return craft._invoke('startLiveActivity', options || {}); },
4886
4944
  update: function(idOrState, state) {
@@ -6932,6 +6990,29 @@ struct CraftWebView: UIViewRepresentable {
6932
6990
  CraftHealth.shared.getSleepIntervals(startDate: startDate, endDate: endDate) { [weak self] in self?.answer(callbackId, $0) }
6933
6991
  }
6934
6992
 
6993
+ // MARK: - Apple Music
6994
+ // The player itself is CraftMusic.swift.
6995
+ private func musicControl(_ command: String, restartAfter: Double?, callbackId: String?) {
6996
+ DispatchQueue.main.async {
6997
+ CraftMusic.shared.control(command, restartAfter: restartAfter) { [weak self] result in
6998
+ switch result {
6999
+ case .success(let snapshot): self?.resolveCallback(callbackId, result: snapshot)
7000
+ case .failure(let failure): self?.rejectCallback(callbackId, error: failure.message, code: failure.code)
7001
+ }
7002
+ }
7003
+ }
7004
+ }
7005
+
7006
+ /// Every change of song or playback state reaches the page as
7007
+ /// `craftMusicChange`, carrying the same snapshot musicGetNowPlaying
7008
+ /// answers with.
7009
+ private func musicObserve(_ enabled: Bool, callbackId: String?) {
7010
+ DispatchQueue.main.async {
7011
+ CraftMusic.shared.observe(enabled ? { [weak self] snapshot in self?.sendToWeb("craftMusicChange", data: snapshot) } : nil)
7012
+ self.resolveCallback(callbackId, result: enabled)
7013
+ }
7014
+ }
7015
+
6935
7016
  // MARK: - Live Activities
6936
7017
  private func startLiveActivity(body: [String: Any], callbackId: String?) {
6937
7018
  guard #available(iOS 16.2, *), ActivityAuthorizationInfo().areActivitiesEnabled else {
@@ -0,0 +1,413 @@
1
+ import AVFoundation
2
+ import Foundation
3
+ import MediaPlayer
4
+ import UIKit
5
+
6
+ /// A music call that did not happen: the message a page sees, and the
7
+ /// bridge's error code.
8
+ struct CraftMusicError: Error {
9
+ let message: String
10
+ var code: String = "CRAFT_ERROR"
11
+ }
12
+
13
+ /// The song a player has up, whoever the player is.
14
+ struct CraftMusicItem {
15
+ let id: String
16
+ let title: String?
17
+ let artist: String?
18
+ let album: String?
19
+ let duration: TimeInterval
20
+ /// The artwork drawn at about `size`, or nil when the song has none.
21
+ let artwork: (CGSize) -> UIImage?
22
+ }
23
+
24
+ /// The slice of MPMusicPlayerController the bridge uses, so the simulator,
25
+ /// whose Music app has nothing to play, can stand a fake in for it.
26
+ protocol CraftMusicPlayer: AnyObject {
27
+ /// "appleMusic" for the Music app, "simulated" for the fake.
28
+ var source: String { get }
29
+ var item: CraftMusicItem? { get }
30
+ var playbackState: MPMusicPlaybackState { get }
31
+ var currentPlaybackTime: TimeInterval { get }
32
+ /// Called on main whenever the song or the playback state changes.
33
+ var onChange: (() -> Void)? { get set }
34
+ func play()
35
+ func pause()
36
+ func skipToNextItem()
37
+ func skipToPreviousItem()
38
+ func skipToBeginning()
39
+ func startObserving()
40
+ func stopObserving()
41
+ }
42
+
43
+ /// What the Music app is playing, and its transport: the page's
44
+ /// `craft.music` (CraftApp.swift).
45
+ ///
46
+ /// Through `MPMusicPlayerController.systemMusicPlayer`, which is the Music
47
+ /// app's own queue: what it plays, this sees and steers, and nothing else.
48
+ /// iOS gives no app another player's controls, so while Spotify or a podcast
49
+ /// app plays, `title` is null and `otherAudioPlaying` is true; a page should
50
+ /// say so rather than show controls that do nothing.
51
+ ///
52
+ /// Media library access is asked for only by `requestAuthorization`, which a
53
+ /// page calls when the person chooses to see their music; until then the
54
+ /// player is not touched at all, so nothing is read without consent.
55
+ ///
56
+ /// Everything here runs on main: MediaPlayer wants it, and every caller hops
57
+ /// there first.
58
+ final class CraftMusic {
59
+ typealias Listener = ([String: Any]) -> Void
60
+
61
+ static let shared = CraftMusic()
62
+
63
+ /// The longest side of the artwork a page is sent, in pixels. Small on
64
+ /// purpose: it is a thumbnail beside a title, and it crosses the bridge as
65
+ /// base64 on every change.
66
+ static let artworkPixels: CGFloat = 120
67
+
68
+ private lazy var player: CraftMusicPlayer = {
69
+ let player = CraftMusic.makePlayer()
70
+ player.onChange = { [weak self] in self?.playerDidChange() }
71
+ return player
72
+ }()
73
+
74
+ private var listener: Listener?
75
+ private var observing = false
76
+ private var changeScheduled = false
77
+ private var foregroundObserver: NSObjectProtocol?
78
+ private var artworkCache: (id: String, dataURL: String?)?
79
+
80
+ /// The real player, or on the simulator the fake when the
81
+ /// `CraftFakeMusicPlayer` default is set: as a launch argument
82
+ /// (`-CraftFakeMusicPlayer YES`) or with
83
+ /// `xcrun simctl spawn booted defaults write <bundle id> CraftFakeMusicPlayer -bool YES`.
84
+ private static func makePlayer() -> CraftMusicPlayer {
85
+ #if targetEnvironment(simulator)
86
+ if UserDefaults.standard.bool(forKey: "CraftFakeMusicPlayer") { return CraftFakeMusicPlayer() }
87
+ #endif
88
+ return CraftSystemMusicPlayer()
89
+ }
90
+
91
+ // MARK: Authorization
92
+
93
+ static func authorizationName(_ status: MPMediaLibraryAuthorizationStatus) -> String {
94
+ switch status {
95
+ case .authorized: return "authorized"
96
+ case .denied: return "denied"
97
+ case .restricted: return "restricted"
98
+ case .notDetermined: return "notDetermined"
99
+ @unknown default: return "denied"
100
+ }
101
+ }
102
+
103
+ var authorization: String { Self.authorizationName(MPMediaLibrary.authorizationStatus()) }
104
+ var isAuthorized: Bool { MPMediaLibrary.authorizationStatus() == .authorized }
105
+
106
+ /// Ask once; iOS answers from memory after that. Resolves with the
107
+ /// status's name.
108
+ func requestAuthorization(completion: @escaping (String) -> Void) {
109
+ guard MPMediaLibrary.authorizationStatus() == .notDetermined else {
110
+ completion(authorization)
111
+ return
112
+ }
113
+ MPMediaLibrary.requestAuthorization { status in
114
+ DispatchQueue.main.async {
115
+ completion(Self.authorizationName(status))
116
+ // A page that was already listening hears what there is now.
117
+ if status == .authorized, self.observing { self.startPlayerObservation() }
118
+ }
119
+ }
120
+ }
121
+
122
+ // MARK: Now playing
123
+
124
+ static func playbackStateName(_ state: MPMusicPlaybackState) -> String {
125
+ switch state {
126
+ case .stopped: return "stopped"
127
+ case .playing: return "playing"
128
+ case .paused: return "paused"
129
+ case .interrupted: return "interrupted"
130
+ case .seekingForward: return "seekingForward"
131
+ case .seekingBackward: return "seekingBackward"
132
+ @unknown default: return "stopped"
133
+ }
134
+ }
135
+
136
+ /// Everything a page shows about the music, in one answer. Without
137
+ /// access, only what can be said without touching the player.
138
+ func snapshot() -> [String: Any] {
139
+ var result: [String: Any] = [
140
+ "available": true,
141
+ "authorization": authorization,
142
+ "otherAudioPlaying": AVAudioSession.sharedInstance().isOtherAudioPlaying,
143
+ "timestamp": Int(Date().timeIntervalSince1970 * 1000),
144
+ "source": NSNull(),
145
+ "playbackState": "unknown",
146
+ "isPlaying": false,
147
+ "itemId": NSNull(),
148
+ "title": NSNull(),
149
+ "artist": NSNull(),
150
+ "album": NSNull(),
151
+ "duration": NSNull(),
152
+ "position": NSNull(),
153
+ "artworkDataURL": NSNull(),
154
+ ]
155
+ guard isAuthorized else { return result }
156
+
157
+ let player = self.player
158
+ let state = player.playbackState
159
+ result["source"] = player.source
160
+ result["playbackState"] = Self.playbackStateName(state)
161
+ result["isPlaying"] = state == .playing || state == .seekingForward || state == .seekingBackward
162
+ guard let item = player.item else { return result }
163
+
164
+ result["itemId"] = item.id
165
+ result["title"] = Self.orNull(item.title)
166
+ result["artist"] = Self.orNull(item.artist)
167
+ result["album"] = Self.orNull(item.album)
168
+ result["duration"] = Self.orNull(item.duration.isFinite && item.duration > 0 ? item.duration : nil)
169
+ let position = player.currentPlaybackTime
170
+ result["position"] = Self.orNull(position.isFinite && position >= 0 ? position : nil)
171
+ result["artworkDataURL"] = Self.orNull(artworkDataURL(for: item))
172
+ return result
173
+ }
174
+
175
+ private static func orNull(_ value: Any?) -> Any { value ?? NSNull() }
176
+
177
+ /// A small JPEG of the artwork as a data URL, drawn once per song.
178
+ private func artworkDataURL(for item: CraftMusicItem) -> String? {
179
+ if let cached = artworkCache, cached.id == item.id { return cached.dataURL }
180
+ let side = Self.artworkPixels
181
+ var dataURL: String?
182
+ if let image = item.artwork(CGSize(width: side, height: side)) {
183
+ let format = UIGraphicsImageRendererFormat()
184
+ format.scale = 1
185
+ format.opaque = true
186
+ let fitted = UIGraphicsImageRenderer(size: CGSize(width: side, height: side), format: format).image { _ in
187
+ // Fill the square, as a cover does, cropping the long side.
188
+ let scale = max(side / max(image.size.width, 1), side / max(image.size.height, 1))
189
+ let size = CGSize(width: image.size.width * scale, height: image.size.height * scale)
190
+ image.draw(in: CGRect(x: (side - size.width) / 2, y: (side - size.height) / 2, width: size.width, height: size.height))
191
+ }
192
+ if let jpeg = fitted.jpegData(compressionQuality: 0.75) {
193
+ dataURL = "data:image/jpeg;base64," + jpeg.base64EncodedString()
194
+ }
195
+ }
196
+ artworkCache = (item.id, dataURL)
197
+ return dataURL
198
+ }
199
+
200
+ // MARK: Transport
201
+
202
+ /// play, pause, togglePlayPause, skipToNext or skipToPrevious. Resolves
203
+ /// with the snapshot a moment later, once the Music app has moved, so a
204
+ /// page can show the outcome without waiting for the change event.
205
+ ///
206
+ /// skipToPrevious behaves like the Music app's own back button: more
207
+ /// than `restartAfter` seconds into a song (3 unless given) it goes back
208
+ /// to the start of that song; 0 always goes to the previous one.
209
+ func control(_ command: String, restartAfter: Double?, completion: @escaping (Result<[String: Any], CraftMusicError>) -> Void) {
210
+ guard isAuthorized else {
211
+ completion(.failure(CraftMusicError(message: "Music access has not been allowed", code: "NOT_AUTHORIZED")))
212
+ return
213
+ }
214
+ let player = self.player
215
+ switch command {
216
+ case "play":
217
+ player.play()
218
+ case "pause":
219
+ player.pause()
220
+ case "togglePlayPause":
221
+ if player.playbackState == .playing { player.pause() } else { player.play() }
222
+ case "skipToNext":
223
+ player.skipToNextItem()
224
+ case "skipToPrevious":
225
+ let threshold = max(restartAfter ?? 3, 0)
226
+ if threshold > 0, player.currentPlaybackTime > threshold { player.skipToBeginning() } else { player.skipToPreviousItem() }
227
+ default:
228
+ completion(.failure(CraftMusicError(message: "Unknown music command: \(command)", code: "INVALID_ARGUMENT")))
229
+ return
230
+ }
231
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
232
+ completion(.success(self.snapshot()))
233
+ }
234
+ }
235
+
236
+ // MARK: Change events
237
+
238
+ /// Hear every change of song or playback state as a snapshot, or stop
239
+ /// hearing them (`nil`). One listener: the web view's page.
240
+ func observe(_ listener: Listener?) {
241
+ self.listener = listener
242
+ if listener != nil, !observing {
243
+ observing = true
244
+ if isAuthorized { startPlayerObservation() }
245
+ // Notifications stop while the app is suspended; coming back,
246
+ // the page hears where the music is now.
247
+ foregroundObserver = NotificationCenter.default.addObserver(
248
+ forName: UIApplication.willEnterForegroundNotification, object: nil, queue: .main
249
+ ) { [weak self] _ in self?.playerDidChange() }
250
+ } else if listener == nil, observing {
251
+ observing = false
252
+ if let observer = foregroundObserver { NotificationCenter.default.removeObserver(observer) }
253
+ foregroundObserver = nil
254
+ if isAuthorized { player.stopObserving() }
255
+ }
256
+ }
257
+
258
+ private func startPlayerObservation() {
259
+ player.startObserving()
260
+ }
261
+
262
+ /// A song change arrives as two or three notifications at once; the page
263
+ /// gets one snapshot for them.
264
+ private func playerDidChange() {
265
+ guard observing, !changeScheduled else { return }
266
+ changeScheduled = true
267
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
268
+ self.changeScheduled = false
269
+ guard self.observing, let listener = self.listener else { return }
270
+ listener(self.snapshot())
271
+ }
272
+ }
273
+ }
274
+
275
+ // MARK: - The Music app
276
+
277
+ /// `MPMusicPlayerController.systemMusicPlayer`: the Music app's queue.
278
+ final class CraftSystemMusicPlayer: CraftMusicPlayer {
279
+ let source = "appleMusic"
280
+ var onChange: (() -> Void)?
281
+ private var observers: [NSObjectProtocol] = []
282
+ private var player: MPMusicPlayerController { MPMusicPlayerController.systemMusicPlayer }
283
+
284
+ var item: CraftMusicItem? {
285
+ guard let item = player.nowPlayingItem else { return nil }
286
+ // A song streamed from the catalog and not in the library has no
287
+ // persistent ID; its store ID, or what it is called, still tells one
288
+ // song from the next.
289
+ let id: String
290
+ if item.persistentID != 0 { id = String(item.persistentID) }
291
+ else if !item.playbackStoreID.isEmpty && item.playbackStoreID != "0" { id = "store:" + item.playbackStoreID }
292
+ else { id = "title:" + (item.title ?? "") + "|" + (item.artist ?? "") }
293
+ let artwork = item.artwork
294
+ return CraftMusicItem(
295
+ id: id,
296
+ title: item.title,
297
+ artist: item.artist ?? item.albumArtist,
298
+ album: item.albumTitle,
299
+ duration: item.playbackDuration,
300
+ artwork: { size in artwork?.image(at: size) }
301
+ )
302
+ }
303
+
304
+ var playbackState: MPMusicPlaybackState { player.playbackState }
305
+ var currentPlaybackTime: TimeInterval { player.currentPlaybackTime }
306
+
307
+ func play() { player.play() }
308
+ func pause() { player.pause() }
309
+ func skipToNextItem() { player.skipToNextItem() }
310
+ func skipToPreviousItem() { player.skipToPreviousItem() }
311
+ func skipToBeginning() { player.skipToBeginning() }
312
+
313
+ func startObserving() {
314
+ guard observers.isEmpty else { return }
315
+ let player = self.player
316
+ player.beginGeneratingPlaybackNotifications()
317
+ let center = NotificationCenter.default
318
+ for name in [Notification.Name.MPMusicPlayerControllerNowPlayingItemDidChange, .MPMusicPlayerControllerPlaybackStateDidChange] {
319
+ observers.append(center.addObserver(forName: name, object: player, queue: .main) { [weak self] _ in self?.onChange?() })
320
+ }
321
+ }
322
+
323
+ func stopObserving() {
324
+ guard !observers.isEmpty else { return }
325
+ observers.forEach { NotificationCenter.default.removeObserver($0) }
326
+ observers.removeAll()
327
+ player.endGeneratingPlaybackNotifications()
328
+ }
329
+ }
330
+
331
+ // MARK: - The simulator's stand-in
332
+
333
+ /// Three made-up songs that play, pause and skip like the Music app, so the
334
+ /// bridge, the page's bar and its events can be tried in the simulator.
335
+ /// Never used on a device (see `CraftMusic.makePlayer`).
336
+ final class CraftFakeMusicPlayer: CraftMusicPlayer {
337
+ let source = "simulated"
338
+ var onChange: (() -> Void)?
339
+ private var observing = false
340
+
341
+ private let songs: [(title: String, artist: String, album: String, duration: TimeInterval, hue: CGFloat)] = [
342
+ ("Intervals", "The Pacers", "Tempo Run", 214, 0.02),
343
+ ("Long Way Home", "Zone Two", "Base Miles", 251, 0.58),
344
+ ("Cooldown", "Recovery Club", "Easy Days", 187, 0.33),
345
+ ]
346
+ private var index = 0
347
+ private var state: MPMusicPlaybackState = .paused
348
+ /// Where the song was when it last started or stopped, and when that was.
349
+ private var offset: TimeInterval = 0
350
+ private var startedAt: Date?
351
+
352
+ var item: CraftMusicItem? {
353
+ let song = songs[index]
354
+ return CraftMusicItem(
355
+ id: "simulated-\(index)",
356
+ title: song.title,
357
+ artist: song.artist,
358
+ album: song.album,
359
+ duration: song.duration,
360
+ artwork: { size in
361
+ UIGraphicsImageRenderer(size: size).image { context in
362
+ UIColor(hue: song.hue, saturation: 0.7, brightness: 0.85, alpha: 1).setFill()
363
+ context.fill(CGRect(origin: .zero, size: size))
364
+ }
365
+ }
366
+ )
367
+ }
368
+
369
+ var playbackState: MPMusicPlaybackState { state }
370
+ var currentPlaybackTime: TimeInterval {
371
+ guard let startedAt = startedAt else { return offset }
372
+ return min(offset + Date().timeIntervalSince(startedAt), songs[index].duration)
373
+ }
374
+
375
+ func play() {
376
+ guard state != .playing else { return }
377
+ state = .playing
378
+ startedAt = Date()
379
+ changed()
380
+ }
381
+
382
+ func pause() {
383
+ guard state == .playing else { return }
384
+ offset = currentPlaybackTime
385
+ startedAt = nil
386
+ state = .paused
387
+ changed()
388
+ }
389
+
390
+ func skipToNextItem() { move(to: (index + 1) % songs.count) }
391
+ func skipToPreviousItem() { move(to: (index + songs.count - 1) % songs.count) }
392
+
393
+ func skipToBeginning() {
394
+ offset = 0
395
+ if state == .playing { startedAt = Date() }
396
+ changed()
397
+ }
398
+
399
+ func startObserving() { observing = true }
400
+ func stopObserving() { observing = false }
401
+
402
+ private func move(to next: Int) {
403
+ index = next
404
+ offset = 0
405
+ startedAt = state == .playing ? Date() : nil
406
+ changed()
407
+ }
408
+
409
+ private func changed() {
410
+ guard observing else { return }
411
+ DispatchQueue.main.async { self.onChange?() }
412
+ }
413
+ }