import AppKit import SwiftUI import TerebiConfiguration @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 pendingRevision: UInt64? @Published private(set) var resolvingYouTube = false @Published private(set) var isLiveStream = false @Published private(set) var isNetworkMedia = false @Published private(set) var youtubeHeights: [Int] = [] @Published private(set) var youtubeHeight: Int? @Published private(set) var youtubeQuality: Int? @Published private(set) var automaticYouTubeQuality = false @Published private(set) var showsBufferInfo = false @Published var seekPosition = 0.0 @Published var isSeeking = false @Published private(set) var volume = 1.0 @Published private(set) var controlsVisible = true @Published private(set) var isFullScreen = false @Published private(set) var isOnTop = false let compactWindow: CompactWindowController let keyBindings: KeyBindings @Published private(set) var playbackIntent: Bool? private var playbackInFlight: (playing: Bool, revision: UInt64)? @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 { isCompact || compactWindow.isTransitioning || (canTogglePlayback && state.has_video && state.video_aspect_ratio.isFinite && state.video_aspect_ratio > 0) } var compactContentMinimumHeight: CGFloat { guard let window else { return 90 } // Hosting-view minimums include the native titlebar safe area, even with full-size content. return max(0, 90 - (window.frame.height - window.contentLayoutRect.height)) } let videoView: NSView? private var handle: OpaquePointer? private var timer: Timer? private weak var window: NSWindow? private var hideDeadline: ContinuousClock.Instant? private var controlsHovered = false private var titleHovered = false private var controlsDragging = false private var windowActive = true private var lastAudibleVolume = 1.0 private var needsVideoFit = false private var seekPending = false private var youtubeTask: Task? private var youtubeRequest: UUID? private var resolvingAutomaticQuality = false private let qualityClock: () -> Double private var automaticQuality = AutomaticQuality() private var qualityNow: Double { qualityClock() } private let playbackProgress: PlaybackProgress private var progressURL: URL? private var resumePosition: Double? private var resumePlaying = true private var lastSavedPosition: Double? private var systemPlayback: SystemPlaybackControls? 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 automaticYouTubeQuality = configuration.automaticQuality showsBufferInfo = configuration.showBufferInfo compactWindow = CompactWindowController(snappingEnabled: configuration.snapToEdges) 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 { (resolvingYouTube && !resolvingAutomaticQuality) || pendingRevision != nil || state.status == PLAYER_OPENING } var canTogglePlayback: Bool { handle != nil && !isBusy && (state.status == PLAYER_PAUSED || state.status == PLAYER_PLAYING || state.status == PLAYER_ENDED) } var canSeek: Bool { canTogglePlayback && state.can_seek && state.duration.isFinite && state.duration > 0 } var canStepFrame: Bool { canTogglePlayback && state.has_video && state.can_seek && !isSeeking } var canChangeYouTubeQuality: Bool { canTogglePlayback && !youtubeHeights.isEmpty && !isSeeking && !seekPending && !state.seek_pending } var canOpen: Bool { handle != nil } var canClose: Bool { handle != nil && (isBusy || state.status != PLAYER_EMPTY || fileName != nil) } var canSetVolume: Bool { handle != nil && !isBusy && state.has_audio && state.status != PLAYER_ERROR } var duration: Double { state.duration.isFinite ? max(0, state.duration) : 0 } var bufferedRange: ClosedRange? { guard fileName != nil, canSeek, !seekPending, !state.seek_pending, state.buffered_start.isFinite, state.buffered_end.isFinite, state.buffered_start >= 0, state.buffered_end > state.buffered_start, state.buffered_end <= duration else { return nil } return state.buffered_start...state.buffered_end } var errorText: String? { if let commandError { return commandError } guard !isBusy, state.status == PLAYER_ERROR else { return nil } var error = state.error return withUnsafeBytes(of: &error) { bytes in let message = String(decoding: bytes.prefix { $0 != 0 }, as: UTF8.self) return message.isEmpty ? "An unknown playback error occurred." : message } } func openPanel() { guard canOpen else { return } defer { window?.makeFirstResponder(nil) } revealControls() let panel = NSOpenPanel() panel.canChooseFiles = true panel.canChooseDirectories = false panel.allowsMultipleSelection = false panel.prompt = "Open" guard panel.runModal() == .OK, let url = panel.url else { return } open(url) } func openYouTubePanel() { guard canOpen else { return } defer { window?.makeFirstResponder(nil) } revealControls() let alert = NSAlert() alert.messageText = "Open YouTube Video" alert.informativeText = "Paste a YouTube video URL." alert.addButton(withTitle: "Open") alert.addButton(withTitle: "Cancel") let field = NSTextField(frame: NSRect(x: 0, y: 0, width: 360, height: 24)) field.placeholderString = "https://www.youtube.com/watch?v=…" field.setAccessibilityLabel("YouTube video URL") alert.accessoryView = field alert.window.initialFirstResponder = field guard alert.runModal() == .alertFirstButtonReturn else { return } openYouTube(field.stringValue) } func openYouTube(_ text: String) { guard canOpen else { return } let url: URL do { url = try YouTubeResolver.validateURL(text) } catch { commandError = error.localizedDescription revealControls() return } resolveYouTube(url, height: nil) } func setShowsBufferInfo(_ enabled: Bool) { showsBufferInfo = enabled revealControls() } func setAutomaticYouTubeQuality(_ enabled: Bool) { automaticYouTubeQuality = enabled automaticQuality = AutomaticQuality() if !enabled && resolvingAutomaticQuality { cancelYouTubeResolution() } revealControls() } func setYouTubeQuality(_ height: Int?) { // A manual choice is an override, including reselecting the active/default resolution. setAutomaticYouTubeQuality(false) guard canChangeYouTubeQuality, let url = progressURL, let handle, height == nil || youtubeHeights.contains(height!) else { return } guard height != youtubeQuality else { return } let target = height ?? youtubeHeights.filter { $0 <= 1080 }.max() if target == youtubeHeight { youtubeQuality = height return } let current = player_get_state(handle) let position = !isLiveStream && current.can_seek ? current.position : nil resolveYouTube(url, height: height, restoring: (position, effectivePlaying)) } private func resolveYouTube(_ url: URL, height: Int?, restoring: (position: Double?, playing: Bool)? = nil, automatic: Bool = false) { guard let handle else { return } cancelYouTubeResolution() if !automatic { automaticQuality = AutomaticQuality() } if !automatic && !player_pause(handle) { commandError = "Could not pause the current media to open YouTube. Please try again." revealControls() return } if !automatic { playbackIntent = nil playbackInFlight = nil } let request = UUID() youtubeRequest = request resolvingYouTube = true resolvingAutomaticQuality = automatic systemPlayback?.update() commandError = nil revealControls() youtubeTask = Task { [weak self] in do { let video = try await YouTubeResolver.resolve(url, height: height) guard !Task.isCancelled, let self, self.youtubeRequest == request, self.handle != nil else { return } if automatic { let current = player_get_state(handle) guard self.automaticYouTubeQuality, current.status == PLAYER_PLAYING, !self.isSeeking, !self.seekPending, !current.seek_pending, self.pendingRevision == nil, self.isLiveStream || !current.duration.isFinite || current.duration <= 0 || current.duration - current.position > 15 else { self.cancelYouTubeResolution() self.automaticQuality.resetHealth() return } let position = !self.isLiveStream && current.can_seek ? current.position : nil self.cancelYouTubeResolution() self.openResolvedYouTube(video, height: height, restoring: (position, true), automatic: true) } else { self.cancelYouTubeResolution() self.openResolvedYouTube(video, height: height, restoring: restoring) } } catch { guard !Task.isCancelled, let self, self.youtubeRequest == request else { return } self.cancelYouTubeResolution() if automatic { self.automaticQuality.extractionFailed(at: self.qualityNow) } else { self.commandError = error.localizedDescription if restoring?.playing == true, let handle = self.handle, !player_play(handle) { self.commandError = error.localizedDescription + "\nCould not resume the previous playback." } self.revealControls() } } } } private func cancelYouTubeResolution() { youtubeRequest = nil youtubeTask?.cancel() youtubeTask = nil resolvingYouTube = false resolvingAutomaticQuality = false } private func openResolvedYouTube(_ video: YouTubeVideo, height: Int?, restoring: (position: Double?, playing: Bool)?, automatic: Bool = false) { guard let handle else { return } saveProgress() let revision = player_get_state(handle).revision let initialPosition = video.isLive ? 0 : (restoring.map { $0.position ?? 0 } ?? playbackProgress.position(for: video.webpageURL) ?? 0) let 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 source = 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, &source) } if let audio = video.audio { return audio.url.withCString { audioURL in audio.headers.withCString { enqueue(audioURL: audioURL, audioHeaders: $0) } } } return enqueue(audioURL: nil, audioHeaders: nil) } } } guard accepted else { if automatic { automaticQuality.extractionFailed(at: qualityNow) return } commandError = "Could not queue the YouTube video to open. Please try again." if restoring?.playing == true, !player_play(handle) { commandError = "Could not queue the YouTube video or resume the previous playback. Please try again." } revealControls() return } didAcceptOpen(revision: revision, title: video.title, icon: nil, url: video.webpageURL, live: video.isLive, restoring: restoring, automatic: automatic) youtubeHeights = video.availableHeights youtubeHeight = video.height youtubeQuality = height } private func didAcceptOpen(revision: UInt64, title: String, icon: NSImage?, url: URL, live: Bool = false, restoring: (position: Double?, playing: Bool)? = nil, automatic: Bool = false) { guard let handle else { return } playbackIntent = nil playbackInFlight = nil if automatic { automaticQuality.resetHealth() } else { automaticQuality = AutomaticQuality() } pendingRevision = revision fileName = title fileIcon = icon isLiveStream = live isNetworkMedia = !url.isFileURL youtubeHeights = [] youtubeHeight = nil youtubeQuality = nil progressURL = url if !url.isFileURL { // Network restoration is part of the backend open transaction, so // reporting cannot observe a pre-resume position as initial state. resumePosition = nil } else if let restoring { resumePosition = restoring.position } else { resumePosition = live ? nil : playbackProgress.position(for: url) } resumePlaying = restoring?.playing ?? true lastSavedPosition = nil needsVideoFit = restoring == nil commandError = resumePosition != nil || !resumePlaying || player_play(handle) ? nil : "Could not queue playback. Please try again." isSeeking = false seekPending = false seekPosition = 0 window?.representedURL = url window?.title = title revealControls() systemPlayback?.update() } func open(_ url: URL) { guard canOpen, let handle else { return } guard url.isFileURL else { openYouTube(url.absoluteString) return } cancelYouTubeResolution() saveProgress() let revision = player_get_state(handle).revision let accepted = url.withUnsafeFileSystemRepresentation { path in guard let path else { return false } return player_open(handle, path) } guard accepted else { commandError = "Could not queue the file to open. Please try again." return } didAcceptOpen(revision: revision, title: url.lastPathComponent, icon: NSWorkspace.shared.icon(forFile: url.path), url: url) } func togglePlayback() { perform(playbackAction) } @discardableResult func setPlayback(playing: Bool) -> Bool { guard let handle, canTogglePlayback else { return false } if !playing && resolvingAutomaticQuality { cancelYouTubeResolution() } if !playing { automaticQuality.resetHealth() } if playbackInFlight != nil { playbackIntent = playing revealControls() return true } guard effectivePlaying != playing else { return true } let revision = player_get_state(handle).revision let accepted = playing ? player_play(handle) : player_pause(handle) if accepted { playbackIntent = playing playbackInFlight = (playing, revision) } commandError = accepted ? nil : "Could not queue playback. Please try again." revealControls() return accepted } func finishSeeking() { if resolvingAutomaticQuality { cancelYouTubeResolution() } automaticQuality.resetHealth() isSeeking = false guard let handle, canSeek else { return } let position = min(max(0, seekPosition), duration) let accepted = player_seek(handle, position) if accepted { seekPending = true } commandError = accepted ? nil : "Could not queue the seek. Please try again." revealControls() } func closeFile() { guard let handle, canClose else { return } saveProgress() cancelYouTubeResolution() automaticQuality = AutomaticQuality() guard player_close(handle) else { commandError = "Could not queue closing the file. Please try again." return } playbackIntent = nil playbackInFlight = nil compactIntent = nil compactWindow.restore(resumeFullScreen: false) fileName = nil fileIcon = nil isLiveStream = false isNetworkMedia = false youtubeHeights = [] youtubeHeight = nil youtubeQuality = nil progressURL = nil resumePosition = nil lastSavedPosition = nil needsVideoFit = false window?.aspectRatio = .zero commandError = nil isSeeking = false seekPending = false window?.representedURL = nil window?.title = "terebi" revealControls() systemPlayback?.update() } @discardableResult func seekRelative(_ seconds: Double) -> Bool { guard canSeek, let handle, seconds.isFinite else { return false } if resolvingAutomaticQuality { cancelYouTubeResolution() } automaticQuality.resetHealth() let target = min(max(0, (seekPending ? seekPosition : state.position) + seconds), duration) let accepted = player_seek(handle, target) if accepted { seekPosition = target seekPending = true } commandError = accepted ? nil : "Could not queue the seek. Please try again." revealControls() return accepted } @discardableResult func stepFrame(_ direction: Int32) -> Bool { guard canStepFrame, let handle else { return false } let accepted = player_step_frame(handle, direction) if accepted { seekPending = false playbackIntent = nil playbackInFlight = nil } commandError = accepted ? nil : "Could not queue the frame step. Please try again." revealControls() return accepted } func setVolume(_ value: Double) { guard let handle else { return } if player_set_volume(handle, Float(value)) { volume = Double(player_get_volume(handle)) if volume > 0 { lastAudibleVolume = volume } } revealControls() } func toggleMute() { perform(muteAction) } func attachWindow(_ window: NSWindow) { guard self.window !== window else { return } self.window = window window.title = fileName ?? "terebi" window.contentMinSize = NSSize(width: 400, height: 240) compactWindow.attach(window) updateWindowState() fitVideoWindow() } func updateWindowState() { guard let window else { return } isFullScreen = window.styleMask.contains(.fullScreen) if !compactWindow.isTransitioning && fullScreenIntent == isFullScreen { fullScreenIntent = nil } windowActive = window.isKeyWindow if !compactWindow.isTransitioning { window.level = isCompact || (isOnTop && !isFullScreen) ? .floating : .normal } if !isFullScreen && !compactWindow.isTransitioning { fitVideoWindow() } revealControls() } func toggleFullScreen() { perform(fullscreenAction) } func toggleCompact() { perform(compactAction) } func toggleOnTop() { perform(onTopAction) } func canPerform(_ action: PlayerAction) -> Bool { switch action { case .play: return canTogglePlayback && !effectivePlaying case .pause: return canTogglePlayback && effectivePlaying case .mute: return canSetVolume && volume > 0 case .unmute: return canSetVolume && volume == 0 case .volumeUp: return canSetVolume && volume < 1 case .volumeDown: return canSetVolume && volume > 0 case .seekForward5s, .seekBackward5s, .seekForward10s, .seekBackward10s: return canSeek case .stepFrameForward, .stepFrameBackward: return canStepFrame case .enterFullscreen, .exitFullscreen: return window != nil && fullScreenIntent == nil && !compactWindow.isTransitioning && (action == .enterFullscreen ? !isFullScreen : isFullScreen) case .enterCompact: return window != nil && !(compactIntent ?? isCompact) && !compactWindow.isTransitioning && 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) } 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 setControlsHovered(_ hovered: Bool) { guard controlsHovered != hovered else { return } controlsHovered = hovered revealControls() } func setTitleHovered(_ hovered: Bool) { guard titleHovered != hovered else { return } titleHovered = hovered revealControls() } func setControlsDragging(_ dragging: Bool) { guard controlsDragging != dragging else { return } controlsDragging = dragging revealControls() } func hideControls(hideCursor: Bool = true) { guard shouldHideControls else { return } hideDeadline = nil withAnimation(NSWorkspace.shared.accessibilityDisplayShouldReduceMotion ? nil : .easeIn(duration: 0.25)) { controlsVisible = false } NSCursor.setHiddenUntilMouseMoves(hideCursor && windowActive && !isCompact) } func beginSeeking() { if resolvingAutomaticQuality { cancelYouTubeResolution() } automaticQuality.resetHealth() isSeeking = true revealControls() } func revealControls() { hideDeadline = nil NSCursor.setHiddenUntilMouseMoves(false) if !controlsVisible { withAnimation(NSWorkspace.shared.accessibilityDisplayShouldReduceMotion ? nil : .easeOut(duration: 0.25)) { controlsVisible = true } } guard shouldHideControls else { return } hideDeadline = ContinuousClock.now.advanced(by: .milliseconds(2500)) } private var shouldHideControls: Bool { canTogglePlayback && state.has_video && fileName != nil && !isSeeking && !seekPending && !controlsHovered && !titleHovered && !controlsDragging && (windowActive || isCompact) && errorText == nil && !NSWorkspace.shared.isVoiceOverEnabled } private func fitVideoWindow() { guard needsVideoFit, canTogglePlayback, let window, !isFullScreen, !compactWindow.isTransitioning else { return } let aspect = state.video_aspect_ratio if isCompact { compactWindow.updateAspect(state.video_aspect_ratio) return } needsVideoFit = false guard state.has_video, aspect.isFinite, aspect > 0 else { window.aspectRatio = .zero return } let screen = (window.screen ?? NSScreen.main)?.visibleFrame ?? window.frame let width = min(window.frame.width, screen.width, screen.height * aspect) let size = NSSize(width: width, height: width / aspect) // ponytail: extreme ratios retain letterboxing; compact controls if exact fitting is needed. guard size.width >= window.minSize.width, size.height >= window.minSize.height else { window.aspectRatio = .zero return } window.aspectRatio = NSSize(width: aspect, height: 1) let frame = NSRect( x: min(max(window.frame.midX - size.width / 2, screen.minX), screen.maxX - size.width), y: min(max(window.frame.midY - size.height / 2, screen.minY), screen.maxY - size.height), width: size.width, height: size.height) window.setFrame(frame, display: true) } private func saveProgress(_ snapshot: PlayerState? = nil, force: Bool = true) { guard let handle, let url = progressURL, pendingRevision == nil, resumePosition == nil, !isLiveStream else { return } let current = snapshot ?? player_get_state(handle) guard current.status == PLAYER_PAUSED || current.status == PLAYER_PLAYING || current.status == PLAYER_ENDED, current.has_video, current.can_seek, !current.seek_pending, current.duration.isFinite, current.duration > 0, current.position.isFinite, current.position >= 0 else { return } let position = current.status == PLAYER_ENDED ? 0 : min(current.position, current.duration) guard force || lastSavedPosition == nil || abs(position - lastSavedPosition!) >= 5 else { return } playbackProgress.save(position > 0 ? position : nil, for: url) lastSavedPosition = position } private func poll() { guard let handle else { return } if reportingEnabled && !reportingErrorShown { if player_get_progress_error(handle, &reportingErrorBuffer) { showReportingError(reportingErrorMessage) } } applyState(player_get_state(handle)) } func applyState(_ next: PlayerState) { if let revision = pendingRevision { guard next.revision != revision, next.status != PLAYER_OPENING else { return } pendingRevision = nil } defer { systemPlayback?.update() } let statusChanged = state.status != next.status state = next if next.status == PLAYER_EMPTY || next.status == PLAYER_ERROR { playbackIntent = nil playbackInFlight = nil } else if let flight = playbackInFlight, next.revision != flight.revision, next.status == PLAYER_ENDED || (next.status == PLAYER_PLAYING) == flight.playing, next.status == PLAYER_PLAYING || next.status == PLAYER_PAUSED || next.status == PLAYER_ENDED { let desired = playbackIntent playbackIntent = nil playbackInFlight = nil if let desired, desired != flight.playing { setPlayback(playing: desired) } } if next.status == PLAYER_EMPTY || next.status == PLAYER_ERROR || (!isBusy && !next.has_video) { compactWindow.restore(resumeFullScreen: false) } if !next.seek_pending || next.status == PLAYER_ERROR { seekPending = false } if !isSeeking && !seekPending { seekPosition = min(max(0, next.position.isFinite ? next.position : 0), duration) } fitVideoWindow() if statusChanged { revealControls() } if let hideDeadline, ContinuousClock.now >= hideDeadline { hideControls() } saveProgress(next, force: statusChanged && (next.status == PLAYER_PAUSED || next.status == PLAYER_ENDED)) updateAutomaticQuality() if let saved = resumePosition { resumePosition = nil guard let handle, next.status == PLAYER_PAUSED || next.status == PLAYER_PLAYING else { return } if next.has_video && next.can_seek && next.duration.isFinite && next.duration > 0 { if saved < next.duration { guard player_seek(handle, saved) else { // Preserve the stored bookmark if restoration could not be queued. progressURL = nil commandError = "Could not resume saved progress. Please try opening the video again." return } seekPosition = saved seekPending = true } else if let url = progressURL { playbackProgress.save(nil, for: url) } } commandError = !resumePlaying || player_play(handle) ? nil : "Could not queue playback. Please try again." } } private func updateAutomaticQuality() { guard automaticYouTubeQuality else { return } let reserve = bufferedRange.map { max(0, $0.upperBound - state.position) } let sample = AutomaticQuality.Sample( enabled: automaticYouTubeQuality, network: isNetworkMedia, playing: effectivePlaying, opening: isBusy || resolvingYouTube || resumePosition != nil, seeking: isSeeking || seekPending || state.seek_pending, live: isLiveStream, buffering: state.buffering, position: state.position, duration: state.duration, ahead: reserve, height: youtubeHeight, heights: youtubeHeights) guard let height = automaticQuality.choose(sample, at: qualityNow), let url = progressURL else { return } resolveYouTube(url, height: height, automatic: true) } private func showReportingError(_ message: String, terminating: Bool = false) { guard !reportingErrorShown else { return } reportingErrorShown = true FileHandle.standardError.write(Data("terebi: \(message)\n".utf8)) let alert = NSAlert() alert.alertStyle = .warning alert.messageText = "Playback progress reporting failed" alert.informativeText = message + "\n\nReporting has been disabled. Playback is unaffected." if let window, !terminating { alert.beginSheetModal(for: window) } else { alert.runModal() } } func shutdown() { saveProgress() cancelYouTubeResolution() systemPlayback?.shutdown() systemPlayback = nil compactWindow.restore(resumeFullScreen: 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 struct PlayerGlass: NSViewRepresentable { var titlebar = false func makeNSView(context: Context) -> NSVisualEffectView { let view = NSVisualEffectView() view.blendingMode = .withinWindow view.state = .active view.appearance = NSAppearance(named: .vibrantDark) view.material = titlebar ? .titlebar : .hudWindow return view } func updateNSView(_ view: NSVisualEffectView, context: Context) {} } @MainActor private struct PlaybackSlider: NSViewRepresentable { @Binding var value: Double let range: ClosedRange 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.doubleValue = value slider.isEnabled = enabled slider.editingChanged = onEditingChanged if let cell = slider.cell as? Cell, cell.bufferedRange != bufferedRange { cell.bufferedRange = bufferedRange slider.needsDisplay = true } slider.setAccessibilityLabel(volume ? "Volume" : "Playback Position") } @MainActor final class Coordinator: NSObject { var parent: PlaybackSlider init(_ parent: PlaybackSlider) { self.parent = parent } @objc func changed(_ slider: Slider) { if !slider.tracking { parent.onEditingChanged(true) } parent.value = slider.doubleValue if !slider.tracking { parent.onEditingChanged(false) } } } final class Slider: NSSlider { var editingChanged: (Bool) -> Void = { _ in } private(set) var tracking = false override func mouseDown(with event: NSEvent) { guard isEnabled else { return } tracking = true editingChanged(true) super.mouseDown(with: event) tracking = false editingChanged(false) } } final class Cell: NSSliderCell { var volume = false var bufferedRange: ClosedRange? override func drawBar(inside rect: NSRect, flipped: Bool) { let bar = NSRect(x: rect.minX + knobThickness / 2, y: rect.midY - 1.5, width: max(0, rect.width - knobThickness), height: 3) NSColor.white.withAlphaComponent(0.12).setFill() NSBezierPath(roundedRect: bar, xRadius: 1.5, yRadius: 1.5).fill() if volume { let fraction = (doubleValue - minValue) / (maxValue - minValue) let fill = NSRect(x: bar.minX, y: bar.minY, width: bar.width * fraction, height: bar.height) NSColor.systemOrange.withAlphaComponent(0.85).setFill() NSBezierPath(roundedRect: fill, xRadius: 1.5, yRadius: 1.5).fill() } else if let bufferedRange, maxValue > minValue { let lower = min(maxValue, max(minValue, bufferedRange.lowerBound)) let upper = min(maxValue, max(minValue, bufferedRange.upperBound)) let scale = bar.width / (maxValue - minValue) let fill = NSRect(x: bar.minX + (lower - minValue) * scale, y: bar.minY, width: (upper - lower) * scale, height: bar.height) NSColor.white.withAlphaComponent(0.4).setFill() NSBezierPath(roundedRect: fill, xRadius: 1.5, yRadius: 1.5).fill() } } override func drawKnob(_ rect: NSRect) { let width: CGFloat = volume ? 16 : 3 let height: CGFloat = volume ? 12 : 15 let knob = NSRect(x: rect.midX - width / 2, y: rect.midY - height / 2, width: width, height: height) let color = volume ? NSColor(white: isEnabled ? 0.85 : 0.45, alpha: 1) : NSColor.white.withAlphaComponent(isEnabled ? 0.65 : 0.25) color.setFill() NSBezierPath(roundedRect: knob, xRadius: volume ? 6 : 1, yRadius: volume ? 6 : 1).fill() } } } @MainActor private struct VideoSurface: NSViewRepresentable { let view: NSView func makeNSView(context: Context) -> NSView { view } func updateNSView(_ nsView: NSView, context: Context) {} } @MainActor private struct CompactDragSurface: NSViewRepresentable { let model: PlayerModel func makeNSView(context: Context) -> DragView { DragView(model: model) } func updateNSView(_ view: DragView, context: Context) {} final class DragView: NSView { private let model: PlayerModel private var hoverTracking: NSTrackingArea? init(model: PlayerModel) { self.model = model super.init(frame: .zero) } required init?(coder: NSCoder) { nil } override func updateTrackingAreas() { super.updateTrackingAreas() if let hoverTracking { removeTrackingArea(hoverTracking) } let area = NSTrackingArea(rect: .zero, options: [.inVisibleRect, .activeAlways, .mouseEnteredAndExited, .mouseMoved], owner: self, userInfo: nil) hoverTracking = area addTrackingArea(area) } override func mouseEntered(with event: NSEvent) { model.revealControls() } override func mouseMoved(with event: NSEvent) { model.revealControls() } override func mouseExited(with event: NSEvent) { model.hideControls(hideCursor: false) } override func mouseDown(with event: NSEvent) { if event.clickCount == 2 { model.toggleCompact() return } model.setControlsDragging(true) window?.performDrag(with: event) model.compactWindow.snapAfterDrag() model.setControlsDragging(false) } } } @MainActor private struct PlayerContent: View { @ObservedObject var model: PlayerModel @State private var showsRemainingTime = false @State private var dropTargeted = false @State private var controlSize = CGSize(width: 440, height: 68) // Relative offsets preserve the dragged position across resize/fullscreen. @State private var controlOffset = CGSize.zero @State private var dragOrigin = CGSize.zero private var showsBufferReadout: Bool { model.isNetworkMedia && model.showsBufferInfo } private var controlHeight: CGFloat { showsBufferReadout ? 88 : 68 } private var compactControlHeight: CGFloat { showsBufferReadout ? 52 : 40 } private var bufferedStatus: String? { model.bufferedRange.map { let behind = max(0, model.state.position - $0.lowerBound) let ahead = max(0, $0.upperBound - model.state.position) return "Reusable buffer: \(String(format: "%.1f", behind)) seconds behind, " + "\(String(format: "%.1f", ahead)) seconds ahead; buffered through \(time($0.upperBound))." } } private var bufferReadout: some View { Text(model.bufferedRange.map { let behind = max(0, model.state.position - $0.lowerBound) let ahead = max(0, $0.upperBound - model.state.position) return (model.isCompact ? "" : "Buffer: ") + String(format: "−%.0fs +%.0fs", behind, ahead) } ?? (model.isCompact ? "Buffer —" : "Buffer: —")) .font(.system(size: 10).monospacedDigit()) .foregroundStyle(.white.opacity(0.75)) .lineLimit(1) .frame(height: 12) .help(bufferedStatus ?? "Reusable buffer unavailable.") .accessibilityLabel("Reusable Buffer") .accessibilityValue(bufferedStatus ?? "Unavailable") } private func time(_ seconds: Double) -> String { let value = Int(min(max(0, seconds.isFinite ? seconds : 0), Double(Int.max / 2))) let hours = value / 3600 return hours > 0 ? String(format: "%d:%02d:%02d", hours, value / 60 % 60, value % 60) : String(format: "%02d:%02d", value / 60, value % 60) } private func iconButton(_ symbol: String, _ label: String, action: @escaping () -> Void) -> some View { Button(action: action) { Image(systemName: symbol).font(.system(size: 17)).frame(width: 24, height: 24) } .buttonStyle(.plain) .help(label) .accessibilityLabel(label) } private var titlebar: some View { HStack(spacing: 4) { if let icon = model.fileIcon { Image(nsImage: icon).resizable().frame(width: 16, height: 16) } else { Image(systemName: "play.rectangle.fill").font(.system(size: 14)) } Text(model.fileName ?? "terebi") .font(.system(size: 13, weight: .semibold)) .lineLimit(1) .truncationMode(.middle) .help(model.fileName ?? "terebi") Spacer(minLength: 12) iconButton(model.isOnTop ? "pin.fill" : "pin", model.isOnTop ? "Stop Keeping Window on Top" : "Keep Window on Top", action: model.toggleOnTop) .foregroundStyle(.white.opacity(model.isOnTop ? 1 : 0.6)) .disabled(model.isFullScreen || model.compactWindow.isTransitioning) } .padding(.leading, model.isFullScreen ? 16 : 86) .padding(.trailing, 12) .frame(height: 32) .foregroundStyle(.white.opacity(0.6)) .background(PlayerGlass(titlebar: true).allowsHitTesting(false)) .onHover(perform: model.setTitleHovered) .opacity(model.controlsVisible ? 1 : 0) .allowsHitTesting(model.controlsVisible) .accessibilityHidden(!model.controlsVisible) } private var actionsMenu: some View { Menu { PlayerActionButton(model: model, action: .openFile, title: "Open File…") PlayerActionButton(model: model, action: .openYoutube, title: "Open YouTube URL…") PlayerActionButton(model: model, action: .closeFile, title: "Close File") Divider() PlayerQualityMenu(model: model) Divider() PlayerActionButton(model: model, action: model.bufferInfoAction, title: model.showsBufferInfo ? "Hide Buffer Info" : "Show Buffer Info") Divider() PlayerActionButton(model: model, action: model.compactAction, title: model.compactAction == .exitCompact ? "Exit Compact Mode" : "Enter Compact Mode") PlayerActionButton(model: model, action: model.snappingAction, title: model.compactWindow.snappingEnabled ? "Disable Compact Snapping" : "Enable Compact Snapping") Divider() PlayerActionButton(model: model, action: model.onTopAction, title: model.isOnTop ? "Stop Keeping Window on Top" : "Keep Window on Top") } label: { Image(systemName: "gearshape").font(.system(size: 17)).frame(width: 24, height: 24) } .menuStyle(.borderlessButton) .menuIndicator(.hidden) .fixedSize() .accessibilityLabel("Playback Actions") .help("Playback Actions") } private var compactControls: some View { VStack(spacing: 4) { HStack(spacing: 16) { iconButton(model.effectivePlaying ? "pause.fill" : "play.fill", model.effectivePlaying ? "Pause" : "Play", action: model.togglePlayback) .disabled(!model.canTogglePlayback) iconButton("pip.exit", "Exit Compact Mode", action: model.toggleCompact) .disabled(model.compactWindow.isTransitioning) actionsMenu } if showsBufferReadout { bufferReadout } } .padding(.horizontal, 12) .padding(.vertical, showsBufferReadout ? 6 : 8) .foregroundStyle(.white.opacity(0.85)) .background(PlayerGlass().clipShape(RoundedRectangle(cornerRadius: 10))) .onHover(perform: model.setControlsHovered) .opacity(model.controlsVisible ? 1 : 0) .allowsHitTesting(model.controlsVisible) .accessibilityHidden(!model.controlsVisible) } private var playbackControls: some View { let buffered = model.bufferedRange return VStack(spacing: 8) { HStack(spacing: 0) { HStack(spacing: 4) { iconButton(model.volume == 0 ? "speaker.slash.fill" : "speaker.wave.2.fill", model.volume == 0 ? "Unmute" : "Mute", action: model.toggleMute) PlaybackSlider(value: Binding(get: { model.volume }, set: model.setVolume), range: 0...1, volume: true, enabled: model.canSetVolume, onEditingChanged: model.setControlsDragging) .frame(width: 70, height: 14) .accessibilityLabel("Volume") .accessibilityValue("\(Int((model.volume * 100).rounded())) percent") } .disabled(!model.canSetVolume) .frame(width: 106, alignment: .leading) Spacer(minLength: 8) HStack(spacing: 24) { iconButton("backward.fill", "Seek Back 5 Seconds") { model.seekRelative(-5) } .disabled(!model.canSeek) Button(action: model.togglePlayback) { Image(systemName: model.effectivePlaying ? "pause.fill" : "play.fill") .font(.system(size: 24)) .frame(width: 24, height: 24) } .buttonStyle(.plain) .accessibilityLabel(model.effectivePlaying ? "Pause" : "Play") .help(model.effectivePlaying ? "Pause" : "Play") .disabled(!model.canTogglePlayback) iconButton("forward.fill", "Seek Forward 5 Seconds") { model.seekRelative(5) } .disabled(!model.canSeek) } Spacer(minLength: 8) HStack(spacing: 12) { iconButton("pip.enter", "Enter Compact Mode", action: model.toggleCompact) .disabled(!model.canToggleCompact) actionsMenu } .frame(width: 106, alignment: .trailing) } .padding(.horizontal, 16) HStack(spacing: 10) { Text(time(model.seekPosition)).frame(minWidth: 32, alignment: .trailing) PlaybackSlider(value: $model.seekPosition, range: 0...max(model.duration, 0.001), enabled: model.canSeek, bufferedRange: buffered, onEditingChanged: { editing in if editing { model.beginSeeking() } else { model.finishSeeking() } }) .frame(height: 14) .accessibilityLabel("Playback Position") .accessibilityValue(time(model.seekPosition) + (bufferedStatus.map { ", " + $0 } ?? "")) .help(bufferedStatus ?? "Playback Position") Button { showsRemainingTime.toggle() model.revealControls() } label: { Text(model.isLiveStream ? "LIVE" : (showsRemainingTime ? "−" + time(model.duration - model.seekPosition) : time(model.duration))) .frame(minWidth: 32, alignment: .leading) } .buttonStyle(.plain) .disabled(model.isLiveStream) .help(model.isLiveStream ? "Live Stream" : (showsRemainingTime ? "Show Duration" : "Show Remaining Time")) .accessibilityLabel(model.isLiveStream ? "Live Stream" : (showsRemainingTime ? "Remaining Time" : "Duration")) .accessibilityValue(model.isLiveStream ? "Live" : time(showsRemainingTime ? model.duration - model.seekPosition : model.duration)) } .font(.system(size: 11).monospacedDigit()) .padding(.horizontal, 16) if showsBufferReadout { bufferReadout } } .padding(.vertical, 11) // Optical centering accounts for symbol insets and the native seek thumb. .offset(y: -1.5) .foregroundStyle(.white.opacity(0.6)) .onHover(perform: model.setControlsHovered) } private func controllerPosition(in size: CGSize) -> CGPoint { let x = size.width / 2 + controlOffset.width * size.width let y = size.height * 0.96 - controlSize.height / 2 + controlOffset.height * size.height return CGPoint( x: min(max(x, controlSize.width / 2 + 12), size.width - controlSize.width / 2 - 12), y: min(max(y, 32 + controlSize.height / 2 + 12), size.height - controlSize.height / 2 - 12) ) } var body: some View { GeometryReader { geometry in ZStack(alignment: .top) { Color.black if let view = model.videoView { VideoSurface(view: view) .accessibilityLabel("Video") .gesture( TapGesture(count: 2).onEnded { model.toggleFullScreen() } .exclusively(before: TapGesture().onEnded { model.hideControls() }) ) } if model.isCompact { CompactDragSurface(model: model).accessibilityHidden(true) } if let error = model.errorText { VStack(spacing: 12) { Image(systemName: "exclamationmark.triangle").font(.system(size: 32)).foregroundStyle(.yellow) Text("Unable to Play").font(.headline) Text(error).font(.callout).multilineTextAlignment(.center).textSelection(.enabled) Button("Open Another File…", action: model.openPanel).disabled(!model.canOpen) } .padding(24) .frame(maxWidth: 420) .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12)) .frame(maxWidth: .infinity, maxHeight: .infinity) } else if model.isBusy { VStack(spacing: 12) { ProgressView().controlSize(.large) Text(model.resolvingYouTube ? "Resolving YouTube…" : "Opening…") .font(.callout).foregroundStyle(.secondary) } .frame(maxWidth: .infinity, maxHeight: .infinity) } else if model.fileName == nil { VStack(spacing: 14) { Image(systemName: "play.rectangle").font(.system(size: 56, weight: .ultraLight)) .foregroundStyle(.white.opacity(0.65)) Text("terebi").font(.system(size: 22, weight: .medium)) Text("Drop a media file or YouTube link here").font(.callout).foregroundStyle(.secondary) Button("Open File…", action: model.openPanel) .buttonStyle(.bordered).controlSize(.large).disabled(!model.canOpen) Button("Open YouTube URL…", action: model.openYouTubePanel) .buttonStyle(.bordered).disabled(!model.canOpen) } .frame(maxWidth: .infinity, maxHeight: .infinity) } else if !model.state.has_video && model.state.has_audio { VStack(spacing: 16) { Image(systemName: "waveform").font(.system(size: 64, weight: .ultraLight)) Text(model.fileName ?? "").font(.headline).lineLimit(1) } .foregroundStyle(.secondary) .padding(40) .frame(maxWidth: .infinity, maxHeight: .infinity) } if model.state.buffering && !model.isBusy && model.errorText == nil { ProgressView("Buffering…") .padding(12) .background(PlayerGlass().clipShape(RoundedRectangle(cornerRadius: 10))) .position(x: geometry.size.width / 2, y: geometry.size.height / 2) .allowsHitTesting(false) } if model.isCompact { Text(model.fileName ?? "terebi") .font(.system(size: 11, weight: .medium)) .lineLimit(1) .padding(8) .frame(maxWidth: .infinity) .foregroundStyle(.white.opacity(0.75)) .background(PlayerGlass(titlebar: true)) .opacity(model.controlsVisible ? 1 : 0) .allowsHitTesting(false) .accessibilityHidden(!model.controlsVisible) compactControls .position(x: geometry.size.width / 2, y: geometry.size.height - compactControlHeight / 2 - 8) } else { titlebar } if model.canTogglePlayback && !model.isCompact { playbackControls .frame(width: 440, height: controlHeight) .background { PlayerGlass() .clipShape(RoundedRectangle(cornerRadius: 12)) .gesture(DragGesture(minimumDistance: 4).onChanged { value in model.setControlsDragging(true) controlOffset = CGSize(width: dragOrigin.width + value.translation.width / geometry.size.width, height: dragOrigin.height + value.translation.height / geometry.size.height) }.onEnded { _ in let position = controllerPosition(in: geometry.size) controlOffset = CGSize(width: (position.x - geometry.size.width / 2) / geometry.size.width, height: (position.y - (geometry.size.height * 0.96 - controlSize.height / 2)) / geometry.size.height) dragOrigin = controlOffset model.setControlsDragging(false) }) } .scaleEffect(min(1, (geometry.size.width - 32) / 440)) .frame(width: min(440, geometry.size.width - 32), height: controlHeight * min(1, (geometry.size.width - 32) / 440)) .onGeometryChange(for: CGSize.self) { $0.size } action: { controlSize = $0 } .position(controllerPosition(in: geometry.size)) .opacity(model.controlsVisible ? 1 : 0) .allowsHitTesting(model.controlsVisible) .accessibilityHidden(!model.controlsVisible) } if dropTargeted { RoundedRectangle(cornerRadius: 10) .strokeBorder(.white.opacity(0.75), lineWidth: 2) .padding(8) .allowsHitTesting(false) } } .dropDestination(for: URL.self) { urls, _ in guard model.canOpen, let url = urls.first else { return false } if !url.isFileURL && (try? YouTubeResolver.validateURL(url.absoluteString)) == nil { return false } model.open(url) return true } isTargeted: { dropTargeted = $0 model.revealControls() } .onContinuousHover { phase in switch phase { case .active: if model.isCompact { model.revealControls() } case .ended: model.hideControls(hideCursor: false) } } } .frame(minWidth: model.isCompact ? 160 : 400, minHeight: model.isCompact ? model.compactContentMinimumHeight : 240) .ignoresSafeArea() .preferredColorScheme(.dark) .background(WindowChrome(model: model)) } } @MainActor private struct WindowChrome: NSViewRepresentable { @ObservedObject var model: PlayerModel func makeNSView(context: Context) -> ChromeView { ChromeView(model: model) } func updateNSView(_ view: ChromeView, context: Context) { view.updateButtons() } static func dismantleNSView(_ view: ChromeView, coordinator: ()) { view.detach() } final class ChromeView: NSView { private let model: PlayerModel private weak var configuredWindow: NSWindow? private var monitor: Any? private var observers: [NSObjectProtocol] = [] private var dragOrigin: NSPoint? init(model: PlayerModel) { self.model = model super.init(frame: .zero) } required init?(coder: NSCoder) { nil } func handleKeyEvent(_ event: NSEvent) -> Bool { guard 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.backgroundColor = .black window.acceptsMouseMovedEvents = true window.collectionBehavior.insert(.fullScreenPrimary) window.appearance = NSAppearance(named: .darkAqua) for name in [NSWindow.didBecomeKeyNotification, NSWindow.didResignKeyNotification, NSWindow.didEnterFullScreenNotification, NSWindow.didExitFullScreenNotification] { observers.append(NotificationCenter.default.addObserver(forName: name, object: window, queue: .main) { [weak self] _ in MainActor.assumeIsolated { self?.model.updateWindowState() self?.updateButtons() } }) } monitor = NSEvent.addLocalMonitorForEvents(matching: [.mouseMoved, .leftMouseDown, .leftMouseUp, .keyDown]) { [weak self] event in let consumed = MainActor.assumeIsolated { guard let self, let window = self.configuredWindow else { return false } if event.type == .leftMouseUp { let origin = self.dragOrigin self.dragOrigin = nil if let origin { Task { @MainActor [weak self, weak window] in guard let self, let window, self.configuredWindow === window, self.model.isCompact, window.frame.origin != origin else { return } self.model.compactWindow.snapAfterDrag() } } return false } guard event.window === window else { return false } if event.type == .leftMouseDown { self.dragOrigin = window.frame.origin } if event.type == .keyDown && event.keyCode == 48 { self.model.revealControls() } if event.type == .mouseMoved || event.type == .leftMouseDown { self.model.revealControls() } else if event.type == .keyDown { return self.handleKeyEvent(event) } return false } return consumed ? nil : event } Task { @MainActor [weak self, weak window] in guard let self, let window else { return } self.model.attachWindow(window) self.updateButtons() window.makeFirstResponder(nil) } } func updateButtons() { let hidden = !model.controlsVisible || model.isFullScreen || model.isCompact for button in [NSWindow.ButtonType.closeButton, .miniaturizeButton, .zoomButton] { if let view = configuredWindow?.standardWindowButton(button), view.isHidden != hidden { view.isHidden = hidden } } } override func mouseDown(with event: NSEvent) { model.setControlsDragging(true) window?.performDrag(with: event) model.compactWindow.snapAfterDrag() model.setControlsDragging(false) } func detach() { if let monitor { NSEvent.removeMonitor(monitor) } monitor = nil dragOrigin = nil observers.forEach(NotificationCenter.default.removeObserver) observers.removeAll() configuredWindow = nil } } } @MainActor final class AppDelegate: NSObject, NSApplicationDelegate { func applicationWillFinishLaunching(_ notification: Notification) { // Own external opens: SwiftUI's document routing can close the singleton Window. let events = NSAppleEventManager.shared() events.setEventHandler(self, andSelector: #selector(openInternetURL(_:withReplyEvent:)), forEventClass: AEEventClass(kInternetEventClass), andEventID: AEEventID(kAEGetURL)) } @objc private func openInternetURL(_ event: NSAppleEventDescriptor, withReplyEvent reply: NSAppleEventDescriptor) { guard let text = event.paramDescriptor(forKeyword: keyDirectObject)?.stringValue else { return } PlayerModel.shared.openYouTube(text) } @objc private func openDocuments(_ event: NSAppleEventDescriptor, withReplyEvent reply: NSAppleEventDescriptor) { guard let url = event.paramDescriptor(forKeyword: keyDirectObject)?.atIndex(1)?.fileURLValue else { return } PlayerModel.shared.open(url) } func applicationDidFinishLaunching(_ notification: Notification) { NSApp.setActivationPolicy(.regular) NSApp.activate(ignoringOtherApps: true) NSAppleEventManager.shared().setEventHandler(self, andSelector: #selector(openDocuments(_:withReplyEvent:)), forEventClass: AEEventClass(kCoreEventClass), andEventID: AEEventID(kAEOpenDocuments)) let arguments = Array(CommandLine.arguments.dropFirst()) if let index = arguments.firstIndex(of: "--open-media"), index + 1 < arguments.count, let url = URL(string: arguments[index + 1]) { PlayerModel.shared.open(url) } } // Let SwiftUI create the initial window for a Finder/document launch. func application(_ application: NSApplication, open urls: [URL]) { if let url = urls.first { PlayerModel.shared.open(url) } } func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { true } func applicationWillTerminate(_ notification: Notification) { PlayerModel.shared.shutdown() } } @MainActor struct TerebiApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) private var delegate @StateObject private var model = PlayerModel.shared var body: some Scene { Window("\(model.fileName ?? "terebi")", id: "terebi") { PlayerContent(model: model) .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() }