YrXtals/ios/src/LibraryController.swift

461 lines
22 KiB
Swift

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<CChar>?] = titles.map { strdup($0) }
defer { cstrs.forEach { if let p = $0 { free(p) } } }
var pointers: [UnsafePointer<CChar>?] = 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)
}
}