Zum Inhalt

macos/Sources/PerlinLiveWallpaper/LivePhotoExportController.swift

import AppKit

/// Presents a small window to configure and run a Live Photo export for the
/// app's currently-applied preset, then delivers the result. Off-main-thread
/// export keeps the UI responsive; all UI mutation hops back to main.
final class LivePhotoExportController: NSObject {
    private var window: NSWindow?
    private let presetProvider: () -> Preset

    /// `presetProvider` returns the look to export (the app's current preset).
    init(presetProvider: @escaping () -> Preset) { self.presetProvider = presetProvider }

    func present() {
        // If the window is already open, just bring it forward instead of
        // spawning a second one and orphaning any in-progress export.
        if let win = window, win.isVisible {
            NSApp.activate(ignoringOtherApps: true)
            win.makeKeyAndOrderFront(nil)
            return
        }
        let vc = LivePhotoExportViewController(preset: presetProvider())
        let win = NSWindow(contentViewController: vc)
        win.title = "Export Live Photo"
        win.styleMask = [.titled, .closable]
        win.isReleasedWhenClosed = false
        win.center()
        NSApp.activate(ignoringOtherApps: true)
        win.makeKeyAndOrderFront(nil)
        self.window = win
    }
}

private final class LivePhotoExportViewController: NSViewController {
    private let preset: Preset
    private let resolutionPopUp = NSPopUpButton()
    private let durationField = NSTextField(string: "3.0")
    private let fpsField = NSTextField(string: "30")
    private let status = NSTextField(labelWithString: "")
    private let exportButton = NSButton(title: "Export", target: nil, action: nil)

    init(preset: Preset) { self.preset = preset; super.init(nibName: nil, bundle: nil) }
    required init?(coder: NSCoder) { fatalError("not used") }

    override func loadView() {
        let stack = NSStackView()
        stack.orientation = .vertical
        stack.alignment = .leading
        stack.spacing = 10
        stack.edgeInsets = NSEdgeInsets(top: 20, left: 20, bottom: 20, right: 20)

        LivePhotoExportOptions.resolutionPresets.forEach { resolutionPopUp.addItem(withTitle: $0.label) }
        // NSStackView ignores `frame.size.width` on arranged subviews; use
        // Auto Layout constraints so the widths are actually honoured.
        durationField.widthAnchor.constraint(equalToConstant: 60).isActive = true
        fpsField.widthAnchor.constraint(equalToConstant: 60).isActive = true
        exportButton.target = self
        exportButton.action = #selector(runExport)
        exportButton.keyEquivalent = "\r"

        stack.addArrangedSubview(NSTextField(labelWithString: "Resolution"))
        stack.addArrangedSubview(resolutionPopUp)
        stack.addArrangedSubview(NSTextField(labelWithString: "Duration (s) / fps"))
        let row = NSStackView(views: [durationField, fpsField]); row.spacing = 8
        stack.addArrangedSubview(row)
        stack.addArrangedSubview(exportButton)
        stack.addArrangedSubview(status)
        stack.setFrameSize(NSSize(width: 320, height: 240))
        self.view = stack
    }

    private func currentOptions() -> LivePhotoExportOptions {
        var o = LivePhotoExportOptions()
        let p = LivePhotoExportOptions.resolutionPresets[max(0, resolutionPopUp.indexOfSelectedItem)]
        o.width = p.w; o.height = p.h
        o.duration = Double(durationField.stringValue) ?? 3.0
        o.fps = Int(fpsField.stringValue) ?? 30
        return o
    }

    @objc private func runExport() {
        exportButton.isEnabled = false
        status.stringValue = "Rendering…"
        let opts = currentOptions()
        let preset = self.preset
        let tmp = FileManager.default.temporaryDirectory
        let still = tmp.appendingPathComponent("perlin-livephoto.heic")
        let video = tmp.appendingPathComponent("perlin-livephoto.mov")

        DispatchQueue.global(qos: .userInitiated).async {
            let id = LivePhotoExporter.exportPair(preset: preset, options: opts, stillURL: still, videoURL: video)
            guard id != nil else { self.finish("Export failed.") ; return }
            PhotoLibraryDelivery.add(stillURL: still, videoURL: video) { result in
                switch result {
                case .added:
                    self.finish("Saved to Photos. On your iPhone: Settings → Wallpaper → Add New Wallpaper → Photos → \"Live Photo\" filter → pick it → Add → Set as Wallpaper Pair. Keep the Live-Photo play toggle ON.")
                case .denied:
                    self.exportToFolderFallback(still: still, video: video)
                case .failed(let msg):
                    self.finish("Saved files, but adding to Photos failed: \(msg)")
                }
            }
        }
    }

    private func exportToFolderFallback(still: URL, video: URL) {
        DispatchQueue.main.async {
            let panel = NSOpenPanel()
            panel.canChooseDirectories = true; panel.canChooseFiles = false; panel.allowsMultipleSelection = false
            panel.prompt = "Export Here"
            panel.message = "Photos access denied — choose a folder to save the pair, then AirDrop it to your iPhone."
            guard panel.runModal() == .OK, let dir = panel.url else { self.finish("Cancelled."); return }
            do {
                let stillDst = dir.appendingPathComponent("perlin-livephoto.heic")
                let videoDst = dir.appendingPathComponent("perlin-livephoto.mov")
                // Remove any prior export so re-export to the same folder never
                // throws "file already exists".
                try? FileManager.default.removeItem(at: stillDst)
                try? FileManager.default.removeItem(at: videoDst)
                try FileManager.default.copyItem(at: still, to: stillDst)
                try FileManager.default.copyItem(at: video, to: videoDst)
                self.finish("Exported the pair to \(dir.lastPathComponent). AirDrop both files to your iPhone, then set the Live Photo as your wallpaper.")
            } catch { self.finish("Folder export failed: \(error.localizedDescription)") }
        }
    }

    private func finish(_ message: String) {
        DispatchQueue.main.async {
            self.status.stringValue = message
            self.exportButton.isEnabled = true
        }
    }
}