chore: rename project and app to terebi

This commit is contained in:
2026-10-10 19:25:52 +02:00
parent 33858bd8ce
commit 681a7be6f7
4 changed files with 16 additions and 16 deletions
+857
View File
@@ -0,0 +1,857 @@
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 fileIcon: NSImage?
@Published private(set) var commandError: String?
@Published private(set) var pendingRevision: UInt64?
@Published var seekPosition = 0.0
@Published var isSeeking = false
@Published private(set) var volume = 1.0
@Published private(set) var controlsVisible = true
@Published private(set) var isFullScreen = false
@Published private(set) var isOnTop = false
let videoView: NSView?
private var handle: OpaquePointer?
private var timer: Timer?
private weak var window: NSWindow?
private var hideDeadline: ContinuousClock.Instant?
private var controlsHovered = false
private var titleHovered = false
private var controlsDragging = false
private var windowActive = true
private var lastAudibleVolume = 1.0
private var needsVideoFit = false
private var seekPending = false
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)
volume = Double(player_get_volume(player))
} else {
videoView = nil
commandError = "Could not create the media player."
}
timer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { [weak self] _ in
MainActor.assumeIsolated { 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 canStepFrame: Bool { canTogglePlayback && state.has_video && !isSeeking }
var canOpen: Bool { handle != nil && !isBusy }
var canClose: Bool { handle != nil && !isBusy && state.status != PLAYER_EMPTY }
var canSetVolume: Bool { handle != nil && !isBusy && state.has_audio && state.status != PLAYER_ERROR }
var duration: Double { state.duration.isFinite ? max(0, state.duration) : 0 }
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 }
revealControls()
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)
}
func open(_ url: URL) {
guard canOpen, let handle else { return }
guard url.isFileURL else {
commandError = "Only local media files are supported."
revealControls()
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
}
pendingRevision = revision
fileName = url.lastPathComponent
fileIcon = NSWorkspace.shared.icon(forFile: url.path)
needsVideoFit = true
commandError = player_play(handle) ? nil : "Could not queue playback. Please try again."
isSeeking = false
seekPending = false
seekPosition = 0
window?.representedURL = url
window?.title = url.lastPathComponent
revealControls()
}
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."
revealControls()
}
func finishSeeking() {
isSeeking = false
guard let handle, canSeek else { return }
let position = min(max(0, seekPosition), duration)
let accepted = player_seek(handle, position)
if accepted { seekPending = true }
commandError = accepted ? nil : "Could not queue the seek. Please try again."
revealControls()
}
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
fileIcon = nil
needsVideoFit = false
window?.aspectRatio = .zero
commandError = nil
isSeeking = false
seekPending = false
window?.representedURL = nil
window?.title = "terebi"
revealControls()
}
func seekRelative(_ seconds: Double) {
guard canSeek, let handle else { return }
let target = min(max(0, (seekPending ? seekPosition : state.position) + seconds), duration)
let accepted = player_seek(handle, target)
if accepted {
seekPosition = target
seekPending = true
}
commandError = accepted ? nil : "Could not queue the seek. Please try again."
revealControls()
}
func stepFrame(_ direction: Int32) {
guard canStepFrame, let handle else { return }
let accepted = player_step_frame(handle, direction)
if accepted { seekPending = false }
commandError = accepted ? nil : "Could not queue the frame step. Please try again."
revealControls()
}
func setVolume(_ value: Double) {
guard let handle else { return }
if player_set_volume(handle, Float(value)) {
volume = Double(player_get_volume(handle))
if volume > 0 { lastAudibleVolume = volume }
}
revealControls()
}
func toggleMute() {
guard canSetVolume else { return }
setVolume(volume == 0 ? lastAudibleVolume : 0)
}
func attachWindow(_ window: NSWindow) {
self.window = window
window.title = fileName ?? "terebi"
updateWindowState()
fitVideoWindow()
}
func updateWindowState() {
guard let window else { return }
isFullScreen = window.styleMask.contains(.fullScreen)
windowActive = window.isKeyWindow
window.level = isOnTop && !isFullScreen ? .floating : .normal
if !isFullScreen { fitVideoWindow() }
revealControls()
}
func toggleFullScreen() {
window?.toggleFullScreen(nil)
revealControls()
}
func toggleOnTop() {
guard !isFullScreen else { return }
isOnTop.toggle()
window?.level = isOnTop ? .floating : .normal
revealControls()
}
func setControlsHovered(_ hovered: Bool) {
guard controlsHovered != hovered else { return }
controlsHovered = hovered
revealControls()
}
func setTitleHovered(_ hovered: Bool) {
guard titleHovered != hovered else { return }
titleHovered = hovered
revealControls()
}
func setControlsDragging(_ dragging: Bool) {
guard controlsDragging != dragging else { return }
controlsDragging = dragging
revealControls()
}
func hideControls(hideCursor: Bool = true) {
guard shouldHideControls else { return }
hideDeadline = nil
withAnimation(NSWorkspace.shared.accessibilityDisplayShouldReduceMotion ? nil : .easeIn(duration: 0.25)) {
controlsVisible = false
}
NSCursor.setHiddenUntilMouseMoves(hideCursor)
}
func beginSeeking() {
isSeeking = true
revealControls()
}
func revealControls() {
hideDeadline = nil
NSCursor.setHiddenUntilMouseMoves(false)
if !controlsVisible {
withAnimation(NSWorkspace.shared.accessibilityDisplayShouldReduceMotion ? nil : .easeOut(duration: 0.25)) {
controlsVisible = true
}
}
guard shouldHideControls else { return }
hideDeadline = ContinuousClock.now.advanced(by: .milliseconds(2500))
}
private var shouldHideControls: Bool {
canTogglePlayback && state.has_video && fileName != nil &&
!isSeeking && !seekPending && !controlsHovered && !titleHovered && !controlsDragging &&
windowActive && errorText == nil &&
!NSWorkspace.shared.isVoiceOverEnabled
}
private func fitVideoWindow() {
guard needsVideoFit, canTogglePlayback, let window, !isFullScreen else { return }
needsVideoFit = false
let aspect = state.video_aspect_ratio
guard state.has_video, aspect.isFinite, aspect > 0 else {
window.aspectRatio = .zero
return
}
let screen = (window.screen ?? NSScreen.main)?.visibleFrame ?? window.frame
let width = min(window.frame.width, screen.width, screen.height * aspect)
let size = NSSize(width: width, height: width / aspect)
// ponytail: extreme ratios retain letterboxing; compact controls if exact fitting is needed.
guard size.width >= window.minSize.width, size.height >= window.minSize.height else {
window.aspectRatio = .zero
return
}
window.aspectRatio = NSSize(width: aspect, height: 1)
let frame = NSRect(
x: min(max(window.frame.midX - size.width / 2, screen.minX), screen.maxX - size.width),
y: min(max(window.frame.midY - size.height / 2, screen.minY), screen.maxY - size.height),
width: size.width, height: size.height)
window.setFrame(frame, display: true)
}
private func poll() {
guard let handle else { return }
applyState(player_get_state(handle))
}
func applyState(_ next: PlayerState) {
if let revision = pendingRevision {
guard next.revision != revision, next.status != PLAYER_OPENING else { return }
pendingRevision = nil
}
let statusChanged = state.status != next.status
state = next
if !next.seek_pending || next.status == PLAYER_ERROR { seekPending = false }
if !isSeeking && !seekPending {
seekPosition = min(max(0, next.position.isFinite ? next.position : 0), duration)
}
fitVideoWindow()
if statusChanged { revealControls() }
if let hideDeadline, ContinuousClock.now >= hideDeadline { hideControls() }
}
func shutdown() {
timer?.invalidate()
timer = nil
hideDeadline = nil
NSCursor.setHiddenUntilMouseMoves(false)
guard let player = handle else { return }
handle = nil
player_destroy(player)
}
}
@MainActor
private struct PlayerGlass: NSViewRepresentable {
var titlebar = false
func makeNSView(context: Context) -> NSVisualEffectView {
let view = NSVisualEffectView()
view.blendingMode = .withinWindow
view.state = .active
view.appearance = NSAppearance(named: .vibrantDark)
view.material = titlebar ? .titlebar : .hudWindow
return view
}
func updateNSView(_ view: NSVisualEffectView, context: Context) {}
}
@MainActor
private struct PlaybackSlider: NSViewRepresentable {
@Binding var value: Double
let range: ClosedRange<Double>
var volume = false
var enabled = true
var onEditingChanged: (Bool) -> Void = { _ in }
func makeCoordinator() -> Coordinator { Coordinator(self) }
func makeNSView(context: Context) -> Slider {
let slider = Slider()
let cell = Cell()
cell.volume = volume
slider.cell = cell
slider.isContinuous = true
slider.target = context.coordinator
slider.action = #selector(Coordinator.changed(_:))
return slider
}
func updateNSView(_ slider: Slider, context: Context) {
context.coordinator.parent = self
slider.minValue = range.lowerBound
slider.maxValue = range.upperBound
slider.doubleValue = value
slider.isEnabled = enabled
slider.editingChanged = onEditingChanged
slider.setAccessibilityLabel(volume ? "Volume" : "Playback Position")
}
@MainActor
final class Coordinator: NSObject {
var parent: PlaybackSlider
init(_ parent: PlaybackSlider) { self.parent = parent }
@objc func changed(_ slider: Slider) {
if !slider.tracking { parent.onEditingChanged(true) }
parent.value = slider.doubleValue
if !slider.tracking { parent.onEditingChanged(false) }
}
}
final class Slider: NSSlider {
var editingChanged: (Bool) -> Void = { _ in }
private(set) var tracking = false
override func mouseDown(with event: NSEvent) {
guard isEnabled else { return }
tracking = true
editingChanged(true)
super.mouseDown(with: event)
tracking = false
editingChanged(false)
}
}
final class Cell: NSSliderCell {
var volume = false
override func drawBar(inside rect: NSRect, flipped: Bool) {
let bar = NSRect(x: rect.minX + knobThickness / 2, y: rect.midY - 1.5,
width: max(0, rect.width - knobThickness), height: 3)
NSColor.white.withAlphaComponent(0.12).setFill()
NSBezierPath(roundedRect: bar, xRadius: 1.5, yRadius: 1.5).fill()
if volume {
let fraction = (doubleValue - minValue) / (maxValue - minValue)
let fill = NSRect(x: bar.minX, y: bar.minY, width: bar.width * fraction, height: bar.height)
NSColor.systemOrange.withAlphaComponent(0.85).setFill()
NSBezierPath(roundedRect: fill, xRadius: 1.5, yRadius: 1.5).fill()
}
}
override func drawKnob(_ rect: NSRect) {
let width: CGFloat = volume ? 16 : 3
let height: CGFloat = volume ? 12 : 15
let knob = NSRect(x: rect.midX - width / 2, y: rect.midY - height / 2, width: width, height: height)
let color = volume ? NSColor(white: isEnabled ? 0.85 : 0.45, alpha: 1)
: NSColor.white.withAlphaComponent(isEnabled ? 0.65 : 0.25)
color.setFill()
NSBezierPath(roundedRect: knob, xRadius: volume ? 6 : 1, yRadius: volume ? 6 : 1).fill()
}
}
}
@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
@State private var showsRemainingTime = false
@State private var dropTargeted = false
@State private var controlSize = CGSize(width: 440, height: 68)
// Relative offsets preserve the dragged position across resize/fullscreen.
@State private var controlOffset = CGSize.zero
@State private var dragOrigin = CGSize.zero
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: "%02d:%02d", value / 60, value % 60)
}
private func iconButton(_ symbol: String, _ label: String, action: @escaping () -> Void) -> some View {
Button(action: action) {
Image(systemName: symbol).font(.system(size: 17)).frame(width: 24, height: 24)
}
.buttonStyle(.plain)
.help(label)
.accessibilityLabel(label)
}
private var titlebar: some View {
HStack(spacing: 4) {
if let icon = model.fileIcon {
Image(nsImage: icon).resizable().frame(width: 16, height: 16)
} else {
Image(systemName: "play.rectangle.fill").font(.system(size: 14))
}
Text(model.fileName ?? "terebi")
.font(.system(size: 13, weight: .semibold))
.lineLimit(1)
.truncationMode(.middle)
.help(model.fileName ?? "terebi")
Spacer(minLength: 12)
iconButton(model.isOnTop ? "pin.fill" : "pin",
model.isOnTop ? "Stop Keeping Window on Top" : "Keep Window on Top", action: model.toggleOnTop)
.foregroundStyle(.white.opacity(model.isOnTop ? 1 : 0.6))
.disabled(model.isFullScreen)
}
.padding(.leading, model.isFullScreen ? 16 : 86)
.padding(.trailing, 12)
.frame(height: 32)
.foregroundStyle(.white.opacity(0.6))
.background(PlayerGlass(titlebar: true).allowsHitTesting(false))
.background(WindowChrome(model: model))
.onHover(perform: model.setTitleHovered)
.opacity(model.controlsVisible ? 1 : 0)
.allowsHitTesting(model.controlsVisible)
.accessibilityHidden(!model.controlsVisible)
}
private var playbackControls: some View {
VStack(spacing: 8) {
HStack(spacing: 0) {
HStack(spacing: 4) {
iconButton(model.volume == 0 ? "speaker.slash.fill" : "speaker.wave.2.fill",
model.volume == 0 ? "Unmute" : "Mute", action: model.toggleMute)
PlaybackSlider(value: Binding(get: { model.volume }, set: model.setVolume),
range: 0...1, volume: true, enabled: model.canSetVolume,
onEditingChanged: model.setControlsDragging)
.frame(width: 70, height: 14)
.accessibilityLabel("Volume")
.accessibilityValue("\(Int((model.volume * 100).rounded())) percent")
}
.disabled(!model.canSetVolume)
.frame(width: 106, alignment: .leading)
Spacer(minLength: 8)
HStack(spacing: 24) {
iconButton("backward.fill", "Seek Back 5 Seconds") { model.seekRelative(-5) }
.disabled(!model.canSeek)
Button(action: model.togglePlayback) {
Image(systemName: model.state.status == PLAYER_PLAYING ? "pause.fill" : "play.fill")
.font(.system(size: 24))
.frame(width: 24, height: 24)
}
.buttonStyle(.plain)
.accessibilityLabel(model.state.status == PLAYER_PLAYING ? "Pause" : "Play")
.help(model.state.status == PLAYER_PLAYING ? "Pause (K / Space)" : "Play (K / Space)")
.disabled(!model.canTogglePlayback)
iconButton("forward.fill", "Seek Forward 5 Seconds") { model.seekRelative(5) }
.disabled(!model.canSeek)
}
Spacer(minLength: 8)
HStack(spacing: 12) {
iconButton(model.isFullScreen ? "arrow.down.right.and.arrow.up.left" : "arrow.up.left.and.arrow.down.right",
model.isFullScreen ? "Exit Full Screen" : "Enter Full Screen", action: model.toggleFullScreen)
Menu {
Button("Open File…", action: model.openPanel).disabled(!model.canOpen)
Button("Close File", action: model.closeFile).disabled(!model.canClose)
Divider()
Button(model.isOnTop ? "Stop Keeping Window on Top" : "Keep Window on Top", action: model.toggleOnTop)
.disabled(model.isFullScreen)
} label: {
Image(systemName: "gearshape").font(.system(size: 17)).frame(width: 24, height: 24)
}
.menuStyle(.borderlessButton)
.menuIndicator(.hidden)
.fixedSize()
.accessibilityLabel("Playback Actions")
.help("Playback Actions")
}
.frame(width: 106, alignment: .trailing)
}
.padding(.horizontal, 16)
HStack(spacing: 10) {
Text(time(model.seekPosition)).frame(minWidth: 32, alignment: .trailing)
PlaybackSlider(value: $model.seekPosition, range: 0...max(model.duration, 0.001),
enabled: model.canSeek, onEditingChanged: { editing in
if editing { model.beginSeeking() } else { model.finishSeeking() }
})
.frame(height: 14)
.accessibilityLabel("Playback Position")
.accessibilityValue(time(model.seekPosition))
Button {
showsRemainingTime.toggle()
model.revealControls()
} label: {
Text(showsRemainingTime ? "−" + time(model.duration - model.seekPosition) : time(model.duration))
.frame(minWidth: 32, alignment: .leading)
}
.buttonStyle(.plain)
.help(showsRemainingTime ? "Show Duration" : "Show Remaining Time")
.accessibilityLabel(showsRemainingTime ? "Remaining Time" : "Duration")
.accessibilityValue(time(showsRemainingTime ? model.duration - model.seekPosition : model.duration))
}
.font(.system(size: 11).monospacedDigit())
.padding(.horizontal, 16)
}
.padding(.vertical, 11)
// Optical centering accounts for symbol insets and the native seek thumb.
.offset(y: -1.5)
.foregroundStyle(.white.opacity(0.6))
.onHover(perform: model.setControlsHovered)
}
private func controllerPosition(in size: CGSize) -> CGPoint {
let x = size.width / 2 + controlOffset.width * size.width
let y = size.height * 0.96 - controlSize.height / 2 + controlOffset.height * size.height
return CGPoint(
x: min(max(x, controlSize.width / 2 + 12), size.width - controlSize.width / 2 - 12),
y: min(max(y, 32 + controlSize.height / 2 + 12), size.height - controlSize.height / 2 - 12)
)
}
var body: some View {
GeometryReader { geometry in
ZStack(alignment: .top) {
Color.black
if let view = model.videoView {
VideoSurface(view: view)
.accessibilityLabel("Video")
.gesture(
TapGesture(count: 2).onEnded { model.toggleFullScreen() }
.exclusively(before: TapGesture().onEnded { model.hideControls() })
)
}
if let error = model.errorText {
VStack(spacing: 12) {
Image(systemName: "exclamationmark.triangle").font(.system(size: 32)).foregroundStyle(.yellow)
Text("Unable to Play").font(.headline)
Text(error).font(.callout).multilineTextAlignment(.center).textSelection(.enabled)
Button("Open Another File…", action: model.openPanel).disabled(!model.canOpen)
}
.padding(24)
.frame(maxWidth: 420)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12))
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if model.isBusy {
VStack(spacing: 12) {
ProgressView().controlSize(.large)
Text("Opening…").font(.callout).foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if model.fileName == nil {
VStack(spacing: 14) {
Image(systemName: "play.rectangle").font(.system(size: 56, weight: .ultraLight))
.foregroundStyle(.white.opacity(0.65))
Text("terebi").font(.system(size: 22, weight: .medium))
Text("Drop a media file here").font(.callout).foregroundStyle(.secondary)
Button("Open File…", action: model.openPanel)
.buttonStyle(.bordered).controlSize(.large).disabled(!model.canOpen)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if !model.state.has_video && model.state.has_audio {
VStack(spacing: 16) {
Image(systemName: "waveform").font(.system(size: 64, weight: .ultraLight))
Text(model.fileName ?? "").font(.headline).lineLimit(1)
}
.foregroundStyle(.secondary)
.padding(40)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
titlebar
if model.canTogglePlayback {
playbackControls
.frame(width: 440, height: 68)
.background {
PlayerGlass()
.clipShape(RoundedRectangle(cornerRadius: 12))
.gesture(DragGesture(minimumDistance: 4).onChanged { value in
model.setControlsDragging(true)
controlOffset = CGSize(width: dragOrigin.width + value.translation.width / geometry.size.width,
height: dragOrigin.height + value.translation.height / geometry.size.height)
}.onEnded { _ in
let position = controllerPosition(in: geometry.size)
controlOffset = CGSize(width: (position.x - geometry.size.width / 2) / geometry.size.width,
height: (position.y - (geometry.size.height * 0.96 - controlSize.height / 2)) / geometry.size.height)
dragOrigin = controlOffset
model.setControlsDragging(false)
})
}
.scaleEffect(min(1, (geometry.size.width - 32) / 440))
.frame(width: min(440, geometry.size.width - 32),
height: 68 * min(1, (geometry.size.width - 32) / 440))
.onGeometryChange(for: CGSize.self) { $0.size } action: { controlSize = $0 }
.position(controllerPosition(in: geometry.size))
.opacity(model.controlsVisible ? 1 : 0)
.allowsHitTesting(model.controlsVisible)
.accessibilityHidden(!model.controlsVisible)
}
if dropTargeted {
RoundedRectangle(cornerRadius: 10)
.strokeBorder(.white.opacity(0.75), lineWidth: 2)
.padding(8)
.allowsHitTesting(false)
}
}
.dropDestination(for: URL.self) { urls, _ in
guard model.canOpen, let url = urls.first, url.isFileURL else { return false }
model.open(url)
return true
} isTargeted: {
dropTargeted = $0
model.revealControls()
}
.onContinuousHover { phase in
switch phase {
case .active: break
case .ended: model.hideControls(hideCursor: false)
}
}
}
.ignoresSafeArea()
.frame(minWidth: 400, minHeight: 240)
.preferredColorScheme(.dark)
}
}
@MainActor
private struct WindowChrome: NSViewRepresentable {
@ObservedObject var model: PlayerModel
func makeNSView(context: Context) -> ChromeView { ChromeView(model: model) }
func updateNSView(_ view: ChromeView, context: Context) { view.updateButtons() }
static func dismantleNSView(_ view: ChromeView, coordinator: ()) { view.detach() }
final class ChromeView: NSView {
private let model: PlayerModel
private weak var configuredWindow: NSWindow?
private var monitor: Any?
private var observers: [NSObjectProtocol] = []
init(model: PlayerModel) {
self.model = model
super.init(frame: .zero)
}
required init?(coder: NSCoder) { nil }
func handleKeyEvent(_ event: NSEvent) -> Bool {
guard event.type == .keyDown, let window = configuredWindow, event.window === window,
!(window.firstResponder is NSTextView),
event.modifierFlags.intersection([.command, .control, .option]).isEmpty else { return false }
if event.keyCode == 53 && model.isFullScreen {
if !event.isARepeat { model.toggleFullScreen() }
return true
}
let key = event.charactersIgnoringModifiers?.lowercased()
switch key {
case "j": model.seekRelative(-10)
case "l": model.seekRelative(10)
case ",": model.stepFrame(-1)
case ".": model.stepFrame(1)
case "k", " ":
if !event.isARepeat { model.togglePlayback() }
case "m":
if !event.isARepeat { model.toggleMute() }
case "f":
if !event.isARepeat { model.toggleFullScreen() }
case "p":
if !event.isARepeat { model.toggleOnTop() }
case "o":
if !event.isARepeat { model.openPanel() }
case "q":
if !event.isARepeat { NSApp.terminate(nil) }
default: return false
}
return true
}
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
guard let window else { detach(); return }
guard window !== configuredWindow else { return }
detach()
configuredWindow = window
window.styleMask.insert(.fullSizeContentView)
window.titleVisibility = .hidden
window.titlebarAppearsTransparent = true
window.backgroundColor = .black
window.acceptsMouseMovedEvents = true
window.collectionBehavior.insert(.fullScreenPrimary)
window.appearance = NSAppearance(named: .darkAqua)
for name in [NSWindow.didBecomeKeyNotification, NSWindow.didResignKeyNotification,
NSWindow.didEnterFullScreenNotification, NSWindow.didExitFullScreenNotification] {
observers.append(NotificationCenter.default.addObserver(forName: name, object: window, queue: .main) { [weak self] _ in
MainActor.assumeIsolated {
self?.model.updateWindowState()
self?.updateButtons()
}
})
}
monitor = NSEvent.addLocalMonitorForEvents(matching: [.mouseMoved, .leftMouseDown, .keyDown]) { [weak self] event in
let consumed = MainActor.assumeIsolated {
guard let self, event.window === self.configuredWindow else { return false }
if event.type == .keyDown && event.keyCode == 48 { self.model.revealControls() }
if event.type == .mouseMoved || event.type == .leftMouseDown {
self.model.revealControls()
} else if event.type == .keyDown {
return self.handleKeyEvent(event)
}
return false
}
return consumed ? nil : event
}
Task { @MainActor [weak self, weak window] in
guard let self, let window else { return }
self.model.attachWindow(window)
self.updateButtons()
}
}
func updateButtons() {
let hidden = !model.controlsVisible || model.isFullScreen
for button in [NSWindow.ButtonType.closeButton, .miniaturizeButton, .zoomButton] {
if let view = configuredWindow?.standardWindowButton(button), view.isHidden != hidden {
view.isHidden = hidden
}
}
}
override func mouseDown(with event: NSEvent) { window?.performDrag(with: event) }
func detach() {
if let monitor { NSEvent.removeMonitor(monitor) }
monitor = nil
observers.forEach(NotificationCenter.default.removeObserver)
observers.removeAll()
configuredWindow = nil
}
}
}
@MainActor
final class AppDelegate: NSObject, NSApplicationDelegate {
func applicationDidFinishLaunching(_ notification: Notification) {
NSApp.setActivationPolicy(.regular)
NSApp.activate(ignoringOtherApps: true)
}
func application(_ application: NSApplication, open urls: [URL]) {
if let url = urls.first { PlayerModel.shared.open(url) }
}
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { true }
func applicationWillTerminate(_ notification: Notification) {
PlayerModel.shared.shutdown()
}
}
@main
@MainActor
struct TerebiApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) private var delegate
@StateObject private var model = PlayerModel.shared
var body: some Scene {
Window("\(model.fileName ?? "terebi")", id: "terebi") {
PlayerContent(model: model)
}
.defaultSize(width: 900, height: 560)
.windowStyle(.hiddenTitleBar)
.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)
}
CommandMenu("Playback") {
Button(model.state.status == PLAYER_PLAYING ? "Pause" : "Play", action: model.togglePlayback)
.disabled(!model.canTogglePlayback)
Button("Seek Back 5 Seconds") { model.seekRelative(-5) }
.keyboardShortcut(.leftArrow, modifiers: []).disabled(!model.canSeek)
Button("Seek Forward 5 Seconds") { model.seekRelative(5) }
.keyboardShortcut(.rightArrow, modifiers: []).disabled(!model.canSeek)
Button("Seek Back 10 Seconds") { model.seekRelative(-10) }.disabled(!model.canSeek)
Button("Seek Forward 10 Seconds") { model.seekRelative(10) }.disabled(!model.canSeek)
Divider()
Button("Previous Frame") { model.stepFrame(-1) }.disabled(!model.canStepFrame)
Button("Next Frame") { model.stepFrame(1) }.disabled(!model.canStepFrame)
Divider()
Button("Volume Up") { model.setVolume(min(1, model.volume + 0.05)) }
.keyboardShortcut(.upArrow, modifiers: []).disabled(!model.canSetVolume)
Button("Volume Down") { model.setVolume(max(0, model.volume - 0.05)) }
.keyboardShortcut(.downArrow, modifiers: []).disabled(!model.canSetVolume)
Button(model.volume == 0 ? "Unmute" : "Mute", action: model.toggleMute)
.disabled(!model.canSetVolume)
}
CommandGroup(after: .windowArrangement) {
Button(model.isFullScreen ? "Exit Full Screen" : "Enter Full Screen", action: model.toggleFullScreen)
.keyboardShortcut("f", modifiers: [.control, .command])
Button(model.isOnTop ? "Stop Keeping Window on Top" : "Keep Window on Top", action: model.toggleOnTop)
.disabled(model.isFullScreen)
}
}
}
}