expo-mpv 0.1.0

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.
@@ -0,0 +1,589 @@
1
+ import ExpoModulesCore
2
+ import Libmpv
3
+
4
+ class ExpoMpvView: ExpoView {
5
+ // MARK: - Metal Layer
6
+
7
+ private let metalLayer = MetalLayer()
8
+
9
+ // MARK: - MPV
10
+
11
+ private var mpv: OpaquePointer?
12
+ private lazy var queue = DispatchQueue(label: "com.expo.mpv.event", qos: .userInitiated)
13
+
14
+ // MARK: - State
15
+
16
+ private var isInitialized = false
17
+ private var pendingSource: String?
18
+ private var progressTimer: Timer?
19
+
20
+ // MARK: - Event Dispatchers
21
+
22
+ let onPlaybackStateChange = EventDispatcher()
23
+ let onProgress = EventDispatcher()
24
+ let onLoad = EventDispatcher()
25
+ let onError = EventDispatcher()
26
+ let onEnd = EventDispatcher()
27
+ let onBuffer = EventDispatcher()
28
+ let onSeek = EventDispatcher()
29
+ let onVolumeChange = EventDispatcher()
30
+
31
+ // MARK: - Init
32
+
33
+ required init(appContext: AppContext? = nil) {
34
+ super.init(appContext: appContext)
35
+ clipsToBounds = true
36
+ backgroundColor = .black
37
+ setupMetalLayer()
38
+ setupMpv()
39
+ setupNotifications()
40
+ }
41
+
42
+ deinit {
43
+ stopProgressTimer()
44
+ NotificationCenter.default.removeObserver(self)
45
+ destroy()
46
+ }
47
+
48
+ // MARK: - Layout
49
+
50
+ override func layoutSubviews() {
51
+ super.layoutSubviews()
52
+ metalLayer.frame = bounds
53
+ let scale = window?.screen.nativeScale ?? UIScreen.main.nativeScale
54
+ let w = bounds.width * scale
55
+ let h = bounds.height * scale
56
+ if w > 1 && h > 1 {
57
+ metalLayer.drawableSize = CGSize(width: w, height: h)
58
+ }
59
+ }
60
+
61
+ // MARK: - Metal Layer Setup
62
+
63
+ private func setupMetalLayer() {
64
+ metalLayer.contentsScale = UIScreen.main.nativeScale
65
+ metalLayer.framebufferOnly = true
66
+ metalLayer.backgroundColor = UIColor.black.cgColor
67
+ metalLayer.pixelFormat = .bgra8Unorm
68
+ metalLayer.device = MTLCreateSystemDefaultDevice()
69
+ if metalLayer.device == nil {
70
+ log("WARNING: MTLCreateSystemDefaultDevice() returned nil — Metal not available")
71
+ }
72
+ layer.addSublayer(metalLayer)
73
+ }
74
+
75
+ // MARK: - MPV Setup
76
+
77
+ private func setupMpv() {
78
+ log("Creating mpv instance...")
79
+
80
+ mpv = mpv_create()
81
+ guard let mpv = mpv else {
82
+ let msg = "Failed to create mpv instance"
83
+ log("ERROR: \(msg)")
84
+ DispatchQueue.main.async { self.onError(["error": msg]) }
85
+ return
86
+ }
87
+
88
+ // Enable mpv log messages so we can see what's happening
89
+ checkError(mpv_request_log_messages(mpv, "v"), label: "request_log_messages")
90
+
91
+ // Pass the CAMetalLayer as the wid (window ID)
92
+ // mpv expects the raw pointer value as an Int64
93
+ var wid = unsafeBitCast(metalLayer, to: Int64.self)
94
+ checkError(mpv_set_option(mpv, "wid", MPV_FORMAT_INT64, &wid), label: "set wid")
95
+
96
+ // Rendering configuration: gpu-next + vulkan + moltenvk → Metal
97
+ setOptionString("vo", "gpu-next")
98
+ setOptionString("gpu-api", "vulkan")
99
+ setOptionString("gpu-context", "moltenvk")
100
+ setOptionString("hwdec", "videotoolbox-copy")
101
+
102
+ // General options
103
+ setOptionString("keep-open", "yes")
104
+ setOptionString("idle", "yes")
105
+ setOptionString("input-default-bindings", "no")
106
+ setOptionString("input-vo-keyboard", "no")
107
+
108
+ // Initialize mpv
109
+ log("Initializing mpv...")
110
+ let initResult = mpv_initialize(mpv)
111
+ guard initResult == 0 else {
112
+ let errStr = String(cString: mpv_error_string(initResult))
113
+ let msg = "mpv_initialize failed: \(errStr) (\(initResult))"
114
+ log("ERROR: \(msg)")
115
+ DispatchQueue.main.async { self.onError(["error": msg]) }
116
+ mpv_destroy(mpv)
117
+ self.mpv = nil
118
+ return
119
+ }
120
+
121
+ isInitialized = true
122
+ log("mpv initialized successfully")
123
+
124
+ // Observe properties
125
+ mpv_observe_property(mpv, 0, "pause", MPV_FORMAT_FLAG)
126
+ mpv_observe_property(mpv, 1, "duration", MPV_FORMAT_DOUBLE)
127
+ mpv_observe_property(mpv, 2, "time-pos", MPV_FORMAT_DOUBLE)
128
+ mpv_observe_property(mpv, 3, "paused-for-cache", MPV_FORMAT_FLAG)
129
+ mpv_observe_property(mpv, 4, "eof-reached", MPV_FORMAT_FLAG)
130
+ mpv_observe_property(mpv, 5, "volume", MPV_FORMAT_DOUBLE)
131
+ mpv_observe_property(mpv, 6, "mute", MPV_FORMAT_FLAG)
132
+ mpv_observe_property(mpv, 7, "speed", MPV_FORMAT_DOUBLE)
133
+ mpv_observe_property(mpv, 8, "demuxer-cache-duration", MPV_FORMAT_DOUBLE)
134
+ mpv_observe_property(mpv, 9, "video-params/w", MPV_FORMAT_INT64)
135
+ mpv_observe_property(mpv, 10, "video-params/h", MPV_FORMAT_INT64)
136
+
137
+ // Set wakeup callback for the event loop
138
+ let rawSelf = UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque())
139
+ mpv_set_wakeup_callback(mpv, { ctx in
140
+ guard let ctx = ctx else { return }
141
+ let view = Unmanaged<ExpoMpvView>.fromOpaque(ctx).takeUnretainedValue()
142
+ view.readEvents()
143
+ }, rawSelf)
144
+
145
+ // Load pending source if any
146
+ if let source = pendingSource {
147
+ pendingSource = nil
148
+ loadFile(source)
149
+ }
150
+ }
151
+
152
+ // MARK: - App Lifecycle
153
+
154
+ private func setupNotifications() {
155
+ NotificationCenter.default.addObserver(
156
+ self,
157
+ selector: #selector(appDidEnterBackground),
158
+ name: UIApplication.didEnterBackgroundNotification,
159
+ object: nil
160
+ )
161
+ NotificationCenter.default.addObserver(
162
+ self,
163
+ selector: #selector(appWillEnterForeground),
164
+ name: UIApplication.willEnterForegroundNotification,
165
+ object: nil
166
+ )
167
+ }
168
+
169
+ @objc private func appDidEnterBackground() {
170
+ guard mpv != nil else { return }
171
+ mpv_set_property_string(mpv, "vid", "no")
172
+ }
173
+
174
+ @objc private func appWillEnterForeground() {
175
+ guard mpv != nil else { return }
176
+ mpv_set_property_string(mpv, "vid", "auto")
177
+ }
178
+
179
+ // MARK: - Progress Timer
180
+
181
+ private func startProgressTimer() {
182
+ stopProgressTimer()
183
+ DispatchQueue.main.async { [weak self] in
184
+ self?.progressTimer = Timer.scheduledTimer(withTimeInterval: 0.25, repeats: true) { [weak self] _ in
185
+ self?.emitProgressEvent()
186
+ }
187
+ }
188
+ }
189
+
190
+ private func stopProgressTimer() {
191
+ progressTimer?.invalidate()
192
+ progressTimer = nil
193
+ }
194
+
195
+ private func emitProgressEvent() {
196
+ guard mpv != nil else { return }
197
+ let position = getDouble("time-pos")
198
+ let duration = getDouble("duration")
199
+ let cachedDuration = getDouble("demuxer-cache-duration")
200
+
201
+ guard position.isFinite && duration.isFinite else { return }
202
+
203
+ onProgress([
204
+ "position": position,
205
+ "duration": duration,
206
+ "bufferedDuration": cachedDuration.isFinite ? cachedDuration : 0,
207
+ ])
208
+ }
209
+
210
+ // MARK: - Event Loop
211
+
212
+ private func readEvents() {
213
+ queue.async { [weak self] in
214
+ guard let self = self, self.mpv != nil else { return }
215
+
216
+ while true {
217
+ let event = mpv_wait_event(self.mpv, 0)
218
+ guard let event = event else { break }
219
+
220
+ if event.pointee.event_id == MPV_EVENT_NONE {
221
+ break
222
+ }
223
+
224
+ switch event.pointee.event_id {
225
+ case MPV_EVENT_PROPERTY_CHANGE:
226
+ self.handlePropertyChange(event)
227
+
228
+ case MPV_EVENT_LOG_MESSAGE:
229
+ if let data = event.pointee.data {
230
+ let msg = data.assumingMemoryBound(to: mpv_event_log_message.self).pointee
231
+ if let text = msg.text {
232
+ let logText = String(cString: text).trimmingCharacters(in: .whitespacesAndNewlines)
233
+ let prefix = msg.prefix.map { String(cString: $0) } ?? "?"
234
+ let level = msg.level.map { String(cString: $0) } ?? "?"
235
+ self.log("[\(prefix)] [\(level)] \(logText)")
236
+ }
237
+ }
238
+
239
+ case MPV_EVENT_FILE_LOADED:
240
+ self.log("EVENT: file-loaded")
241
+ DispatchQueue.main.async {
242
+ let duration = self.getDouble("duration")
243
+ let width = self.getInt("video-params/w")
244
+ let height = self.getInt("video-params/h")
245
+ self.log("Media loaded: duration=\(duration) size=\(width)x\(height)")
246
+ self.onLoad([
247
+ "duration": duration.isFinite ? duration : 0,
248
+ "width": width,
249
+ "height": height,
250
+ ])
251
+ self.startProgressTimer()
252
+ }
253
+
254
+ case MPV_EVENT_START_FILE:
255
+ self.log("EVENT: start-file")
256
+
257
+ case MPV_EVENT_END_FILE:
258
+ if let data = event.pointee.data {
259
+ let endFile = data.assumingMemoryBound(to: mpv_event_end_file.self).pointee
260
+ self.log("EVENT: end-file reason=\(endFile.reason) error=\(endFile.error)")
261
+ DispatchQueue.main.async {
262
+ self.stopProgressTimer()
263
+ let reason: String
264
+ switch endFile.reason {
265
+ case MPV_END_FILE_REASON_EOF:
266
+ reason = "ended"
267
+ case MPV_END_FILE_REASON_ERROR:
268
+ reason = "error"
269
+ let errStr = String(cString: mpv_error_string(endFile.error))
270
+ let msg = "Playback error: \(errStr) (code \(endFile.error))"
271
+ self.log("ERROR: \(msg)")
272
+ self.onError(["error": msg])
273
+ case MPV_END_FILE_REASON_STOP:
274
+ reason = "stopped"
275
+ default:
276
+ reason = "unknown"
277
+ }
278
+ self.onEnd(["reason": reason])
279
+ }
280
+ }
281
+
282
+ case MPV_EVENT_SHUTDOWN:
283
+ self.log("EVENT: shutdown")
284
+ self.mpv = nil
285
+ return
286
+
287
+ case MPV_EVENT_SEEK:
288
+ DispatchQueue.main.async {
289
+ self.onSeek([:])
290
+ }
291
+
292
+ default:
293
+ let eventName = mpv_event_name(event.pointee.event_id)
294
+ if let eventName = eventName {
295
+ self.log("EVENT: \(String(cString: eventName))")
296
+ }
297
+ }
298
+ }
299
+ }
300
+ }
301
+
302
+ private func handlePropertyChange(_ event: UnsafePointer<mpv_event>) {
303
+ guard let data = event.pointee.data else { return }
304
+ let prop = data.assumingMemoryBound(to: mpv_event_property.self).pointee
305
+
306
+ guard let cName = prop.name else { return }
307
+ let name = String(cString: cName)
308
+
309
+ switch name {
310
+ case "pause":
311
+ if prop.format == MPV_FORMAT_FLAG, let flagPtr = prop.data {
312
+ let paused = flagPtr.assumingMemoryBound(to: Int32.self).pointee != 0
313
+ DispatchQueue.main.async {
314
+ self.onPlaybackStateChange([
315
+ "state": paused ? "paused" : "playing",
316
+ "isPlaying": !paused,
317
+ ])
318
+ if paused {
319
+ self.stopProgressTimer()
320
+ } else {
321
+ self.startProgressTimer()
322
+ }
323
+ }
324
+ }
325
+
326
+ case "paused-for-cache":
327
+ if prop.format == MPV_FORMAT_FLAG, let flagPtr = prop.data {
328
+ let buffering = flagPtr.assumingMemoryBound(to: Int32.self).pointee != 0
329
+ DispatchQueue.main.async {
330
+ self.onBuffer(["isBuffering": buffering])
331
+ }
332
+ }
333
+
334
+ case "volume":
335
+ if prop.format == MPV_FORMAT_DOUBLE, let dataPtr = prop.data {
336
+ let volume = dataPtr.assumingMemoryBound(to: Double.self).pointee
337
+ DispatchQueue.main.async {
338
+ self.onVolumeChange(["volume": volume, "muted": self.getFlag("mute")])
339
+ }
340
+ }
341
+
342
+ case "mute":
343
+ if prop.format == MPV_FORMAT_FLAG, let flagPtr = prop.data {
344
+ let muted = flagPtr.assumingMemoryBound(to: Int32.self).pointee != 0
345
+ DispatchQueue.main.async {
346
+ self.onVolumeChange(["volume": self.getDouble("volume"), "muted": muted])
347
+ }
348
+ }
349
+
350
+ default:
351
+ break
352
+ }
353
+ }
354
+
355
+ // MARK: - Public API
356
+
357
+ func loadFile(_ url: String) {
358
+ guard isInitialized, mpv != nil else {
359
+ log("loadFile deferred (not initialized yet): \(url)")
360
+ pendingSource = url
361
+ return
362
+ }
363
+ log("loadFile: \(url)")
364
+ commandAsync("loadfile", args: [url, "replace"])
365
+ }
366
+
367
+ func play() {
368
+ setFlag("pause", false)
369
+ }
370
+
371
+ func pause() {
372
+ setFlag("pause", true)
373
+ }
374
+
375
+ func togglePlay() {
376
+ let isPaused = getFlag("pause")
377
+ setFlag("pause", !isPaused)
378
+ }
379
+
380
+ func stop() {
381
+ commandAsync("stop")
382
+ stopProgressTimer()
383
+ }
384
+
385
+ func seekTo(_ position: Double) {
386
+ commandAsync("seek", args: [String(position), "absolute"])
387
+ }
388
+
389
+ func seekBy(_ offset: Double) {
390
+ commandAsync("seek", args: [String(offset), "relative"])
391
+ }
392
+
393
+ func setSpeed(_ speed: Double) {
394
+ setDouble("speed", speed)
395
+ }
396
+
397
+ func setVolume(_ volume: Double) {
398
+ setDouble("volume", volume)
399
+ }
400
+
401
+ func setMuted(_ muted: Bool) {
402
+ setFlag("mute", muted)
403
+ }
404
+
405
+ func setLooping(_ loop: Bool) {
406
+ guard mpv != nil else { return }
407
+ mpv_set_property_string(mpv, "loop-file", loop ? "inf" : "no")
408
+ }
409
+
410
+ func setSubtitleTrack(_ trackId: Int) {
411
+ setInt("sid", Int64(trackId))
412
+ }
413
+
414
+ func setAudioTrack(_ trackId: Int) {
415
+ setInt("aid", Int64(trackId))
416
+ }
417
+
418
+ func getPlaybackInfo() -> [String: Any] {
419
+ let position = getDouble("time-pos")
420
+ let duration = getDouble("duration")
421
+ let isPaused = getFlag("pause")
422
+ let speed = getDouble("speed")
423
+ let volume = getDouble("volume")
424
+ let muted = getFlag("mute")
425
+
426
+ return [
427
+ "position": position.isFinite ? position : 0,
428
+ "duration": duration.isFinite ? duration : 0,
429
+ "isPlaying": !isPaused,
430
+ "speed": speed,
431
+ "volume": volume,
432
+ "muted": muted,
433
+ ]
434
+ }
435
+
436
+ func destroy() {
437
+ stopProgressTimer()
438
+ if let mpv = mpv {
439
+ log("Destroying mpv...")
440
+ mpv_set_wakeup_callback(mpv, nil, nil)
441
+ mpv_terminate_destroy(mpv)
442
+ self.mpv = nil
443
+ }
444
+ }
445
+
446
+ // MARK: - MPV Helpers
447
+
448
+ private func commandAsync(_ command: String, args: [String] = []) {
449
+ guard mpv != nil else {
450
+ log("commandAsync ignored (mpv is nil): \(command)")
451
+ return
452
+ }
453
+
454
+ // Build null-terminated args string for mpv_command_string is simplest,
455
+ // but mpv_command_string doesn't exist. Use mpv_command with proper memory management.
456
+ let allArgs = [command] + args
457
+ // Create C strings that live long enough
458
+ var cStrings = allArgs.map { strdup($0) }
459
+ cStrings.append(nil) // null-terminate
460
+
461
+ // Create array of const pointers
462
+ let result = cStrings.withUnsafeMutableBufferPointer { buffer -> Int32 in
463
+ // Build an array of UnsafePointer<CChar>? from UnsafeMutablePointer<CChar>?
464
+ var constPtrs = buffer.map { UnsafePointer($0) }
465
+ return constPtrs.withUnsafeMutableBufferPointer { constBuffer in
466
+ mpv_command(mpv, constBuffer.baseAddress)
467
+ }
468
+ }
469
+
470
+ // Free strdup'd strings
471
+ for ptr in cStrings {
472
+ if let ptr = ptr { free(ptr) }
473
+ }
474
+
475
+ if result < 0 {
476
+ let errStr = String(cString: mpv_error_string(result))
477
+ log("ERROR: command '\(command) \(args.joined(separator: " "))' failed: \(errStr) (\(result))")
478
+ DispatchQueue.main.async {
479
+ self.onError(["error": "Command '\(command)' failed: \(errStr)"])
480
+ }
481
+ } else {
482
+ log("Command OK: \(command) \(args.joined(separator: " "))")
483
+ }
484
+ }
485
+
486
+ private func setOptionString(_ name: String, _ value: String) {
487
+ guard let mpv = mpv else { return }
488
+ let result = mpv_set_option_string(mpv, name, value)
489
+ if result < 0 {
490
+ let errStr = String(cString: mpv_error_string(result))
491
+ log("ERROR: set option '\(name)'='\(value)' failed: \(errStr)")
492
+ } else {
493
+ log("Option: \(name) = \(value)")
494
+ }
495
+ }
496
+
497
+ private func getDouble(_ name: String) -> Double {
498
+ guard mpv != nil else { return 0 }
499
+ var data: Double = 0
500
+ mpv_get_property(mpv, name, MPV_FORMAT_DOUBLE, &data)
501
+ return data
502
+ }
503
+
504
+ private func getInt(_ name: String) -> Int64 {
505
+ guard mpv != nil else { return 0 }
506
+ var data: Int64 = 0
507
+ mpv_get_property(mpv, name, MPV_FORMAT_INT64, &data)
508
+ return data
509
+ }
510
+
511
+ private func getFlag(_ name: String) -> Bool {
512
+ guard mpv != nil else { return false }
513
+ var data: Int32 = 0
514
+ mpv_get_property(mpv, name, MPV_FORMAT_FLAG, &data)
515
+ return data != 0
516
+ }
517
+
518
+ private func setDouble(_ name: String, _ value: Double) {
519
+ guard mpv != nil else { return }
520
+ var data = value
521
+ let result = mpv_set_property(mpv, name, MPV_FORMAT_DOUBLE, &data)
522
+ if result < 0 {
523
+ let errStr = String(cString: mpv_error_string(result))
524
+ log("ERROR: set property '\(name)'=\(value) failed: \(errStr)")
525
+ }
526
+ }
527
+
528
+ private func setInt(_ name: String, _ value: Int64) {
529
+ guard mpv != nil else { return }
530
+ var data = value
531
+ let result = mpv_set_property(mpv, name, MPV_FORMAT_INT64, &data)
532
+ if result < 0 {
533
+ let errStr = String(cString: mpv_error_string(result))
534
+ log("ERROR: set property '\(name)'=\(value) failed: \(errStr)")
535
+ }
536
+ }
537
+
538
+ private func setFlag(_ name: String, _ flag: Bool) {
539
+ guard mpv != nil else { return }
540
+ var data: Int32 = flag ? 1 : 0
541
+ let result = mpv_set_property(mpv, name, MPV_FORMAT_FLAG, &data)
542
+ if result < 0 {
543
+ let errStr = String(cString: mpv_error_string(result))
544
+ log("ERROR: set flag '\(name)'=\(flag) failed: \(errStr)")
545
+ }
546
+ }
547
+
548
+ @discardableResult
549
+ private func checkError(_ status: Int32, label: String = "") -> Bool {
550
+ if status < 0 {
551
+ let errStr = String(cString: mpv_error_string(status))
552
+ log("ERROR [\(label)]: \(errStr) (\(status))")
553
+ return false
554
+ }
555
+ return true
556
+ }
557
+
558
+ private func log(_ message: String) {
559
+ NSLog("[ExpoMpv] %@", message)
560
+ }
561
+ }
562
+
563
+ // MARK: - MetalLayer
564
+
565
+ private class MetalLayer: CAMetalLayer {
566
+ // Guard against MoltenVK 1×1 drawableSize bug
567
+ override var drawableSize: CGSize {
568
+ get { super.drawableSize }
569
+ set {
570
+ if Int(newValue.width) > 1 && Int(newValue.height) > 1 {
571
+ super.drawableSize = newValue
572
+ }
573
+ }
574
+ }
575
+
576
+ // EDR/HDR content must be set on main thread
577
+ override var wantsExtendedDynamicRangeContent: Bool {
578
+ get { super.wantsExtendedDynamicRangeContent }
579
+ set {
580
+ if Thread.isMainThread {
581
+ super.wantsExtendedDynamicRangeContent = newValue
582
+ } else {
583
+ DispatchQueue.main.sync {
584
+ super.wantsExtendedDynamicRangeContent = newValue
585
+ }
586
+ }
587
+ }
588
+ }
589
+ }
@@ -0,0 +1,85 @@
1
+ #!/bin/bash
2
+ set -e
3
+
4
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
5
+ XCFW_DIR="${SCRIPT_DIR}/Frameworks"
6
+ MPVKIT_VERSION="0.41.0"
7
+ LOCKFILE="${XCFW_DIR}/.version"
8
+
9
+ # Check if already downloaded at the right version
10
+ if [ -f "${LOCKFILE}" ] && [ "$(cat ${LOCKFILE})" = "${MPVKIT_VERSION}" ]; then
11
+ echo "✅ MPVKit ${MPVKIT_VERSION} xcframeworks already present."
12
+ exit 0
13
+ fi
14
+
15
+ echo "🔽 Downloading MPVKit ${MPVKIT_VERSION} xcframeworks..."
16
+ rm -rf "${XCFW_DIR}"
17
+ mkdir -p "${XCFW_DIR}"
18
+
19
+ MPVKIT_URL="https://github.com/mpvkit/MPVKit/releases/download/${MPVKIT_VERSION}"
20
+
21
+ download_xcframework() {
22
+ local name=$1
23
+ local url=$2
24
+ echo " 📦 ${name}..."
25
+ curl -sL "${url}" -o "${XCFW_DIR}/${name}.zip"
26
+ unzip -q -o "${XCFW_DIR}/${name}.zip" -d "${XCFW_DIR}/"
27
+ rm -f "${XCFW_DIR}/${name}.zip"
28
+ }
29
+
30
+ # Core MPVKit - libmpv
31
+ download_xcframework "Libmpv" "${MPVKIT_URL}/Libmpv.xcframework.zip"
32
+
33
+ # FFmpeg frameworks
34
+ for lib in Libavcodec Libavdevice Libavformat Libavfilter Libavutil Libswresample Libswscale; do
35
+ download_xcframework "${lib}" "${MPVKIT_URL}/${lib}.xcframework.zip"
36
+ done
37
+
38
+ # OpenSSL
39
+ OPENSSL_VERSION="3.3.5"
40
+ for lib in Libcrypto Libssl; do
41
+ download_xcframework "${lib}" "https://github.com/mpvkit/openssl-build/releases/download/${OPENSSL_VERSION}/${lib}.xcframework.zip"
42
+ done
43
+
44
+ # GnuTLS
45
+ GNUTLS_VERSION="3.8.11"
46
+ for lib in gmp nettle hogweed gnutls; do
47
+ download_xcframework "${lib}" "https://github.com/mpvkit/gnutls-build/releases/download/${GNUTLS_VERSION}/${lib}.xcframework.zip"
48
+ done
49
+
50
+ # Libass and dependencies
51
+ LIBASS_VERSION="0.17.4"
52
+ for lib in Libunibreak Libfreetype Libfribidi Libharfbuzz Libass; do
53
+ download_xcframework "${lib}" "https://github.com/mpvkit/libass-build/releases/download/${LIBASS_VERSION}/${lib}.xcframework.zip"
54
+ done
55
+
56
+ # MoltenVK (Vulkan on Metal)
57
+ download_xcframework "MoltenVK" "https://github.com/mpvkit/moltenvk-build/releases/download/1.4.1/MoltenVK.xcframework.zip"
58
+
59
+ # Shaderc
60
+ download_xcframework "Libshaderc_combined" "https://github.com/mpvkit/libshaderc-build/releases/download/2025.5.0/Libshaderc_combined.xcframework.zip"
61
+
62
+ # lcms2
63
+ download_xcframework "lcms2" "https://github.com/mpvkit/lcms2-build/releases/download/2.17.0/lcms2.xcframework.zip"
64
+
65
+ # Libplacebo
66
+ download_xcframework "Libplacebo" "https://github.com/mpvkit/libplacebo-build/releases/download/7.351.0-2512/Libplacebo.xcframework.zip"
67
+
68
+ # Libdav1d
69
+ download_xcframework "Libdav1d" "https://github.com/mpvkit/libdav1d-build/releases/download/1.5.2-xcode/Libdav1d.xcframework.zip"
70
+
71
+ # Libuchardet
72
+ download_xcframework "Libuchardet" "https://github.com/mpvkit/libuchardet-build/releases/download/0.0.8-xcode/Libuchardet.xcframework.zip"
73
+
74
+ # Libbluray
75
+ download_xcframework "Libbluray" "https://github.com/mpvkit/libbluray-build/releases/download/1.4.0/Libbluray.xcframework.zip"
76
+
77
+ # Libdovi
78
+ download_xcframework "Libdovi" "https://github.com/mpvkit/libdovi-build/releases/download/3.3.2/Libdovi.xcframework.zip"
79
+
80
+ # Libuavs3d (uAVS3 decoder)
81
+ download_xcframework "Libuavs3d" "https://github.com/mpvkit/libuavs3d-build/releases/download/1.2.1-xcode/Libuavs3d.xcframework.zip"
82
+
83
+ echo "${MPVKIT_VERSION}" > "${LOCKFILE}"
84
+ echo ""
85
+ echo "✅ All MPVKit ${MPVKIT_VERSION} xcframeworks downloaded to ${XCFW_DIR}"