461 lines
22 KiB
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)
|
|
}
|
|
}
|