230949bb95
Build .build/terebi beside the app. Parse a single local file or YouTube URL, --url, --, and help; reject invalid commands before GUI launch. Resolve the adjacent app through symlinks and launch through NSWorkspace, reusing its process/window and saved progress. Launch the app without media before delivering URL events. Raw media argv suppress SwiftUI's initial singleton window. Own warm document and internet Apple events because SwiftUI's default external-event routing closes that window; retain initial Finder document delivery to create it. Verified actual CLI 0/64/69 exit behavior, PATH/symlink discovery from another directory, cold local/YouTube playback, warm local/YouTube replacement in the same native window/process, resume, Finder-style cold launch, and make all check.
1213 lines
53 KiB
Swift
1213 lines
53 KiB
Swift
import AppKit
|
||
import SwiftUI
|
||
|
||
@MainActor
|
||
final class PlayerModel: ObservableObject {
|
||
static let shared = PlayerModel()
|
||
|
||
@Published private(set) var state = PlayerState()
|
||
@Published private(set) var fileName: String?
|
||
@Published private(set) var fileIcon: NSImage?
|
||
@Published private(set) var commandError: String?
|
||
@Published private(set) var pendingRevision: UInt64?
|
||
@Published private(set) var resolvingYouTube = false
|
||
@Published private(set) var isLiveStream = false
|
||
@Published var seekPosition = 0.0
|
||
@Published var isSeeking = false
|
||
@Published private(set) var volume = 1.0
|
||
@Published private(set) var controlsVisible = true
|
||
@Published private(set) var isFullScreen = false
|
||
@Published private(set) var isOnTop = false
|
||
let compactWindow = CompactWindowController()
|
||
var isCompact: Bool { compactWindow.isCompact }
|
||
var canToggleCompact: Bool {
|
||
isCompact || compactWindow.isTransitioning ||
|
||
(canTogglePlayback && state.has_video && state.video_aspect_ratio.isFinite && state.video_aspect_ratio > 0)
|
||
}
|
||
var compactContentMinimumHeight: CGFloat {
|
||
guard let window else { return 90 }
|
||
// Hosting-view minimums include the native titlebar safe area, even with full-size content.
|
||
return max(0, 90 - (window.frame.height - window.contentLayoutRect.height))
|
||
}
|
||
|
||
let videoView: NSView?
|
||
private var handle: OpaquePointer?
|
||
private var timer: Timer?
|
||
private weak var window: NSWindow?
|
||
private var hideDeadline: ContinuousClock.Instant?
|
||
private var controlsHovered = false
|
||
private var titleHovered = false
|
||
private var controlsDragging = false
|
||
private var windowActive = true
|
||
private var lastAudibleVolume = 1.0
|
||
private var needsVideoFit = false
|
||
private var seekPending = false
|
||
private var youtubeTask: Task<Void, Never>?
|
||
private var youtubeRequest: UUID?
|
||
private var playbackProgress: PlaybackProgress
|
||
private var progressURL: URL?
|
||
private var resumePosition: Double?
|
||
private var lastSavedPosition: Double?
|
||
|
||
init(defaults: UserDefaults = .standard) {
|
||
playbackProgress = PlaybackProgress(defaults: defaults)
|
||
let player = player_create()
|
||
handle = player
|
||
if let player, let view = player_macos_make_video_view(player) {
|
||
videoView = Unmanaged<NSView>.fromOpaque(view).takeRetainedValue()
|
||
state = player_get_state(player)
|
||
volume = Double(player_get_volume(player))
|
||
} else {
|
||
videoView = nil
|
||
commandError = "Could not create the media player."
|
||
}
|
||
compactWindow.onChange = { [weak self] in self?.objectWillChange.send() }
|
||
timer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { [weak self] _ in
|
||
MainActor.assumeIsolated { self?.poll() }
|
||
}
|
||
}
|
||
|
||
var isBusy: Bool { resolvingYouTube || 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 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 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 let handle else { return }
|
||
cancelYouTubeResolution()
|
||
let url: URL
|
||
do {
|
||
url = try YouTubeResolver.validateURL(text)
|
||
} catch {
|
||
commandError = error.localizedDescription
|
||
revealControls()
|
||
return
|
||
}
|
||
if !player_pause(handle) {
|
||
commandError = "Could not pause the current media to open YouTube. Please try again."
|
||
revealControls()
|
||
return
|
||
}
|
||
let request = UUID()
|
||
youtubeRequest = request
|
||
resolvingYouTube = true
|
||
commandError = nil
|
||
revealControls()
|
||
youtubeTask = Task { [weak self] in
|
||
do {
|
||
let video = try await YouTubeResolver.resolve(url)
|
||
guard !Task.isCancelled, let self, self.youtubeRequest == request, self.handle != nil else { return }
|
||
self.youtubeTask = nil
|
||
self.youtubeRequest = nil
|
||
self.resolvingYouTube = false
|
||
self.openResolvedYouTube(video)
|
||
} catch {
|
||
guard !Task.isCancelled, let self, self.youtubeRequest == request else { return }
|
||
self.youtubeTask = nil
|
||
self.youtubeRequest = nil
|
||
self.resolvingYouTube = false
|
||
self.commandError = error.localizedDescription
|
||
self.revealControls()
|
||
}
|
||
}
|
||
}
|
||
|
||
private func cancelYouTubeResolution() {
|
||
youtubeRequest = nil
|
||
youtubeTask?.cancel()
|
||
youtubeTask = nil
|
||
resolvingYouTube = false
|
||
}
|
||
|
||
private func openResolvedYouTube(_ video: YouTubeVideo) {
|
||
guard let handle else { return }
|
||
saveProgress()
|
||
let revision = player_get_state(handle).revision
|
||
let accepted = video.video.url.withCString { videoURL in
|
||
video.video.headers.withCString { videoHeaders in
|
||
func enqueue(audioURL: UnsafePointer<CChar>?, audioHeaders: UnsafePointer<CChar>?) -> Bool {
|
||
var source = PlayerNetworkSource(video_url: videoURL, audio_url: audioURL,
|
||
video_headers: videoHeaders, audio_headers: audioHeaders,
|
||
duration: video.duration, is_live: video.isLive)
|
||
return player_open_network(handle, &source)
|
||
}
|
||
if let audio = video.audio {
|
||
return audio.url.withCString { audioURL in
|
||
audio.headers.withCString { enqueue(audioURL: audioURL, audioHeaders: $0) }
|
||
}
|
||
}
|
||
return enqueue(audioURL: nil, audioHeaders: nil)
|
||
}
|
||
}
|
||
guard accepted else {
|
||
commandError = "Could not queue the YouTube video to open. Please try again."
|
||
revealControls()
|
||
return
|
||
}
|
||
didAcceptOpen(revision: revision, title: video.title, icon: nil, url: video.webpageURL, live: video.isLive)
|
||
}
|
||
|
||
private func didAcceptOpen(revision: UInt64, title: String, icon: NSImage?, url: URL, live: Bool = false) {
|
||
guard let handle else { return }
|
||
pendingRevision = revision
|
||
fileName = title
|
||
fileIcon = icon
|
||
isLiveStream = live
|
||
progressURL = url
|
||
resumePosition = live ? nil : playbackProgress.position(for: url)
|
||
lastSavedPosition = nil
|
||
needsVideoFit = true
|
||
commandError = resumePosition != nil || player_play(handle) ? nil :
|
||
"Could not queue playback. Please try again."
|
||
isSeeking = false
|
||
seekPending = false
|
||
seekPosition = 0
|
||
window?.representedURL = url
|
||
window?.title = title
|
||
revealControls()
|
||
}
|
||
|
||
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() {
|
||
guard let handle, canTogglePlayback else { return }
|
||
let accepted = state.status == PLAYER_PLAYING ? player_pause(handle) : player_play(handle)
|
||
commandError = accepted ? nil : "Could not queue playback. Please try again."
|
||
revealControls()
|
||
}
|
||
|
||
func finishSeeking() {
|
||
isSeeking = false
|
||
guard let handle, canSeek else { return }
|
||
let position = min(max(0, seekPosition), duration)
|
||
let accepted = player_seek(handle, position)
|
||
if accepted { seekPending = true }
|
||
commandError = accepted ? nil : "Could not queue the seek. Please try again."
|
||
revealControls()
|
||
}
|
||
|
||
func closeFile() {
|
||
guard let handle, canClose else { return }
|
||
saveProgress()
|
||
cancelYouTubeResolution()
|
||
guard player_close(handle) else {
|
||
commandError = "Could not queue closing the file. Please try again."
|
||
return
|
||
}
|
||
compactWindow.restore(resumeFullScreen: false)
|
||
fileName = nil
|
||
fileIcon = nil
|
||
isLiveStream = false
|
||
progressURL = nil
|
||
resumePosition = nil
|
||
lastSavedPosition = nil
|
||
needsVideoFit = false
|
||
window?.aspectRatio = .zero
|
||
commandError = nil
|
||
isSeeking = false
|
||
seekPending = false
|
||
window?.representedURL = nil
|
||
window?.title = "terebi"
|
||
revealControls()
|
||
}
|
||
|
||
func seekRelative(_ seconds: Double) {
|
||
guard canSeek, let handle else { return }
|
||
let target = min(max(0, (seekPending ? seekPosition : state.position) + seconds), duration)
|
||
let accepted = player_seek(handle, target)
|
||
if accepted {
|
||
seekPosition = target
|
||
seekPending = true
|
||
}
|
||
commandError = accepted ? nil : "Could not queue the seek. Please try again."
|
||
revealControls()
|
||
}
|
||
|
||
func stepFrame(_ direction: Int32) {
|
||
guard canStepFrame, let handle else { return }
|
||
let accepted = player_step_frame(handle, direction)
|
||
if accepted { seekPending = false }
|
||
commandError = accepted ? nil : "Could not queue the frame step. Please try again."
|
||
revealControls()
|
||
}
|
||
|
||
func setVolume(_ value: Double) {
|
||
guard let handle else { return }
|
||
if player_set_volume(handle, Float(value)) {
|
||
volume = Double(player_get_volume(handle))
|
||
if volume > 0 { lastAudibleVolume = volume }
|
||
}
|
||
revealControls()
|
||
}
|
||
|
||
func toggleMute() {
|
||
guard canSetVolume else { return }
|
||
setVolume(volume == 0 ? lastAudibleVolume : 0)
|
||
}
|
||
|
||
func attachWindow(_ window: NSWindow) {
|
||
guard self.window !== window else { return }
|
||
self.window = window
|
||
window.title = fileName ?? "terebi"
|
||
window.contentMinSize = NSSize(width: 400, height: 240)
|
||
compactWindow.attach(window)
|
||
updateWindowState()
|
||
fitVideoWindow()
|
||
}
|
||
|
||
func updateWindowState() {
|
||
guard let window else { return }
|
||
isFullScreen = window.styleMask.contains(.fullScreen)
|
||
windowActive = window.isKeyWindow
|
||
if !compactWindow.isTransitioning {
|
||
window.level = isCompact || (isOnTop && !isFullScreen) ? .floating : .normal
|
||
}
|
||
if !isFullScreen && !compactWindow.isTransitioning { fitVideoWindow() }
|
||
revealControls()
|
||
}
|
||
|
||
func toggleFullScreen() {
|
||
guard !compactWindow.isTransitioning else { return }
|
||
if isCompact { compactWindow.restore(resumeFullScreen: false) }
|
||
window?.toggleFullScreen(nil)
|
||
revealControls()
|
||
}
|
||
|
||
func toggleCompact() {
|
||
guard canToggleCompact else { return }
|
||
compactWindow.toggle(aspect: state.video_aspect_ratio)
|
||
controlsHovered = false
|
||
titleHovered = false
|
||
revealControls()
|
||
}
|
||
|
||
func toggleOnTop() {
|
||
guard !isFullScreen, !isCompact, !compactWindow.isTransitioning else { return }
|
||
isOnTop.toggle()
|
||
window?.level = isOnTop ? .floating : .normal
|
||
revealControls()
|
||
}
|
||
|
||
func setControlsHovered(_ hovered: Bool) {
|
||
guard controlsHovered != hovered else { return }
|
||
controlsHovered = hovered
|
||
revealControls()
|
||
}
|
||
|
||
func setTitleHovered(_ hovered: Bool) {
|
||
guard titleHovered != hovered else { return }
|
||
titleHovered = hovered
|
||
revealControls()
|
||
}
|
||
|
||
func setControlsDragging(_ dragging: Bool) {
|
||
guard controlsDragging != dragging else { return }
|
||
controlsDragging = dragging
|
||
revealControls()
|
||
}
|
||
|
||
func hideControls(hideCursor: Bool = true) {
|
||
guard shouldHideControls else { return }
|
||
hideDeadline = nil
|
||
withAnimation(NSWorkspace.shared.accessibilityDisplayShouldReduceMotion ? nil : .easeIn(duration: 0.25)) {
|
||
controlsVisible = false
|
||
}
|
||
NSCursor.setHiddenUntilMouseMoves(hideCursor && windowActive && !isCompact)
|
||
}
|
||
|
||
func beginSeeking() {
|
||
isSeeking = true
|
||
revealControls()
|
||
}
|
||
|
||
func revealControls() {
|
||
hideDeadline = nil
|
||
NSCursor.setHiddenUntilMouseMoves(false)
|
||
if !controlsVisible {
|
||
withAnimation(NSWorkspace.shared.accessibilityDisplayShouldReduceMotion ? nil : .easeOut(duration: 0.25)) {
|
||
controlsVisible = true
|
||
}
|
||
}
|
||
guard shouldHideControls else { return }
|
||
hideDeadline = ContinuousClock.now.advanced(by: .milliseconds(2500))
|
||
}
|
||
|
||
private var shouldHideControls: Bool {
|
||
canTogglePlayback && state.has_video && fileName != nil &&
|
||
!isSeeking && !seekPending && !controlsHovered && !titleHovered && !controlsDragging &&
|
||
(windowActive || isCompact) && errorText == nil &&
|
||
!NSWorkspace.shared.isVoiceOverEnabled
|
||
}
|
||
|
||
private func fitVideoWindow() {
|
||
guard needsVideoFit, canTogglePlayback, let window, !isFullScreen,
|
||
!compactWindow.isTransitioning else { return }
|
||
let aspect = state.video_aspect_ratio
|
||
if isCompact {
|
||
compactWindow.updateAspect(state.video_aspect_ratio)
|
||
return
|
||
}
|
||
needsVideoFit = false
|
||
guard state.has_video, aspect.isFinite, aspect > 0 else {
|
||
window.aspectRatio = .zero
|
||
return
|
||
}
|
||
let screen = (window.screen ?? NSScreen.main)?.visibleFrame ?? window.frame
|
||
let width = min(window.frame.width, screen.width, screen.height * aspect)
|
||
let size = NSSize(width: width, height: width / aspect)
|
||
// ponytail: extreme ratios retain letterboxing; compact controls if exact fitting is needed.
|
||
guard size.width >= window.minSize.width, size.height >= window.minSize.height else {
|
||
window.aspectRatio = .zero
|
||
return
|
||
}
|
||
window.aspectRatio = NSSize(width: aspect, height: 1)
|
||
let frame = NSRect(
|
||
x: min(max(window.frame.midX - size.width / 2, screen.minX), screen.maxX - size.width),
|
||
y: min(max(window.frame.midY - size.height / 2, screen.minY), screen.maxY - size.height),
|
||
width: size.width, height: size.height)
|
||
window.setFrame(frame, display: true)
|
||
}
|
||
|
||
private func saveProgress(_ snapshot: PlayerState? = nil, force: Bool = true) {
|
||
guard let handle, let url = progressURL, pendingRevision == nil,
|
||
resumePosition == nil, !isLiveStream else { return }
|
||
let current = snapshot ?? player_get_state(handle)
|
||
guard current.status == PLAYER_PAUSED || current.status == PLAYER_PLAYING || current.status == PLAYER_ENDED,
|
||
current.has_video, current.can_seek, !current.seek_pending,
|
||
current.duration.isFinite, current.duration > 0,
|
||
current.position.isFinite, current.position >= 0 else { return }
|
||
let position = current.status == PLAYER_ENDED ? 0 : min(current.position, current.duration)
|
||
guard force || lastSavedPosition == nil || abs(position - lastSavedPosition!) >= 5 else { return }
|
||
playbackProgress.save(position > 0 ? position : nil, for: url)
|
||
lastSavedPosition = position
|
||
}
|
||
|
||
private func poll() {
|
||
guard let handle else { return }
|
||
applyState(player_get_state(handle))
|
||
}
|
||
|
||
func applyState(_ next: PlayerState) {
|
||
if let revision = pendingRevision {
|
||
guard next.revision != revision, next.status != PLAYER_OPENING else { return }
|
||
pendingRevision = nil
|
||
}
|
||
let statusChanged = state.status != next.status
|
||
state = next
|
||
if next.status == PLAYER_EMPTY || next.status == PLAYER_ERROR ||
|
||
(!isBusy && !next.has_video) {
|
||
compactWindow.restore(resumeFullScreen: false)
|
||
}
|
||
if !next.seek_pending || next.status == PLAYER_ERROR { seekPending = false }
|
||
if !isSeeking && !seekPending {
|
||
seekPosition = min(max(0, next.position.isFinite ? next.position : 0), duration)
|
||
}
|
||
fitVideoWindow()
|
||
if statusChanged { revealControls() }
|
||
if let hideDeadline, ContinuousClock.now >= hideDeadline { hideControls() }
|
||
saveProgress(next, force: statusChanged && (next.status == PLAYER_PAUSED || next.status == PLAYER_ENDED))
|
||
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 = player_play(handle) ? nil : "Could not queue playback. Please try again."
|
||
}
|
||
}
|
||
|
||
func shutdown() {
|
||
saveProgress()
|
||
cancelYouTubeResolution()
|
||
compactWindow.restore(resumeFullScreen: false)
|
||
timer?.invalidate()
|
||
timer = nil
|
||
hideDeadline = nil
|
||
NSCursor.setHiddenUntilMouseMoves(false)
|
||
guard let player = handle else { return }
|
||
handle = nil
|
||
player_destroy(player)
|
||
}
|
||
}
|
||
|
||
@MainActor
|
||
private struct PlayerGlass: NSViewRepresentable {
|
||
var titlebar = false
|
||
|
||
func makeNSView(context: Context) -> NSVisualEffectView {
|
||
let view = NSVisualEffectView()
|
||
view.blendingMode = .withinWindow
|
||
view.state = .active
|
||
view.appearance = NSAppearance(named: .vibrantDark)
|
||
view.material = titlebar ? .titlebar : .hudWindow
|
||
return view
|
||
}
|
||
|
||
func updateNSView(_ view: NSVisualEffectView, context: Context) {}
|
||
}
|
||
|
||
@MainActor
|
||
private struct PlaybackSlider: NSViewRepresentable {
|
||
@Binding var value: Double
|
||
let range: ClosedRange<Double>
|
||
var volume = false
|
||
var enabled = true
|
||
var onEditingChanged: (Bool) -> Void = { _ in }
|
||
|
||
func makeCoordinator() -> Coordinator { Coordinator(self) }
|
||
|
||
func makeNSView(context: Context) -> Slider {
|
||
let slider = Slider()
|
||
let cell = Cell()
|
||
cell.volume = volume
|
||
slider.cell = cell
|
||
slider.isContinuous = true
|
||
slider.target = context.coordinator
|
||
slider.action = #selector(Coordinator.changed(_:))
|
||
return slider
|
||
}
|
||
|
||
func updateNSView(_ slider: Slider, context: Context) {
|
||
context.coordinator.parent = self
|
||
slider.minValue = range.lowerBound
|
||
slider.maxValue = range.upperBound
|
||
slider.doubleValue = value
|
||
slider.isEnabled = enabled
|
||
slider.editingChanged = onEditingChanged
|
||
slider.setAccessibilityLabel(volume ? "Volume" : "Playback Position")
|
||
}
|
||
|
||
@MainActor
|
||
final class Coordinator: NSObject {
|
||
var parent: PlaybackSlider
|
||
init(_ parent: PlaybackSlider) { self.parent = parent }
|
||
@objc func changed(_ slider: Slider) {
|
||
if !slider.tracking { parent.onEditingChanged(true) }
|
||
parent.value = slider.doubleValue
|
||
if !slider.tracking { parent.onEditingChanged(false) }
|
||
}
|
||
}
|
||
|
||
final class Slider: NSSlider {
|
||
var editingChanged: (Bool) -> Void = { _ in }
|
||
private(set) var tracking = false
|
||
override func mouseDown(with event: NSEvent) {
|
||
guard isEnabled else { return }
|
||
tracking = true
|
||
editingChanged(true)
|
||
super.mouseDown(with: event)
|
||
tracking = false
|
||
editingChanged(false)
|
||
}
|
||
}
|
||
|
||
final class Cell: NSSliderCell {
|
||
var volume = false
|
||
override func drawBar(inside rect: NSRect, flipped: Bool) {
|
||
let bar = NSRect(x: rect.minX + knobThickness / 2, y: rect.midY - 1.5,
|
||
width: max(0, rect.width - knobThickness), height: 3)
|
||
NSColor.white.withAlphaComponent(0.12).setFill()
|
||
NSBezierPath(roundedRect: bar, xRadius: 1.5, yRadius: 1.5).fill()
|
||
if volume {
|
||
let fraction = (doubleValue - minValue) / (maxValue - minValue)
|
||
let fill = NSRect(x: bar.minX, y: bar.minY, width: bar.width * fraction, height: bar.height)
|
||
NSColor.systemOrange.withAlphaComponent(0.85).setFill()
|
||
NSBezierPath(roundedRect: fill, xRadius: 1.5, yRadius: 1.5).fill()
|
||
}
|
||
}
|
||
override func drawKnob(_ rect: NSRect) {
|
||
let width: CGFloat = volume ? 16 : 3
|
||
let height: CGFloat = volume ? 12 : 15
|
||
let knob = NSRect(x: rect.midX - width / 2, y: rect.midY - height / 2, width: width, height: height)
|
||
let color = volume ? NSColor(white: isEnabled ? 0.85 : 0.45, alpha: 1)
|
||
: NSColor.white.withAlphaComponent(isEnabled ? 0.65 : 0.25)
|
||
color.setFill()
|
||
NSBezierPath(roundedRect: knob, xRadius: volume ? 6 : 1, yRadius: volume ? 6 : 1).fill()
|
||
}
|
||
}
|
||
}
|
||
|
||
@MainActor
|
||
private struct VideoSurface: NSViewRepresentable {
|
||
let view: NSView
|
||
|
||
func makeNSView(context: Context) -> NSView { view }
|
||
func updateNSView(_ nsView: NSView, context: Context) {}
|
||
}
|
||
|
||
@MainActor
|
||
private struct 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 func time(_ seconds: Double) -> String {
|
||
let value = Int(min(max(0, seconds.isFinite ? seconds : 0), Double(Int.max / 2)))
|
||
let hours = value / 3600
|
||
return hours > 0
|
||
? String(format: "%d:%02d:%02d", hours, value / 60 % 60, value % 60)
|
||
: String(format: "%02d:%02d", value / 60, value % 60)
|
||
}
|
||
|
||
private func iconButton(_ symbol: String, _ label: String, action: @escaping () -> Void) -> some View {
|
||
Button(action: action) {
|
||
Image(systemName: symbol).font(.system(size: 17)).frame(width: 24, height: 24)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.help(label)
|
||
.accessibilityLabel(label)
|
||
}
|
||
|
||
private var titlebar: some View {
|
||
HStack(spacing: 4) {
|
||
if let icon = model.fileIcon {
|
||
Image(nsImage: icon).resizable().frame(width: 16, height: 16)
|
||
} else {
|
||
Image(systemName: "play.rectangle.fill").font(.system(size: 14))
|
||
}
|
||
Text(model.fileName ?? "terebi")
|
||
.font(.system(size: 13, weight: .semibold))
|
||
.lineLimit(1)
|
||
.truncationMode(.middle)
|
||
.help(model.fileName ?? "terebi")
|
||
Spacer(minLength: 12)
|
||
iconButton(model.isOnTop ? "pin.fill" : "pin",
|
||
model.isOnTop ? "Stop Keeping Window on Top" : "Keep Window on Top", action: model.toggleOnTop)
|
||
.foregroundStyle(.white.opacity(model.isOnTop ? 1 : 0.6))
|
||
.disabled(model.isFullScreen || model.compactWindow.isTransitioning)
|
||
}
|
||
.padding(.leading, model.isFullScreen ? 16 : 86)
|
||
.padding(.trailing, 12)
|
||
.frame(height: 32)
|
||
.foregroundStyle(.white.opacity(0.6))
|
||
.background(PlayerGlass(titlebar: true).allowsHitTesting(false))
|
||
.onHover(perform: model.setTitleHovered)
|
||
.opacity(model.controlsVisible ? 1 : 0)
|
||
.allowsHitTesting(model.controlsVisible)
|
||
.accessibilityHidden(!model.controlsVisible)
|
||
}
|
||
|
||
private var actionsMenu: some View {
|
||
Menu {
|
||
Button("Open File…", action: model.openPanel).disabled(!model.canOpen)
|
||
Button("Open YouTube URL…", action: model.openYouTubePanel).disabled(!model.canOpen)
|
||
Button("Close File", action: model.closeFile).disabled(!model.canClose)
|
||
Divider()
|
||
Button(model.isCompact ? "Exit Compact Mode" : "Enter Compact Mode", action: model.toggleCompact)
|
||
.disabled(!model.canToggleCompact)
|
||
Toggle("Snap to Screen Edges", isOn: Binding(
|
||
get: { model.compactWindow.snappingEnabled },
|
||
set: { model.compactWindow.snappingEnabled = $0 }))
|
||
Divider()
|
||
Button(model.isOnTop ? "Stop Keeping Window on Top" : "Keep Window on Top", action: model.toggleOnTop)
|
||
.disabled(model.isFullScreen || model.isCompact || model.compactWindow.isTransitioning)
|
||
} label: {
|
||
Image(systemName: "gearshape").font(.system(size: 17)).frame(width: 24, height: 24)
|
||
}
|
||
.menuStyle(.borderlessButton)
|
||
.menuIndicator(.hidden)
|
||
.fixedSize()
|
||
.accessibilityLabel("Playback Actions")
|
||
.help("Playback Actions")
|
||
}
|
||
|
||
private var compactControls: some View {
|
||
HStack(spacing: 16) {
|
||
iconButton(model.state.status == PLAYER_PLAYING ? "pause.fill" : "play.fill",
|
||
model.state.status == PLAYER_PLAYING ? "Pause" : "Play", action: model.togglePlayback)
|
||
.disabled(!model.canTogglePlayback)
|
||
iconButton("pip.exit", "Exit Compact Mode", action: model.toggleCompact)
|
||
.disabled(model.compactWindow.isTransitioning)
|
||
actionsMenu
|
||
}
|
||
.padding(.horizontal, 12)
|
||
.padding(.vertical, 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 {
|
||
VStack(spacing: 8) {
|
||
HStack(spacing: 0) {
|
||
HStack(spacing: 4) {
|
||
iconButton(model.volume == 0 ? "speaker.slash.fill" : "speaker.wave.2.fill",
|
||
model.volume == 0 ? "Unmute" : "Mute", action: model.toggleMute)
|
||
PlaybackSlider(value: Binding(get: { model.volume }, set: model.setVolume),
|
||
range: 0...1, volume: true, enabled: model.canSetVolume,
|
||
onEditingChanged: model.setControlsDragging)
|
||
.frame(width: 70, height: 14)
|
||
.accessibilityLabel("Volume")
|
||
.accessibilityValue("\(Int((model.volume * 100).rounded())) percent")
|
||
}
|
||
.disabled(!model.canSetVolume)
|
||
.frame(width: 106, alignment: .leading)
|
||
Spacer(minLength: 8)
|
||
HStack(spacing: 24) {
|
||
iconButton("backward.fill", "Seek Back 5 Seconds") { model.seekRelative(-5) }
|
||
.disabled(!model.canSeek)
|
||
Button(action: model.togglePlayback) {
|
||
Image(systemName: model.state.status == PLAYER_PLAYING ? "pause.fill" : "play.fill")
|
||
.font(.system(size: 24))
|
||
.frame(width: 24, height: 24)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.accessibilityLabel(model.state.status == PLAYER_PLAYING ? "Pause" : "Play")
|
||
.help(model.state.status == PLAYER_PLAYING ? "Pause (K / Space)" : "Play (K / Space)")
|
||
.disabled(!model.canTogglePlayback)
|
||
iconButton("forward.fill", "Seek Forward 5 Seconds") { model.seekRelative(5) }
|
||
.disabled(!model.canSeek)
|
||
}
|
||
Spacer(minLength: 8)
|
||
HStack(spacing: 12) {
|
||
iconButton("pip.enter", "Enter Compact Mode", action: model.toggleCompact)
|
||
.disabled(!model.canToggleCompact)
|
||
actionsMenu
|
||
}
|
||
.frame(width: 106, alignment: .trailing)
|
||
}
|
||
.padding(.horizontal, 16)
|
||
|
||
HStack(spacing: 10) {
|
||
Text(time(model.seekPosition)).frame(minWidth: 32, alignment: .trailing)
|
||
PlaybackSlider(value: $model.seekPosition, range: 0...max(model.duration, 0.001),
|
||
enabled: model.canSeek, onEditingChanged: { editing in
|
||
if editing { model.beginSeeking() } else { model.finishSeeking() }
|
||
})
|
||
.frame(height: 14)
|
||
.accessibilityLabel("Playback Position")
|
||
.accessibilityValue(time(model.seekPosition))
|
||
Button {
|
||
showsRemainingTime.toggle()
|
||
model.revealControls()
|
||
} label: {
|
||
Text(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)
|
||
}
|
||
.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 - 28)
|
||
} else {
|
||
titlebar
|
||
}
|
||
|
||
if model.canTogglePlayback && !model.isCompact {
|
||
playbackControls
|
||
.frame(width: 440, height: 68)
|
||
.background {
|
||
PlayerGlass()
|
||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||
.gesture(DragGesture(minimumDistance: 4).onChanged { value in
|
||
model.setControlsDragging(true)
|
||
controlOffset = CGSize(width: dragOrigin.width + value.translation.width / geometry.size.width,
|
||
height: dragOrigin.height + value.translation.height / geometry.size.height)
|
||
}.onEnded { _ in
|
||
let position = controllerPosition(in: geometry.size)
|
||
controlOffset = CGSize(width: (position.x - geometry.size.width / 2) / geometry.size.width,
|
||
height: (position.y - (geometry.size.height * 0.96 - controlSize.height / 2)) / geometry.size.height)
|
||
dragOrigin = controlOffset
|
||
model.setControlsDragging(false)
|
||
})
|
||
}
|
||
.scaleEffect(min(1, (geometry.size.width - 32) / 440))
|
||
.frame(width: min(440, geometry.size.width - 32),
|
||
height: 68 * min(1, (geometry.size.width - 32) / 440))
|
||
.onGeometryChange(for: CGSize.self) { $0.size } action: { controlSize = $0 }
|
||
.position(controllerPosition(in: geometry.size))
|
||
.opacity(model.controlsVisible ? 1 : 0)
|
||
.allowsHitTesting(model.controlsVisible)
|
||
.accessibilityHidden(!model.controlsVisible)
|
||
}
|
||
if dropTargeted {
|
||
RoundedRectangle(cornerRadius: 10)
|
||
.strokeBorder(.white.opacity(0.75), lineWidth: 2)
|
||
.padding(8)
|
||
.allowsHitTesting(false)
|
||
}
|
||
}
|
||
.dropDestination(for: URL.self) { urls, _ in
|
||
guard model.canOpen, let url = urls.first else { return false }
|
||
if !url.isFileURL && (try? YouTubeResolver.validateURL(url.absoluteString)) == nil { return false }
|
||
model.open(url)
|
||
return true
|
||
} isTargeted: {
|
||
dropTargeted = $0
|
||
model.revealControls()
|
||
}
|
||
.onContinuousHover { phase in
|
||
switch phase {
|
||
case .active:
|
||
if model.isCompact { model.revealControls() }
|
||
case .ended: model.hideControls(hideCursor: false)
|
||
}
|
||
}
|
||
}
|
||
.frame(minWidth: model.isCompact ? 160 : 400,
|
||
minHeight: model.isCompact ? model.compactContentMinimumHeight : 240)
|
||
.ignoresSafeArea()
|
||
.preferredColorScheme(.dark)
|
||
.background(WindowChrome(model: model))
|
||
}
|
||
}
|
||
|
||
@MainActor
|
||
private struct WindowChrome: NSViewRepresentable {
|
||
@ObservedObject var model: PlayerModel
|
||
|
||
func makeNSView(context: Context) -> ChromeView { ChromeView(model: model) }
|
||
func updateNSView(_ view: ChromeView, context: Context) { view.updateButtons() }
|
||
static func dismantleNSView(_ view: ChromeView, coordinator: ()) { view.detach() }
|
||
|
||
final class ChromeView: NSView {
|
||
private let model: PlayerModel
|
||
private weak var configuredWindow: NSWindow?
|
||
private var monitor: Any?
|
||
private var observers: [NSObjectProtocol] = []
|
||
private var dragOrigin: NSPoint?
|
||
|
||
init(model: PlayerModel) {
|
||
self.model = model
|
||
super.init(frame: .zero)
|
||
}
|
||
required init?(coder: NSCoder) { nil }
|
||
|
||
func handleKeyEvent(_ event: NSEvent) -> Bool {
|
||
guard event.type == .keyDown, let window = configuredWindow, event.window === window,
|
||
!(window.firstResponder is NSTextView),
|
||
event.modifierFlags.intersection([.command, .control, .option]).isEmpty else { return false }
|
||
if event.keyCode == 53 && model.isFullScreen {
|
||
if !event.isARepeat { model.toggleFullScreen() }
|
||
return true
|
||
}
|
||
let key = event.charactersIgnoringModifiers?.lowercased()
|
||
switch key {
|
||
case "j": model.seekRelative(-10)
|
||
case "l": model.seekRelative(10)
|
||
case ",": model.stepFrame(-1)
|
||
case ".": model.stepFrame(1)
|
||
case "k", " ":
|
||
if !event.isARepeat { model.togglePlayback() }
|
||
case "m":
|
||
if !event.isARepeat { model.toggleMute() }
|
||
case "f":
|
||
if !event.isARepeat { model.toggleFullScreen() }
|
||
case "p":
|
||
if !event.isARepeat { model.toggleOnTop() }
|
||
case "o":
|
||
if !event.isARepeat { model.openPanel() }
|
||
case "q":
|
||
if !event.isARepeat { NSApp.terminate(nil) }
|
||
default: return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
override func viewDidMoveToWindow() {
|
||
super.viewDidMoveToWindow()
|
||
guard let window else { detach(); return }
|
||
guard window !== configuredWindow else { return }
|
||
detach()
|
||
configuredWindow = window
|
||
window.styleMask.insert(.fullSizeContentView)
|
||
window.titleVisibility = .hidden
|
||
window.titlebarAppearsTransparent = true
|
||
window.backgroundColor = .black
|
||
window.acceptsMouseMovedEvents = true
|
||
window.collectionBehavior.insert(.fullScreenPrimary)
|
||
window.appearance = NSAppearance(named: .darkAqua)
|
||
for name in [NSWindow.didBecomeKeyNotification, NSWindow.didResignKeyNotification,
|
||
NSWindow.didEnterFullScreenNotification, NSWindow.didExitFullScreenNotification] {
|
||
observers.append(NotificationCenter.default.addObserver(forName: name, object: window, queue: .main) { [weak self] _ in
|
||
MainActor.assumeIsolated {
|
||
self?.model.updateWindowState()
|
||
self?.updateButtons()
|
||
}
|
||
})
|
||
}
|
||
monitor = NSEvent.addLocalMonitorForEvents(matching: [.mouseMoved, .leftMouseDown, .leftMouseUp, .keyDown]) { [weak self] event in
|
||
let consumed = MainActor.assumeIsolated {
|
||
guard let self, let window = self.configuredWindow else { return false }
|
||
if event.type == .leftMouseUp {
|
||
let origin = self.dragOrigin
|
||
self.dragOrigin = nil
|
||
if let origin {
|
||
Task { @MainActor [weak self, weak window] in
|
||
guard let self, let window, self.configuredWindow === window,
|
||
self.model.isCompact, window.frame.origin != origin else { return }
|
||
self.model.compactWindow.snapAfterDrag()
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
guard event.window === window else { return false }
|
||
if event.type == .leftMouseDown { self.dragOrigin = window.frame.origin }
|
||
if event.type == .keyDown && event.keyCode == 48 { self.model.revealControls() }
|
||
if event.type == .mouseMoved || event.type == .leftMouseDown {
|
||
self.model.revealControls()
|
||
} else if event.type == .keyDown {
|
||
return self.handleKeyEvent(event)
|
||
}
|
||
return false
|
||
}
|
||
return consumed ? nil : event
|
||
}
|
||
Task { @MainActor [weak self, weak window] in
|
||
guard let self, let window else { return }
|
||
self.model.attachWindow(window)
|
||
self.updateButtons()
|
||
window.makeFirstResponder(nil)
|
||
}
|
||
}
|
||
|
||
func updateButtons() {
|
||
let hidden = !model.controlsVisible || model.isFullScreen || model.isCompact
|
||
for button in [NSWindow.ButtonType.closeButton, .miniaturizeButton, .zoomButton] {
|
||
if let view = configuredWindow?.standardWindowButton(button), view.isHidden != hidden {
|
||
view.isHidden = hidden
|
||
}
|
||
}
|
||
}
|
||
|
||
override func mouseDown(with event: NSEvent) {
|
||
model.setControlsDragging(true)
|
||
window?.performDrag(with: event)
|
||
model.compactWindow.snapAfterDrag()
|
||
model.setControlsDragging(false)
|
||
}
|
||
|
||
func detach() {
|
||
if let monitor { NSEvent.removeMonitor(monitor) }
|
||
monitor = nil
|
||
dragOrigin = nil
|
||
observers.forEach(NotificationCenter.default.removeObserver)
|
||
observers.removeAll()
|
||
configuredWindow = nil
|
||
}
|
||
}
|
||
}
|
||
|
||
@MainActor
|
||
final class AppDelegate: NSObject, NSApplicationDelegate {
|
||
func applicationWillFinishLaunching(_ notification: Notification) {
|
||
// Own external opens: SwiftUI's document routing can close the singleton Window.
|
||
let events = NSAppleEventManager.shared()
|
||
events.setEventHandler(self, andSelector: #selector(openInternetURL(_:withReplyEvent:)),
|
||
forEventClass: AEEventClass(kInternetEventClass), andEventID: AEEventID(kAEGetURL))
|
||
}
|
||
|
||
@objc private func openInternetURL(_ event: NSAppleEventDescriptor, withReplyEvent reply: NSAppleEventDescriptor) {
|
||
guard let text = event.paramDescriptor(forKeyword: keyDirectObject)?.stringValue else { return }
|
||
PlayerModel.shared.openYouTube(text)
|
||
}
|
||
|
||
@objc private func openDocuments(_ event: NSAppleEventDescriptor, withReplyEvent reply: NSAppleEventDescriptor) {
|
||
guard let url = event.paramDescriptor(forKeyword: keyDirectObject)?.atIndex(1)?.fileURLValue else { return }
|
||
PlayerModel.shared.open(url)
|
||
}
|
||
|
||
func applicationDidFinishLaunching(_ notification: Notification) {
|
||
NSApp.setActivationPolicy(.regular)
|
||
NSApp.activate(ignoringOtherApps: true)
|
||
NSAppleEventManager.shared().setEventHandler(self, andSelector: #selector(openDocuments(_:withReplyEvent:)),
|
||
forEventClass: AEEventClass(kCoreEventClass), andEventID: AEEventID(kAEOpenDocuments))
|
||
}
|
||
|
||
// Let 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()
|
||
}
|
||
}
|
||
|
||
@main
|
||
@MainActor
|
||
struct TerebiApp: App {
|
||
@NSApplicationDelegateAdaptor(AppDelegate.self) private var delegate
|
||
@StateObject private var model = PlayerModel.shared
|
||
|
||
var body: some Scene {
|
||
Window("\(model.fileName ?? "terebi")", id: "terebi") {
|
||
PlayerContent(model: model)
|
||
}
|
||
.defaultSize(width: 900, height: 560)
|
||
.windowStyle(.hiddenTitleBar)
|
||
.commands {
|
||
CommandGroup(replacing: .newItem) {
|
||
Button("Open File…", action: model.openPanel).keyboardShortcut("o")
|
||
.disabled(!model.canOpen)
|
||
Button("Open YouTube URL…", action: model.openYouTubePanel)
|
||
.keyboardShortcut("o", modifiers: [.command, .shift])
|
||
.disabled(!model.canOpen)
|
||
Button("Close File", action: model.closeFile)
|
||
.keyboardShortcut("w", modifiers: [.command, .shift])
|
||
.disabled(!model.canClose)
|
||
}
|
||
CommandMenu("Playback") {
|
||
Button(model.state.status == PLAYER_PLAYING ? "Pause" : "Play", action: model.togglePlayback)
|
||
.disabled(!model.canTogglePlayback)
|
||
Button("Seek Back 5 Seconds") { model.seekRelative(-5) }
|
||
.keyboardShortcut(.leftArrow, modifiers: []).disabled(!model.canSeek)
|
||
Button("Seek Forward 5 Seconds") { model.seekRelative(5) }
|
||
.keyboardShortcut(.rightArrow, modifiers: []).disabled(!model.canSeek)
|
||
Button("Seek Back 10 Seconds") { model.seekRelative(-10) }.disabled(!model.canSeek)
|
||
Button("Seek Forward 10 Seconds") { model.seekRelative(10) }.disabled(!model.canSeek)
|
||
Divider()
|
||
Button("Previous Frame") { model.stepFrame(-1) }.disabled(!model.canStepFrame)
|
||
Button("Next Frame") { model.stepFrame(1) }.disabled(!model.canStepFrame)
|
||
Divider()
|
||
Button("Volume Up") { model.setVolume(min(1, model.volume + 0.05)) }
|
||
.keyboardShortcut(.upArrow, modifiers: []).disabled(!model.canSetVolume)
|
||
Button("Volume Down") { model.setVolume(max(0, model.volume - 0.05)) }
|
||
.keyboardShortcut(.downArrow, modifiers: []).disabled(!model.canSetVolume)
|
||
Button(model.volume == 0 ? "Unmute" : "Mute", action: model.toggleMute)
|
||
.disabled(!model.canSetVolume)
|
||
}
|
||
CommandGroup(after: .windowArrangement) {
|
||
Button(model.isFullScreen ? "Exit Full Screen" : "Enter Full Screen", action: model.toggleFullScreen)
|
||
.keyboardShortcut("f", modifiers: [.control, .command])
|
||
.disabled(model.compactWindow.isTransitioning)
|
||
Button(model.isCompact ? "Exit Compact Mode" : "Enter Compact Mode", action: model.toggleCompact)
|
||
.keyboardShortcut("p", modifiers: [.control, .command])
|
||
.disabled(!model.canToggleCompact)
|
||
Toggle("Snap to Screen Edges", isOn: Binding(
|
||
get: { model.compactWindow.snappingEnabled },
|
||
set: { model.compactWindow.snappingEnabled = $0 }))
|
||
Button(model.isOnTop ? "Stop Keeping Window on Top" : "Keep Window on Top", action: model.toggleOnTop)
|
||
.disabled(model.isFullScreen || model.isCompact || model.compactWindow.isTransitioning)
|
||
}
|
||
}
|
||
}
|
||
}
|