import UIKit import UniformTypeIdentifiers import MediaPlayer import AVFoundation /// owns the document and media pickers and forwards picked results into the Rust viewport. final class LibraryController: NSObject, UIDocumentPickerDelegate, MPMediaPickerControllerDelegate { weak var view: IcedViewportView? weak var presentationHost: UIViewController? private var pendingKind: UInt8 = 0 private var scopedURL: URL? /// presents a folder picker for kind 1 or the music library picker for kind 2. func presentPicker(kind: UInt8) { guard pendingKind == 0 else { print("[YrXtals] presentPicker(\(kind)) ignored; pendingKind already \(pendingKind)") return } guard let root = presentationHost ?? topViewController() else { print("[YrXtals] presentPicker(\(kind)) failed — no top view controller") return } pendingKind = kind print("[YrXtals] presentPicker kind=\(kind), presenting on \(type(of: root))") #if DEBUG dumpPresentationContext(label: "presentPicker.before", root: root) #endif if kind == 1 { let picker = UIDocumentPickerViewController(forOpeningContentTypes: [.folder], asCopy: false) picker.delegate = self picker.allowsMultipleSelection = false picker.shouldShowFileExtensions = true #if DEBUG print("[YrXtals.dbg] folder picker style=\(picker.modalPresentationStyle.rawValue) " + "definesPresCtx=\(picker.definesPresentationContext) " + "modalInPresentation=\(picker.isModalInPresentation) " + "view.userInteraction=\(picker.view.isUserInteractionEnabled)") #endif root.present(picker, animated: true) { [weak self, weak picker] in #if DEBUG guard let p = picker else { print("[YrXtals.dbg] folder picker present: deallocated"); return } self?.dumpPickerState(label: "folder.presented", picker: p) #endif } } else { let auth = MPMediaLibrary.authorizationStatus() print("[YrXtals] media library auth = \(auth.rawValue)") if auth == .denied || auth == .restricted { pendingKind = 0 showAlert(on: root, message: "Music library access is denied. Enable it in Settings → YrXtals.") return } let picker = MPMediaPickerController(mediaTypes: .anyAudio) picker.delegate = self picker.allowsPickingMultipleItems = true picker.showsCloudItems = true picker.prompt = "Pick tracks (tap an album then \"Done\" to load it whole)" #if DEBUG print("[YrXtals.dbg] media picker style=\(picker.modalPresentationStyle.rawValue)") #endif root.present(picker, animated: true) { [weak self, weak picker] in #if DEBUG guard let p = picker else { print("[YrXtals.dbg] media picker present: deallocated"); return } self?.dumpPickerState(label: "media.presented", picker: p) #endif } } } #if DEBUG /// dumps the responder chain, key window, and presentation chain at the moment of picker request. private func dumpPresentationContext(label: String, root: UIViewController) { let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene } for (si, scene) in scenes.enumerated() { print("[YrXtals.dbg] \(label): scene[\(si)] state=\(scene.activationState.rawValue) windows=\(scene.windows.count)") for (wi, w) in scene.windows.enumerated() { print("[YrXtals.dbg] \(label): scene[\(si)].window[\(wi)] key=\(w.isKeyWindow) hidden=\(w.isHidden) " + "level=\(w.windowLevel.rawValue) bounds=\(w.bounds) rootVC=\(String(describing: w.rootViewController)) " + "userInteraction=\(w.isUserInteractionEnabled)") } } var presented: UIViewController? = root var depth = 0 while let p = presented { print("[YrXtals.dbg] \(label): chain[\(depth)] vc=\(type(of: p)) " + "view.userInteraction=\(p.view.isUserInteractionEnabled) " + "modalStyle=\(p.modalPresentationStyle.rawValue)") presented = p.presentedViewController depth += 1 } } /// dumps the picker view hierarchy and gesture state once present(animated:completion:) returns. private func dumpPickerState(label: String, picker: UIViewController) { let v = picker.view let nav = (picker as? UINavigationController) ?? picker.children.compactMap { $0 as? UINavigationController }.first print("[YrXtals.dbg] \(label): picker=\(type(of: picker)) view=\(String(describing: v)) " + "frame=\(v?.frame ?? .zero) userInteraction=\(v?.isUserInteractionEnabled ?? false) " + "alpha=\(v?.alpha ?? 0) hidden=\(v?.isHidden ?? true) " + "window=\(String(describing: v?.window)) " + "subviews=\(v?.subviews.count ?? 0) " + "navStack=\(nav?.viewControllers.count ?? -1) " + "delegate=\(String(describing: (picker as? UIDocumentPickerViewController)?.delegate))") if let v = v { recurseViewDump(label: label, view: v, depth: 0, maxDepth: 6) } if let w = v?.window { let recogs = w.gestureRecognizers ?? [] print("[YrXtals.dbg] \(label): window gestureRecognizers count=\(recogs.count)") for (i, g) in recogs.enumerated() { print("[YrXtals.dbg] \(label): window.gr[\(i)]=\(type(of: g)) enabled=\(g.isEnabled) state=\(g.state.rawValue) " + "cancelsInView=\(g.cancelsTouchesInView) delaysBegan=\(g.delaysTouchesBegan) delaysEnded=\(g.delaysTouchesEnded)") } } attachDebugTapSpy(to: picker) } /// recurses subviews up to maxDepth, printing frame, interaction, and gesture counts at each level. private func recurseViewDump(label: String, view: UIView, depth: Int, maxDepth: Int) { let pad = String(repeating: " ", count: depth) print("[YrXtals.dbg] \(label): \(pad)\(type(of: view)) frame=\(view.frame) " + "userInteraction=\(view.isUserInteractionEnabled) hidden=\(view.isHidden) alpha=\(view.alpha) " + "subviews=\(view.subviews.count) gestureRecognizers=\(view.gestureRecognizers?.count ?? 0) " + "layer=\(type(of: view.layer))") if depth + 1 > maxDepth { return } for sv in view.subviews { recurseViewDump(label: label, view: sv, depth: depth + 1, maxDepth: maxDepth) } } /// adds a passing-through UITapGestureRecognizer to the picker view. private func attachDebugTapSpy(to picker: UIViewController) { guard let v = picker.view else { return } let tap = UITapGestureRecognizer(target: self, action: #selector(debugTapSpy(_:))) tap.cancelsTouchesInView = false tap.delaysTouchesBegan = false tap.delaysTouchesEnded = false v.addGestureRecognizer(tap) print("[YrXtals.dbg] attached debug tap spy to \(type(of: picker)).view") } @objc private func debugTapSpy(_ g: UITapGestureRecognizer) { let p = g.location(in: g.view) print("[YrXtals.dbg] debugTapSpy fired at \(p) on \(String(describing: g.view.map { type(of: $0) })) state=\(g.state.rawValue)") } #endif /// claims the security-scoped folder URL and pushes the path to the Rust library scanner. func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { let kind = pendingKind pendingKind = 0 print("[YrXtals] documentPicker didPick kind=\(kind) urls=\(urls)") guard let url = urls.first else { return } guard let h = view?.viewportHandle else { print("[YrXtals] documentPicker: no viewportHandle, dropping pick") return } if let prev = scopedURL { prev.stopAccessingSecurityScopedResource() scopedURL = nil } let scoped = url.startAccessingSecurityScopedResource() print("[YrXtals] startAccessingSecurityScopedResource = \(scoped) for \(url.path)") if scoped { scopedURL = url } #if DEBUG let fm = FileManager.default var isDir: ObjCBool = false let exists = fm.fileExists(atPath: url.path, isDirectory: &isDir) let reachable = (try? url.checkResourceIsReachable()) ?? false let readable = fm.isReadableFile(atPath: url.path) print("[YrXtals.dbg] picked path exists=\(exists) isDir=\(isDir.boolValue) reachable=\(reachable) readable=\(readable)") if let entries = try? fm.contentsOfDirectory(atPath: url.path) { print("[YrXtals.dbg] picked dir entries=\(entries.count) sample=\(entries.prefix(5))") } else { print("[YrXtals.dbg] picked dir contentsOfDirectory failed") } #endif url.path.withCString { cstr in viewport_apply_picked_folder(h, cstr) } } /// clears the pending picker flag after the document picker dismisses without a selection. func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { pendingKind = 0 print("[YrXtals] documentPicker cancelled") } /// sorts picked MPMediaItems by track number, seeds the sidebar with placeholders, and dispatches artwork and export jobs. func mediaPicker(_ mediaPicker: MPMediaPickerController, didPickMediaItems collection: MPMediaItemCollection) { pendingKind = 0 mediaPicker.dismiss(animated: true) guard !collection.items.isEmpty else { print("[YrXtals] mediaPicker returned no items") return } let items = collection.items.sorted { lhs, rhs in let l = lhs.albumTrackNumber == 0 ? Int.max : lhs.albumTrackNumber let r = rhs.albumTrackNumber == 0 ? Int.max : rhs.albumTrackNumber if l != r { return l < r } return (lhs.title ?? "").localizedCaseInsensitiveCompare(rhs.title ?? "") == .orderedAscending } print("[YrXtals] mediaPicker picked \(items.count) item(s)") if let h = view?.viewportHandle { let titles = items.map { $0.title ?? "Untitled" } let trackNumbers: [UInt32] = items.map { UInt32($0.albumTrackNumber) } let cstrs: [UnsafeMutablePointer?] = titles.map { strdup($0) } defer { cstrs.forEach { if let p = $0 { free(p) } } } var pointers: [UnsafePointer?] = cstrs.map { $0.flatMap { UnsafePointer($0) } } pointers.withUnsafeMutableBufferPointer { titlesBuf in trackNumbers.withUnsafeBufferPointer { tnBuf in viewport_set_pending_titles(h, titlesBuf.baseAddress, tnBuf.baseAddress, titles.count) } } } pushArtwork(for: items) exportAndDeliver(items: items) } /// pulls 256-pixel JPEG artwork off a background queue and pushes each into the viewport on the main thread. private func pushArtwork(for items: [MPMediaItem]) { DispatchQueue.global(qos: .userInitiated).async { [weak self] in for (idx, item) in items.enumerated() { guard let img = item.artwork?.image(at: CGSize(width: 256, height: 256)) else { continue } guard let data = img.jpegData(compressionQuality: 0.85) else { continue } DispatchQueue.main.async { [weak self] in guard let h = self?.view?.viewportHandle else { return } data.withUnsafeBytes { (raw: UnsafeRawBufferPointer) in guard let base = raw.baseAddress else { return } viewport_set_track_art(h, idx, base.assumingMemoryBound(to: UInt8.self), data.count) } } } } } /// transcodes each MPMediaItem to a temporary m4a, reports progress, and forwards the resulting paths to Rust. private func exportAndDeliver(items: [MPMediaItem]) { let total = items.count var skipped: [String] = [] var done = 0 if let h = view?.viewportHandle { viewport_set_library_progress(h, 0, UInt32(total)) } let finish: () -> Void = { [weak self] in guard let self = self, let h = self.view?.viewportHandle else { return } viewport_set_library_progress(h, 0, 0) print("[YrXtals] exports finished: \(total - skipped.count)/\(total) succeeded; skipped=\(skipped)") if total - skipped.count == 0, let root = self.topViewController(), !skipped.isEmpty { self.showAlert(on: root, message: "Couldn't load any of the picked tracks. \(skipped.first ?? "")") } } for (idx, item) in items.enumerated() { guard let assetURL = item.assetURL else { skipped.append("'\(item.title ?? "?")' is DRM-protected") done += 1 if done == total { finish() } continue } let asset = AVURLAsset(url: assetURL) let tmpURL = URL(fileURLWithPath: NSTemporaryDirectory()) .appendingPathComponent("\(UUID().uuidString).m4a") try? FileManager.default.removeItem(at: tmpURL) guard let exporter = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetAppleM4A) else { skipped.append("AVAssetExportSession init failed for '\(item.title ?? "?")'") done += 1 if done == total { finish() } continue } exporter.outputURL = tmpURL exporter.outputFileType = .m4a exporter.shouldOptimizeForNetworkUse = false print("[YrXtals] exporting [\(idx+1)/\(total)] \(item.title ?? "?") → \(tmpURL.lastPathComponent)") exporter.exportAsynchronously { [weak self] in DispatchQueue.main.async { var landedPath: String? = nil if exporter.status == .completed { let attrs = try? FileManager.default.attributesOfItem(atPath: tmpURL.path) let size = (attrs?[.size] as? Int64) ?? -1 print("[YrXtals] export[\(idx)] size=\(size) path=\(tmpURL.path)") if size > 0 { landedPath = tmpURL.path } else { skipped.append("'\(item.title ?? "?")' exported empty (size=\(size))") } } else { skipped.append("export failed for '\(item.title ?? "?")': status=\(exporter.status.rawValue) err=\(String(describing: exporter.error))") } if let path = landedPath, let h = self?.view?.viewportHandle { path.withCString { p in viewport_set_track_path(h, idx, p) } } done += 1 if let h = self?.view?.viewportHandle { viewport_set_library_progress(h, UInt32(done), UInt32(total)) } if done == total { finish() } } } } } /// clears the pending picker flag and dismisses the media picker on cancel. func mediaPickerDidCancel(_ mediaPicker: MPMediaPickerController) { pendingKind = 0 mediaPicker.dismiss(animated: true) print("[YrXtals] mediaPicker cancelled") } deinit { #if DEBUG print("[YrXtals.dbg] LibraryController deinit (delegate would go nil)") #endif scopedURL?.stopAccessingSecurityScopedResource() } /// walks the active scene's window stack down to the topmost presented controller. private func topViewController() -> UIViewController? { let scenes = UIApplication.shared.connectedScenes .compactMap { $0 as? UIWindowScene } .filter { $0.activationState == .foregroundActive || $0.activationState == .foregroundInactive } for scene in scenes { let candidates = scene.windows.sorted { lhs, _ in lhs.isKeyWindow } for window in candidates { if let root = window.rootViewController { var top = root while let presented = top.presentedViewController { top = presented } return top } } } return nil } /// presents a single-button OK alert anchored on the given root controller. private func showAlert(on root: UIViewController, message: String) { let alert = UIAlertController(title: "YrXtals", message: message, preferredStyle: .alert) alert.addAction(UIAlertAction(title: "OK", style: .default)) root.present(alert, animated: true) } }