react-native-kookit 0.2.0 → 0.2.2

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.
@@ -1,6 +1,502 @@
1
1
  import ExpoModulesCore
2
+ import UIKit
3
+ import MediaPlayer
4
+ import AVFoundation
5
+ import Foundation
6
+ import Network
7
+
8
+ // MARK: - FTP Related Types and Classes
9
+
10
+ struct FtpConnectionConfig {
11
+ let host: String
12
+ let port: Int
13
+ let username: String
14
+ let password: String
15
+ let passive: Bool
16
+ let timeout: TimeInterval
17
+
18
+ init(host: String, port: Int = 21, username: String, password: String, passive: Bool = true, timeout: TimeInterval = 30.0) {
19
+ self.host = host
20
+ self.port = port
21
+ self.username = username
22
+ self.password = password
23
+ self.passive = passive
24
+ self.timeout = timeout
25
+ }
26
+ }
27
+
28
+ struct FtpFileInfo {
29
+ let name: String
30
+ let isDirectory: Bool
31
+ let size: Int64
32
+ let lastModified: String
33
+ let permissions: String?
34
+ }
35
+
36
+ protocol FtpProgressDelegate: AnyObject {
37
+ func onProgress(transferred: Int64, total: Int64)
38
+ func onComplete()
39
+ func onError(error: String)
40
+ }
41
+
42
+ enum FtpError: Error, LocalizedError {
43
+ case notConnected
44
+ case connectionTimeout
45
+ case connectionCancelled
46
+ case authenticationFailed(String)
47
+ case commandFailed(String)
48
+ case fileNotFound(String)
49
+ case invalidResponse
50
+
51
+ var errorDescription: String? {
52
+ switch self {
53
+ case .notConnected:
54
+ return "Not connected to FTP server"
55
+ case .connectionTimeout:
56
+ return "Connection timeout"
57
+ case .connectionCancelled:
58
+ return "Connection cancelled"
59
+ case .authenticationFailed(let message):
60
+ return "Authentication failed: \(message)"
61
+ case .commandFailed(let message):
62
+ return "Command failed: \(message)"
63
+ case .fileNotFound(let message):
64
+ return "File not found: \(message)"
65
+ case .invalidResponse:
66
+ return "Invalid response from server"
67
+ }
68
+ }
69
+ }
70
+
71
+ class FtpClient {
72
+ private var controlConnection: NWConnection?
73
+ private var isConnected = false
74
+ private var config: FtpConnectionConfig?
75
+ private weak var progressDelegate: FtpProgressDelegate?
76
+
77
+ func connect(config: FtpConnectionConfig) async throws {
78
+ self.config = config
79
+
80
+ let host = NWEndpoint.Host(config.host)
81
+ let port = NWEndpoint.Port(integerLiteral: UInt16(config.port))
82
+ let endpoint = NWEndpoint.hostPort(host: host, port: port)
83
+
84
+ controlConnection = NWConnection(to: endpoint, using: .tcp)
85
+
86
+ return try await withCheckedThrowingContinuation { continuation in
87
+ controlConnection?.stateUpdateHandler = { state in
88
+ switch state {
89
+ case .ready:
90
+ Task {
91
+ do {
92
+ try await self.performAuthentication()
93
+ self.isConnected = true
94
+ continuation.resume()
95
+ } catch {
96
+ continuation.resume(throwing: error)
97
+ }
98
+ }
99
+ case .failed(let error):
100
+ continuation.resume(throwing: error)
101
+ case .cancelled:
102
+ continuation.resume(throwing: FtpError.connectionCancelled)
103
+ default:
104
+ break
105
+ }
106
+ }
107
+
108
+ controlConnection?.start(queue: .global())
109
+
110
+ // Set timeout
111
+ DispatchQueue.global().asyncAfter(deadline: .now() + config.timeout) {
112
+ if !self.isConnected {
113
+ self.controlConnection?.cancel()
114
+ continuation.resume(throwing: FtpError.connectionTimeout)
115
+ }
116
+ }
117
+ }
118
+ }
119
+
120
+ func disconnect() async {
121
+ if isConnected {
122
+ do {
123
+ _ = try await sendCommand("QUIT")
124
+ } catch {
125
+ // Ignore errors during disconnect
126
+ }
127
+ }
128
+
129
+ controlConnection?.cancel()
130
+ controlConnection = nil
131
+ isConnected = false
132
+ }
133
+
134
+ func listFiles(path: String? = nil) async throws -> [FtpFileInfo] {
135
+ guard isConnected else { throw FtpError.notConnected }
136
+
137
+ let dataConnection = try await enterPassiveMode()
138
+
139
+ let command = path != nil ? "LIST \(path!)" : "LIST"
140
+ let response = try await sendCommand(command)
141
+
142
+ guard response.hasPrefix("150") || response.hasPrefix("125") else {
143
+ dataConnection.cancel()
144
+ throw FtpError.commandFailed("LIST failed: \(response)")
145
+ }
146
+
147
+ let data = try await receiveData(from: dataConnection)
148
+ dataConnection.cancel()
149
+
150
+ let finalResponse = try await readResponse()
151
+ guard finalResponse.hasPrefix("226") else {
152
+ throw FtpError.commandFailed("LIST completion failed: \(finalResponse)")
153
+ }
154
+
155
+ return parseListingData(data)
156
+ }
157
+
158
+ func downloadFile(remotePath: String, localPath: String) async throws {
159
+ guard isConnected else { throw FtpError.notConnected }
160
+
161
+ let dataConnection = try await enterPassiveMode()
162
+
163
+ let response = try await sendCommand("RETR \(remotePath)")
164
+ guard response.hasPrefix("150") || response.hasPrefix("125") else {
165
+ dataConnection.cancel()
166
+ throw FtpError.commandFailed("Download failed: \(response)")
167
+ }
168
+
169
+ let localURL = URL(fileURLWithPath: localPath)
170
+
171
+ // Create parent directories if needed
172
+ try FileManager.default.createDirectory(at: localURL.deletingLastPathComponent(),
173
+ withIntermediateDirectories: true,
174
+ attributes: nil)
175
+
176
+ let data = try await receiveData(from: dataConnection, reportProgress: true)
177
+ dataConnection.cancel()
178
+
179
+ try data.write(to: localURL)
180
+
181
+ let finalResponse = try await readResponse()
182
+ guard finalResponse.hasPrefix("226") else {
183
+ try? FileManager.default.removeItem(at: localURL)
184
+ throw FtpError.commandFailed("Download completion failed: \(finalResponse)")
185
+ }
186
+
187
+ progressDelegate?.onComplete()
188
+ }
189
+
190
+ func uploadFile(localPath: String, remotePath: String) async throws {
191
+ guard isConnected else { throw FtpError.notConnected }
192
+
193
+ let localURL = URL(fileURLWithPath: localPath)
194
+ guard FileManager.default.fileExists(atPath: localPath) else {
195
+ throw FtpError.fileNotFound("Local file not found: \(localPath)")
196
+ }
197
+
198
+ let data = try Data(contentsOf: localURL)
199
+ let dataConnection = try await enterPassiveMode()
200
+
201
+ let response = try await sendCommand("STOR \(remotePath)")
202
+ guard response.hasPrefix("150") || response.hasPrefix("125") else {
203
+ dataConnection.cancel()
204
+ throw FtpError.commandFailed("Upload failed: \(response)")
205
+ }
206
+
207
+ try await sendData(data, to: dataConnection, reportProgress: true)
208
+ dataConnection.cancel()
209
+
210
+ let finalResponse = try await readResponse()
211
+ guard finalResponse.hasPrefix("226") else {
212
+ throw FtpError.commandFailed("Upload completion failed: \(finalResponse)")
213
+ }
214
+
215
+ progressDelegate?.onComplete()
216
+ }
217
+
218
+ func deleteFile(remotePath: String, isDirectory: Bool = false) async throws {
219
+ guard isConnected else { throw FtpError.notConnected }
220
+
221
+ let command = isDirectory ? "RMD \(remotePath)" : "DELE \(remotePath)"
222
+ let response = try await sendCommand(command)
223
+
224
+ guard response.hasPrefix("250") else {
225
+ throw FtpError.commandFailed("Delete failed: \(response)")
226
+ }
227
+ }
228
+
229
+ func createDirectory(remotePath: String) async throws {
230
+ guard isConnected else { throw FtpError.notConnected }
231
+
232
+ let response = try await sendCommand("MKD \(remotePath)")
233
+ guard response.hasPrefix("257") else {
234
+ throw FtpError.commandFailed("Create directory failed: \(response)")
235
+ }
236
+ }
237
+
238
+ func changeDirectory(remotePath: String) async throws {
239
+ guard isConnected else { throw FtpError.notConnected }
240
+
241
+ let response = try await sendCommand("CWD \(remotePath)")
242
+ guard response.hasPrefix("250") else {
243
+ throw FtpError.commandFailed("Change directory failed: \(response)")
244
+ }
245
+ }
246
+
247
+ func getCurrentDirectory() async throws -> String {
248
+ guard isConnected else { throw FtpError.notConnected }
249
+
250
+ let response = try await sendCommand("PWD")
251
+ guard response.hasPrefix("257") else {
252
+ throw FtpError.commandFailed("Get current directory failed: \(response)")
253
+ }
254
+
255
+ // Extract directory from response like: 257 "/home/user" is current directory
256
+ let pattern = #""([^"]+)""#
257
+ if let range = response.range(of: pattern, options: .regularExpression) {
258
+ let match = String(response[range])
259
+ return String(match.dropFirst().dropLast())
260
+ }
261
+
262
+ throw FtpError.commandFailed("Failed to parse current directory response: \(response)")
263
+ }
264
+
265
+ func setProgressDelegate(_ delegate: FtpProgressDelegate?) {
266
+ self.progressDelegate = delegate
267
+ }
268
+
269
+ // MARK: - Private Methods
270
+
271
+ private func performAuthentication() async throws {
272
+ // Read welcome message
273
+ let welcome = try await readResponse()
274
+ guard welcome.hasPrefix("220") else {
275
+ throw FtpError.authenticationFailed("Server not ready: \(welcome)")
276
+ }
277
+
278
+ // Send username
279
+ let userResponse = try await sendCommand("USER \(config!.username)")
280
+ guard userResponse.hasPrefix("331") || userResponse.hasPrefix("230") else {
281
+ throw FtpError.authenticationFailed("Username rejected: \(userResponse)")
282
+ }
283
+
284
+ // Send password if needed
285
+ if userResponse.hasPrefix("331") {
286
+ let passResponse = try await sendCommand("PASS \(config!.password)")
287
+ guard passResponse.hasPrefix("230") else {
288
+ throw FtpError.authenticationFailed("Authentication failed: \(passResponse)")
289
+ }
290
+ }
291
+
292
+ // Set binary mode
293
+ let typeResponse = try await sendCommand("TYPE I")
294
+ guard typeResponse.hasPrefix("200") else {
295
+ throw FtpError.commandFailed("Failed to set binary mode: \(typeResponse)")
296
+ }
297
+ }
298
+
299
+ private func sendCommand(_ command: String) async throws -> String {
300
+ guard let connection = controlConnection else {
301
+ throw FtpError.notConnected
302
+ }
303
+
304
+ let commandData = "\(command)\r\n".data(using: .utf8)!
305
+
306
+ return try await withCheckedThrowingContinuation { continuation in
307
+ connection.send(content: commandData, completion: .contentProcessed { error in
308
+ if let error = error {
309
+ continuation.resume(throwing: error)
310
+ } else {
311
+ Task {
312
+ do {
313
+ let response = try await self.readResponse()
314
+ continuation.resume(returning: response)
315
+ } catch {
316
+ continuation.resume(throwing: error)
317
+ }
318
+ }
319
+ }
320
+ })
321
+ }
322
+ }
323
+
324
+ private func readResponse() async throws -> String {
325
+ guard let connection = controlConnection else {
326
+ throw FtpError.notConnected
327
+ }
328
+
329
+ return try await withCheckedThrowingContinuation { continuation in
330
+ connection.receive(minimumIncompleteLength: 1, maximumLength: 4096) { data, _, isComplete, error in
331
+ if let error = error {
332
+ continuation.resume(throwing: error)
333
+ } else if let data = data, let response = String(data: data, encoding: .utf8) {
334
+ continuation.resume(returning: response.trimmingCharacters(in: .whitespacesAndNewlines))
335
+ } else {
336
+ continuation.resume(throwing: FtpError.invalidResponse)
337
+ }
338
+ }
339
+ }
340
+ }
341
+
342
+ private func enterPassiveMode() async throws -> NWConnection {
343
+ let response = try await sendCommand("PASV")
344
+ guard response.hasPrefix("227") else {
345
+ throw FtpError.commandFailed("Failed to enter passive mode: \(response)")
346
+ }
347
+
348
+ // Parse passive mode response: 227 Entering Passive Mode (h1,h2,h3,h4,p1,p2)
349
+ let pattern = #"\((\d+),(\d+),(\d+),(\d+),(\d+),(\d+)\)"#
350
+ let regex = try NSRegularExpression(pattern: pattern)
351
+ let nsString = response as NSString
352
+
353
+ guard let match = regex.firstMatch(in: response, range: NSRange(location: 0, length: nsString.length)) else {
354
+ throw FtpError.commandFailed("Failed to parse passive mode response: \(response)")
355
+ }
356
+
357
+ let h1 = Int(nsString.substring(with: match.range(at: 1)))!
358
+ let h2 = Int(nsString.substring(with: match.range(at: 2)))!
359
+ let h3 = Int(nsString.substring(with: match.range(at: 3)))!
360
+ let h4 = Int(nsString.substring(with: match.range(at: 4)))!
361
+ let p1 = Int(nsString.substring(with: match.range(at: 5)))!
362
+ let p2 = Int(nsString.substring(with: match.range(at: 6)))!
363
+
364
+ let host = "\(h1).\(h2).\(h3).\(h4)"
365
+ let port = p1 * 256 + p2
366
+
367
+ let dataHost = NWEndpoint.Host(host)
368
+ let dataPort = NWEndpoint.Port(integerLiteral: UInt16(port))
369
+ let dataEndpoint = NWEndpoint.hostPort(host: dataHost, port: dataPort)
370
+
371
+ let dataConnection = NWConnection(to: dataEndpoint, using: .tcp)
372
+
373
+ return try await withCheckedThrowingContinuation { continuation in
374
+ dataConnection.stateUpdateHandler = { state in
375
+ switch state {
376
+ case .ready:
377
+ continuation.resume(returning: dataConnection)
378
+ case .failed(let error):
379
+ continuation.resume(throwing: error)
380
+ case .cancelled:
381
+ continuation.resume(throwing: FtpError.connectionCancelled)
382
+ default:
383
+ break
384
+ }
385
+ }
386
+
387
+ dataConnection.start(queue: .global())
388
+ }
389
+ }
390
+
391
+ private func receiveData(from connection: NWConnection, reportProgress: Bool = false) async throws -> Data {
392
+ var receivedData = Data()
393
+
394
+ return try await withCheckedThrowingContinuation { continuation in
395
+ func receiveMore() {
396
+ connection.receive(minimumIncompleteLength: 1, maximumLength: 8192) { data, _, isComplete, error in
397
+ if let error = error {
398
+ continuation.resume(throwing: error)
399
+ return
400
+ }
401
+
402
+ if let data = data {
403
+ receivedData.append(data)
404
+ if reportProgress {
405
+ self.progressDelegate?.onProgress(transferred: Int64(receivedData.count), total: -1)
406
+ }
407
+ }
408
+
409
+ if isComplete {
410
+ continuation.resume(returning: receivedData)
411
+ } else {
412
+ receiveMore()
413
+ }
414
+ }
415
+ }
416
+ receiveMore()
417
+ }
418
+ }
419
+
420
+ private func sendData(_ data: Data, to connection: NWConnection, reportProgress: Bool = false) async throws {
421
+ let chunkSize = 8192
422
+ let totalSize = data.count
423
+ var sentBytes = 0
424
+
425
+ for i in stride(from: 0, to: totalSize, by: chunkSize) {
426
+ let endIndex = min(i + chunkSize, totalSize)
427
+ let chunk = data.subdata(in: i..<endIndex)
428
+
429
+ try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
430
+ connection.send(content: chunk, completion: .contentProcessed { error in
431
+ if let error = error {
432
+ continuation.resume(throwing: error)
433
+ } else {
434
+ sentBytes += chunk.count
435
+ if reportProgress {
436
+ self.progressDelegate?.onProgress(transferred: Int64(sentBytes), total: Int64(totalSize))
437
+ }
438
+ continuation.resume()
439
+ }
440
+ })
441
+ }
442
+ }
443
+ }
444
+
445
+ private func parseListingData(_ data: Data) -> [FtpFileInfo] {
446
+ guard let string = String(data: data, encoding: .utf8) else { return [] }
447
+
448
+ let lines = string.components(separatedBy: .newlines)
449
+ var files: [FtpFileInfo] = []
450
+
451
+ for line in lines {
452
+ if let fileInfo = parseListing(line) {
453
+ files.append(fileInfo)
454
+ }
455
+ }
456
+
457
+ return files
458
+ }
459
+
460
+ private func parseListing(_ line: String) -> FtpFileInfo? {
461
+ let trimmedLine = line.trimmingCharacters(in: .whitespacesAndNewlines)
462
+ guard !trimmedLine.isEmpty else { return nil }
463
+
464
+ // Parse Unix-style listing: drwxr-xr-x 3 user group 4096 Mar 15 10:30 dirname
465
+ let components = trimmedLine.components(separatedBy: .whitespaces).filter { !$0.isEmpty }
466
+ guard components.count >= 9 else { return nil }
467
+
468
+ let permissions = components[0]
469
+ let isDirectory = permissions.hasPrefix("d")
470
+ let size = isDirectory ? 0 : Int64(components[4]) ?? 0
471
+
472
+ // Reconstruct filename (can contain spaces)
473
+ let name = components[8...].joined(separator: " ")
474
+
475
+ // Parse date
476
+ let month = components[5]
477
+ let day = components[6]
478
+ let yearOrTime = components[7]
479
+ let lastModified = "\(month) \(day) \(yearOrTime)"
480
+
481
+ return FtpFileInfo(
482
+ name: name,
483
+ isDirectory: isDirectory,
484
+ size: size,
485
+ lastModified: lastModified,
486
+ permissions: permissions
487
+ )
488
+ }
489
+ }
490
+
491
+ // MARK: - Main Module
2
492
 
3
493
  public class ReactNativeKookitModule: Module {
494
+ private var volumeView: MPVolumeView?
495
+ private var volumeObserver: NSKeyValueObservation?
496
+ private var isVolumeKeyInterceptionEnabled = false
497
+ private var previousVolume: Float = 0.0
498
+ private var ftpClient: FtpClient?
499
+
4
500
  // Each module class must implement the definition function. The definition consists of components
5
501
  // that describes the module's functionality and behavior.
6
502
  // See https://docs.expo.dev/modules/module-api for more details about available components.
@@ -16,7 +512,7 @@ public class ReactNativeKookitModule: Module {
16
512
  ])
17
513
 
18
514
  // Defines event names that the module can send to JavaScript.
19
- Events("onChange")
515
+ Events("onChange", "onVolumeButtonPressed", "onFtpProgress", "onFtpComplete", "onFtpError")
20
516
 
21
517
  // Defines a JavaScript synchronous function that runs the native code on the JavaScript thread.
22
518
  Function("hello") {
@@ -32,8 +528,141 @@ public class ReactNativeKookitModule: Module {
32
528
  ])
33
529
  }
34
530
 
35
- // Enables the module to be used as a native view. Definition components that are accepted as part of the
36
- // view definition: Prop, Events.
531
+ // Enables volume key interception
532
+ Function("enableVolumeKeyInterception") {
533
+ self.enableVolumeKeyInterception()
534
+ }
535
+
536
+ // Disables volume key interception
537
+ Function("disableVolumeKeyInterception") {
538
+ self.disableVolumeKeyInterception()
539
+ }
540
+
541
+ // FTP Functions
542
+ AsyncFunction("ftpConnect") { (config: [String: Any], promise: Promise) in
543
+ Task {
544
+ do {
545
+ let ftpConfig = FtpConnectionConfig(
546
+ host: config["host"] as! String,
547
+ port: config["port"] as? Int ?? 21,
548
+ username: config["username"] as! String,
549
+ password: config["password"] as! String,
550
+ passive: config["passive"] as? Bool ?? true,
551
+ timeout: config["timeout"] as? TimeInterval ?? 30.0
552
+ )
553
+
554
+ self.ftpClient = FtpClient()
555
+ let progressDelegate = FtpProgressDelegateImpl(module: self)
556
+ self.ftpClient?.setProgressDelegate(progressDelegate)
557
+
558
+ try await self.ftpClient?.connect(config: ftpConfig)
559
+ promise.resolve()
560
+ } catch {
561
+ promise.reject("FTP_CONNECT_ERROR", error.localizedDescription)
562
+ }
563
+ }
564
+ }
565
+
566
+ AsyncFunction("ftpDisconnect") { (promise: Promise) in
567
+ Task {
568
+ do {
569
+ await self.ftpClient?.disconnect()
570
+ self.ftpClient = nil
571
+ promise.resolve()
572
+ } catch {
573
+ promise.reject("FTP_DISCONNECT_ERROR", error.localizedDescription)
574
+ }
575
+ }
576
+ }
577
+
578
+ AsyncFunction("ftpList") { (path: String?, promise: Promise) in
579
+ Task {
580
+ do {
581
+ let files = try await self.ftpClient?.listFiles(path: path) ?? []
582
+ let result = files.map { file in
583
+ [
584
+ "name": file.name,
585
+ "isDirectory": file.isDirectory,
586
+ "size": file.size,
587
+ "lastModified": file.lastModified,
588
+ "permissions": file.permissions as Any
589
+ ]
590
+ }
591
+ promise.resolve(result)
592
+ } catch {
593
+ promise.reject("FTP_LIST_ERROR", error.localizedDescription)
594
+ }
595
+ }
596
+ }
597
+
598
+ AsyncFunction("ftpDownload") { (remotePath: String, localPath: String, promise: Promise) in
599
+ Task {
600
+ do {
601
+ try await self.ftpClient?.downloadFile(remotePath: remotePath, localPath: localPath)
602
+ promise.resolve()
603
+ } catch {
604
+ promise.reject("FTP_DOWNLOAD_ERROR", error.localizedDescription)
605
+ }
606
+ }
607
+ }
608
+
609
+ AsyncFunction("ftpUpload") { (localPath: String, remotePath: String, promise: Promise) in
610
+ Task {
611
+ do {
612
+ try await self.ftpClient?.uploadFile(localPath: localPath, remotePath: remotePath)
613
+ promise.resolve()
614
+ } catch {
615
+ promise.reject("FTP_UPLOAD_ERROR", error.localizedDescription)
616
+ }
617
+ }
618
+ }
619
+
620
+ AsyncFunction("ftpDelete") { (remotePath: String, isDirectory: Bool?, promise: Promise) in
621
+ Task {
622
+ do {
623
+ try await self.ftpClient?.deleteFile(remotePath: remotePath, isDirectory: isDirectory ?? false)
624
+ promise.resolve()
625
+ } catch {
626
+ promise.reject("FTP_DELETE_ERROR", error.localizedDescription)
627
+ }
628
+ }
629
+ }
630
+
631
+ AsyncFunction("ftpCreateDirectory") { (remotePath: String, promise: Promise) in
632
+ Task {
633
+ do {
634
+ try await self.ftpClient?.createDirectory(remotePath: remotePath)
635
+ promise.resolve()
636
+ } catch {
637
+ promise.reject("FTP_CREATE_DIR_ERROR", error.localizedDescription)
638
+ }
639
+ }
640
+ }
641
+
642
+ AsyncFunction("ftpChangeDirectory") { (remotePath: String, promise: Promise) in
643
+ Task {
644
+ do {
645
+ try await self.ftpClient?.changeDirectory(remotePath: remotePath)
646
+ promise.resolve()
647
+ } catch {
648
+ promise.reject("FTP_CHANGE_DIR_ERROR", error.localizedDescription)
649
+ }
650
+ }
651
+ }
652
+
653
+ AsyncFunction("ftpGetCurrentDirectory") { (promise: Promise) in
654
+ Task {
655
+ do {
656
+ let currentDir = try await self.ftpClient?.getCurrentDirectory() ?? "/"
657
+ promise.resolve(currentDir)
658
+ } catch {
659
+ promise.reject("FTP_PWD_ERROR", error.localizedDescription)
660
+ }
661
+ }
662
+ }
663
+
664
+ // Enables the module to be used as a native view. Definition components that are accepted as part of
665
+ // the view definition: Prop, Events.
37
666
  View(ReactNativeKookitView.self) {
38
667
  // Defines a setter for the `url` prop.
39
668
  Prop("url") { (view: ReactNativeKookitView, url: URL) in
@@ -45,4 +674,110 @@ public class ReactNativeKookitModule: Module {
45
674
  Events("onLoad")
46
675
  }
47
676
  }
677
+
678
+ private func enableVolumeKeyInterception() {
679
+ guard !isVolumeKeyInterceptionEnabled else { return }
680
+
681
+ isVolumeKeyInterceptionEnabled = true
682
+
683
+ DispatchQueue.main.async {
684
+ // Store initial volume
685
+ let audioSession = AVAudioSession.sharedInstance()
686
+ self.previousVolume = audioSession.outputVolume
687
+
688
+ // Configure audio session to allow volume button interception
689
+ do {
690
+ try AVAudioSession.sharedInstance().setCategory(.ambient, mode: .default, options: [.mixWithOthers])
691
+ try AVAudioSession.sharedInstance().setActive(true)
692
+ } catch {
693
+ print("Failed to configure audio session: \(error)")
694
+ }
695
+
696
+ // Create a hidden volume view to prevent system volume HUD from showing
697
+ self.volumeView = MPVolumeView(frame: CGRect(x: -1000, y: -1000, width: 1, height: 1))
698
+ self.volumeView?.clipsToBounds = true
699
+ self.volumeView?.alpha = 0.01 // Make it nearly invisible but still functional
700
+ self.volumeView?.isUserInteractionEnabled = false
701
+
702
+ // Add volume view to the key window
703
+ if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
704
+ let keyWindow = windowScene.windows.first(where: { $0.isKeyWindow }) {
705
+ keyWindow.addSubview(self.volumeView!)
706
+ keyWindow.sendSubviewToBack(self.volumeView!)
707
+ }
708
+
709
+ // Set up volume observation with a small delay to ensure proper setup
710
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
711
+ self.volumeObserver = audioSession.observe(\.outputVolume, options: [.new]) { [weak self] (audioSession, change) in
712
+ guard let self = self, self.isVolumeKeyInterceptionEnabled else { return }
713
+
714
+ if let newVolume = change.newValue {
715
+ DispatchQueue.main.async {
716
+ // Determine if volume was increased or decreased
717
+ let key = newVolume > self.previousVolume ? "up" : "down"
718
+
719
+ // Send event to JavaScript
720
+ self.sendEvent("onVolumeButtonPressed", [
721
+ "key": key
722
+ ])
723
+
724
+ // Reset volume to prevent actual volume change
725
+ if let volumeSlider = self.volumeView?.subviews.compactMap({ $0 as? UISlider }).first {
726
+ volumeSlider.setValue(self.previousVolume, animated: false)
727
+ }
728
+ }
729
+ }
730
+ }
731
+ }
732
+ }
733
+ }
734
+
735
+ private func disableVolumeKeyInterception() {
736
+ guard isVolumeKeyInterceptionEnabled else { return }
737
+
738
+ isVolumeKeyInterceptionEnabled = false
739
+
740
+ DispatchQueue.main.async {
741
+ // Remove volume observer
742
+ self.volumeObserver?.invalidate()
743
+ self.volumeObserver = nil
744
+
745
+ // Remove volume view
746
+ self.volumeView?.removeFromSuperview()
747
+ self.volumeView = nil
748
+
749
+ // Reset audio session
750
+ do {
751
+ try AVAudioSession.sharedInstance().setActive(false)
752
+ } catch {
753
+ print("Failed to deactivate audio session: \(error)")
754
+ }
755
+ }
756
+ }
757
+ }
758
+
759
+ // Helper class to handle FTP progress callbacks
760
+ private class FtpProgressDelegateImpl: FtpProgressDelegate {
761
+ weak var module: ReactNativeKookitModule?
762
+
763
+ init(module: ReactNativeKookitModule) {
764
+ self.module = module
765
+ }
766
+
767
+ func onProgress(transferred: Int64, total: Int64) {
768
+ let percentage = total > 0 ? Int((transferred * 100) / total) : 0
769
+ module?.sendEvent("onFtpProgress", [
770
+ "transferred": transferred,
771
+ "total": total,
772
+ "percentage": percentage
773
+ ])
774
+ }
775
+
776
+ func onComplete() {
777
+ module?.sendEvent("onFtpComplete")
778
+ }
779
+
780
+ func onError(error: String) {
781
+ module?.sendEvent("onFtpError", ["error": error])
782
+ }
48
783
  }