Files
terebi/Sources/App/TerebiApp.swift
T

2190 lines
99 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
import TerebiConfiguration
func compactProgressText(position: Double, duration: Double, format: CompactTitleProgress) -> String? {
guard format != .none, duration.isFinite, duration > 0, position.isFinite else { return nil }
let position = min(max(0, position), duration)
if format == .percentage { return String(Int((position / duration * 100).rounded(.down))) }
let total = Int(min(duration, Double(Int.max / 2)))
let current = Int(min(position, Double(Int.max / 2)))
if duration < 60 { return "\(current) / \(total) s" }
let divisor = duration < 3600 ? 1 : 60
let currentUnits = current / divisor
let totalUnits = total / divisor
let currentTime = String(format: "%02lld:%02lld", Int64(currentUnits / 60), Int64(currentUnits % 60))
let totalTime = String(format: "%02lld:%02lld", Int64(totalUnits / 60), Int64(totalUnits % 60))
return "\(currentTime) / \(totalTime) \(duration < 3600 ? "m" : "h")"
}
@MainActor
final class PlayerModel: ObservableObject {
static var startupConfiguration = PlayerConfiguration()
static var startupProgressFile: String?
static let shared = PlayerModel(configuration: startupConfiguration, progressFile: startupProgressFile)
@Published private(set) var state = PlayerState()
@Published private(set) var fileName: String?
@Published private(set) var fileIcon: NSImage?
@Published private(set) var commandError: String?
private var transition = PlaybackTransition()
var pendingRevision: UInt64? { transition.pendingRevision }
var resolvingYouTube: Bool { transition.isResolving }
var isLiveStream: Bool { transition.isLive }
var isNetworkMedia: Bool { transition.isNetwork }
@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 private(set) var subtitleTracks: [ResolvedSubtitle] = []
@Published private(set) var selectedSubtitle: Int?
@Published private(set) var subtitleText = ""
@Published private(set) var subtitleError: String?
private var subtitleDocument: WebVTTDocument?
private var subtitleTask: Task<Void, Never>?
@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
let keyBindings: KeyBindings
let compactTitleProgress: CompactTitleProgress
var compactProgress: String? {
guard !isBusy, !isLiveStream, fileName != nil, state.status != PLAYER_ERROR else { return nil }
return compactProgressText(position: state.position, duration: state.duration, format: compactTitleProgress)
}
var playbackIntent: Bool? { transition.playbackIntent }
@Published private(set) var fullScreenIntent: Bool?
@Published private(set) var compactIntent: Bool?
var effectivePlaying: Bool { playbackIntent ?? (state.status == PLAYER_PLAYING) }
var playbackAction: PlayerAction { effectivePlaying ? .pause : .play }
var fullscreenAction: PlayerAction { (fullScreenIntent ?? isFullScreen) ? .exitFullscreen : .enterFullscreen }
var compactAction: PlayerAction { (compactIntent ?? isCompact) ? .exitCompact : .enterCompact }
var muteAction: PlayerAction { volume == 0 ? .unmute : .mute }
var onTopAction: PlayerAction { isOnTop ? .disableAlwaysOnTop : .enableAlwaysOnTop }
var bufferInfoAction: PlayerAction { showsBufferInfo ? .hideBufferInfo : .showBufferInfo }
var snappingAction: PlayerAction {
compactWindow.snappingEnabled ? .disableCompactSnapping : .enableCompactSnapping
}
var automaticQualityAction: PlayerAction {
automaticYouTubeQuality ? .disableAutomaticQuality : .enableAutomaticQuality
}
var isCompact: Bool { compactWindow.isCompact }
var canToggleCompact: Bool { canPerform(compactAction) }
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 pointerInsideWindow = true
private var keyboardNavigation = false
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 let qualityClock: () -> Double
private var automaticQuality = AutomaticQuality()
private var qualityNow: Double { qualityClock() }
private let playbackProgress: PlaybackProgress
private var systemPlayback: SystemPlaybackControls?
private(set) var progressInitializationError: String?
private var reportingEnabled = false
private var reportingErrorShown = false
private var reportingErrorBuffer = [CChar]()
private var reportingErrorMessage: String {
reportingErrorBuffer.withUnsafeBufferPointer { String(cString: $0.baseAddress!) }
}
init(defaults: UserDefaults = .standard, configuration: PlayerConfiguration = PlayerConfiguration(),
progressFile: String? = nil,
qualityClock: @escaping () -> Double = { ProcessInfo.processInfo.systemUptime }) {
self.qualityClock = qualityClock
keyBindings = configuration.keys
compactTitleProgress = configuration.compactTitleProgress
automaticYouTubeQuality = configuration.automaticQuality
showsBufferInfo = configuration.showBufferInfo
compactWindow = CompactWindowController(snappingEnabled: configuration.snapToEdges, defaults: defaults)
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."
}
if let progressFile {
reportingErrorBuffer = [CChar](repeating: 0, count: 512)
if let player {
reportingEnabled = progressFile.withCString { player_set_progress_file(player, $0, &reportingErrorBuffer) }
if !reportingEnabled { progressInitializationError = reportingErrorMessage }
} else {
progressInitializationError = "Could not create the media player to initialize progress reporting."
}
}
compactWindow.onChange = { [weak self] in
guard let self else { return }
if !self.compactWindow.isTransitioning {
self.compactIntent = nil
self.fullScreenIntent = nil
}
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 { transition.isBusy(state.status) }
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 && !transition.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, !transition.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 openResolvedSource(_ file: URL) {
do {
let source = try ResolvedSource.load(from: file)
cancelYouTubeResolution()
openMedia(.resolved(source))
} catch {
commandError = error.localizedDescription
revealControls()
}
}
func selectSubtitle(_ index: Int?) {
subtitleTask?.cancel()
subtitleTask = nil
subtitleDocument = nil
subtitleText = ""
subtitleError = nil
selectedSubtitle = index
guard let index, subtitleTracks.indices.contains(index) else { return }
let track = subtitleTracks[index]
subtitleTask = Task { [weak self] in
do {
let document = try await WebVTTDocument.load(url: track.url, headers: track.headers)
guard !Task.isCancelled, let self else { return }
self.subtitleDocument = document
self.updateSubtitle()
} catch {
guard !Task.isCancelled, let self else { return }
self.subtitleError = error.localizedDescription
}
}
}
private func updateSubtitle() {
subtitleText = state.seek_pending || transition.seekPending || isBusy ||
state.status == PLAYER_EMPTY || state.status == PLAYER_ERROR ? "" :
subtitleDocument?.text(at: state.position) ?? ""
}
func setShowsBufferInfo(_ enabled: Bool) {
showsBufferInfo = enabled
revealControls()
}
func setAutomaticYouTubeQuality(_ enabled: Bool) {
automaticYouTubeQuality = enabled
automaticQuality = AutomaticQuality()
if !enabled && transition.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 = transition.mediaURL, 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 change = transition.qualityChange(player_get_state(handle), playing: effectivePlaying)
resolveYouTube(url, height: height, change: change)
}
private func resolveYouTube(_ url: URL, height: Int?, change: MediaChange = .open) {
guard let handle else { return }
cancelYouTubeResolution()
if !change.automatic { automaticQuality = AutomaticQuality() }
if !change.automatic && !player_pause(handle) {
commandError = "Could not pause the current media to open YouTube. Please try again."
revealControls()
return
}
objectWillChange.send()
let request = transition.beginResolution(change)
systemPlayback?.update()
commandError = nil
revealControls()
let task = Task { [weak self] in
do {
let video = try await YouTubeResolver.resolve(url, height: height)
guard !Task.isCancelled, let self, let handle = self.handle else { return }
self.objectWillChange.send()
guard var change = self.transition.finishResolution(request) else { return }
if change.automatic {
let current = player_get_state(handle)
guard self.transition.canApplyAutomaticQuality(current, enabled: self.automaticYouTubeQuality,
previewingSeek: self.isSeeking) else {
self.automaticQuality.resetHealth()
return
}
change = self.transition.qualityChange(current, playing: true, automatic: true)
}
self.openMedia(.youtube(video, height: height), change: change)
} catch {
guard !Task.isCancelled, let self else { return }
self.objectWillChange.send()
guard let change = self.transition.finishResolution(request) else { return }
self.failYouTubeChange(change, error: error)
}
}
transition.attachResolution(task, request: request)
}
private func cancelYouTubeResolution() {
guard transition.isResolving else { return }
objectWillChange.send()
transition.cancelResolution()
}
private func failYouTubeChange(_ change: MediaChange, error: Error? = nil) {
if change.automatic {
automaticQuality.extractionFailed(at: qualityNow)
return
}
commandError = error?.localizedDescription ?? "Could not queue the YouTube video to open. Please try again."
if change.isQuality && change.playing, let handle, !player_play(handle) {
commandError = error.map { $0.localizedDescription + "\nCould not resume the previous playback." } ??
"Could not queue the YouTube video or resume the previous playback. Please try again."
}
revealControls()
}
private func openMedia(_ source: MediaSource, change: MediaChange = .open) {
guard let handle else { return }
saveProgress()
let revision = player_get_state(handle).revision
let savedPosition = change.isQuality ? change.position :
(source.live ? nil : playbackProgress.position(for: source.url))
let accepted: Bool
switch source {
case .file(let url):
accepted = url.withUnsafeFileSystemRepresentation { path in
guard let path else { return false }
return player_open(handle, path)
}
case .youtube(let video, _):
let initialPosition = source.live ? 0 : savedPosition ?? 0
accepted = video.webpageURL.absoluteString.withCString { mediaURL in
video.video.url.withCString { videoURL in
video.video.headers.withCString { videoHeaders in
func enqueue(audioURL: UnsafePointer<CChar>?, audioHeaders: UnsafePointer<CChar>?) -> Bool {
var input = PlayerNetworkSource(video_url: videoURL, audio_url: audioURL,
video_headers: videoHeaders, audio_headers: audioHeaders,
duration: video.duration, is_live: video.isLive,
media_url: mediaURL, initial_position: initialPosition)
return player_open_network(handle, &input)
}
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)
}
}
}
case .resolved(let source):
accepted = source.identity.withCString { identity in
source.url.withCString { url in
source.headers.withCString { headers in
var input = PlayerNetworkSource(video_url: url, audio_url: nil,
video_headers: headers, audio_headers: nil, duration: 0, is_live: false,
media_url: identity, initial_position: savedPosition ?? 0)
return player_open_network(handle, &input)
}
}
}
}
guard accepted else {
switch source {
case .file:
commandError = "Could not queue the file to open. Please try again."
case .resolved:
commandError = "Could not queue the resolved stream to open."
case .youtube:
failYouTubeChange(change)
}
return
}
transition.acceptOpen(revision: revision, source: source, change: change, savedPosition: savedPosition)
if change.automatic { automaticQuality.resetHealth() } else { automaticQuality = AutomaticQuality() }
switch source {
case .file(let url):
fileName = url.lastPathComponent
fileIcon = NSWorkspace.shared.icon(forFile: url.path)
youtubeHeights = []
youtubeHeight = nil
youtubeQuality = nil
case .youtube(let video, let height):
fileName = video.title
fileIcon = nil
youtubeHeights = video.availableHeights
youtubeHeight = video.height
youtubeQuality = height
case .resolved(let source):
fileName = source.title
fileIcon = nil
youtubeHeights = []
youtubeHeight = nil
youtubeQuality = nil
}
selectSubtitle(nil)
if case .resolved(let resolved) = source {
subtitleTracks = resolved.subtitles
selectSubtitle(subtitleTracks.firstIndex(where: \.isDefault))
} else {
subtitleTracks = []
}
needsVideoFit = !change.isQuality
commandError = transition.needsLocalRestoration || !change.playing || player_play(handle) ? nil :
"Could not queue playback. Please try again."
isSeeking = false
seekPosition = 0
window?.representedURL = source.url
window?.title = fileName ?? "terebi"
revealControls()
systemPlayback?.update()
}
func open(_ url: URL) {
guard canOpen else { return }
guard url.isFileURL else {
openYouTube(url.absoluteString)
return
}
cancelYouTubeResolution()
openMedia(.file(url))
}
func togglePlayback() {
perform(playbackAction)
}
@discardableResult
func setPlayback(playing: Bool) -> Bool {
guard let handle, canTogglePlayback else { return false }
if !playing && transition.resolvingAutomaticQuality { cancelYouTubeResolution() }
if !playing { automaticQuality.resetHealth() }
switch transition.setPlayback(playing, status: state.status, handle: handle) {
case .unchanged:
return true
case .deferred:
objectWillChange.send()
revealControls()
return true
case .submitted(let accepted):
commandError = accepted ? nil : "Could not queue playback. Please try again."
revealControls()
return accepted
}
}
func finishSeeking() {
if transition.resolvingAutomaticQuality { cancelYouTubeResolution() }
automaticQuality.resetHealth()
isSeeking = false
guard let handle, canSeek else { return }
let position = min(max(0, seekPosition), duration)
let accepted = transition.seek(position, handle: handle)
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
}
transition.closeMedia()
selectSubtitle(nil)
subtitleTracks = []
compactIntent = nil
compactWindow.restore(resumeFullScreen: false, animated: false)
fileName = nil
fileIcon = nil
youtubeHeights = []
youtubeHeight = nil
youtubeQuality = nil
needsVideoFit = false
window?.aspectRatio = .zero
commandError = nil
isSeeking = 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 transition.resolvingAutomaticQuality { cancelYouTubeResolution() }
automaticQuality.resetHealth()
let target = min(max(0, (transition.seekPending ? seekPosition : state.position) + seconds), duration)
let accepted = transition.seek(target, handle: handle)
if accepted { seekPosition = target }
commandError = accepted ? nil : "Could not queue the seek. Please try again."
revealControls()
return accepted
}
@discardableResult
func stepFrame(_ direction: Int32) -> Bool {
guard canStepFrame, let handle else { return false }
let accepted = transition.stepFrame(direction, handle: handle)
commandError = accepted ? nil : "Could not queue the frame step. Please try again."
revealControls()
return accepted
}
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() {
perform(muteAction)
}
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)
if !compactWindow.isTransitioning && fullScreenIntent == isFullScreen { fullScreenIntent = nil }
windowActive = window.isKeyWindow
if !compactWindow.isTransitioning {
window.level = isCompact || (isOnTop && !isFullScreen) ? .floating : .normal
}
if !isFullScreen && !compactWindow.isTransitioning { fitVideoWindow() }
revealControls()
}
func toggleFullScreen() {
perform(fullscreenAction)
}
func toggleCompact() {
perform(compactAction)
}
func toggleOnTop() {
perform(onTopAction)
}
func canPerform(_ action: PlayerAction) -> Bool {
switch action {
case .play: return canTogglePlayback && !effectivePlaying
case .pause: return canTogglePlayback && effectivePlaying
case .mute: return canSetVolume && volume > 0
case .unmute: return canSetVolume && volume == 0
case .volumeUp: return canSetVolume && volume < 1
case .volumeDown: return canSetVolume && volume > 0
case .seekForward5s, .seekBackward5s, .seekForward10s, .seekBackward10s: return canSeek
case .stepFrameForward, .stepFrameBackward: return canStepFrame
case .enterFullscreen, .exitFullscreen:
return window != nil && fullScreenIntent == nil && !compactWindow.isTransitioning &&
(action == .enterFullscreen ? !isFullScreen : isFullScreen)
case .enterCompact:
return window != nil && !(compactIntent ?? isCompact) && canOpen &&
(isBusy || (canTogglePlayback && state.has_video &&
state.video_aspect_ratio.isFinite && state.video_aspect_ratio > 0))
case .exitCompact: return window != nil && (compactIntent ?? isCompact)
case .enableAlwaysOnTop, .disableAlwaysOnTop:
return window != nil && !isFullScreen && fullScreenIntent == nil &&
!isCompact && compactIntent != true && !compactWindow.isTransitioning &&
(action == .enableAlwaysOnTop ? !isOnTop : isOnTop)
case .openFile, .openYoutube: return canOpen
case .closeFile: return canClose
case .quit: return true
case .showBufferInfo: return !showsBufferInfo
case .hideBufferInfo: return showsBufferInfo
case .enableCompactSnapping: return !compactWindow.snappingEnabled
case .disableCompactSnapping: return compactWindow.snappingEnabled
case .enableAutomaticQuality: return !automaticYouTubeQuality
case .disableAutomaticQuality: return automaticYouTubeQuality
case .qualityDefault:
return canChangeYouTubeQuality && (automaticYouTubeQuality || youtubeQuality != nil)
case let .quality(height):
return canChangeYouTubeQuality && youtubeHeights.contains(height) &&
(automaticYouTubeQuality || youtubeQuality != height)
}
}
@discardableResult
func perform(_ action: PlayerAction) -> Bool {
guard canPerform(action) else { return false }
switch action {
case .play: return setPlayback(playing: true)
case .pause: return setPlayback(playing: false)
case .mute: setVolume(0)
case .unmute: setVolume(lastAudibleVolume)
case .volumeUp: setVolume(min(1, volume + 0.05))
case .volumeDown: setVolume(max(0, volume - 0.05))
case .seekForward5s: return seekRelative(5)
case .seekBackward5s: return seekRelative(-5)
case .seekForward10s: return seekRelative(10)
case .seekBackward10s: return seekRelative(-10)
case .stepFrameForward: return stepFrame(1)
case .stepFrameBackward: return stepFrame(-1)
case .enterFullscreen, .exitFullscreen:
if isCompact {
compactIntent = false
compactWindow.restore(resumeFullScreen: false, animated: false)
}
fullScreenIntent = action == .enterFullscreen
window?.toggleFullScreen(nil)
revealControls()
case .enterCompact:
compactIntent = true
compactWindow.toggle(aspect: state.video_aspect_ratio)
controlsHovered = false
titleHovered = false
revealControls()
case .exitCompact:
compactIntent = false
compactWindow.restore()
revealControls()
case .enableAlwaysOnTop, .disableAlwaysOnTop:
isOnTop = action == .enableAlwaysOnTop
window?.level = isOnTop ? .floating : .normal
revealControls()
case .openFile: openPanel()
case .openYoutube: openYouTubePanel()
case .closeFile: closeFile()
case .quit: NSApp.terminate(nil)
case .showBufferInfo: setShowsBufferInfo(true)
case .hideBufferInfo: setShowsBufferInfo(false)
case .enableCompactSnapping: compactWindow.snappingEnabled = true
case .disableCompactSnapping: compactWindow.snappingEnabled = false
case .enableAutomaticQuality: setAutomaticYouTubeQuality(true)
case .disableAutomaticQuality: setAutomaticYouTubeQuality(false)
case .qualityDefault: setYouTubeQuality(nil)
case let .quality(height): setYouTubeQuality(height)
}
return true
}
@discardableResult
func handleKey(_ chord: KeyChord, isRepeat: Bool = false) -> Bool {
let actions = keyBindings.actions(for: chord)
guard !actions.isEmpty else { return false }
for action in actions where canPerform(action) {
if !isRepeat || action.allowsRepeat { perform(action) }
break
}
return true
}
func setPointerInsideWindow(_ inside: Bool) {
pointerInsideWindow = inside
keyboardNavigation = false
if inside {
revealControls()
} else {
controlsHovered = false
titleHovered = false
hideControls(hideCursor: false)
}
}
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
keyboardNavigation = false
withAnimation(NSWorkspace.shared.accessibilityDisplayShouldReduceMotion ? nil : .easeIn(duration: 0.25)) {
controlsVisible = false
}
NSCursor.setHiddenUntilMouseMoves(hideCursor && pointerInsideWindow && windowActive && !isCompact)
}
func beginSeeking() {
if transition.resolvingAutomaticQuality { cancelYouTubeResolution() }
automaticQuality.resetHealth()
isSeeking = true
revealControls()
}
func revealControls(forKeyboardNavigation: Bool = false) {
hideDeadline = nil
NSCursor.setHiddenUntilMouseMoves(false)
if forKeyboardNavigation { keyboardNavigation = true }
if !keyboardNavigation && !pointerInsideWindow && shouldHideControls {
hideControls(hideCursor: false)
return
}
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 && fileName != nil && (state.has_video || !pointerInsideWindow)) ||
(!pointerInsideWindow && isBusy)) &&
!isSeeking && !transition.seekPending && !controlsDragging &&
(!pointerInsideWindow || (!controlsHovered && !titleHovered)) &&
(windowActive || isCompact || !pointerInsideWindow) && 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 else { return }
transition.saveProgress(snapshot, handle: handle, force: force, history: playbackProgress)
}
private func poll() {
guard let handle else { return }
if reportingEnabled && !reportingErrorShown {
if player_get_progress_error(handle, &reportingErrorBuffer) {
showReportingError(reportingErrorMessage)
}
}
applyState(player_get_state(handle))
}
func applyState(_ next: PlayerState) {
let statusChanged = state.status != next.status
let observation = transition.advance(next,
forceCheckpoint: statusChanged && (next.status == PLAYER_PAUSED || next.status == PLAYER_ENDED),
handle: handle, history: playbackProgress)
guard observation.ready else { return }
defer { systemPlayback?.update() }
state = next
updateSubtitle()
if let playing = observation.playback { setPlayback(playing: playing) }
if !isBusy && (next.status == PLAYER_EMPTY || next.status == PLAYER_ERROR || !next.has_video) {
compactWindow.restore(resumeFullScreen: false, animated: false)
}
if !isSeeking && !transition.seekPending {
seekPosition = min(max(0, next.position.isFinite ? next.position : 0), duration)
}
fitVideoWindow()
if statusChanged || (!pointerInsideWindow && !keyboardNavigation && controlsVisible) { revealControls() }
if let hideDeadline, ContinuousClock.now >= hideDeadline { hideControls() }
updateAutomaticQuality()
if let restored = observation.restored {
if let position = restored.position { seekPosition = position }
commandError = restored.error
}
}
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: effectivePlaying, opening: transition.blocksAutomaticQuality(state.status),
seeking: isSeeking || transition.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 = transition.mediaURL else { return }
resolveYouTube(url, height: height, change: .quality(position: nil, playing: true, automatic: true))
}
private func showReportingError(_ message: String, terminating: Bool = false) {
guard !reportingErrorShown else { return }
reportingErrorShown = true
FileHandle.standardError.write(Data("terebi: \(message)\n".utf8))
let alert = NSAlert()
alert.alertStyle = .warning
alert.messageText = "Playback progress reporting failed"
alert.informativeText = message + "\n\nReporting has been disabled. Playback is unaffected."
if let window, !terminating {
alert.beginSheetModal(for: window)
} else {
alert.runModal()
}
}
func shutdown() {
saveProgress()
cancelYouTubeResolution()
selectSubtitle(nil)
systemPlayback?.shutdown()
systemPlayback = nil
compactWindow.restore(resumeFullScreen: false, animated: false)
timer?.invalidate()
timer = nil
hideDeadline = nil
NSCursor.setHiddenUntilMouseMoves(false)
guard let player = handle else { return }
if reportingEnabled {
if !player_shutdown(player, &reportingErrorBuffer) {
showReportingError(reportingErrorMessage, terminating: true)
}
}
handle = nil
player_destroy(player)
}
private enum MediaChange {
case open
case quality(position: Double?, playing: Bool, automatic: Bool)
var automatic: Bool {
if case .quality(_, _, let automatic) = self { return automatic }
return false
}
var playing: Bool {
if case .quality(_, let playing, _) = self { return playing }
return true
}
var position: Double? {
if case .quality(let position, _, _) = self { return position }
return nil
}
var isQuality: Bool {
if case .quality = self { return true }
return false
}
}
private enum MediaSource {
case file(URL)
case youtube(YouTubeVideo, height: Int?)
case resolved(ResolvedSource)
var url: URL {
switch self {
case .file(let url): return url
case .youtube(let video, _): return video.webpageURL
case .resolved(let source): return URL(string: source.identity)!
}
}
var live: Bool {
if case .youtube(let video, _) = self { return video.isLive }
return false
}
}
private struct PlaybackTransition {
enum PlaybackSubmission {
case unchanged
case deferred
case submitted(Bool)
}
private struct PlaybackRequest {
var desired: Bool
let submitted: Bool
let revision: UInt64
}
private struct Resolution {
let id: UUID
let change: MediaChange
var task: Task<Void, Never>?
}
private(set) var pendingRevision: UInt64?
private(set) var seekPending = false
private(set) var mediaURL: URL?
private(set) var isLive = false
private(set) var isNetwork = false
private var playback: PlaybackRequest?
private var resolution: Resolution?
private var restoration: (position: Double, playing: Bool)?
private var lastSavedPosition: Double?
var playbackIntent: Bool? { playback?.desired }
var isResolving: Bool { resolution != nil }
var resolvingAutomaticQuality: Bool { resolution?.change.automatic == true }
func isBusy(_ status: PlayerStatus) -> Bool {
(isResolving && !resolvingAutomaticQuality) || pendingRevision != nil || status == PLAYER_OPENING
}
func qualityChange(_ state: PlayerState, playing: Bool, automatic: Bool = false) -> MediaChange {
.quality(position: !isLive && state.can_seek ? state.position : nil,
playing: playing, automatic: automatic)
}
mutating func beginResolution(_ change: MediaChange) -> UUID {
if !change.automatic { playback = nil }
let id = UUID()
resolution = Resolution(id: id, change: change)
return id
}
mutating func attachResolution(_ task: Task<Void, Never>, request: UUID) {
guard resolution?.id == request else { task.cancel(); return }
resolution?.task = task
}
mutating func finishResolution(_ request: UUID) -> MediaChange? {
guard let resolution, resolution.id == request else { return nil }
let change = resolution.change
cancelResolution()
return change
}
mutating func cancelResolution() {
resolution?.task?.cancel()
resolution = nil
}
func canApplyAutomaticQuality(_ state: PlayerState, enabled: Bool, previewingSeek: Bool) -> Bool {
enabled && state.status == PLAYER_PLAYING && !previewingSeek && !seekPending &&
!state.seek_pending && pendingRevision == nil &&
(isLive || !state.duration.isFinite || state.duration <= 0 || state.duration - state.position > 15)
}
func blocksAutomaticQuality(_ status: PlayerStatus) -> Bool {
isBusy(status) || isResolving || restoration != nil
}
mutating func acceptOpen(revision: UInt64, source: MediaSource, change: MediaChange,
savedPosition: Double?) {
playback = nil
pendingRevision = revision
isLive = source.live
isNetwork = !source.url.isFileURL
mediaURL = source.url
// Network restoration is acknowledged by the backend open transaction.
restoration = isNetwork ? nil : savedPosition.map { ($0, change.playing) }
lastSavedPosition = nil
seekPending = false
}
mutating func advance(_ next: PlayerState, forceCheckpoint: Bool, handle: OpaquePointer?,
history: PlaybackProgress)
-> (ready: Bool, playback: Bool?, restored: (position: Double?, error: String?)?) {
if let revision = pendingRevision {
guard next.revision != revision, next.status != PLAYER_OPENING else { return (false, nil, nil) }
pendingRevision = nil
}
var followUp: Bool?
if next.status == PLAYER_EMPTY || next.status == PLAYER_ERROR {
playback = nil
} else if let request = playback, next.revision != request.revision,
next.status == PLAYER_ENDED || (next.status == PLAYER_PLAYING) == request.submitted,
next.status == PLAYER_PLAYING || next.status == PLAYER_PAUSED || next.status == PLAYER_ENDED {
playback = nil
if request.desired != request.submitted { followUp = request.desired }
}
if !next.seek_pending || next.status == PLAYER_ERROR { seekPending = false }
// Checkpoint before consuming local restoration: ready is not yet resumed.
let restored: (position: Double?, error: String?)?
if let handle {
saveProgress(next, handle: handle, force: forceCheckpoint, history: history)
restored = restoreLocalPosition(next, handle: handle, history: history)
} else {
restored = nil
}
return (true, followUp, restored)
}
mutating func setPlayback(_ playing: Bool, status: PlayerStatus, handle: OpaquePointer) -> PlaybackSubmission {
if playback != nil {
playback?.desired = playing
return .deferred
}
guard (status == PLAYER_PLAYING) != playing else { return .unchanged }
let revision = player_get_state(handle).revision
let accepted = playing ? player_play(handle) : player_pause(handle)
if accepted { playback = PlaybackRequest(desired: playing, submitted: playing, revision: revision) }
return .submitted(accepted)
}
mutating func seek(_ position: Double, handle: OpaquePointer) -> Bool {
let accepted = player_seek(handle, position)
if accepted { seekPending = true }
return accepted
}
mutating func stepFrame(_ direction: Int32, handle: OpaquePointer) -> Bool {
let accepted = player_step_frame(handle, direction)
if accepted {
seekPending = false
playback = nil
}
return accepted
}
mutating func closeMedia() {
playback = nil
isLive = false
isNetwork = false
mediaURL = nil
restoration = nil
lastSavedPosition = nil
seekPending = false
}
mutating func saveProgress(_ snapshot: PlayerState?, handle: OpaquePointer,
force: Bool, history: PlaybackProgress) {
guard let url = mediaURL, pendingRevision == nil, restoration == nil, !isLive else { return }
let state = snapshot ?? player_get_state(handle)
guard state.status == PLAYER_PAUSED || state.status == PLAYER_PLAYING || state.status == PLAYER_ENDED,
state.has_video, state.can_seek, !state.seek_pending,
state.duration.isFinite, state.duration > 0,
state.position.isFinite, state.position >= 0 else { return }
let position = state.status == PLAYER_ENDED ? 0 : min(state.position, state.duration)
guard force || lastSavedPosition == nil || abs(position - lastSavedPosition!) >= 5 else { return }
history.save(position > 0 ? position : nil, for: url)
lastSavedPosition = position
}
mutating func restoreLocalPosition(_ state: PlayerState, handle: OpaquePointer,
history: PlaybackProgress) -> (position: Double?, error: String?)? {
guard let saved = restoration else { return nil }
restoration = nil
guard state.status == PLAYER_PAUSED || state.status == PLAYER_PLAYING else { return nil }
var position: Double?
if state.has_video && state.can_seek && state.duration.isFinite && state.duration > 0 {
if saved.position < state.duration {
guard seek(saved.position, handle: handle) else {
mediaURL = nil
return (nil, "Could not resume saved progress. Please try opening the video again.")
}
position = saved.position
} else if let url = mediaURL {
history.save(nil, for: url)
}
}
let accepted = !saved.playing || player_play(handle)
return (position, accepted ? nil : "Could not queue playback. Please try again.")
}
var needsLocalRestoration: Bool { restoration != nil }
}
}
@MainActor
private enum SunsetPalette {
static let background = NSColor(srgbRed: 0.12, green: 0.09, blue: 0.08, alpha: 0.35)
static let text = NSColor(srgbRed: 1.00, green: 0.89, blue: 0.71, alpha: 1)
static let secondaryText = NSColor(srgbRed: 0.85, green: 0.78, blue: 0.65, alpha: 1)
static let accent = NSColor(srgbRed: 0.95, green: 0.58, blue: 0.23, alpha: 1)
static let ember = NSColor(srgbRed: 0.76, green: 0.27, blue: 0.18, alpha: 1)
static let outline = secondaryText.withAlphaComponent(0.22)
static let actionFill = accent.withAlphaComponent(0.08)
static let track = secondaryText.withAlphaComponent(0.18)
static let bufferedTrack = secondaryText.withAlphaComponent(0.4)
static let disabledThumb = secondaryText.withAlphaComponent(0.25)
}
@MainActor
private final class PlayerBackdrop: NSView {
var ambientGlows = false
private static let amber = NSGradient(colors: [
SunsetPalette.accent.withAlphaComponent(0.18),
SunsetPalette.accent.withAlphaComponent(0.065),
SunsetPalette.accent.withAlphaComponent(0),
])!
private static let ember = NSGradient(colors: [
SunsetPalette.ember.withAlphaComponent(0.26),
SunsetPalette.ember.withAlphaComponent(0.10),
SunsetPalette.ember.withAlphaComponent(0),
])!
override func hitTest(_ point: NSPoint) -> NSView? { nil }
override func draw(_ dirtyRect: NSRect) {
SunsetPalette.background.setFill()
dirtyRect.fill()
guard ambientGlows else { return }
let upperGlow = NSPoint(x: bounds.width * 0.58, y: bounds.height * 0.94)
Self.ember.draw(fromCenter: upperGlow, radius: 0, toCenter: upperGlow,
radius: bounds.width * 0.40, options: [])
let lowerGlow = NSPoint(x: bounds.width * 0.88, y: bounds.height * 0.10)
Self.amber.draw(fromCenter: lowerGlow, radius: 0, toCenter: lowerGlow,
radius: bounds.width * 0.55, options: [])
let leftGlow = NSPoint(x: bounds.width * 0.08, y: bounds.height * 0.65)
NSGraphicsContext.saveGraphicsState()
NSGraphicsContext.current?.cgContext.setAlpha(0.45)
Self.amber.draw(fromCenter: leftGlow, radius: 0, toCenter: leftGlow,
radius: bounds.width * 0.32, options: [])
NSGraphicsContext.restoreGraphicsState()
}
}
@MainActor
private struct PlayerGlass: NSViewRepresentable {
var blendingMode: NSVisualEffectView.BlendingMode = .withinWindow
var cornerRadius: CGFloat = 12
var ambientGlows = false
func makeCoordinator() -> PlayerBackdrop { PlayerBackdrop(frame: .zero) }
func makeNSView(context: Context) -> NSVisualEffectView {
let view = NSVisualEffectView()
view.blendingMode = blendingMode
view.state = .active
view.appearance = NSAppearance(named: .vibrantDark)
view.material = .hudWindow
view.wantsLayer = true
view.layer?.cornerRadius = cornerRadius
view.layer?.masksToBounds = true
view.layer?.borderWidth = cornerRadius > 0 ? 0.5 : 0
view.layer?.borderColor = SunsetPalette.outline.cgColor
let backdrop = context.coordinator
backdrop.frame = view.bounds
backdrop.autoresizingMask = [.width, .height]
backdrop.ambientGlows = ambientGlows
backdrop.setAccessibilityElement(false)
view.addSubview(backdrop)
return view
}
func updateNSView(_ view: NSVisualEffectView, context: Context) {
view.blendingMode = blendingMode
if context.coordinator.ambientGlows != ambientGlows {
context.coordinator.ambientGlows = ambientGlows
context.coordinator.needsDisplay = true
}
}
}
@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)
SunsetPalette.track.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)
SunsetPalette.accent.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)
SunsetPalette.bufferedTrack.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 = isEnabled ? (volume ? SunsetPalette.text : SunsetPalette.secondaryText)
: SunsetPalette.disabledThumb
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 CaptionSelector: NSViewRepresentable {
let model: PlayerModel
func makeNSView(context: Context) -> Selector { Selector(model: model) }
func updateNSView(_ view: Selector, context: Context) { view.updateTracks() }
final class Selector: NSPopUpButton {
private let model: PlayerModel
private var displayedTracks: [ResolvedSubtitle] = []
private var displayedSelection: Int?
private let captionItem = NSMenuItem(title: "", action: nil, keyEquivalent: "")
init(model: PlayerModel) {
self.model = model
super.init(frame: NSRect(x: 0, y: 0, width: 24, height: 24), pullsDown: true)
usesItemFromMenu = false
preferredEdge = .maxY
isBordered = false
imagePosition = .imageOnly
imageScaling = .scaleProportionallyUpOrDown
(cell as? NSPopUpButtonCell)?.arrowPosition = .noArrow
let symbol = NSImage(systemSymbolName: "captions.bubble", accessibilityDescription: "Subtitles")?
.withSymbolConfiguration(.init(pointSize: 22, weight: .regular))
symbol?.size = NSSize(width: 24, height: 24)
captionItem.image = symbol
contentTintColor = SunsetPalette.text
toolTip = "Subtitles"
setAccessibilityLabel("Subtitles")
updateTracks()
}
required init?(coder: NSCoder) { nil }
func updateTracks() {
guard menu == nil || displayedSelection != model.selectedSubtitle ||
displayedTracks.count != model.subtitleTracks.count ||
zip(displayedTracks, model.subtitleTracks).contains(where: {
$0.label != $1.label || $0.language != $1.language
}) else { return }
displayedTracks = model.subtitleTracks
displayedSelection = model.selectedSubtitle
let tracks = NSMenu()
tracks.autoenablesItems = false
func add(_ label: String, index: Int?) {
let item = NSMenuItem(title: label, action: #selector(selectTrack(_:)), keyEquivalent: "")
item.target = self
item.tag = index ?? -1
item.state = model.selectedSubtitle == index ? .on : .off
tracks.addItem(item)
}
add("Off", index: nil)
for (index, track) in model.subtitleTracks.enumerated() {
add(track.label + " (" + track.language + ")", index: index)
}
menu = tracks
(cell as? NSPopUpButtonCell)?.menuItem = captionItem
isEnabled = !model.subtitleTracks.isEmpty
}
@objc private func selectTrack(_ item: NSMenuItem) {
model.selectSubtitle(item.tag < 0 ? nil : item.tag)
model.revealControls()
}
}
}
@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
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var showsRemainingTime = false
@State private var dropTargeted = false
@State private var showsYouTubeInput = false
@State private var youTubeURL = ""
@FocusState private var youTubeInputFocused: Bool
@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 compactTitleLabel(progress: String?, constrained: Bool) -> some View {
HStack(spacing: 0) {
Text(model.fileName ?? "terebi")
.lineLimit(1)
.truncationMode(.tail)
.frame(minWidth: 0, maxWidth: constrained ? .infinity : nil, alignment: .leading)
.clipped()
if let progress {
Text(" · \(progress)")
.monospacedDigit()
.accessibilityLabel(model.compactTitleProgress == .percentage ? "\(progress) percent" : progress)
.fixedSize(horizontal: true, vertical: false)
}
}
}
private func toggleYouTubeInput() {
if showsYouTubeInput { youTubeInputFocused = false }
withAnimation(reduceMotion ? nil : .easeInOut(duration: 0.22)) {
showsYouTubeInput.toggle()
}
if showsYouTubeInput { youTubeInputFocused = true }
}
private var showsBufferReadout: Bool { model.isNetworkMedia && model.showsBufferInfo }
private var controlHeight: CGFloat { showsBufferReadout ? 88 : 68 }
private var compactControlHeight: CGFloat { showsBufferReadout ? 52 : 40 }
private var compactChromeAnimation: Animation? {
reduceMotion || !model.compactWindow.isTransitioning
? nil : .easeInOut(duration: CompactWindowController.transitionDuration)
}
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(Color(SunsetPalette.secondaryText))
.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) {
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")
}
.padding(.horizontal, 8)
.frame(height: 24)
.background(PlayerGlass(cornerRadius: 8).clipShape(RoundedRectangle(cornerRadius: 8)).allowsHitTesting(false))
Spacer(minLength: 12)
titleControls(compact: false)
}
.padding(.leading, model.isFullScreen ? 16 : 86)
.padding(.trailing, 12)
.frame(height: 32)
.foregroundStyle(Color(SunsetPalette.text))
.onHover(perform: model.setTitleHovered)
.opacity(model.controlsVisible ? 1 : 0)
.allowsHitTesting(model.controlsVisible)
.accessibilityHidden(!model.controlsVisible)
}
private func titleControls(compact: Bool) -> some View {
HStack(spacing: 4) {
iconButton(model.isOnTop ? "pin.fill" : "pin",
model.isOnTop ? "Stop Keeping Window on Top" : "Keep Window on Top", action: model.toggleOnTop)
.imageScale(.small)
.foregroundStyle(Color(model.isOnTop ? SunsetPalette.accent : SunsetPalette.secondaryText))
.disabled(model.isFullScreen || model.compactWindow.isTransitioning)
actionsMenu
}
.padding(.horizontal, 8)
.frame(height: compact ? 28 : 24)
.background(PlayerGlass(cornerRadius: compact ? 10 : 8)
.clipShape(RoundedRectangle(cornerRadius: compact ? 10 : 8)).allowsHitTesting(false))
}
private var captionsMenu: some View {
CaptionSelector(model: model)
.frame(width: 24, height: 24)
}
private var actionsMenu: some View {
Menu {
PlayerActionButton(model: model, action: .openFile, title: "Open File…")
PlayerActionButton(model: model, action: .openYoutube, title: "Open YouTube URL…")
PlayerActionButton(model: model, action: .closeFile, title: "Close File")
Divider()
PlayerQualityMenu(model: model)
Divider()
PlayerActionButton(model: model, action: model.bufferInfoAction,
title: model.showsBufferInfo ? "Hide Buffer Info" : "Show Buffer Info")
Divider()
PlayerActionButton(model: model, action: model.compactAction,
title: model.compactAction == .exitCompact ? "Exit Compact Mode" : "Enter Compact Mode")
PlayerActionButton(model: model, action: model.snappingAction,
title: model.compactWindow.snappingEnabled ? "Disable Compact Snapping" : "Enable Compact Snapping")
Divider()
PlayerActionButton(model: model, action: model.onTopAction,
title: model.isOnTop ? "Stop Keeping Window on Top" : "Keep Window on Top")
} 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.effectivePlaying ? "pause.fill" : "play.fill",
model.effectivePlaying ? "Pause" : "Play", action: model.togglePlayback)
.disabled(!model.canTogglePlayback)
iconButton("pip.exit", "Exit Compact Mode", action: model.toggleCompact)
.disabled(!model.canToggleCompact)
captionsMenu
}
if showsBufferReadout { bufferReadout }
}
.padding(.horizontal, 12)
.padding(.vertical, showsBufferReadout ? 6 : 8)
.foregroundStyle(Color(SunsetPalette.text))
.background(PlayerGlass(cornerRadius: 10).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.effectivePlaying ? "pause.fill" : "play.fill")
.font(.system(size: 24))
.frame(width: 24, height: 24)
}
.buttonStyle(.plain)
.accessibilityLabel(model.effectivePlaying ? "Pause" : "Play")
.help(model.effectivePlaying ? "Pause" : "Play")
.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)
captionsMenu
}
.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())
.foregroundStyle(Color(SunsetPalette.secondaryText))
.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(Color(SunsetPalette.text))
.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) {
if model.fileName == nil {
PlayerGlass(blendingMode: .behindWindow, cornerRadius: 0,
ambientGlows: !model.isBusy && model.errorText == nil)
.allowsHitTesting(false)
.accessibilityHidden(true)
} else {
Color.black
}
if let view = model.videoView {
VideoSurface(view: view)
.accessibilityLabel("Video")
.opacity(model.fileName == nil ? 0 : 1)
.gesture(
TapGesture(count: 2).onEnded { model.toggleFullScreen() }
.exclusively(before: TapGesture().onEnded { model.hideControls() })
)
}
if model.isCompact {
CompactDragSurface(model: model).accessibilityHidden(true)
}
if !model.subtitleText.isEmpty {
Text(model.subtitleText)
.font(.system(size: max(16, min(28, geometry.size.width / 32)), weight: .semibold))
.foregroundStyle(.white)
.multilineTextAlignment(.center)
.padding(8)
.background(PlayerGlass().clipShape(RoundedRectangle(cornerRadius: 12)).allowsHitTesting(false))
.padding(.horizontal, 24)
.padding(.bottom, model.controlsVisible ? 110 : 24)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
.allowsHitTesting(false)
}
if let error = model.subtitleError {
Text(error)
.font(.caption)
.foregroundStyle(.yellow)
.padding(8)
.background(PlayerGlass().clipShape(RoundedRectangle(cornerRadius: 12)).allowsHitTesting(false))
.padding(.top, 44).padding(.trailing, 16)
.frame(maxWidth: .infinity, alignment: .trailing)
}
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(PlayerGlass().clipShape(RoundedRectangle(cornerRadius: 12)).allowsHitTesting(false))
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if model.isBusy {
VStack(spacing: 12) {
ProgressView().controlSize(.large)
Text(model.resolvingYouTube ? "Resolving YouTube…" : "Opening…")
.font(.callout).foregroundStyle(Color(SunsetPalette.secondaryText))
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if model.fileName == nil {
VStack(spacing: 18) {
HStack(spacing: 12) {
Image(systemName: "play.rectangle.fill")
.font(.system(size: 28))
.frame(width: 40, height: 40)
.foregroundStyle(Color(SunsetPalette.accent))
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 4) {
Text("terebi").font(.system(size: 20, weight: .semibold))
.foregroundStyle(Color(SunsetPalette.text))
Text("Open media to get started")
.font(.callout)
.foregroundStyle(Color(SunsetPalette.secondaryText))
}
Spacer(minLength: 0)
}
HStack(spacing: 10) {
Button(action: model.openPanel) {
Label("Open File…", systemImage: "doc")
.frame(maxWidth: .infinity, minHeight: 40)
.contentShape(Rectangle())
}
.background(Color(SunsetPalette.actionFill), in: RoundedRectangle(cornerRadius: 8))
.overlay(RoundedRectangle(cornerRadius: 8)
.strokeBorder(Color(SunsetPalette.outline), lineWidth: 0.5)
.allowsHitTesting(false))
Button(action: toggleYouTubeInput) {
Label("YouTube URL…", systemImage: "link")
.frame(maxWidth: .infinity, minHeight: 40)
.contentShape(Rectangle())
}
.accessibilityLabel("Open YouTube URL…")
.background(Color(SunsetPalette.actionFill), in: RoundedRectangle(cornerRadius: 8))
.overlay(RoundedRectangle(cornerRadius: 8)
.strokeBorder(Color(SunsetPalette.outline), lineWidth: 0.5)
.allowsHitTesting(false))
}
.font(.system(size: 13, weight: .medium))
.foregroundStyle(Color(SunsetPalette.text))
.imageScale(.large)
.buttonStyle(.plain)
.disabled(!model.canOpen)
if showsYouTubeInput {
HStack(spacing: 8) {
TextField("YouTube video URL", text: $youTubeURL,
prompt: Text("YouTube video URL")
.foregroundStyle(Color(SunsetPalette.secondaryText).opacity(0.6)))
.textFieldStyle(.plain)
.focused($youTubeInputFocused)
.accessibilityLabel("YouTube video URL")
.onAppear { youTubeInputFocused = true }
.onSubmit { model.openYouTube(youTubeURL) }
Button {
model.openYouTube(youTubeURL)
} label: {
Image(systemName: "arrow.right")
.frame(width: 24, height: 24)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityLabel("Open YouTube Video")
.help("Open YouTube Video")
.disabled(youTubeURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
.font(.system(size: 12))
.foregroundStyle(Color(SunsetPalette.text))
.padding(.horizontal, 12)
.padding(.vertical, 4)
.background(Color(SunsetPalette.actionFill), in: RoundedRectangle(cornerRadius: 8))
.overlay(RoundedRectangle(cornerRadius: 8)
.strokeBorder(Color(SunsetPalette.outline), lineWidth: 0.5)
.allowsHitTesting(false))
.transition(.opacity)
.disabled(!model.canOpen)
}
Text("Drop a media file or YouTube link here")
.font(.system(size: 12))
.foregroundStyle(Color(SunsetPalette.secondaryText))
.multilineTextAlignment(.center)
}
.padding(20)
.frame(width: min(440, geometry.size.width - 48))
.background(PlayerGlass().clipShape(RoundedRectangle(cornerRadius: 12)).allowsHitTesting(false))
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(.top, 32)
} 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(Color(SunsetPalette.secondaryText))
.padding(40)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
if model.state.buffering && !model.isBusy && model.errorText == nil {
ProgressView("Buffering…")
.padding(12)
.background(PlayerGlass(cornerRadius: 10).clipShape(RoundedRectangle(cornerRadius: 10)))
.position(x: geometry.size.width / 2, y: geometry.size.height / 2)
.allowsHitTesting(false)
}
let progress = model.isCompact || model.compactWindow.isTransitioning ? model.compactProgress : nil
ZStack(alignment: .top) {
HStack(spacing: 8) {
ViewThatFits(in: .horizontal) {
compactTitleLabel(progress: progress, constrained: false)
.fixedSize(horizontal: true, vertical: false)
compactTitleLabel(progress: progress, constrained: true)
}
.font(.system(size: 11, weight: .medium))
.accessibilityElement(children: .combine)
.help((model.fileName ?? "terebi") + (progress.map { " · \($0)" } ?? ""))
.padding(.horizontal, 8)
.frame(height: 28)
.background(PlayerGlass(cornerRadius: 10).clipShape(RoundedRectangle(cornerRadius: 10)).allowsHitTesting(false))
.allowsHitTesting(false)
Spacer(minLength: 0)
titleControls(compact: true)
}
.padding(8)
.foregroundStyle(Color(SunsetPalette.text))
.onHover(perform: model.setTitleHovered)
.opacity(model.controlsVisible ? 1 : 0)
.allowsHitTesting(model.controlsVisible)
.accessibilityHidden(!model.controlsVisible)
compactControls
.position(x: geometry.size.width / 2, y: geometry.size.height - compactControlHeight / 2 - 8)
}
.animation(compactChromeAnimation) { content in
content.opacity(model.isCompact ? 1 : 0)
}
.allowsHitTesting(model.isCompact)
.accessibilityHidden(!model.isCompact)
titlebar
.frame(width: geometry.size.width)
.animation(compactChromeAnimation) { content in
content.opacity(model.isCompact ? 0 : 1)
}
.allowsHitTesting(!model.isCompact && model.controlsVisible)
.accessibilityHidden(model.isCompact || !model.controlsVisible)
if model.canTogglePlayback {
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)
.animation(compactChromeAnimation) { content in
content.opacity(model.isCompact ? 0 : 1)
}
.allowsHitTesting(!model.isCompact && model.controlsVisible)
.accessibilityHidden(model.isCompact || !model.controlsVisible)
}
if dropTargeted {
RoundedRectangle(cornerRadius: 10)
.strokeBorder(Color(SunsetPalette.accent), 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:
model.setPointerInsideWindow(true)
case .ended: model.setPointerInsideWindow(false)
}
}
}
.frame(minWidth: model.isCompact || model.compactWindow.isTransitioning ? 160 : 400,
minHeight: model.isCompact || model.compactWindow.isTransitioning ? model.compactContentMinimumHeight : 240)
.ignoresSafeArea()
.preferredColorScheme(.dark)
.foregroundStyle(Color(SunsetPalette.text))
.tint(Color(SunsetPalette.accent))
.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 let window = configuredWindow else { return false }
return model.handleKeyEvent(event, in: window)
}
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.titlebarSeparatorStyle = .none
window.isOpaque = false
window.backgroundColor = .clear
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(forKeyboardNavigation: true)
}
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 let index = arguments.firstIndex(of: "--open-media"), index + 1 < arguments.count,
let url = URL(string: arguments[index + 1]) {
PlayerModel.shared.open(url)
}
if let index = arguments.firstIndex(of: "--source-file"), index + 1 < arguments.count {
PlayerModel.shared.openResolvedSource(URL(fileURLWithPath: arguments[index + 1]))
}
}
// 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)
.focusedSceneObject(model)
}
.defaultSize(width: 900, height: 560)
.windowStyle(.hiddenTitleBar)
.commands { PlayerCommands() }
}
}
#if !PLAYER_CHECK
@_cdecl("terebi_app_main")
#endif
@MainActor
public func terebiAppMain() {
do {
PlayerModel.startupConfiguration = try PlayerConfiguration.loadForApplication(
arguments: Array(CommandLine.arguments.dropFirst()),
environment: ProcessInfo.processInfo.environment,
homeDirectory: FileManager.default.homeDirectoryForCurrentUser)
} catch {
FileHandle.standardError.write(Data("terebi: \(error.localizedDescription)\n".utf8))
NSApplication.shared.setActivationPolicy(.regular)
NSApp.activate(ignoringOtherApps: true)
let alert = NSAlert()
alert.alertStyle = .critical
alert.messageText = "Could not load terebi configuration"
alert.informativeText = error.localizedDescription
alert.runModal()
exit(78)
}
let arguments = Array(CommandLine.arguments.dropFirst())
if let index = arguments.firstIndex(of: "--progress-file") {
guard index + 1 < arguments.count, arguments[index + 1].hasPrefix("/"),
arguments.filter({ $0 == "--progress-file" }).count == 1 else {
let alert = NSAlert()
alert.alertStyle = .critical
alert.messageText = "Could not initialize playback progress reporting"
alert.informativeText = "--progress-file requires one absolute filesystem path."
alert.runModal()
exit(73)
}
PlayerModel.startupProgressFile = arguments[index + 1]
_ = NSApplication.shared
if let error = PlayerModel.shared.progressInitializationError {
PlayerModel.shared.shutdown()
FileHandle.standardError.write(Data("terebi: \(error)\n".utf8))
NSApp.setActivationPolicy(.regular)
NSApp.activate(ignoringOtherApps: true)
let alert = NSAlert()
alert.alertStyle = .critical
alert.messageText = "Could not initialize playback progress reporting"
alert.informativeText = error
alert.runModal()
exit(73)
}
}
TerebiApp.main()
}