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 private var pendingURL: URL? private var coordAttempt: Int = 0 private var coordSession: Int = 0 private var coordWatchdog: Timer? private var coordCountdown: Timer? private var activeCoordinator: NSFileCoordinator? private static let coordRetryWaits = [3, 5, 10] private static let coordAttemptTimeout: TimeInterval = 12.0 private static let coordInitialMessage = "File Coordinator has to Cache before we can Access the items. This may take a moment, please wait while loading." private static let coordFailMessage = """ The File Coordinator might need to Cache. If you can, try files that are not downloaded from iCloud. Even with Keep Downloaded, the Cache of the URLs happens at varied intervals. Close the app, close files, open the app again, try again. It will work if you wait until the URLs have had time to Cache. There is nothing I can do to change this; it's not my bug. """ /// claims the security-scoped folder URL and starts the coordinator-with-retry pipeline. 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 view?.viewportHandle != nil 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 } pendingURL = url coordAttempt = 0 setCoordinatingMessage(Self.coordInitialMessage) startCoordinationAttempt() } /// pushes a coordinator-status string into the Rust overlay, or clears it on nil. private func setCoordinatingMessage(_ msg: String?) { DispatchQueue.main.async { [weak self] in guard let h = self?.view?.viewportHandle else { return } if let m = msg { m.withCString { cstr in viewport_set_coordinating_message(h, cstr) } } else { viewport_set_coordinating_message(h, nil) } } } /// runs one NSFileCoordinator attempt with a watchdog timeout that escalates to a retry on timeout. private func startCoordinationAttempt() { guard let url = pendingURL else { return } coordSession += 1 let mySession = coordSession if coordAttempt > 0 { setCoordinatingMessage("(\(coordAttempt)/3) Retry — Loading...") } coordWatchdog?.invalidate() coordWatchdog = Timer.scheduledTimer(withTimeInterval: Self.coordAttemptTimeout, repeats: false) { [weak self] _ in self?.handleAttemptTimeout(session: mySession) } DispatchQueue.global(qos: .userInitiated).async { [weak self] in let coordinator = NSFileCoordinator() self?.activeCoordinator = coordinator var err: NSError? coordinator.coordinate(readingItemAt: url, options: [], error: &err) { coordinatedURL in DispatchQueue.main.async { guard let self = self else { return } guard self.coordSession == mySession else { return } self.coordWatchdog?.invalidate() self.coordWatchdog = nil self.activeCoordinator = nil self.setCoordinatingMessage(nil) self.pendingURL = nil self.coordAttempt = 0 guard let h = self.view?.viewportHandle else { return } coordinatedURL.path.withCString { cstr in viewport_apply_picked_folder(h, cstr) } } } if let e = err { print("[YrXtals] NSFileCoordinator error: \(e.localizedDescription)") } } } /// cancels the pending coordinator call and starts the retry countdown after the watchdog elapses. private func handleAttemptTimeout(session: Int) { guard session == coordSession else { return } activeCoordinator?.cancel() activeCoordinator = nil coordAttempt += 1 if coordAttempt > Self.coordRetryWaits.count { setCoordinatingMessage(nil) pendingURL = nil coordAttempt = 0 showCoordFailureAlert() return } let waitSeconds = Self.coordRetryWaits[coordAttempt - 1] startCountdown(seconds: waitSeconds) } /// runs the visible per-second countdown banner before the next coordinator attempt. private func startCountdown(seconds: Int) { var remaining = seconds setCoordinatingMessage("(\(coordAttempt)/3) Retry in \(remaining)s...") coordCountdown?.invalidate() coordCountdown = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] timer in guard let self = self else { timer.invalidate(); return } remaining -= 1 if remaining <= 0 { timer.invalidate() self.coordCountdown = nil self.startCoordinationAttempt() } else { self.setCoordinatingMessage("(\(self.coordAttempt)/3) Retry in \(remaining)s...") } } } /// shows the final-failure alert when all three retries have elapsed without a successful coordinator return. private func showCoordFailureAlert() { guard let root = presentationHost ?? topViewController() else { return } let alert = UIAlertController(title: "Loading Failed", message: Self.coordFailMessage, preferredStyle: .alert) alert.addAction(UIAlertAction(title: "OK", style: .default)) root.present(alert, animated: true) } /// 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) } }