YrXtals/ios/src/LibraryController.swift

361 lines
17 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
/// 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<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)
}
}