@craft-native/ios 0.0.142 → 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 +38 -0
- package/dist/cli.js +10 -2
- package/dist/index.d.ts +14 -0
- package/dist/index.js +10 -2
- package/package.json +1 -1
- package/templates/CraftApp.swift +81 -0
- package/templates/CraftMusic.swift +413 -0
- package/templates/CraftNativeActions.swift +18 -1
- package/templates/CraftNativeFlatList.swift +1 -1
- package/templates/CraftNativeScreen.swift +2 -2
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
package/templates/CraftApp.swift
CHANGED
|
@@ -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
|
+
}
|
|
@@ -38,7 +38,7 @@ enum CraftNativeActions {
|
|
|
38
38
|
private static let sqliteTransient = unsafeBitCast(-1, to: sqlite3_destructor_type.self)
|
|
39
39
|
|
|
40
40
|
static func capabilities(config: CraftConfig) -> [String] {
|
|
41
|
-
var values = ["device", "clipboard", "haptics", "storage", "lifecycle", "fetch"]
|
|
41
|
+
var values = ["device", "clipboard", "haptics", "storage", "lifecycle", "fetch", "dialog"]
|
|
42
42
|
if config.enableBiometric { values.append("biometric") }
|
|
43
43
|
if config.enableSecureStorage { values.append("secureStorage") }
|
|
44
44
|
if config.enableLocalDatabase { values.append("database") }
|
|
@@ -146,6 +146,23 @@ enum CraftNativeActions {
|
|
|
146
146
|
}
|
|
147
147
|
databaseQueue.async { complete(requestToken, database(method: method, args: args), completion) }
|
|
148
148
|
return
|
|
149
|
+
case ("Dialog", _):
|
|
150
|
+
// The system's own alert, confirmation and action sheet, the same
|
|
151
|
+
// ones a page gets (CraftNativeUI.swift), over the native screen.
|
|
152
|
+
let body = args.first as? [String: Any] ?? [:]
|
|
153
|
+
DispatchQueue.main.async {
|
|
154
|
+
switch method {
|
|
155
|
+
case "alert":
|
|
156
|
+
CraftDialogs.alert(body, from: nil) { complete(requestToken, .success(NSNull()), completion) }
|
|
157
|
+
case "confirm":
|
|
158
|
+
CraftDialogs.confirm(body, from: nil) { complete(requestToken, .success($0), completion) }
|
|
159
|
+
case "actionSheet":
|
|
160
|
+
CraftDialogs.actionSheet(body, from: nil) { complete(requestToken, .success($0 ?? NSNull()), completion) }
|
|
161
|
+
default:
|
|
162
|
+
complete(requestToken, failure("UNKNOWN_ACTION", "Unsupported Dialog method"), completion)
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return
|
|
149
166
|
case ("Health", _):
|
|
150
167
|
// Apple Health, the same as a page's `craft.health` (CraftHealth.swift).
|
|
151
168
|
guard config.enableHealthKit else {
|
|
@@ -525,7 +525,7 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
|
|
|
525
525
|
|
|
526
526
|
private static func identity(for node: [String: Any]) -> String? {
|
|
527
527
|
let props = node["props"] as? [String: Any] ?? [:]
|
|
528
|
-
return [node["
|
|
528
|
+
return [node["key"], props["key"], props["testID"], node["id"]]
|
|
529
529
|
.compactMap { $0 as? String }
|
|
530
530
|
.first { !$0.isEmpty }
|
|
531
531
|
}
|
|
@@ -3528,7 +3528,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
3528
3528
|
private func explicitKey(_ node: [String: Any], props: [String: Any]) -> String? {
|
|
3529
3529
|
// The current @stacksjs/stx native compiler emits key in props; accept the IR
|
|
3530
3530
|
// field too so keyed children keep working when the compiler adopts it.
|
|
3531
|
-
[node["
|
|
3531
|
+
[node["key"], props["key"], props["testID"], node["id"]]
|
|
3532
3532
|
.compactMap { $0 as? String }
|
|
3533
3533
|
.first { !$0.isEmpty }
|
|
3534
3534
|
}
|
|
@@ -4328,7 +4328,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4328
4328
|
view.accessibilityValue = nil
|
|
4329
4329
|
}
|
|
4330
4330
|
let role = props["accessibilityRole"] as? String
|
|
4331
|
-
view.isAccessibilityElement = role != "none" && (view.accessibilityLabel != nil || role != nil || ["Text", "Button", "Link", "Image", "TextInput", "Picker", "Switch", "Slider"].contains(type))
|
|
4331
|
+
view.isAccessibilityElement = role != "none" && (view.accessibilityLabel != nil || role != nil || ["Text", "Button", "Link", "Image", "Video", "TextInput", "Picker", "Switch", "Slider"].contains(type))
|
|
4332
4332
|
var traits: UIAccessibilityTraits = []
|
|
4333
4333
|
switch role ?? type.lowercased() {
|
|
4334
4334
|
case "button": traits.insert(.button)
|