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