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