Files
terebi/Sources/App/TerebiApp.swift
T

1456 lines
65 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 private(set) var resolvingYouTube = false
@Published private(set) var isLiveStream = false
@Published private(set) var isNetworkMedia = false
@Published private(set) var youtubeHeights: [Int] = []
@Published private(set) var youtubeHeight: Int?
@Published private(set) var youtubeQuality: Int?
@Published private(set) var automaticYouTubeQuality = false
@Published private(set) var showsBufferInfo = false
@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 compactWindow = CompactWindowController()
var isCompact: Bool { compactWindow.isCompact }
var canToggleCompact: Bool {
isCompact || compactWindow.isTransitioning ||
(canTogglePlayback && state.has_video && state.video_aspect_ratio.isFinite && state.video_aspect_ratio > 0)
}
var compactContentMinimumHeight: CGFloat {
guard let window else { return 90 }
// Hosting-view minimums include the native titlebar safe area, even with full-size content.
return max(0, 90 - (window.frame.height - window.contentLayoutRect.height))
}
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 var youtubeTask: Task<Void, Never>?
private var youtubeRequest: UUID?
private var resolvingAutomaticQuality = false
private let defaults: UserDefaults
private let qualityClock: () -> Double
private var automaticQuality = AutomaticQuality()
private var qualityNow: Double { qualityClock() }
private let playbackProgress: PlaybackProgress
private var progressURL: URL?
private var resumePosition: Double?
private var resumePlaying = true
private var lastSavedPosition: Double?
private var systemPlayback: SystemPlaybackControls?
init(defaults: UserDefaults = .standard, qualityClock: @escaping () -> Double = { ProcessInfo.processInfo.systemUptime }) {
self.defaults = defaults
self.qualityClock = qualityClock
automaticYouTubeQuality = defaults.bool(forKey: "automaticYouTubeQuality")
showsBufferInfo = defaults.bool(forKey: "showsBufferInfo")
playbackProgress = PlaybackProgress(defaults: defaults)
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."
}
compactWindow.onChange = { [weak self] in self?.objectWillChange.send() }
timer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { [weak self] _ in
MainActor.assumeIsolated { self?.poll() }
}
systemPlayback = SystemPlaybackControls(model: self)
}
var isBusy: Bool { (resolvingYouTube && !resolvingAutomaticQuality) || 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.can_seek && state.duration.isFinite && state.duration > 0 }
var canStepFrame: Bool { canTogglePlayback && state.has_video && state.can_seek && !isSeeking }
var canChangeYouTubeQuality: Bool {
canTogglePlayback && !youtubeHeights.isEmpty && !isSeeking && !seekPending && !state.seek_pending
}
var canOpen: Bool { handle != nil }
var canClose: Bool { handle != nil && (isBusy || state.status != PLAYER_EMPTY || fileName != nil) }
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 bufferedRange: ClosedRange<Double>? {
guard fileName != nil, canSeek, !seekPending, !state.seek_pending,
state.buffered_start.isFinite, state.buffered_end.isFinite,
state.buffered_start >= 0, state.buffered_end > state.buffered_start,
state.buffered_end <= duration else { return nil }
return state.buffered_start...state.buffered_end
}
var errorText: String? {
if let commandError { return commandError }
guard !isBusy, 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 }
defer { window?.makeFirstResponder(nil) }
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 openYouTubePanel() {
guard canOpen else { return }
defer { window?.makeFirstResponder(nil) }
revealControls()
let alert = NSAlert()
alert.messageText = "Open YouTube Video"
alert.informativeText = "Paste a YouTube video URL."
alert.addButton(withTitle: "Open")
alert.addButton(withTitle: "Cancel")
let field = NSTextField(frame: NSRect(x: 0, y: 0, width: 360, height: 24))
field.placeholderString = "https://www.youtube.com/watch?v=…"
field.setAccessibilityLabel("YouTube video URL")
alert.accessoryView = field
alert.window.initialFirstResponder = field
guard alert.runModal() == .alertFirstButtonReturn else { return }
openYouTube(field.stringValue)
}
func openYouTube(_ text: String) {
guard canOpen else { return }
let url: URL
do {
url = try YouTubeResolver.validateURL(text)
} catch {
commandError = error.localizedDescription
revealControls()
return
}
resolveYouTube(url, height: nil)
}
func setShowsBufferInfo(_ enabled: Bool) {
showsBufferInfo = enabled
defaults.set(enabled, forKey: "showsBufferInfo")
revealControls()
}
func setAutomaticYouTubeQuality(_ enabled: Bool) {
automaticYouTubeQuality = enabled
defaults.set(enabled, forKey: "automaticYouTubeQuality")
automaticQuality = AutomaticQuality()
if !enabled && resolvingAutomaticQuality { cancelYouTubeResolution() }
revealControls()
}
func setYouTubeQuality(_ height: Int?) {
// A manual choice is an override, including reselecting the active/default resolution.
setAutomaticYouTubeQuality(false)
guard canChangeYouTubeQuality, let url = progressURL, let handle,
height == nil || youtubeHeights.contains(height!) else { return }
guard height != youtubeQuality else { return }
let target = height ?? youtubeHeights.filter { $0 <= 1080 }.max()
if target == youtubeHeight {
youtubeQuality = height
return
}
let current = player_get_state(handle)
let position = !isLiveStream && current.can_seek ? current.position : nil
resolveYouTube(url, height: height, restoring: (position, current.status == PLAYER_PLAYING))
}
private func resolveYouTube(_ url: URL, height: Int?, restoring: (position: Double?, playing: Bool)? = nil,
automatic: Bool = false) {
guard let handle else { return }
cancelYouTubeResolution()
if !automatic { automaticQuality = AutomaticQuality() }
if !automatic && !player_pause(handle) {
commandError = "Could not pause the current media to open YouTube. Please try again."
revealControls()
return
}
let request = UUID()
youtubeRequest = request
resolvingYouTube = true
resolvingAutomaticQuality = automatic
systemPlayback?.update()
commandError = nil
revealControls()
youtubeTask = Task { [weak self] in
do {
let video = try await YouTubeResolver.resolve(url, height: height)
guard !Task.isCancelled, let self, self.youtubeRequest == request, self.handle != nil else { return }
if automatic {
let current = player_get_state(handle)
guard self.automaticYouTubeQuality, current.status == PLAYER_PLAYING,
!self.isSeeking, !self.seekPending, !current.seek_pending,
self.pendingRevision == nil,
self.isLiveStream || !current.duration.isFinite || current.duration <= 0 ||
current.duration - current.position > 15 else {
self.cancelYouTubeResolution()
self.automaticQuality.resetHealth()
return
}
let position = !self.isLiveStream && current.can_seek ? current.position : nil
self.cancelYouTubeResolution()
self.openResolvedYouTube(video, height: height, restoring: (position, true), automatic: true)
} else {
self.cancelYouTubeResolution()
self.openResolvedYouTube(video, height: height, restoring: restoring)
}
} catch {
guard !Task.isCancelled, let self, self.youtubeRequest == request else { return }
self.cancelYouTubeResolution()
if automatic {
self.automaticQuality.extractionFailed(at: self.qualityNow)
} else {
self.commandError = error.localizedDescription
if restoring?.playing == true, let handle = self.handle, !player_play(handle) {
self.commandError = error.localizedDescription + "\nCould not resume the previous playback."
}
self.revealControls()
}
}
}
}
private func cancelYouTubeResolution() {
youtubeRequest = nil
youtubeTask?.cancel()
youtubeTask = nil
resolvingYouTube = false
resolvingAutomaticQuality = false
}
private func openResolvedYouTube(_ video: YouTubeVideo, height: Int?, restoring: (position: Double?, playing: Bool)?,
automatic: Bool = false) {
guard let handle else { return }
saveProgress()
let revision = player_get_state(handle).revision
let accepted = video.video.url.withCString { videoURL in
video.video.headers.withCString { videoHeaders in
func enqueue(audioURL: UnsafePointer<CChar>?, audioHeaders: UnsafePointer<CChar>?) -> Bool {
var source = PlayerNetworkSource(video_url: videoURL, audio_url: audioURL,
video_headers: videoHeaders, audio_headers: audioHeaders,
duration: video.duration, is_live: video.isLive)
return player_open_network(handle, &source)
}
if let audio = video.audio {
return audio.url.withCString { audioURL in
audio.headers.withCString { enqueue(audioURL: audioURL, audioHeaders: $0) }
}
}
return enqueue(audioURL: nil, audioHeaders: nil)
}
}
guard accepted else {
if automatic {
automaticQuality.extractionFailed(at: qualityNow)
return
}
commandError = "Could not queue the YouTube video to open. Please try again."
if restoring?.playing == true, !player_play(handle) {
commandError = "Could not queue the YouTube video or resume the previous playback. Please try again."
}
revealControls()
return
}
didAcceptOpen(revision: revision, title: video.title, icon: nil, url: video.webpageURL,
live: video.isLive, restoring: restoring, automatic: automatic)
youtubeHeights = video.availableHeights
youtubeHeight = video.height
youtubeQuality = height
}
private func didAcceptOpen(revision: UInt64, title: String, icon: NSImage?, url: URL, live: Bool = false,
restoring: (position: Double?, playing: Bool)? = nil, automatic: Bool = false) {
guard let handle else { return }
if automatic { automaticQuality.resetHealth() } else { automaticQuality = AutomaticQuality() }
pendingRevision = revision
fileName = title
fileIcon = icon
isLiveStream = live
isNetworkMedia = !url.isFileURL
youtubeHeights = []
youtubeHeight = nil
youtubeQuality = nil
progressURL = url
if let restoring {
resumePosition = restoring.position
} else {
resumePosition = live ? nil : playbackProgress.position(for: url)
}
resumePlaying = restoring?.playing ?? true
lastSavedPosition = nil
needsVideoFit = restoring == nil
commandError = resumePosition != nil || !resumePlaying || player_play(handle) ? nil :
"Could not queue playback. Please try again."
isSeeking = false
seekPending = false
seekPosition = 0
window?.representedURL = url
window?.title = title
revealControls()
systemPlayback?.update()
}
func open(_ url: URL) {
guard canOpen, let handle else { return }
guard url.isFileURL else {
openYouTube(url.absoluteString)
return
}
cancelYouTubeResolution()
saveProgress()
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
}
didAcceptOpen(revision: revision, title: url.lastPathComponent,
icon: NSWorkspace.shared.icon(forFile: url.path), url: url)
}
func togglePlayback() {
setPlayback(playing: state.status != PLAYER_PLAYING)
}
@discardableResult
func setPlayback(playing: Bool) -> Bool {
guard let handle, canTogglePlayback else { return false }
if !playing && resolvingAutomaticQuality { cancelYouTubeResolution() }
if !playing { automaticQuality.resetHealth() }
let accepted = playing ? player_play(handle) : player_pause(handle)
commandError = accepted ? nil : "Could not queue playback. Please try again."
revealControls()
return accepted
}
func finishSeeking() {
if resolvingAutomaticQuality { cancelYouTubeResolution() }
automaticQuality.resetHealth()
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 }
saveProgress()
cancelYouTubeResolution()
automaticQuality = AutomaticQuality()
guard player_close(handle) else {
commandError = "Could not queue closing the file. Please try again."
return
}
compactWindow.restore(resumeFullScreen: false)
fileName = nil
fileIcon = nil
isLiveStream = false
isNetworkMedia = false
youtubeHeights = []
youtubeHeight = nil
youtubeQuality = nil
progressURL = nil
resumePosition = nil
lastSavedPosition = nil
needsVideoFit = false
window?.aspectRatio = .zero
commandError = nil
isSeeking = false
seekPending = false
window?.representedURL = nil
window?.title = "terebi"
revealControls()
systemPlayback?.update()
}
@discardableResult
func seekRelative(_ seconds: Double) -> Bool {
guard canSeek, let handle, seconds.isFinite else { return false }
if resolvingAutomaticQuality { cancelYouTubeResolution() }
automaticQuality.resetHealth()
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()
return accepted
}
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) {
guard self.window !== window else { return }
self.window = window
window.title = fileName ?? "terebi"
window.contentMinSize = NSSize(width: 400, height: 240)
compactWindow.attach(window)
updateWindowState()
fitVideoWindow()
}
func updateWindowState() {
guard let window else { return }
isFullScreen = window.styleMask.contains(.fullScreen)
windowActive = window.isKeyWindow
if !compactWindow.isTransitioning {
window.level = isCompact || (isOnTop && !isFullScreen) ? .floating : .normal
}
if !isFullScreen && !compactWindow.isTransitioning { fitVideoWindow() }
revealControls()
}
func toggleFullScreen() {
guard !compactWindow.isTransitioning else { return }
if isCompact { compactWindow.restore(resumeFullScreen: false) }
window?.toggleFullScreen(nil)
revealControls()
}
func toggleCompact() {
guard canToggleCompact else { return }
compactWindow.toggle(aspect: state.video_aspect_ratio)
controlsHovered = false
titleHovered = false
revealControls()
}
func toggleOnTop() {
guard !isFullScreen, !isCompact, !compactWindow.isTransitioning 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 && windowActive && !isCompact)
}
func beginSeeking() {
if resolvingAutomaticQuality { cancelYouTubeResolution() }
automaticQuality.resetHealth()
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 || isCompact) && errorText == nil &&
!NSWorkspace.shared.isVoiceOverEnabled
}
private func fitVideoWindow() {
guard needsVideoFit, canTogglePlayback, let window, !isFullScreen,
!compactWindow.isTransitioning else { return }
let aspect = state.video_aspect_ratio
if isCompact {
compactWindow.updateAspect(state.video_aspect_ratio)
return
}
needsVideoFit = false
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 saveProgress(_ snapshot: PlayerState? = nil, force: Bool = true) {
guard let handle, let url = progressURL, pendingRevision == nil,
resumePosition == nil, !isLiveStream else { return }
let current = snapshot ?? player_get_state(handle)
guard current.status == PLAYER_PAUSED || current.status == PLAYER_PLAYING || current.status == PLAYER_ENDED,
current.has_video, current.can_seek, !current.seek_pending,
current.duration.isFinite, current.duration > 0,
current.position.isFinite, current.position >= 0 else { return }
let position = current.status == PLAYER_ENDED ? 0 : min(current.position, current.duration)
guard force || lastSavedPosition == nil || abs(position - lastSavedPosition!) >= 5 else { return }
playbackProgress.save(position > 0 ? position : nil, for: url)
lastSavedPosition = position
}
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
}
defer { systemPlayback?.update() }
let statusChanged = state.status != next.status
state = next
if next.status == PLAYER_EMPTY || next.status == PLAYER_ERROR ||
(!isBusy && !next.has_video) {
compactWindow.restore(resumeFullScreen: false)
}
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() }
saveProgress(next, force: statusChanged && (next.status == PLAYER_PAUSED || next.status == PLAYER_ENDED))
updateAutomaticQuality()
if let saved = resumePosition {
resumePosition = nil
guard let handle, next.status == PLAYER_PAUSED || next.status == PLAYER_PLAYING else { return }
if next.has_video && next.can_seek && next.duration.isFinite && next.duration > 0 {
if saved < next.duration {
guard player_seek(handle, saved) else {
// Preserve the stored bookmark if restoration could not be queued.
progressURL = nil
commandError = "Could not resume saved progress. Please try opening the video again."
return
}
seekPosition = saved
seekPending = true
} else if let url = progressURL {
playbackProgress.save(nil, for: url)
}
}
commandError = !resumePlaying || player_play(handle) ? nil : "Could not queue playback. Please try again."
}
}
private func updateAutomaticQuality() {
guard automaticYouTubeQuality else { return }
let reserve = bufferedRange.map { max(0, $0.upperBound - state.position) }
let sample = AutomaticQuality.Sample(
enabled: automaticYouTubeQuality, network: isNetworkMedia,
playing: state.status == PLAYER_PLAYING, opening: isBusy || resolvingYouTube || resumePosition != nil,
seeking: isSeeking || seekPending || state.seek_pending, live: isLiveStream,
buffering: state.buffering, position: state.position, duration: state.duration,
ahead: reserve, height: youtubeHeight, heights: youtubeHeights)
guard let height = automaticQuality.choose(sample, at: qualityNow), let url = progressURL else { return }
resolveYouTube(url, height: height, automatic: true)
}
func shutdown() {
saveProgress()
cancelYouTubeResolution()
systemPlayback?.shutdown()
systemPlayback = nil
compactWindow.restore(resumeFullScreen: false)
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 bufferedRange: ClosedRange<Double>?
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
if let cell = slider.cell as? Cell, cell.bufferedRange != bufferedRange {
cell.bufferedRange = bufferedRange
slider.needsDisplay = true
}
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
var bufferedRange: ClosedRange<Double>?
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()
} else if let bufferedRange, maxValue > minValue {
let lower = min(maxValue, max(minValue, bufferedRange.lowerBound))
let upper = min(maxValue, max(minValue, bufferedRange.upperBound))
let scale = bar.width / (maxValue - minValue)
let fill = NSRect(x: bar.minX + (lower - minValue) * scale, y: bar.minY,
width: (upper - lower) * scale, height: bar.height)
NSColor.white.withAlphaComponent(0.4).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 CompactDragSurface: NSViewRepresentable {
let model: PlayerModel
func makeNSView(context: Context) -> DragView { DragView(model: model) }
func updateNSView(_ view: DragView, context: Context) {}
final class DragView: NSView {
private let model: PlayerModel
private var hoverTracking: NSTrackingArea?
init(model: PlayerModel) {
self.model = model
super.init(frame: .zero)
}
required init?(coder: NSCoder) { nil }
override func updateTrackingAreas() {
super.updateTrackingAreas()
if let hoverTracking { removeTrackingArea(hoverTracking) }
let area = NSTrackingArea(rect: .zero,
options: [.inVisibleRect, .activeAlways, .mouseEnteredAndExited, .mouseMoved],
owner: self, userInfo: nil)
hoverTracking = area
addTrackingArea(area)
}
override func mouseEntered(with event: NSEvent) { model.revealControls() }
override func mouseMoved(with event: NSEvent) { model.revealControls() }
override func mouseExited(with event: NSEvent) { model.hideControls(hideCursor: false) }
override func mouseDown(with event: NSEvent) {
if event.clickCount == 2 {
model.toggleCompact()
return
}
model.setControlsDragging(true)
window?.performDrag(with: event)
model.compactWindow.snapAfterDrag()
model.setControlsDragging(false)
}
}
}
@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 var showsBufferReadout: Bool { model.isNetworkMedia && model.showsBufferInfo }
private var controlHeight: CGFloat { showsBufferReadout ? 88 : 68 }
private var compactControlHeight: CGFloat { showsBufferReadout ? 52 : 40 }
private var bufferedStatus: String? {
model.bufferedRange.map {
let behind = max(0, model.state.position - $0.lowerBound)
let ahead = max(0, $0.upperBound - model.state.position)
return "Reusable buffer: \(String(format: "%.1f", behind)) seconds behind, " +
"\(String(format: "%.1f", ahead)) seconds ahead; buffered through \(time($0.upperBound))."
}
}
private var bufferReadout: some View {
Text(model.bufferedRange.map {
let behind = max(0, model.state.position - $0.lowerBound)
let ahead = max(0, $0.upperBound - model.state.position)
return (model.isCompact ? "" : "Buffer: ") + String(format: "−%.0fs +%.0fs", behind, ahead)
} ?? (model.isCompact ? "Buffer —" : "Buffer: —"))
.font(.system(size: 10).monospacedDigit())
.foregroundStyle(.white.opacity(0.75))
.lineLimit(1)
.frame(height: 12)
.help(bufferedStatus ?? "Reusable buffer unavailable.")
.accessibilityLabel("Reusable Buffer")
.accessibilityValue(bufferedStatus ?? "Unavailable")
}
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 || model.compactWindow.isTransitioning)
}
.padding(.leading, model.isFullScreen ? 16 : 86)
.padding(.trailing, 12)
.frame(height: 32)
.foregroundStyle(.white.opacity(0.6))
.background(PlayerGlass(titlebar: true).allowsHitTesting(false))
.onHover(perform: model.setTitleHovered)
.opacity(model.controlsVisible ? 1 : 0)
.allowsHitTesting(model.controlsVisible)
.accessibilityHidden(!model.controlsVisible)
}
private var actionsMenu: some View {
Menu {
Button("Open File…", action: model.openPanel).disabled(!model.canOpen)
Button("Open YouTube URL…", action: model.openYouTubePanel).disabled(!model.canOpen)
Button("Close File", action: model.closeFile).disabled(!model.canClose)
if !model.youtubeHeights.isEmpty {
Divider()
Menu("Quality") {
Toggle("Automatic Quality (up to 1080p)", isOn: Binding(
get: { model.automaticYouTubeQuality },
set: { model.setAutomaticYouTubeQuality($0) }))
Divider()
Button { model.setYouTubeQuality(nil) } label: {
HStack {
Text("Default (up to 1080p)")
if !model.automaticYouTubeQuality && model.youtubeQuality == nil {
Image(systemName: "checkmark")
}
}
}
.disabled(!model.canChangeYouTubeQuality)
ForEach(model.youtubeHeights, id: \.self) { height in
Button { model.setYouTubeQuality(height) } label: {
HStack {
Text("\(height)p")
if !model.automaticYouTubeQuality && model.youtubeQuality == height {
Image(systemName: "checkmark")
}
}
}
.disabled(!model.canChangeYouTubeQuality)
}
}
Text(model.youtubeHeight.map { "Current: \($0)p" } ?? "Current resolution unknown")
}
Divider()
Toggle("Show Buffer Info", isOn: Binding(
get: { model.showsBufferInfo },
set: { model.setShowsBufferInfo($0) }))
Divider()
Button(model.isCompact ? "Exit Compact Mode" : "Enter Compact Mode", action: model.toggleCompact)
.disabled(!model.canToggleCompact)
Toggle("Snap to Screen Edges", isOn: Binding(
get: { model.compactWindow.snappingEnabled },
set: { model.compactWindow.snappingEnabled = $0 }))
Divider()
Button(model.isOnTop ? "Stop Keeping Window on Top" : "Keep Window on Top", action: model.toggleOnTop)
.disabled(model.isFullScreen || model.isCompact || model.compactWindow.isTransitioning)
} label: {
Image(systemName: "gearshape").font(.system(size: 17)).frame(width: 24, height: 24)
}
.menuStyle(.borderlessButton)
.menuIndicator(.hidden)
.fixedSize()
.accessibilityLabel("Playback Actions")
.help("Playback Actions")
}
private var compactControls: some View {
VStack(spacing: 4) {
HStack(spacing: 16) {
iconButton(model.state.status == PLAYER_PLAYING ? "pause.fill" : "play.fill",
model.state.status == PLAYER_PLAYING ? "Pause" : "Play", action: model.togglePlayback)
.disabled(!model.canTogglePlayback)
iconButton("pip.exit", "Exit Compact Mode", action: model.toggleCompact)
.disabled(model.compactWindow.isTransitioning)
actionsMenu
}
if showsBufferReadout { bufferReadout }
}
.padding(.horizontal, 12)
.padding(.vertical, showsBufferReadout ? 6 : 8)
.foregroundStyle(.white.opacity(0.85))
.background(PlayerGlass().clipShape(RoundedRectangle(cornerRadius: 10)))
.onHover(perform: model.setControlsHovered)
.opacity(model.controlsVisible ? 1 : 0)
.allowsHitTesting(model.controlsVisible)
.accessibilityHidden(!model.controlsVisible)
}
private var playbackControls: some View {
let buffered = model.bufferedRange
return 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("pip.enter", "Enter Compact Mode", action: model.toggleCompact)
.disabled(!model.canToggleCompact)
actionsMenu
}
.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, bufferedRange: buffered, onEditingChanged: { editing in
if editing { model.beginSeeking() } else { model.finishSeeking() }
})
.frame(height: 14)
.accessibilityLabel("Playback Position")
.accessibilityValue(time(model.seekPosition) + (bufferedStatus.map { ", " + $0 } ?? ""))
.help(bufferedStatus ?? "Playback Position")
Button {
showsRemainingTime.toggle()
model.revealControls()
} label: {
Text(model.isLiveStream ? "LIVE" :
(showsRemainingTime ? "−" + time(model.duration - model.seekPosition) : time(model.duration)))
.frame(minWidth: 32, alignment: .leading)
}
.buttonStyle(.plain)
.disabled(model.isLiveStream)
.help(model.isLiveStream ? "Live Stream" : (showsRemainingTime ? "Show Duration" : "Show Remaining Time"))
.accessibilityLabel(model.isLiveStream ? "Live Stream" : (showsRemainingTime ? "Remaining Time" : "Duration"))
.accessibilityValue(model.isLiveStream ? "Live" :
time(showsRemainingTime ? model.duration - model.seekPosition : model.duration))
}
.font(.system(size: 11).monospacedDigit())
.padding(.horizontal, 16)
if showsBufferReadout { bufferReadout }
}
.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 model.isCompact {
CompactDragSurface(model: model).accessibilityHidden(true)
}
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(model.resolvingYouTube ? "Resolving YouTube…" : "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 or YouTube link here").font(.callout).foregroundStyle(.secondary)
Button("Open File…", action: model.openPanel)
.buttonStyle(.bordered).controlSize(.large).disabled(!model.canOpen)
Button("Open YouTube URL…", action: model.openYouTubePanel)
.buttonStyle(.bordered).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)
}
if model.state.buffering && !model.isBusy && model.errorText == nil {
ProgressView("Buffering…")
.padding(12)
.background(PlayerGlass().clipShape(RoundedRectangle(cornerRadius: 10)))
.position(x: geometry.size.width / 2, y: geometry.size.height / 2)
.allowsHitTesting(false)
}
if model.isCompact {
Text(model.fileName ?? "terebi")
.font(.system(size: 11, weight: .medium))
.lineLimit(1)
.padding(8)
.frame(maxWidth: .infinity)
.foregroundStyle(.white.opacity(0.75))
.background(PlayerGlass(titlebar: true))
.opacity(model.controlsVisible ? 1 : 0)
.allowsHitTesting(false)
.accessibilityHidden(!model.controlsVisible)
compactControls
.position(x: geometry.size.width / 2, y: geometry.size.height - compactControlHeight / 2 - 8)
} else {
titlebar
}
if model.canTogglePlayback && !model.isCompact {
playbackControls
.frame(width: 440, height: controlHeight)
.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: controlHeight * 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 else { return false }
if !url.isFileURL && (try? YouTubeResolver.validateURL(url.absoluteString)) == nil { return false }
model.open(url)
return true
} isTargeted: {
dropTargeted = $0
model.revealControls()
}
.onContinuousHover { phase in
switch phase {
case .active:
if model.isCompact { model.revealControls() }
case .ended: model.hideControls(hideCursor: false)
}
}
}
.frame(minWidth: model.isCompact ? 160 : 400,
minHeight: model.isCompact ? model.compactContentMinimumHeight : 240)
.ignoresSafeArea()
.preferredColorScheme(.dark)
.background(WindowChrome(model: model))
}
}
@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] = []
private var dragOrigin: NSPoint?
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, .leftMouseUp, .keyDown]) { [weak self] event in
let consumed = MainActor.assumeIsolated {
guard let self, let window = self.configuredWindow else { return false }
if event.type == .leftMouseUp {
let origin = self.dragOrigin
self.dragOrigin = nil
if let origin {
Task { @MainActor [weak self, weak window] in
guard let self, let window, self.configuredWindow === window,
self.model.isCompact, window.frame.origin != origin else { return }
self.model.compactWindow.snapAfterDrag()
}
}
return false
}
guard event.window === window else { return false }
if event.type == .leftMouseDown { self.dragOrigin = window.frame.origin }
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()
window.makeFirstResponder(nil)
}
}
func updateButtons() {
let hidden = !model.controlsVisible || model.isFullScreen || model.isCompact
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) {
model.setControlsDragging(true)
window?.performDrag(with: event)
model.compactWindow.snapAfterDrag()
model.setControlsDragging(false)
}
func detach() {
if let monitor { NSEvent.removeMonitor(monitor) }
monitor = nil
dragOrigin = nil
observers.forEach(NotificationCenter.default.removeObserver)
observers.removeAll()
configuredWindow = nil
}
}
}
@MainActor
final class AppDelegate: NSObject, NSApplicationDelegate {
func applicationWillFinishLaunching(_ notification: Notification) {
// Own external opens: SwiftUI's document routing can close the singleton Window.
let events = NSAppleEventManager.shared()
events.setEventHandler(self, andSelector: #selector(openInternetURL(_:withReplyEvent:)),
forEventClass: AEEventClass(kInternetEventClass), andEventID: AEEventID(kAEGetURL))
}
@objc private func openInternetURL(_ event: NSAppleEventDescriptor, withReplyEvent reply: NSAppleEventDescriptor) {
guard let text = event.paramDescriptor(forKeyword: keyDirectObject)?.stringValue else { return }
PlayerModel.shared.openYouTube(text)
}
@objc private func openDocuments(_ event: NSAppleEventDescriptor, withReplyEvent reply: NSAppleEventDescriptor) {
guard let url = event.paramDescriptor(forKeyword: keyDirectObject)?.atIndex(1)?.fileURLValue else { return }
PlayerModel.shared.open(url)
}
func applicationDidFinishLaunching(_ notification: Notification) {
NSApp.setActivationPolicy(.regular)
NSApp.activate(ignoringOtherApps: true)
NSAppleEventManager.shared().setEventHandler(self, andSelector: #selector(openDocuments(_:withReplyEvent:)),
forEventClass: AEEventClass(kCoreEventClass), andEventID: AEEventID(kAEOpenDocuments))
let arguments = Array(CommandLine.arguments.dropFirst())
if arguments.count == 2, arguments[0] == "--open-media", let url = URL(string: arguments[1]) {
PlayerModel.shared.open(url)
}
}
// Let SwiftUI create the initial window for a Finder/document launch.
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()
}
}
@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("Open YouTube URL…", action: model.openYouTubePanel)
.keyboardShortcut("o", modifiers: [.command, .shift])
.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])
.disabled(model.compactWindow.isTransitioning)
Button(model.isCompact ? "Exit Compact Mode" : "Enter Compact Mode", action: model.toggleCompact)
.keyboardShortcut("p", modifiers: [.control, .command])
.disabled(!model.canToggleCompact)
Toggle("Snap to Screen Edges", isOn: Binding(
get: { model.compactWindow.snappingEnabled },
set: { model.compactWindow.snappingEnabled = $0 }))
Button(model.isOnTop ? "Stop Keeping Window on Top" : "Keep Window on Top", action: model.toggleOnTop)
.disabled(model.isFullScreen || model.isCompact || model.compactWindow.isTransitioning)
}
}
}
}
#if !PLAYER_CHECK
@_cdecl("terebi_app_main")
@MainActor
public func terebiAppMain() {
TerebiApp.main()
}
#endif