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 { let tenths = Int((position / duration * 1000).rounded(.down)) if tenths == 999 { return "100.0" } return "\(tenths / 10).\(tenths % 10)" } 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? @Published private(set) var queue = PlaybackQueue() @Published var showsQueue = false { didSet { guard showsQueue != oldValue, let window, !isFullScreen, !isCompact, !compactWindow.isTransitioning else { return } // Setting aspectRatio to zero leaves AppKit's resize increments at zero. window.resizeIncrements = NSSize(width: 1, height: 1) var frame = window.frame frame.size.width += showsQueue ? 260 : -260 if let screen = window.screen?.visibleFrame { frame.origin.x = max(screen.minX, min(frame.minX, screen.maxX - frame.width)) } window.setFrame(frame, display: true) if !showsQueue, state.has_video, state.video_aspect_ratio > 0 { window.aspectRatio = NSSize(width: state.video_aspect_ratio, height: 1) } } } private var queuePlaybackRevision: UInt64? private var queueCompletionHandled = false private var queueCastDevice: CastDevice? var canPreviousQueueItem: Bool { canOpen && queue.canPrevious } var canNextQueueItem: Bool { canOpen && queue.canNext } private var transition = PlaybackTransition() let castDiscovery = CastDiscovery() private lazy var casting = CastPlaybackTransition { [weak self] event in self?.receiveCast(event) } var castDevice: CastDevice? { casting.device } var castConnecting: Bool { casting.connecting } private var currentMedia: MediaSource? 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? @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? { casting.active ? casting.playbackIntent : 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.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 { castConnecting || (!casting.active && 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 { castDevice == nil && canTogglePlayback && state.has_video && state.can_seek && !isSeeking } var canChangeYouTubeQuality: Bool { castDevice == nil && 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 canCast: Bool { castDevice == nil && currentMedia != nil && canTogglePlayback && !isSeeking && !state.seek_pending && !transition.seekPending && transition.playbackIntent == nil && !castConnecting } var duration: Double { state.duration.isFinite ? max(0, state.duration) : 0 } var bufferedRange: ClosedRange? { 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 cast(to device: CastDevice, playing: Bool? = nil) { guard canCast, let source = currentMedia, let handle else { return } cancelYouTubeResolution() commandError = nil casting.start(to: device, source: source, title: fileName ?? "Terebi", subtitle: selectedSubtitle, handle: handle, snapshot: state, playing: playing ?? effectivePlaying, volume: volume, audibleVolume: lastAudibleVolume) revealControls() } func stopCasting(resume: Bool = true) { queueCastDevice = nil casting.stop(resume: resume) } private func receiveCast(_ event: CastPlaybackTransition.Event) { objectWillChange.send() switch event { case .changed: break case .activated: transition.acceptCast() subtitleTask?.cancel() subtitleText = "" case .status(let projection): state = projection.state volume = projection.volume if volume > 0 { lastAudibleVolume = volume } selectedSubtitle = projection.subtitle if !isSeeking && !state.seek_pending { seekPosition = state.position } if let handle { _ = player_report_remote_state(handle, &state) } saveProgress() completeQueuePlaybackIfNeeded() case .stopped(let result): if let checkpoint = result.checkpoint { saveCastProgress(checkpoint, source: result.source) } else { saveProgress() } if let revision = result.suspendedRevision { transition.suspendHistory(revision: revision) } volume = result.volume lastAudibleVolume = result.audibleVolume if let restore = result.restore { openMedia(result.source, change: .restore(position: restore.position, playing: restore.playing)) } revealControls() case .error(let message): commandError = message revealControls() } systemPlayback?.update() } @discardableResult private func seekCast(to position: Double) -> Bool { let accepted = casting.seek(position) if accepted { seekPosition = position } commandError = accepted ? nil : "Could not seek Cast playback." revealControls() return accepted } func openPanel() { filePanel(append: false) } func addQueuePanel() { filePanel(append: true) } private func filePanel(append: Bool) { guard canOpen else { return } defer { window?.makeFirstResponder(nil) } revealControls() let panel = NSOpenPanel() panel.canChooseFiles = true panel.canChooseDirectories = false panel.allowsMultipleSelection = true panel.prompt = append ? "Add to Queue" : "Open" guard panel.runModal() == .OK else { return } if append { addToQueue(panel.urls) } else { openFiles(panel.urls) } } 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 } clearQueue() resolveYouTube(url, height: nil) } func openResolvedSource(_ file: URL) { do { let source = try ResolvedSource.load(from: file) clearQueue() cancelYouTubeResolution() openMedia(.resolved(source)) } catch { commandError = error.localizedDescription revealControls() } } func selectSubtitle(_ index: Int?) { if casting.active { if !casting.setSubtitle(index) { commandError = "Could not change Cast subtitles." } return } 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() { if castDevice != nil { subtitleText = ""; return } 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 } if castDevice != nil { stopCasting(resume: false) } 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 } if castDevice != nil { stopCasting(resume: false) } saveProgress() let revision = player_get_state(handle).revision let savedPosition = change.preservesPosition ? 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?, audioHeaders: UnsafePointer?) -> 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) queuePlaybackRevision = queue.currentID == nil ? nil : player_get_state(handle).revision queueCompletionHandled = false currentMedia = source 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) { if url.isFileURL { openFiles([url]) } else { openYouTube(url.absoluteString) } } func openFiles(_ urls: [URL]) { guard canOpen, urls.contains(where: \.isFileURL) else { return } queueCastDevice = nil queue.replace(urls) showsQueue = queue.items.count > 1 openQueueSelection(keepingReceiver: false) } func addToQueue(_ urls: [URL]) { guard canOpen else { return } let wasEmpty = queue.items.isEmpty queue.append(urls) guard !queue.items.isEmpty else { return } showsQueue = true if wasEmpty, fileName == nil, let first = queue.items.first { queue.select(first.id) openQueueSelection() } } func selectQueueItem(_ id: UUID) { guard canOpen, queue.items.contains(where: { $0.id == id }) else { return } queue.select(id) openQueueSelection() } func advanceQueue(_ offset: Int) { guard canOpen, queue.advance(offset) != nil else { return } openQueueSelection() } func removeQueueItem(_ id: UUID) { let wasCurrent = queue.currentID == id let next = queue.remove(id) guard wasCurrent else { return } if next != nil { openQueueSelection() } else { closeFile(clearQueue: false) } } func moveQueueItems(fromOffsets offsets: IndexSet, toOffset destination: Int) { queue.move(fromOffsets: offsets, toOffset: destination) } private func openQueueSelection(keepingReceiver: Bool = true) { guard let url = queue.currentURL else { return } let receiver = keepingReceiver ? (castDevice ?? queueCastDevice) : nil queuePlaybackRevision = nil queueCompletionHandled = true cancelYouTubeResolution() openMedia(.file(url), change: .queue(playing: receiver == nil)) queueCastDevice = receiver } private func clearQueue() { queue.replace([]) queuePlaybackRevision = nil queueCompletionHandled = false queueCastDevice = nil } private func completeQueuePlaybackIfNeeded() { guard queue.currentID != nil, queuePlaybackRevision != nil, !isBusy else { return } if state.status == PLAYER_PLAYING { queueCompletionHandled = false } guard !queueCompletionHandled, !state.seek_pending, !transition.seekPending else { return } if state.status == PLAYER_ERROR { queueCompletionHandled = true queueCastDevice = nil } else if state.status == PLAYER_ENDED { queueCompletionHandled = true if queue.canNext { advanceQueue(1) } } } func togglePlayback() { perform(playbackAction) } @discardableResult func setPlayback(playing: Bool) -> Bool { if casting.active { guard canTogglePlayback else { return false } let accepted = casting.setPlaying(playing) commandError = accepted ? nil : "Could not change Cast playback." revealControls() return accepted } 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 } if casting.active { seekCast(to: min(max(0, seekPosition), duration)) 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(clearQueue: Bool = true) { guard let handle, canClose else { return } saveProgress() if castDevice != nil { stopCasting(resume: false) } cancelYouTubeResolution() automaticQuality = AutomaticQuality() guard player_close(handle) else { commandError = "Could not queue closing the file. Please try again." return } transition.closeMedia() if clearQueue { self.clearQueue() } queuePlaybackRevision = nil queueCompletionHandled = true queueCastDevice = nil currentMedia = nil selectSubtitle(nil) subtitleTracks = [] compactIntent = nil compactWindow.restore(resumeFullScreen: false, animated: false) fileName = nil fileIcon = nil youtubeHeights = [] youtubeHeight = nil youtubeQuality = nil needsVideoFit = false window?.resizeIncrements = NSSize(width: 1, height: 1) 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 || state.seek_pending) ? seekPosition : state.position) + seconds), duration) let accepted = casting.active ? seekCast(to: target) : transition.seek(target, handle: handle) if accepted { seekPosition = target } commandError = accepted ? nil : "Could not queue the seek. Please try again." if !accepted { 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) { if casting.active { if casting.setVolume(value) { volume = value if volume > 0 { lastAudibleVolume = volume } } revealControls() return } 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.normalVideoLayout = { [weak self] in guard let self, self.showsQueue, self.state.has_video else { return nil } return (self.state.video_aspect_ratio, 260) } 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 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 { controlsVisible = true } guard shouldHideControls else { return } hideDeadline = ContinuousClock.now.advanced(by: .milliseconds(2500)) } private var shouldHideControls: Bool { castDevice == nil && ((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.resizeIncrements = NSSize(width: 1, height: 1) return } let screen = (window.screen ?? NSScreen.main)?.visibleFrame ?? window.frame let sidebarWidth: CGFloat = showsQueue ? 260 : 0 let width = min(window.frame.width - sidebarWidth, screen.width - sidebarWidth, screen.height * aspect) let size = NSSize(width: width + sidebarWidth, 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.resizeIncrements = NSSize(width: 1, height: 1) return } if showsQueue { window.resizeIncrements = NSSize(width: 1, height: 1) } else { 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 } if let checkpoint = casting.checkpoint { saveCastProgress(checkpoint.state, source: checkpoint.source) } else { transition.saveProgress(snapshot, handle: handle, force: force, history: playbackProgress) } } private func saveCastProgress(_ state: PlayerState, source: MediaSource) { guard !source.live, state.has_video, state.can_seek, !state.seek_pending, state.duration > 0, state.position.isFinite else { return } playbackProgress.save(state.status == PLAYER_ENDED ? nil : state.position, for: source.url) } private func poll() { guard let handle else { return } if reportingEnabled && !reportingErrorShown { if player_get_progress_error(handle, &reportingErrorBuffer) { showReportingError(reportingErrorMessage) } } if !casting.active { applyState(player_get_state(handle)) } } func applyState(_ next: PlayerState) { guard !casting.active else { return } if let revision = queuePlaybackRevision, next.revision < revision { return } 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 } if let device = queueCastDevice, !isBusy, !transition.seekPending, !state.seek_pending { if state.status == PLAYER_ERROR { queueCastDevice = nil } else if canCast { queueCastDevice = nil cast(to: device, playing: true) } } completeQueuePlaybackIfNeeded() } private func updateAutomaticQuality() { guard castDevice == nil else { return } 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() if castDevice != nil { stopCasting(resume: false) } castDiscovery.stop() 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) } @MainActor private final class CastPlaybackTransition { struct Projection { var state: PlayerState var volume: Double var subtitle: Int? } struct Return { let source: MediaSource let checkpoint: PlayerState? let suspendedRevision: UInt64? let volume: Double let audibleVolume: Double let restore: (position: Double, playing: Bool)? } enum Event { case changed case activated case status(Projection) case stopped(Return) case error(String) } private enum HandoffError: LocalizedError { case pause, acknowledgement, readiness var errorDescription: String? { switch self { case .pause: "Could not pause local playback for casting." case .acknowledgement: "Local playback did not acknowledge the Cast handoff." case .readiness: "Local playback is not ready to cast." } } } @MainActor private final class Operation { let device: CastDevice let source: MediaSource let title: String let subtitle: Int? let handle: OpaquePointer let snapshot: PlayerState let playing: Bool let volume: Double let audibleVolume: Double let session = CastSession() let server = CastMediaServer() var task: Task? var initialStatus: CastStatus? var projection: Projection? var intent: Bool? init(device: CastDevice, source: MediaSource, title: String, subtitle: Int?, handle: OpaquePointer, snapshot: PlayerState, playing: Bool, volume: Double, audibleVolume: Double) { self.device = device self.source = source self.title = title self.subtitle = subtitle self.handle = handle self.snapshot = snapshot self.playing = playing self.volume = volume self.audibleVolume = audibleVolume } } private var operation: Operation? private let observe: (Event) -> Void init(observe: @escaping (Event) -> Void) { self.observe = observe } var device: CastDevice? { operation?.device } var connecting: Bool { operation != nil && !active } var active: Bool { operation?.projection != nil } var playbackIntent: Bool? { operation?.intent } var checkpoint: (source: MediaSource, state: PlayerState)? { guard let operation, let projection = operation.projection else { return nil } return (operation.source, projection.state) } func start(to device: CastDevice, source: MediaSource, title: String, subtitle: Int?, handle: OpaquePointer, snapshot: PlayerState, playing: Bool, volume: Double, audibleVolume: Double) { guard operation == nil else { return } let operation = Operation(device: device, source: source, title: title, subtitle: subtitle, handle: handle, snapshot: snapshot, playing: playing, volume: volume, audibleVolume: audibleVolume) self.operation = operation operation.session.onStatus = { [weak self, weak operation] status in guard let self, let operation, self.operation === operation else { return } if operation.projection == nil { operation.initialStatus = status } else { self.receive(status, operation: operation) } } operation.session.onDisconnect = { [weak self, weak operation] message in guard let self, let operation, self.operation === operation else { return } self.stop() if let message { self.observe(.error(message)) } } operation.session.onError = { [weak self, weak operation] message in guard let self, let operation, self.operation === operation else { return } if !operation.session.playbackPending { operation.intent = nil } self.observe(.error(message)) } observe(.changed) operation.task = Task { [weak self] in await self?.connect(operation) } } private func connect(_ operation: Operation) async { do { try check(operation) try await operation.session.connect(to: operation.device) try check(operation) guard player_pause(operation.handle) else { throw HandoffError.pause } let deadline = ContinuousClock.now.advanced(by: .seconds(5)) while player_get_state(operation.handle).status == PLAYER_PLAYING { guard ContinuousClock.now < deadline else { throw HandoffError.acknowledgement } try await Task.sleep(for: .milliseconds(20)) try check(operation) } let snapshot = player_get_state(operation.handle) guard snapshot.status == PLAYER_PAUSED || snapshot.status == PLAYER_ENDED else { throw HandoffError.readiness } let position = snapshot.can_seek && !operation.source.live && snapshot.status != PLAYER_ENDED ? snapshot.position : 0 let media = try await operation.server.start(input: input(operation.source, duration: snapshot.duration), receiverHost: operation.device.host) try check(operation) try await operation.session.load(media, title: operation.title, position: position, playing: operation.playing, subtitle: operation.subtitle) try check(operation) var state = operation.snapshot state.buffered_start = 0 state.buffered_end = 0 operation.projection = Projection(state: state, volume: operation.volume, subtitle: operation.subtitle) operation.task = nil observe(.activated) if let status = operation.initialStatus { operation.initialStatus = nil receive(status, operation: operation) } else if let projection = operation.projection { observe(.status(projection)) } } catch { guard self.operation === operation else { return } stop() if !(error is CancellationError) { observe(.error(error.localizedDescription)) } } } private func check(_ operation: Operation) throws { try Task.checkCancellation() guard self.operation === operation else { throw CancellationError() } } func stop(resume: Bool = true) { guard let operation else { return } let projection = operation.projection let playing = operation.intent ?? projection.map { $0.state.status == PLAYER_PLAYING } ?? operation.playing let position = projection.map { $0.state.can_seek && !operation.source.live && $0.state.status != PLAYER_ENDED ? $0.state.position : 0 } ?? 0 let revision = projection == nil ? nil : player_get_state(operation.handle).revision // Invalidate before teardown callbacks or any suspended task resumes. All native // accesses are main-actor synchronous, with check() after every suspension: // shutdown can destroy the player immediately after stop(resume: false). self.operation = nil operation.task?.cancel() operation.task = nil operation.session.onStatus = nil operation.session.onDisconnect = nil operation.session.onError = nil operation.session.disconnect() operation.server.stop() _ = player_set_volume(operation.handle, Float(operation.volume)) if resume && projection == nil { _ = operation.playing ? player_play(operation.handle) : player_pause(operation.handle) } observe(.stopped(Return(source: operation.source, checkpoint: projection?.state, suspendedRevision: revision, volume: operation.volume, audibleVolume: operation.audibleVolume, restore: resume && projection != nil ? (position, playing) : nil))) } func setPlaying(_ playing: Bool) -> Bool { guard let operation, operation.projection != nil, operation.session.setPlaying(playing) else { return false } operation.intent = playing observe(.changed) return true } func seek(_ position: Double) -> Bool { guard let operation, operation.projection != nil else { return false } return operation.session.seek(position) } func setVolume(_ volume: Double) -> Bool { guard let operation, operation.projection != nil else { return false } return operation.session.setVolume(volume) } func setSubtitle(_ index: Int?) -> Bool { guard let operation, operation.projection != nil else { return false } return operation.session.setSubtitle(index) } private func receive(_ status: CastStatus, operation: Operation) { guard var projection = operation.projection else { return } projection.state.status = status.ended ? PLAYER_ENDED : (status.playing ? PLAYER_PLAYING : PLAYER_PAUSED) projection.state.position = status.position if status.duration > 0 { projection.state.duration = status.duration } projection.state.buffering = status.buffering projection.state.can_seek = status.seekable && !operation.source.live projection.state.seek_pending = status.seekPending projection.state.buffered_start = 0 projection.state.buffered_end = 0 projection.state.revision &+= 1 projection.volume = status.volume projection.subtitle = status.subtitle if !status.playbackPending { operation.intent = nil } operation.projection = projection observe(.status(projection)) } private func input(_ source: MediaSource, duration: Double) -> CastMediaInput { switch source { case .file(let url): return CastMediaInput(videoURL: url.path, videoHeaders: "", audioURL: nil, audioHeaders: nil, local: true, restricted: false, live: false, duration: duration, subtitles: []) case .youtube(let video, _): return CastMediaInput(videoURL: video.video.url, videoHeaders: video.video.headers, audioURL: video.audio?.url, audioHeaders: video.audio?.headers, local: false, restricted: false, live: video.isLive, duration: duration, subtitles: []) case .resolved(let resolved): return CastMediaInput(videoURL: resolved.url, videoHeaders: resolved.headers, audioURL: nil, audioHeaders: nil, local: false, restricted: true, live: false, duration: duration, subtitles: resolved.subtitles) } } } private enum MediaChange { case open case queue(playing: Bool) case quality(position: Double?, playing: Bool, automatic: Bool) case restore(position: Double, playing: 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 } if case .restore(_, let playing) = self { return playing } if case .queue(let playing) = self { return playing } return true } var position: Double? { if case .quality(let position, _, _) = self { return position } if case .restore(let position, _) = self { return position } return nil } var isQuality: Bool { if case .quality = self { return true } return false } var preservesPosition: Bool { if case .restore = self { return true } return isQuality } } 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? } 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, 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 acceptCast() { playback = nil pendingRevision = nil restoration = nil seekPending = false } mutating func suspendHistory(revision: UInt64) { pendingRevision = revision } 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 var volume = false var enabled = true var bufferedRange: ClosedRange? 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.setModelValue(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 func setModelValue(_ value: Double) { if !tracking { doubleValue = value } } #if PLAYER_CHECK static func feedbackCheck() -> Bool { let slider = Slider() slider.minValue = 0 slider.maxValue = 1 slider.doubleValue = 0.8 slider.tracking = true slider.setModelValue(0.79) let heldUserValue = slider.doubleValue == 0.8 slider.tracking = false slider.setModelValue(0.79) return heldUserValue && slider.doubleValue == 0.79 } #endif 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? 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() } } } #if PLAYER_CHECK @MainActor func castVolumeSliderFeedbackCheck() -> Bool { PlaybackSlider.Slider.feedbackCheck() } #endif @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: 13, weight: .regular)) 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 // AppKit reserves the first pull-down item for the button title. let title = NSMenuItem(title: "Subtitles", action: nil, keyEquivalent: "") title.isHidden = true tracks.addItem(title) 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 struct PlaybackSurface: NSViewRepresentable { @ObservedObject var model: PlayerModel func makeNSView(context: Context) -> DragView { DragView(model: model) } func updateNSView(_ view: DragView, context: Context) { view.updateVideo() } final class DragView: NSView { private let model: PlayerModel private var hoverTracking: NSTrackingArea? private var mouseDownEvent: NSEvent? init(model: PlayerModel) { self.model = model super.init(frame: .zero) identifier = NSUserInterfaceItemIdentifier("PlaybackSurface") if let video = model.videoView { video.frame = bounds video.autoresizingMask = [.width, .height] addSubview(video) } updateVideo() } required init?(coder: NSCoder) { nil } func updateVideo() { model.videoView?.isHidden = model.fileName == nil || model.castDevice != nil } override var mouseDownCanMoveWindow: Bool { false } override func hitTest(_ point: NSPoint) -> NSView? { guard super.hitTest(point) != nil else { return nil } // Leave the outer native resize border alone, not the internal queue boundary. if let content = window?.contentView, !content.bounds.insetBy(dx: 5, dy: 5).contains(content.convert(point, from: superview)) { return nil } return self } 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) { if model.isCompact { model.hideControls(hideCursor: false) } } override func mouseDown(with event: NSEvent) { mouseDownEvent = nil if event.clickCount == 2 { if model.isCompact { model.toggleCompact() } else { model.toggleFullScreen() } } else { mouseDownEvent = event } } override func mouseDragged(with event: NSEvent) { guard let down = mouseDownEvent, let window else { return } mouseDownEvent = nil model.setControlsDragging(true) window.performDrag(with: down) if model.isCompact { model.compactWindow.snapAfterDrag() } model.setControlsDragging(false) } override func mouseUp(with event: NSEvent) { guard mouseDownEvent != nil else { return } mouseDownEvent = nil if !model.isCompact { model.hideControls() } } } } @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) 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 { HStack(alignment: .top, spacing: 0) { if model.compactTitleProgress == .percentage, let decimal = progress.firstIndex(of: ".") { Text(" · \(progress[.. 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, size: CGFloat = 17, action: @escaping () -> Void) -> some View { Button(action: action) { Image(systemName: symbol).font(.system(size: size)).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(.tail) .help(model.fileName ?? "Terebi") } .padding(.horizontal, 8) .frame(height: 24) .background(PlayerGlass(cornerRadius: 8).clipShape(RoundedRectangle(cornerRadius: 8)).allowsHitTesting(false)) Spacer(minLength: 12) if !model.showsQueue { titleControls } } .padding(.leading, model.isFullScreen ? 16 : 86) .padding(.trailing, 4) .frame(height: 32) .foregroundStyle(Color(SunsetPalette.text)) .onHover(perform: model.setTitleHovered) .animation(controlsVisibilityAnimation) { content in content.opacity(model.controlsVisible ? 1 : 0) } .allowsHitTesting(model.controlsVisible) .accessibilityHidden(!model.controlsVisible) } private var titleControls: 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) CastDeviceMenu(model: model) actionsMenu } .padding(.horizontal, model.showsQueue ? 0 : 4) .frame(height: 24) .background { if !model.showsQueue { PlayerGlass(cornerRadius: 8) .clipShape(RoundedRectangle(cornerRadius: 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…") Button("Add to Queue…", action: model.addQueuePanel).disabled(!model.canOpen) Button(model.showsQueue ? "Hide Queue" : "Show Queue") { model.showsQueue.toggle() } Button("Previous Queue Item") { model.advanceQueue(-1) }.disabled(!model.canPreviousQueueItem) Button("Next Queue Item") { model.advanceQueue(1) }.disabled(!model.canNextQueueItem) PlayerActionButton(model: model, action: .openYoutube, title: "Open YouTube URL…") PlayerActionButton(model: model, action: .closeFile, title: "Close File") Divider() PlayerQualityMenu(model: model) Divider() if model.isCompact { Menu("Subtitles") { Button("Off") { model.selectSubtitle(nil) } ForEach(model.subtitleTracks.indices, id: \.self) { index in Button { model.selectSubtitle(index) } label: { Label(model.subtitleTracks[index].label + " (" + model.subtitleTracks[index].language + ")", systemImage: model.selectedSubtitle == index ? "checkmark" : "text.bubble") } } } .disabled(model.subtitleTracks.isEmpty) 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: model.isCompact ? 17 : 14)).frame(width: 24, height: 24) } .menuStyle(.button) .buttonStyle(.plain) .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) CastDeviceMenu(model: model) actionsMenu } 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) .animation(controlsVisibilityAnimation) { content in content.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) { if !model.subtitleTracks.isEmpty { captionsMenu } iconButton("list.bullet", model.showsQueue ? "Hide Queue" : "Show Queue") { model.showsQueue.toggle() } iconButton("pip.enter", "Enter Compact Mode", action: model.toggleCompact) .disabled(!model.canToggleCompact) } .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 let y = size.height * 0.96 - controlSize.height / 2 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) ) } private var queueSidebar: some View { VStack(spacing: 0) { Color.clear.frame(height: 32) List { ForEach(model.queue.items) { item in let isCurrent = item.id == model.queue.currentID HStack(spacing: 8) { Image(systemName: isCurrent ? "play.fill" : "film") .font(.system(size: 12)) .foregroundStyle(isCurrent ? Color(SunsetPalette.accent) : Color(SunsetPalette.secondaryText).opacity(0.6)) .frame(width: 20, alignment: .center) .accessibilityLabel(isCurrent ? "Current item" : "Queued item") VStack(alignment: .leading, spacing: 3) { Text(item.url.deletingPathExtension().lastPathComponent) .font(.system(size: 13, weight: .medium)) .lineLimit(2) .lineSpacing(2) .truncationMode(.middle) if !item.url.pathExtension.isEmpty { Text(item.url.pathExtension.uppercased()) .font(.system(size: 10, weight: .medium)) .tracking(0.6) .foregroundStyle(.secondary) } } .frame(maxWidth: .infinity, alignment: .leading) Button { model.removeQueueItem(item.id) } label: { Image(systemName: "minus.circle") .font(.system(size: 12)) .foregroundStyle(.secondary) .frame(width: 20, height: 24) } .buttonStyle(.plain) .help("Remove from Queue") .accessibilityLabel("Remove \(item.url.lastPathComponent) from Queue") } .padding(.vertical, 8) .padding(.horizontal, 4) .frame(minHeight: 56) .background { if isCurrent { RoundedRectangle(cornerRadius: 8) .fill(Color(SunsetPalette.actionFill)) } } .contentShape(Rectangle()) .onTapGesture(count: 2) { model.selectQueueItem(item.id) } .help(item.url.path + "\nDouble-click to play; drag to reorder.") .contextMenu { Button("Play") { model.selectQueueItem(item.id) } Button("Remove from Queue") { model.removeQueueItem(item.id) } if let index = model.queue.items.firstIndex(where: { $0.id == item.id }) { Button("Move Up") { model.moveQueueItems(fromOffsets: IndexSet(integer: index), toOffset: index - 1) }.disabled(index == 0) Button("Move Down") { model.moveQueueItems(fromOffsets: IndexSet(integer: index), toOffset: index + 2) }.disabled(index + 1 == model.queue.items.count) } } .accessibilityAction(named: Text("Play")) { model.selectQueueItem(item.id) } .listRowInsets(EdgeInsets(top: 2, leading: 0, bottom: 2, trailing: 0)) .listRowSeparator(.hidden) } .onMove { model.moveQueueItems(fromOffsets: $0, toOffset: $1) } } .listStyle(.plain) .contentMargins(.horizontal, 0, for: .scrollContent) .overlay { if model.queue.items.isEmpty { Text("Drop local files here to add them to the queue.") .font(.callout) .foregroundStyle(.secondary) .multilineTextAlignment(.center) .padding() .allowsHitTesting(false) } } HStack(spacing: 4) { HStack(spacing: 4) { Text("Queue") Text("\(model.queue.items.count)").foregroundStyle(.secondary) } .font(.system(size: 13, weight: .medium)) .accessibilityElement(children: .combine) Button(action: model.addQueuePanel) { Image(systemName: "plus") .font(.system(size: 12)) .frame(width: 20, height: 20) .background(Color(SunsetPalette.accent).opacity(0.1), in: RoundedRectangle(cornerRadius: 6)) } .buttonStyle(.plain) .foregroundStyle(Color(SunsetPalette.accent)) .padding(.leading, 4) .help("Add to Queue…") .accessibilityLabel("Add to Queue…") .disabled(!model.canOpen) Spacer() iconButton("backward.end.fill", "Previous Queue Item", size: 12) { model.advanceQueue(-1) } .disabled(!model.canPreviousQueueItem) iconButton("forward.end.fill", "Next Queue Item", size: 12) { model.advanceQueue(1) } .disabled(!model.canNextQueueItem) } .padding(.horizontal, 12) .padding(.vertical, 8) } .frame(width: 260) .background(PlayerGlass(cornerRadius: 0)) .overlay(alignment: .topTrailing) { titleControls .padding(.trailing, 4) .frame(height: 32) } .dropDestination(for: URL.self) { urls, _ in guard model.canOpen, urls.contains(where: \.isFileURL) else { return false } model.addToQueue(urls) return true } .onHover(perform: model.setControlsHovered) } var body: some View { HStack(spacing: 0) { videoContent if model.showsQueue && !model.isCompact && !model.compactWindow.isTransitioning { queueSidebar } } .frame(minWidth: model.isCompact || model.compactWindow.isTransitioning ? 160 : (model.showsQueue ? 660 : 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)) } private var videoContent: some View { GeometryReader { geometry in ZStack(alignment: .top) { if model.fileName == nil || model.castDevice != nil { PlayerGlass(blendingMode: .behindWindow, cornerRadius: 0, ambientGlows: !model.isBusy && model.errorText == nil) .allowsHitTesting(false) .accessibilityHidden(true) } else { Color.black } PlaybackSurface(model: model) .accessibilityLabel("Video") if !model.subtitleText.isEmpty { Text(model.subtitleText) .font(.system(size: max(16, min(28, geometry.size.width / 32)), weight: .semibold)) .foregroundStyle(.white) .multilineTextAlignment(.center) .fixedSize(horizontal: false, vertical: true) .padding(.horizontal, 24) .padding(.vertical, 12) .background(PlayerGlass().opacity(0.6) .clipShape(RoundedRectangle(cornerRadius: 12)).allowsHitTesting(false)) .frame(maxWidth: geometry.size.width * 0.8) .padding(.horizontal, 8) .animation(controlsVisibilityAnimation) { content in content.padding(.bottom, model.controlsVisible ? (model.isCompact ? compactControlHeight + 20 : 110) : (model.isCompact ? 12 : 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.castConnecting ? "Connecting to \(model.castDevice?.name ?? "Cast device")…" : (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.animation(reduceMotion ? nil : .easeInOut(duration: 0.22))) .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 let device = model.castDevice { VStack(spacing: 16) { Image(systemName: "airplayvideo").font(.system(size: 48)) Text("Casting to \(device.name)").font(.headline) Button("Play on This Mac") { model.stopCasting() } } .foregroundStyle(Color(SunsetPalette.text)) .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(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) } .padding(8) .foregroundStyle(Color(SunsetPalette.text)) .onHover(perform: model.setTitleHovered) .animation(controlsVisibilityAnimation) { content in content.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) .opacity(model.isCompact || model.compactWindow.isTransitioning ? 0 : 1) .allowsHitTesting(!model.isCompact && !model.compactWindow.isTransitioning && model.controlsVisible) .accessibilityHidden(model.isCompact || model.compactWindow.isTransitioning || !model.controlsVisible) if model.canTogglePlayback { playbackControls .frame(width: 440, height: controlHeight) .background { PlayerGlass() .clipShape(RoundedRectangle(cornerRadius: 12)) } .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)) .animation(controlsVisibilityAnimation) { content in content.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, !urls.isEmpty else { return false } if urls.allSatisfy(\.isFileURL) { model.openFiles(urls) } else { guard urls.count == 1, let url = urls.first, (try? YouTubeResolver.validateURL(url.absoluteString)) != nil else { 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) } } } } } @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 hitTest(_ point: NSPoint) -> NSView? { nil } 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 files = event.paramDescriptor(forKeyword: keyDirectObject), files.numberOfItems > 0 else { return } let urls = (1...files.numberOfItems).compactMap { files.atIndex($0)?.fileURLValue } PlayerModel.shared.openFiles(urls) } 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 urls.allSatisfy(\.isFileURL) { PlayerModel.shared.openFiles(urls) } else 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() }