import AppKit import SwiftUI @MainActor final class PlayerModel: ObservableObject { static let shared = PlayerModel() @Published private(set) var state = PlayerState() @Published private(set) var fileName: String? @Published private(set) var commandError: String? @Published private(set) var pendingRevision: UInt64? @Published var seekPosition = 0.0 @Published var isSeeking = false let videoView: NSView? private var handle: OpaquePointer? private var timer: Timer? private init() { let player = player_create() handle = player if let player, let view = player_macos_make_video_view(player) { videoView = Unmanaged.fromOpaque(view).takeRetainedValue() state = player_get_state(player) } else { videoView = nil commandError = "Could not create the media player." } timer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { [weak self] _ in Task { @MainActor [weak self] in self?.poll() } } } var isBusy: Bool { pendingRevision != nil || state.status == PLAYER_OPENING } var canTogglePlayback: Bool { handle != nil && !isBusy && (state.status == PLAYER_PAUSED || state.status == PLAYER_PLAYING || state.status == PLAYER_ENDED) } var canSeek: Bool { canTogglePlayback && state.duration.isFinite && state.duration > 0 } var canOpen: Bool { handle != nil && !isBusy } var canClose: Bool { handle != nil && !isBusy && state.status != PLAYER_EMPTY } var duration: Double { state.duration.isFinite ? max(0, state.duration) : 0 } var statusText: String { if isBusy { return "Opening…" } switch state.status { case PLAYER_EMPTY: return "Open a local audio or video file to begin." case PLAYER_OPENING: return "Opening…" case PLAYER_PAUSED: return "Paused" case PLAYER_PLAYING: return "Playing" case PLAYER_ENDED: return "Finished" case PLAYER_ERROR: return "Unable to play this file" default: return "Unavailable" } } var errorText: String? { if let commandError { return commandError } guard state.status == PLAYER_ERROR else { return nil } var error = state.error return withUnsafeBytes(of: &error) { bytes in let message = String(decoding: bytes.prefix { $0 != 0 }, as: UTF8.self) return message.isEmpty ? "An unknown playback error occurred." : message } } func openPanel() { guard canOpen else { return } let panel = NSOpenPanel() panel.canChooseFiles = true panel.canChooseDirectories = false panel.allowsMultipleSelection = false panel.prompt = "Open" guard panel.runModal() == .OK, let url = panel.url else { return } open(url) } private func open(_ url: URL) { guard let handle else { return } let revision = player_get_state(handle).revision let accepted = url.withUnsafeFileSystemRepresentation { path in guard let path else { return false } return player_open(handle, path) } guard accepted else { commandError = "Could not queue the file to open. Please try again." return } // Open is always paused; never queue play against the previous file. pendingRevision = revision fileName = url.lastPathComponent commandError = nil isSeeking = false seekPosition = 0 } func togglePlayback() { guard let handle, canTogglePlayback else { return } let accepted = state.status == PLAYER_PLAYING ? player_pause(handle) : player_play(handle) commandError = accepted ? nil : "Could not queue playback. Please try again." } func finishSeeking() { isSeeking = false guard let handle, canSeek else { return } let position = min(max(0, seekPosition), duration) commandError = player_seek(handle, position) ? nil : "Could not queue the seek. Please try again." } func closeFile() { guard let handle, canClose else { return } guard player_close(handle) else { commandError = "Could not queue closing the file. Please try again." return } fileName = nil commandError = nil isSeeking = false } private func poll() { guard let handle else { return } let next = player_get_state(handle) if let revision = pendingRevision { guard next.revision != revision, next.status != PLAYER_OPENING else { return } pendingRevision = nil } state = next if !isSeeking { seekPosition = min(max(0, next.position.isFinite ? next.position : 0), duration) } } func shutdown() { timer?.invalidate() timer = nil guard let player = handle else { return } handle = nil player_destroy(player) } } @MainActor private struct VideoSurface: NSViewRepresentable { let view: NSView func makeNSView(context: Context) -> NSView { view } func updateNSView(_ nsView: NSView, context: Context) {} } @MainActor private struct PlayerContent: View { @ObservedObject var model: PlayerModel private func time(_ seconds: Double) -> String { let value = Int(min(max(0, seconds.isFinite ? seconds : 0), Double(Int.max / 2))) let hours = value / 3600 return hours > 0 ? String(format: "%d:%02d:%02d", hours, value / 60 % 60, value % 60) : String(format: "%d:%02d", value / 60, value % 60) } var body: some View { VStack(spacing: 0) { ZStack { Color.black if let view = model.videoView { VideoSurface(view: view) } if model.fileName == nil { VStack(spacing: 12) { Image(systemName: "play.rectangle").font(.system(size: 48)) Text("Open a local audio or video file") Button("Open File…", action: model.openPanel) .disabled(!model.canOpen) } .foregroundStyle(.white) } else if !model.isBusy && !model.state.has_video && model.state.has_audio { Image(systemName: "waveform").font(.system(size: 64)).foregroundStyle(.white) } } .frame(maxWidth: .infinity, maxHeight: .infinity) .accessibilityLabel("Video") VStack(alignment: .leading, spacing: 10) { HStack { Button(action: model.togglePlayback) { Image(systemName: model.state.status == PLAYER_PLAYING ? "pause.fill" : "play.fill") .frame(width: 22) } .keyboardShortcut(.space, modifiers: []) .accessibilityLabel(model.state.status == PLAYER_PLAYING ? "Pause" : "Play") .disabled(!model.canTogglePlayback) Text(time(model.seekPosition)).monospacedDigit().frame(minWidth: 52, alignment: .trailing) Slider(value: $model.seekPosition, in: 0...max(model.duration, 0.001), onEditingChanged: { editing in if editing { model.isSeeking = true } else { model.finishSeeking() } }) .disabled(!model.canSeek) .accessibilityLabel("Playback position") Text(time(model.duration)).monospacedDigit().frame(minWidth: 52, alignment: .leading) Button("Open…", action: model.openPanel) .accessibilityLabel("Open media file") .disabled(!model.canOpen) } Text(model.fileName ?? "Player").font(.headline).lineLimit(1).help(model.fileName ?? "Player") Text(model.statusText).font(.caption).foregroundStyle(.secondary) if let error = model.errorText { Text(error).font(.callout).foregroundStyle(.red).textSelection(.enabled) } } .padding() } .frame(minWidth: 560, minHeight: 360) .navigationTitle(model.fileName ?? "Player") } } @MainActor final class AppDelegate: NSObject, NSApplicationDelegate { func applicationDidFinishLaunching(_ notification: Notification) { NSApp.setActivationPolicy(.regular) NSApp.activate(ignoringOtherApps: true) } func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { true } func applicationWillTerminate(_ notification: Notification) { PlayerModel.shared.shutdown() } } @main @MainActor struct PlayerApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) private var delegate @StateObject private var model = PlayerModel.shared var body: some Scene { Window("Player", id: "player") { PlayerContent(model: model) } .defaultSize(width: 900, height: 600) .commands { CommandGroup(replacing: .newItem) { Button("Open File…", action: model.openPanel).keyboardShortcut("o") .disabled(!model.canOpen) Button("Close File", action: model.closeFile) .keyboardShortcut("w", modifiers: [.command, .shift]) .disabled(!model.canClose) } } } }