feat: add native local-file playback
This commit is contained in:
@@ -0,0 +1,253 @@
|
||||
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<NSView>.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user