3406 lines
156 KiB
Swift
3406 lines
156 KiB
Swift
import AppKit
|
||
import SwiftUI
|
||
import TerebiConfiguration
|
||
|
||
func compactProgressText(position: Double, duration: Double, format: CompactTitleProgress) -> String? {
|
||
guard format != .none, duration.isFinite, duration > 0, position.isFinite else { return nil }
|
||
let position = min(max(0, position), duration)
|
||
if format == .percentage {
|
||
let tenths = Int((position / duration * 1000).rounded(.down))
|
||
if tenths == 999 { return "100.0" }
|
||
return "\(tenths / 10).\(tenths % 10)"
|
||
}
|
||
let total = Int(min(duration, Double(Int.max / 2)))
|
||
let current = Int(min(position, Double(Int.max / 2)))
|
||
if duration < 60 { return "\(current) / \(total) s" }
|
||
let divisor = duration < 3600 ? 1 : 60
|
||
let currentUnits = current / divisor
|
||
let totalUnits = total / divisor
|
||
let currentTime = String(format: "%02lld:%02lld", Int64(currentUnits / 60), Int64(currentUnits % 60))
|
||
let totalTime = String(format: "%02lld:%02lld", Int64(totalUnits / 60), Int64(totalUnits % 60))
|
||
return "\(currentTime) / \(totalTime) \(duration < 3600 ? "m" : "h")"
|
||
}
|
||
|
||
@MainActor
|
||
final class PlayerModel: ObservableObject {
|
||
static var startupConfiguration = PlayerConfiguration()
|
||
static var startupProgressFile: String?
|
||
static let shared = PlayerModel(configuration: startupConfiguration, progressFile: startupProgressFile)
|
||
|
||
@Published private(set) var state = PlayerState()
|
||
@Published private(set) var fileName: String?
|
||
@Published var showsYouTubeInput = false
|
||
@Published private(set) var fileIcon: NSImage?
|
||
@Published private(set) var commandError: String?
|
||
@Published private(set) var queue = PlaybackQueue()
|
||
@Published private(set) var recentMedia: [PlaybackProgress.RecentMedia] = []
|
||
@Published private(set) var startContentHeight: CGFloat = 400
|
||
@Published var showsQueue = false {
|
||
didSet {
|
||
if showsQueue != oldValue { transitionQueue() }
|
||
}
|
||
}
|
||
@Published private(set) var queueWidth: CGFloat = 0
|
||
private var queueAnimation: Timer?
|
||
private var queueAnimationCompletion: (() -> Void)?
|
||
private var queueWindowVideoWidth: CGFloat?
|
||
var isQueueTransitioning: Bool { queueAnimation != nil }
|
||
private var queuePlaybackRevision: UInt64?
|
||
private var queueCompletionHandled = false
|
||
private var queueCastDevice: CastDevice?
|
||
var canPreviousQueueItem: Bool { canOpen && queue.canPrevious }
|
||
var canNextQueueItem: Bool { canOpen && queue.canNext }
|
||
private var transition = PlaybackTransition()
|
||
let castDiscovery = CastDiscovery()
|
||
private lazy var casting = CastPlaybackTransition { [weak self] event in self?.receiveCast(event) }
|
||
var castDevice: CastDevice? { casting.device }
|
||
var castConnecting: Bool { casting.connecting }
|
||
private var currentMedia: MediaSource?
|
||
private var shouldRecordRecentMedia = false
|
||
private var metadataLifecycle = MetadataLifecycle()
|
||
private var metadataTask: Task<Void, Never>?
|
||
var visibleIntervals: [MiruroInterval] {
|
||
guard !isBusy, state.status != PLAYER_EMPTY, state.status != PLAYER_ERROR,
|
||
currentMedia?.url.absoluteString == metadataLifecycle.identity else { return [] }
|
||
return metadataLifecycle.visibleIntervals(duration: state.duration)
|
||
}
|
||
var pendingRevision: UInt64? { transition.pendingRevision }
|
||
var resolvingStream: Bool { transition.isResolving }
|
||
var isLiveStream: Bool { transition.isLive }
|
||
var isNetworkMedia: Bool { transition.isNetwork }
|
||
@Published private(set) var youtubeHeights: [Int] = []
|
||
@Published private(set) var youtubeHeight: Int?
|
||
@Published private(set) var youtubeQuality: Int?
|
||
@Published private(set) var automaticYouTubeQuality = false
|
||
@Published private(set) var showsBufferInfo = false
|
||
@Published private(set) var subtitleTracks: [ResolvedSubtitle] = []
|
||
@Published private(set) var selectedSubtitle: Int?
|
||
@Published private(set) var subtitleText = ""
|
||
@Published private(set) var subtitleError: String?
|
||
private var subtitleDocument: WebVTTDocument?
|
||
private var subtitleTask: Task<Void, Never>?
|
||
@Published var seekPosition = 0.0
|
||
@Published var isSeeking = false
|
||
@Published private(set) var volume = 1.0
|
||
@Published private(set) var controlsVisible = true
|
||
@Published private(set) var isFullScreen = false
|
||
@Published private(set) var isOnTop = false
|
||
let compactWindow: CompactWindowController
|
||
let keyBindings: KeyBindings
|
||
let compactTitleProgress: CompactTitleProgress
|
||
var compactProgress: String? {
|
||
guard !isBusy, !isLiveStream, fileName != nil, state.status != PLAYER_ERROR else { return nil }
|
||
return compactProgressText(position: state.position, duration: state.duration, format: compactTitleProgress)
|
||
}
|
||
var playbackIntent: Bool? { casting.active ? casting.playbackIntent : transition.playbackIntent }
|
||
@Published private(set) var fullScreenIntent: Bool?
|
||
@Published private(set) var compactIntent: Bool?
|
||
var effectivePlaying: Bool { playbackIntent ?? (state.status == PLAYER_PLAYING) }
|
||
var playbackAction: PlayerAction { effectivePlaying ? .pause : .play }
|
||
var fullscreenAction: PlayerAction { (fullScreenIntent ?? isFullScreen) ? .exitFullscreen : .enterFullscreen }
|
||
var compactAction: PlayerAction { (compactIntent ?? isCompact) ? .exitCompact : .enterCompact }
|
||
var muteAction: PlayerAction { volume == 0 ? .unmute : .mute }
|
||
var onTopAction: PlayerAction { isOnTop ? .disableAlwaysOnTop : .enableAlwaysOnTop }
|
||
var bufferInfoAction: PlayerAction { showsBufferInfo ? .hideBufferInfo : .showBufferInfo }
|
||
var snappingAction: PlayerAction {
|
||
compactWindow.snappingEnabled ? .disableCompactSnapping : .enableCompactSnapping
|
||
}
|
||
var automaticQualityAction: PlayerAction {
|
||
automaticYouTubeQuality ? .disableAutomaticQuality : .enableAutomaticQuality
|
||
}
|
||
var isCompact: Bool { compactWindow.isCompact }
|
||
var canToggleCompact: Bool { canPerform(compactAction) }
|
||
var compactContentMinimumHeight: CGFloat {
|
||
guard let window else { return 90 }
|
||
// Hosting-view minimums include the native titlebar safe area, even with full-size content.
|
||
return max(0, 90 - (window.frame.height - window.contentLayoutRect.height))
|
||
}
|
||
|
||
let videoView: NSView?
|
||
private var handle: OpaquePointer?
|
||
private var timer: Timer?
|
||
private weak var window: NSWindow?
|
||
private var hideDeadline: ContinuousClock.Instant?
|
||
private var pointerInsideWindow = true
|
||
private var keyboardNavigation = false
|
||
private var controlsHovered = false
|
||
private var titleHovered = false
|
||
private var controlsDragging = false
|
||
private var windowActive = true
|
||
private var lastAudibleVolume = 1.0
|
||
private var needsVideoFit = false
|
||
private let qualityClock: () -> Double
|
||
private var automaticQuality = AutomaticQuality()
|
||
private var qualityNow: Double { qualityClock() }
|
||
private let playbackProgress: PlaybackProgress
|
||
private var systemPlayback: SystemPlaybackControls?
|
||
private(set) var progressInitializationError: String?
|
||
private var reportingEnabled = false
|
||
private var reportingErrorShown = false
|
||
private var reportingErrorBuffer = [CChar]()
|
||
private var reportingErrorMessage: String {
|
||
reportingErrorBuffer.withUnsafeBufferPointer { String(cString: $0.baseAddress!) }
|
||
}
|
||
|
||
init(defaults: UserDefaults = .standard, configuration: PlayerConfiguration = PlayerConfiguration(),
|
||
progressFile: String? = nil,
|
||
qualityClock: @escaping () -> Double = { ProcessInfo.processInfo.systemUptime }) {
|
||
self.qualityClock = qualityClock
|
||
keyBindings = configuration.keys
|
||
compactTitleProgress = configuration.compactTitleProgress
|
||
automaticYouTubeQuality = configuration.automaticQuality
|
||
showsBufferInfo = configuration.showBufferInfo
|
||
compactWindow = CompactWindowController(snappingEnabled: configuration.snapToEdges, defaults: defaults)
|
||
playbackProgress = PlaybackProgress(defaults: defaults)
|
||
recentMedia = playbackProgress.recentMedia
|
||
let player = player_create()
|
||
handle = player
|
||
if let player, let view = player_macos_make_video_view(player) {
|
||
videoView = Unmanaged<NSView>.fromOpaque(view).takeRetainedValue()
|
||
state = player_get_state(player)
|
||
volume = Double(player_get_volume(player))
|
||
} else {
|
||
videoView = nil
|
||
commandError = "Could not create the media player."
|
||
}
|
||
if let progressFile {
|
||
reportingErrorBuffer = [CChar](repeating: 0, count: 512)
|
||
if let player {
|
||
reportingEnabled = progressFile.withCString { player_set_progress_file(player, $0, &reportingErrorBuffer) }
|
||
if !reportingEnabled { progressInitializationError = reportingErrorMessage }
|
||
} else {
|
||
progressInitializationError = "Could not create the media player to initialize progress reporting."
|
||
}
|
||
}
|
||
compactWindow.onChange = { [weak self] in
|
||
guard let self else { return }
|
||
if !self.compactWindow.isTransitioning {
|
||
self.compactIntent = nil
|
||
self.fullScreenIntent = nil
|
||
}
|
||
self.objectWillChange.send()
|
||
}
|
||
timer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { [weak self] _ in
|
||
MainActor.assumeIsolated { self?.poll() }
|
||
}
|
||
systemPlayback = SystemPlaybackControls(model: self)
|
||
castDiscovery.start()
|
||
}
|
||
|
||
var isBusy: Bool { castConnecting || (!casting.active && transition.isBusy(state.status)) }
|
||
var canTogglePlayback: Bool {
|
||
handle != nil && !isBusy &&
|
||
(state.status == PLAYER_PAUSED || state.status == PLAYER_PLAYING || state.status == PLAYER_ENDED)
|
||
}
|
||
var canSeek: Bool { canTogglePlayback && state.can_seek && state.duration.isFinite && state.duration > 0 }
|
||
var canStepFrame: Bool { castDevice == nil && canTogglePlayback && state.has_video && state.can_seek && !isSeeking }
|
||
var canChangeYouTubeQuality: Bool {
|
||
castDevice == nil && canTogglePlayback && !youtubeHeights.isEmpty && !isSeeking && !transition.seekPending && !state.seek_pending
|
||
}
|
||
var canOpen: Bool { handle != nil }
|
||
var canClose: Bool { handle != nil && (isBusy || state.status != PLAYER_EMPTY || fileName != nil) }
|
||
var canSetVolume: Bool { handle != nil && !isBusy && state.has_audio && state.status != PLAYER_ERROR }
|
||
var canCast: Bool {
|
||
castDevice == nil && currentMedia != nil && canTogglePlayback && !isSeeking && !state.seek_pending &&
|
||
!transition.seekPending && transition.playbackIntent == nil && !castConnecting
|
||
}
|
||
var duration: Double { state.duration.isFinite ? max(0, state.duration) : 0 }
|
||
var bufferedRange: ClosedRange<Double>? {
|
||
guard fileName != nil, canSeek, !transition.seekPending, !state.seek_pending,
|
||
state.buffered_start.isFinite, state.buffered_end.isFinite,
|
||
state.buffered_start >= 0, state.buffered_end > state.buffered_start,
|
||
state.buffered_end <= duration else { return nil }
|
||
return state.buffered_start...state.buffered_end
|
||
}
|
||
var errorText: String? {
|
||
if let commandError { return commandError }
|
||
guard !isBusy, state.status == PLAYER_ERROR else { return nil }
|
||
var error = state.error
|
||
return withUnsafeBytes(of: &error) { bytes in
|
||
let message = String(decoding: bytes.prefix { $0 != 0 }, as: UTF8.self)
|
||
return message.isEmpty ? "An unknown playback error occurred." : message
|
||
}
|
||
}
|
||
|
||
func cast(to device: CastDevice, playing: Bool? = nil) {
|
||
guard canCast, let source = currentMedia, let handle else { return }
|
||
cancelStreamResolution()
|
||
commandError = nil
|
||
casting.start(to: device, source: source, title: fileName ?? "Terebi", subtitle: selectedSubtitle,
|
||
handle: handle, snapshot: state, playing: playing ?? effectivePlaying,
|
||
volume: volume, audibleVolume: lastAudibleVolume)
|
||
revealControls()
|
||
}
|
||
|
||
func stopCasting(resume: Bool = true) {
|
||
queueCastDevice = nil
|
||
casting.stop(resume: resume)
|
||
}
|
||
|
||
private func receiveCast(_ event: CastPlaybackTransition.Event) {
|
||
objectWillChange.send()
|
||
switch event {
|
||
case .changed:
|
||
break
|
||
case .activated:
|
||
transition.acceptCast()
|
||
subtitleTask?.cancel()
|
||
subtitleText = ""
|
||
case .status(let projection):
|
||
state = projection.state
|
||
volume = projection.volume
|
||
if volume > 0 { lastAudibleVolume = volume }
|
||
selectedSubtitle = projection.subtitle
|
||
if !isSeeking && !state.seek_pending { seekPosition = state.position }
|
||
if let handle { _ = player_report_remote_state(handle, &state) }
|
||
saveProgress()
|
||
completeQueuePlaybackIfNeeded()
|
||
case .stopped(let result):
|
||
if let checkpoint = result.checkpoint {
|
||
saveCastProgress(checkpoint, source: result.source)
|
||
} else {
|
||
saveProgress()
|
||
}
|
||
if let revision = result.suspendedRevision { transition.suspendHistory(revision: revision) }
|
||
volume = result.volume
|
||
lastAudibleVolume = result.audibleVolume
|
||
if let restore = result.restore {
|
||
openMedia(result.source, change: .restore(position: restore.position, playing: restore.playing))
|
||
}
|
||
revealControls()
|
||
case .error(let message):
|
||
commandError = message
|
||
revealControls()
|
||
}
|
||
systemPlayback?.update()
|
||
}
|
||
|
||
@discardableResult
|
||
private func seekCast(to position: Double) -> Bool {
|
||
let accepted = casting.seek(position)
|
||
if accepted { seekPosition = position }
|
||
commandError = accepted ? nil : "Could not seek Cast playback."
|
||
revealControls()
|
||
return accepted
|
||
}
|
||
|
||
func openPanel() { filePanel(append: false) }
|
||
|
||
func addQueuePanel() { filePanel(append: true) }
|
||
|
||
private func filePanel(append: Bool) {
|
||
guard canOpen else { return }
|
||
defer { window?.makeFirstResponder(nil) }
|
||
revealControls()
|
||
let panel = NSOpenPanel()
|
||
panel.canChooseFiles = true
|
||
panel.canChooseDirectories = false
|
||
panel.allowsMultipleSelection = true
|
||
panel.prompt = append ? "Add to Queue" : "Open"
|
||
guard panel.runModal() == .OK else { return }
|
||
if append { addToQueue(panel.urls) } else { openFiles(panel.urls) }
|
||
}
|
||
|
||
|
||
func openStream(_ text: String) {
|
||
guard canOpen else { return }
|
||
let stream: StreamURL
|
||
do {
|
||
stream = try StreamURL.parse(text)
|
||
} catch {
|
||
commandError = error.localizedDescription
|
||
revealControls()
|
||
return
|
||
}
|
||
showsYouTubeInput = false
|
||
clearQueue()
|
||
switch stream {
|
||
case .youtube(let url): resolveYouTube(url, height: nil)
|
||
case .miruro(let mediaID, let episode): resolveMiruro(mediaID: mediaID, episode: episode)
|
||
}
|
||
}
|
||
|
||
func openResolvedSource(_ file: URL) {
|
||
do {
|
||
let source = try ResolvedSource.load(from: file)
|
||
clearQueue()
|
||
cancelStreamResolution()
|
||
openMedia(.resolved(source))
|
||
} catch {
|
||
commandError = error.localizedDescription
|
||
revealControls()
|
||
}
|
||
}
|
||
|
||
func selectSubtitle(_ index: Int?) {
|
||
if casting.active {
|
||
if !casting.setSubtitle(index) { commandError = "Could not change Cast subtitles." }
|
||
return
|
||
}
|
||
subtitleTask?.cancel()
|
||
subtitleTask = nil
|
||
subtitleDocument = nil
|
||
subtitleText = ""
|
||
subtitleError = nil
|
||
selectedSubtitle = index
|
||
guard let index, subtitleTracks.indices.contains(index) else { return }
|
||
let track = subtitleTracks[index]
|
||
subtitleTask = Task { [weak self] in
|
||
do {
|
||
let document = try await WebVTTDocument.load(url: track.url, headers: track.headers)
|
||
guard !Task.isCancelled, let self else { return }
|
||
self.subtitleDocument = document
|
||
self.updateSubtitle()
|
||
} catch {
|
||
guard !Task.isCancelled, let self else { return }
|
||
self.subtitleError = error.localizedDescription
|
||
}
|
||
}
|
||
}
|
||
|
||
private func updateSubtitle() {
|
||
if castDevice != nil { subtitleText = ""; return }
|
||
subtitleText = state.seek_pending || transition.seekPending || isBusy ||
|
||
state.status == PLAYER_EMPTY || state.status == PLAYER_ERROR ? "" :
|
||
subtitleDocument?.text(at: state.position) ?? ""
|
||
}
|
||
|
||
func setShowsBufferInfo(_ enabled: Bool) {
|
||
showsBufferInfo = enabled
|
||
revealControls()
|
||
}
|
||
|
||
func setAutomaticYouTubeQuality(_ enabled: Bool) {
|
||
automaticYouTubeQuality = enabled
|
||
automaticQuality = AutomaticQuality()
|
||
if !enabled && transition.resolvingAutomaticQuality { cancelStreamResolution() }
|
||
revealControls()
|
||
}
|
||
|
||
func setYouTubeQuality(_ height: Int?) {
|
||
// A manual choice is an override, including reselecting the active/default resolution.
|
||
setAutomaticYouTubeQuality(false)
|
||
guard canChangeYouTubeQuality, let url = transition.mediaURL, let handle,
|
||
height == nil || youtubeHeights.contains(height!) else { return }
|
||
guard height != youtubeQuality else { return }
|
||
let target = height ?? youtubeHeights.filter { $0 <= 1080 }.max()
|
||
if target == youtubeHeight {
|
||
youtubeQuality = height
|
||
return
|
||
}
|
||
let change = transition.qualityChange(player_get_state(handle), playing: effectivePlaying)
|
||
resolveYouTube(url, height: height, change: change)
|
||
}
|
||
|
||
private func resolveMiruro(mediaID: String, episode: Int) {
|
||
guard let handle else { return }
|
||
if castDevice != nil { stopCasting(resume: false) }
|
||
cancelStreamResolution()
|
||
automaticQuality = AutomaticQuality()
|
||
guard player_pause(handle) else {
|
||
commandError = "Could not pause the current media to open the stream. Please try again."
|
||
revealControls()
|
||
return
|
||
}
|
||
objectWillChange.send()
|
||
let request = transition.beginResolution(.open)
|
||
systemPlayback?.update()
|
||
commandError = nil
|
||
revealControls()
|
||
let task = Task { [weak self] in
|
||
do {
|
||
let source = try await MiruroResolver.resolve(mediaID: mediaID, episode: episode)
|
||
guard !Task.isCancelled, let self else { return }
|
||
self.objectWillChange.send()
|
||
guard let change = self.transition.finishResolution(request) else { return }
|
||
self.openMedia(.resolved(source), change: change)
|
||
} catch {
|
||
guard !Task.isCancelled, let self else { return }
|
||
self.objectWillChange.send()
|
||
guard self.transition.finishResolution(request) != nil else { return }
|
||
self.commandError = error.localizedDescription
|
||
self.revealControls()
|
||
}
|
||
}
|
||
transition.attachResolution(task, request: request)
|
||
}
|
||
|
||
private func resolveYouTube(_ url: URL, height: Int?, change: MediaChange = .open) {
|
||
guard let handle else { return }
|
||
if castDevice != nil { stopCasting(resume: false) }
|
||
cancelStreamResolution()
|
||
if !change.automatic { automaticQuality = AutomaticQuality() }
|
||
if !change.automatic && !player_pause(handle) {
|
||
commandError = "Could not pause the current media to open YouTube. Please try again."
|
||
revealControls()
|
||
return
|
||
}
|
||
objectWillChange.send()
|
||
let request = transition.beginResolution(change)
|
||
systemPlayback?.update()
|
||
commandError = nil
|
||
revealControls()
|
||
let task = Task { [weak self] in
|
||
do {
|
||
let video = try await YouTubeResolver.resolve(url, height: height)
|
||
guard !Task.isCancelled, let self, let handle = self.handle else { return }
|
||
self.objectWillChange.send()
|
||
guard var change = self.transition.finishResolution(request) else { return }
|
||
if change.automatic {
|
||
let current = player_get_state(handle)
|
||
guard self.transition.canApplyAutomaticQuality(current, enabled: self.automaticYouTubeQuality,
|
||
previewingSeek: self.isSeeking) else {
|
||
self.automaticQuality.resetHealth()
|
||
return
|
||
}
|
||
change = self.transition.qualityChange(current, playing: true, automatic: true)
|
||
}
|
||
self.openMedia(.youtube(video, height: height), change: change)
|
||
} catch {
|
||
guard !Task.isCancelled, let self else { return }
|
||
self.objectWillChange.send()
|
||
guard let change = self.transition.finishResolution(request) else { return }
|
||
self.failYouTubeChange(change, error: error)
|
||
}
|
||
}
|
||
transition.attachResolution(task, request: request)
|
||
}
|
||
|
||
private func cancelStreamResolution() {
|
||
guard transition.isResolving else { return }
|
||
objectWillChange.send()
|
||
transition.cancelResolution()
|
||
}
|
||
|
||
private func failYouTubeChange(_ change: MediaChange, error: Error? = nil) {
|
||
if change.automatic {
|
||
automaticQuality.extractionFailed(at: qualityNow)
|
||
return
|
||
}
|
||
commandError = error?.localizedDescription ?? "Could not queue the YouTube video to open. Please try again."
|
||
if change.isQuality && change.playing, let handle, !player_play(handle) {
|
||
commandError = error.map { $0.localizedDescription + "\nCould not resume the previous playback." } ??
|
||
"Could not queue the YouTube video or resume the previous playback. Please try again."
|
||
}
|
||
revealControls()
|
||
}
|
||
|
||
private func replaceMetadata(for source: MediaSource?) {
|
||
metadataTask?.cancel()
|
||
metadataTask = nil
|
||
let parts = source?.url.absoluteString.split(separator: ":") ?? []
|
||
let episode = parts.count == 3 && parts[0] == "miruro" ? Int(parts[2]) : nil
|
||
let generation = metadataLifecycle.replace(identity: source?.url.absoluteString, episode: episode)
|
||
guard let source, let episode else { return }
|
||
let identity = source.url.absoluteString
|
||
let mediaID = String(parts[1])
|
||
metadataTask = Task { [weak self] in
|
||
guard let metadata = try? await MiruroMetadataResolver.resolve(mediaID: mediaID, episode: episode),
|
||
!Task.isCancelled, let self,
|
||
self.currentMedia?.url.absoluteString == identity,
|
||
self.metadataLifecycle.accept(metadata, identity: identity, generation: generation) else { return }
|
||
self.objectWillChange.send()
|
||
if let title = metadata.title {
|
||
self.fileName = title
|
||
self.window?.title = title
|
||
self.casting.updateTitle(title)
|
||
self.systemPlayback?.update()
|
||
}
|
||
self.metadataTask = nil
|
||
}
|
||
}
|
||
|
||
private func openMedia(_ source: MediaSource, change: MediaChange = .open) {
|
||
guard let handle else { return }
|
||
if castDevice != nil { stopCasting(resume: false) }
|
||
saveProgress()
|
||
let revision = player_get_state(handle).revision
|
||
let savedPosition = change.preservesPosition ? change.position :
|
||
(source.live ? nil : playbackProgress.position(for: source.url))
|
||
let accepted: Bool
|
||
switch source {
|
||
case .file(let url):
|
||
accepted = url.withUnsafeFileSystemRepresentation { path in
|
||
guard let path else { return false }
|
||
return player_open(handle, path)
|
||
}
|
||
case .youtube(let video, _):
|
||
let initialPosition = source.live ? 0 : savedPosition ?? 0
|
||
accepted = video.webpageURL.absoluteString.withCString { mediaURL in
|
||
video.video.url.withCString { videoURL in
|
||
video.video.headers.withCString { videoHeaders in
|
||
func enqueue(audioURL: UnsafePointer<CChar>?, audioHeaders: UnsafePointer<CChar>?) -> Bool {
|
||
var input = PlayerNetworkSource(video_url: videoURL, audio_url: audioURL,
|
||
video_headers: videoHeaders, audio_headers: audioHeaders,
|
||
duration: video.duration, is_live: video.isLive,
|
||
media_url: mediaURL, initial_position: initialPosition)
|
||
return player_open_network(handle, &input)
|
||
}
|
||
if let audio = video.audio {
|
||
return audio.url.withCString { audioURL in
|
||
audio.headers.withCString { enqueue(audioURL: audioURL, audioHeaders: $0) }
|
||
}
|
||
}
|
||
return enqueue(audioURL: nil, audioHeaders: nil)
|
||
}
|
||
}
|
||
}
|
||
case .resolved(let source):
|
||
accepted = source.identity.withCString { identity in
|
||
source.url.withCString { url in
|
||
source.headers.withCString { headers in
|
||
var input = PlayerNetworkSource(video_url: url, audio_url: nil,
|
||
video_headers: headers, audio_headers: nil, duration: 0, is_live: false,
|
||
media_url: identity, initial_position: savedPosition ?? 0)
|
||
return player_open_network(handle, &input)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
guard accepted else {
|
||
switch source {
|
||
case .file:
|
||
commandError = "Could not queue the file to open. Please try again."
|
||
case .resolved:
|
||
commandError = "Could not queue the resolved stream to open."
|
||
case .youtube:
|
||
failYouTubeChange(change)
|
||
}
|
||
return
|
||
}
|
||
if fileName == nil { configureStartWindow(showing: false) }
|
||
transition.acceptOpen(revision: revision, source: source, change: change, savedPosition: savedPosition)
|
||
queuePlaybackRevision = queue.currentID == nil ? nil : player_get_state(handle).revision
|
||
queueCompletionHandled = false
|
||
currentMedia = source
|
||
shouldRecordRecentMedia = !change.isQuality
|
||
if change.automatic { automaticQuality.resetHealth() } else { automaticQuality = AutomaticQuality() }
|
||
switch source {
|
||
case .file(let url):
|
||
fileName = url.lastPathComponent
|
||
fileIcon = NSWorkspace.shared.icon(forFile: url.path)
|
||
youtubeHeights = []
|
||
youtubeHeight = nil
|
||
youtubeQuality = nil
|
||
case .youtube(let video, let height):
|
||
fileName = video.title
|
||
fileIcon = nil
|
||
youtubeHeights = video.availableHeights
|
||
youtubeHeight = video.height
|
||
youtubeQuality = height
|
||
case .resolved(let source):
|
||
fileName = source.title
|
||
fileIcon = nil
|
||
youtubeHeights = []
|
||
youtubeHeight = nil
|
||
youtubeQuality = nil
|
||
}
|
||
selectSubtitle(nil)
|
||
if case .resolved(let resolved) = source {
|
||
subtitleTracks = resolved.subtitles
|
||
selectSubtitle(subtitleTracks.firstIndex(where: \.isDefault))
|
||
} else {
|
||
subtitleTracks = []
|
||
}
|
||
needsVideoFit = !change.isQuality
|
||
commandError = transition.needsLocalRestoration || !change.playing || player_play(handle) ? nil :
|
||
"Could not queue playback. Please try again."
|
||
isSeeking = false
|
||
seekPosition = 0
|
||
window?.representedURL = source.url
|
||
window?.title = fileName ?? "Terebi"
|
||
revealControls()
|
||
replaceMetadata(for: source)
|
||
systemPlayback?.update()
|
||
}
|
||
|
||
func open(_ url: URL) {
|
||
if url.isFileURL { openFiles([url]) } else { openStream(url.absoluteString) }
|
||
}
|
||
|
||
func openFiles(_ urls: [URL]) {
|
||
guard canOpen, urls.contains(where: \.isFileURL) else { return }
|
||
queueCastDevice = nil
|
||
queue.replace(urls)
|
||
showsQueue = queue.items.count > 1
|
||
openQueueSelection(keepingReceiver: false)
|
||
}
|
||
|
||
func addToQueue(_ urls: [URL]) {
|
||
guard canOpen else { return }
|
||
let wasEmpty = queue.items.isEmpty
|
||
queue.append(urls)
|
||
guard !queue.items.isEmpty else { return }
|
||
showsQueue = true
|
||
if wasEmpty, fileName == nil, let first = queue.items.first {
|
||
queue.select(first.id)
|
||
openQueueSelection()
|
||
}
|
||
}
|
||
|
||
func selectQueueItem(_ id: UUID) {
|
||
guard canOpen, queue.items.contains(where: { $0.id == id }) else { return }
|
||
queue.select(id)
|
||
openQueueSelection()
|
||
}
|
||
|
||
func advanceQueue(_ offset: Int) {
|
||
guard canOpen, queue.advance(offset) != nil else { return }
|
||
openQueueSelection()
|
||
}
|
||
|
||
func removeQueueItem(_ id: UUID) {
|
||
let wasCurrent = queue.currentID == id
|
||
let next = queue.remove(id)
|
||
guard wasCurrent else { return }
|
||
if next != nil { openQueueSelection() } else { closeFile(clearQueue: false) }
|
||
}
|
||
|
||
func moveQueueItems(fromOffsets offsets: IndexSet, toOffset destination: Int) {
|
||
queue.move(fromOffsets: offsets, toOffset: destination)
|
||
}
|
||
|
||
private func openQueueSelection(keepingReceiver: Bool = true) {
|
||
guard let url = queue.currentURL else { return }
|
||
let receiver = keepingReceiver ? (castDevice ?? queueCastDevice) : nil
|
||
queuePlaybackRevision = nil
|
||
queueCompletionHandled = true
|
||
cancelStreamResolution()
|
||
openMedia(.file(url), change: .queue(playing: receiver == nil))
|
||
queueCastDevice = receiver
|
||
}
|
||
|
||
private func clearQueue() {
|
||
queue.replace([])
|
||
queuePlaybackRevision = nil
|
||
queueCompletionHandled = false
|
||
queueCastDevice = nil
|
||
}
|
||
|
||
private func completeQueuePlaybackIfNeeded() {
|
||
guard queue.currentID != nil, queuePlaybackRevision != nil, !isBusy else { return }
|
||
if state.status == PLAYER_PLAYING { queueCompletionHandled = false }
|
||
guard !queueCompletionHandled, !state.seek_pending, !transition.seekPending else { return }
|
||
if state.status == PLAYER_ERROR {
|
||
queueCompletionHandled = true
|
||
queueCastDevice = nil
|
||
} else if state.status == PLAYER_ENDED {
|
||
queueCompletionHandled = true
|
||
if queue.canNext { advanceQueue(1) }
|
||
}
|
||
}
|
||
|
||
func togglePlayback() {
|
||
perform(playbackAction)
|
||
}
|
||
|
||
@discardableResult
|
||
func setPlayback(playing: Bool) -> Bool {
|
||
if casting.active {
|
||
guard canTogglePlayback else { return false }
|
||
let accepted = casting.setPlaying(playing)
|
||
commandError = accepted ? nil : "Could not change Cast playback."
|
||
revealControls()
|
||
return accepted
|
||
}
|
||
guard let handle, canTogglePlayback else { return false }
|
||
if !playing && transition.resolvingAutomaticQuality { cancelStreamResolution() }
|
||
if !playing { automaticQuality.resetHealth() }
|
||
switch transition.setPlayback(playing, status: state.status, handle: handle) {
|
||
case .unchanged:
|
||
return true
|
||
case .deferred:
|
||
objectWillChange.send()
|
||
revealControls()
|
||
return true
|
||
case .submitted(let accepted):
|
||
commandError = accepted ? nil : "Could not queue playback. Please try again."
|
||
revealControls()
|
||
return accepted
|
||
}
|
||
}
|
||
|
||
func finishSeeking() {
|
||
if transition.resolvingAutomaticQuality { cancelStreamResolution() }
|
||
automaticQuality.resetHealth()
|
||
isSeeking = false
|
||
guard let handle, canSeek else { return }
|
||
if casting.active {
|
||
seekCast(to: min(max(0, seekPosition), duration))
|
||
return
|
||
}
|
||
let position = min(max(0, seekPosition), duration)
|
||
let accepted = transition.seek(position, handle: handle)
|
||
commandError = accepted ? nil : "Could not queue the seek. Please try again."
|
||
revealControls()
|
||
}
|
||
|
||
func closeFile(clearQueue: Bool = true) {
|
||
guard let handle, canClose else { return }
|
||
saveProgress()
|
||
if castDevice != nil { stopCasting(resume: false) }
|
||
cancelStreamResolution()
|
||
automaticQuality = AutomaticQuality()
|
||
guard player_close(handle) else {
|
||
commandError = "Could not queue closing the file. Please try again."
|
||
return
|
||
}
|
||
transition.closeMedia()
|
||
if clearQueue { self.clearQueue() }
|
||
queuePlaybackRevision = nil
|
||
queueCompletionHandled = true
|
||
queueCastDevice = nil
|
||
currentMedia = nil
|
||
replaceMetadata(for: nil)
|
||
shouldRecordRecentMedia = false
|
||
selectSubtitle(nil)
|
||
subtitleTracks = []
|
||
compactIntent = nil
|
||
compactWindow.restore(resumeFullScreen: false, animated: false)
|
||
fileName = nil
|
||
fileIcon = nil
|
||
youtubeHeights = []
|
||
youtubeHeight = nil
|
||
youtubeQuality = nil
|
||
needsVideoFit = false
|
||
window?.resizeIncrements = NSSize(width: 1, height: 1)
|
||
commandError = nil
|
||
isSeeking = false
|
||
window?.representedURL = nil
|
||
window?.title = "Terebi"
|
||
configureStartWindow(showing: true)
|
||
revealControls()
|
||
systemPlayback?.update()
|
||
}
|
||
|
||
@discardableResult
|
||
func seekRelative(_ seconds: Double) -> Bool {
|
||
guard canSeek, let handle, seconds.isFinite else { return false }
|
||
if transition.resolvingAutomaticQuality { cancelStreamResolution() }
|
||
automaticQuality.resetHealth()
|
||
let target = min(max(0, ((transition.seekPending || state.seek_pending) ? seekPosition : state.position) + seconds), duration)
|
||
let accepted = casting.active ? seekCast(to: target) : transition.seek(target, handle: handle)
|
||
if accepted { seekPosition = target }
|
||
commandError = accepted ? nil : "Could not queue the seek. Please try again."
|
||
if !accepted { revealControls() }
|
||
return accepted
|
||
}
|
||
|
||
@discardableResult
|
||
func stepFrame(_ direction: Int32) -> Bool {
|
||
guard canStepFrame, let handle else { return false }
|
||
let accepted = transition.stepFrame(direction, handle: handle)
|
||
commandError = accepted ? nil : "Could not queue the frame step. Please try again."
|
||
revealControls()
|
||
return accepted
|
||
}
|
||
|
||
func setVolume(_ value: Double) {
|
||
if casting.active {
|
||
if casting.setVolume(value) {
|
||
volume = value
|
||
if volume > 0 { lastAudibleVolume = volume }
|
||
}
|
||
revealControls()
|
||
return
|
||
}
|
||
guard let handle else { return }
|
||
if player_set_volume(handle, Float(value)) {
|
||
volume = Double(player_get_volume(handle))
|
||
if volume > 0 { lastAudibleVolume = volume }
|
||
}
|
||
revealControls()
|
||
}
|
||
|
||
func toggleMute() {
|
||
perform(muteAction)
|
||
}
|
||
|
||
private func transitionQueue() {
|
||
queueAnimation?.invalidate()
|
||
queueAnimation = nil
|
||
queueAnimationCompletion = nil
|
||
let targetWidth: CGFloat = showsQueue ? 260 : 0
|
||
guard let window, !isCompact, !compactWindow.isTransitioning else {
|
||
queueWidth = targetWidth
|
||
queueWindowVideoWidth = nil
|
||
return
|
||
}
|
||
let startWidth = queueWidth
|
||
let startFrame = window.frame
|
||
var targetFrame = startFrame
|
||
if !isFullScreen {
|
||
// Setting aspectRatio to zero leaves AppKit's resize increments at zero.
|
||
window.resizeIncrements = NSSize(width: 1, height: 1)
|
||
let videoWidth = queueWindowVideoWidth ?? startFrame.width - startWidth
|
||
queueWindowVideoWidth = videoWidth
|
||
targetFrame.size.width = videoWidth + targetWidth
|
||
if let screen = window.screen?.visibleFrame {
|
||
targetFrame.origin.x = max(screen.minX, min(startFrame.minX, screen.maxX - targetFrame.width))
|
||
}
|
||
}
|
||
// One clock drives the reveal and native frame so the video never shrinks between them.
|
||
let update: @MainActor (Double) -> Void = { [weak self, weak window] progress in
|
||
guard let self, let window else { return }
|
||
self.queueWidth = startWidth + (targetWidth - startWidth) * progress
|
||
if targetFrame != startFrame {
|
||
window.setFrame(NSRect(x: startFrame.minX + (targetFrame.minX - startFrame.minX) * progress,
|
||
y: startFrame.minY,
|
||
width: startFrame.width + (targetFrame.width - startFrame.width) * progress,
|
||
height: startFrame.height), display: true)
|
||
}
|
||
}
|
||
queueAnimationCompletion = { [weak self, weak window] in
|
||
update(1)
|
||
if let self, let window, !self.showsQueue, !self.isFullScreen,
|
||
self.state.has_video, self.state.video_aspect_ratio > 0 {
|
||
window.aspectRatio = NSSize(width: self.state.video_aspect_ratio, height: 1)
|
||
}
|
||
}
|
||
guard !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion else {
|
||
finishQueueTransition()
|
||
return
|
||
}
|
||
let start = ContinuousClock.now
|
||
let animation = Timer(timeInterval: 1.0 / 60, repeats: true) { [weak self] _ in
|
||
MainActor.assumeIsolated {
|
||
let progress = min(1, start.duration(to: .now) / .milliseconds(250))
|
||
if progress >= 1 { self?.finishQueueTransition() }
|
||
else { update(progress * progress * (3 - 2 * progress)) }
|
||
}
|
||
}
|
||
queueAnimation = animation
|
||
RunLoop.main.add(animation, forMode: .common)
|
||
}
|
||
|
||
func finishQueueTransition() {
|
||
queueAnimation?.invalidate()
|
||
queueAnimation = nil
|
||
let completion = queueAnimationCompletion
|
||
queueAnimationCompletion = nil
|
||
queueWindowVideoWidth = nil
|
||
completion?()
|
||
}
|
||
|
||
func attachWindow(_ window: NSWindow) {
|
||
guard self.window !== window else { return }
|
||
startContentHeight = 400 - (window.frame.height - window.contentLayoutRect.height)
|
||
finishQueueTransition()
|
||
self.window = window
|
||
window.title = fileName ?? "Terebi"
|
||
window.contentMinSize = NSSize(width: 400, height: 240)
|
||
compactWindow.normalVideoLayout = { [weak self] in
|
||
guard let self, self.showsQueue, self.state.has_video else { return nil }
|
||
return (self.state.video_aspect_ratio, 260)
|
||
}
|
||
compactWindow.attach(window)
|
||
updateWindowState()
|
||
if fileName == nil { configureStartWindow(showing: true) }
|
||
fitVideoWindow()
|
||
}
|
||
|
||
private func configureStartWindow(showing: Bool) {
|
||
guard let window else { return }
|
||
if showing {
|
||
let size = NSSize(width: 640, height: 400)
|
||
window.resizeIncrements = NSSize(width: 1, height: 1)
|
||
window.contentMinSize = size
|
||
window.contentMaxSize = size
|
||
window.setContentSize(size)
|
||
window.styleMask.remove(.resizable)
|
||
} else {
|
||
window.styleMask.insert(.resizable)
|
||
window.contentMaxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
|
||
window.contentMinSize = NSSize(width: 400, height: 240)
|
||
}
|
||
}
|
||
|
||
func updateWindowState() {
|
||
guard let window else { return }
|
||
isFullScreen = window.styleMask.contains(.fullScreen)
|
||
if !compactWindow.isTransitioning && fullScreenIntent == isFullScreen { fullScreenIntent = nil }
|
||
windowActive = window.isKeyWindow
|
||
if !compactWindow.isTransitioning {
|
||
window.level = isCompact || (isOnTop && !isFullScreen) ? .floating : .normal
|
||
}
|
||
if !isFullScreen && !compactWindow.isTransitioning { fitVideoWindow() }
|
||
revealControls()
|
||
}
|
||
|
||
func toggleFullScreen() {
|
||
perform(fullscreenAction)
|
||
}
|
||
|
||
func toggleCompact() {
|
||
perform(compactAction)
|
||
}
|
||
|
||
func toggleOnTop() {
|
||
perform(onTopAction)
|
||
}
|
||
|
||
func canPerform(_ action: PlayerAction) -> Bool {
|
||
switch action {
|
||
case .play: return canTogglePlayback && !effectivePlaying
|
||
case .pause: return canTogglePlayback && effectivePlaying
|
||
case .mute: return canSetVolume && volume > 0
|
||
case .unmute: return canSetVolume && volume == 0
|
||
case .volumeUp: return canSetVolume && volume < 1
|
||
case .volumeDown: return canSetVolume && volume > 0
|
||
case .seekForward5s, .seekBackward5s, .seekForward10s, .seekBackward10s: return canSeek
|
||
case .stepFrameForward, .stepFrameBackward: return canStepFrame
|
||
case .enterFullscreen, .exitFullscreen:
|
||
return window != nil && fullScreenIntent == nil && !compactWindow.isTransitioning &&
|
||
(action == .enterFullscreen ? !isFullScreen : isFullScreen)
|
||
case .enterCompact:
|
||
return window != nil && !(compactIntent ?? isCompact) && canOpen &&
|
||
(isBusy || (canTogglePlayback && state.has_video &&
|
||
state.video_aspect_ratio.isFinite && state.video_aspect_ratio > 0))
|
||
case .exitCompact: return window != nil && (compactIntent ?? isCompact)
|
||
case .enableAlwaysOnTop, .disableAlwaysOnTop:
|
||
return window != nil && !isFullScreen && fullScreenIntent == nil &&
|
||
!isCompact && compactIntent != true && !compactWindow.isTransitioning &&
|
||
(action == .enableAlwaysOnTop ? !isOnTop : isOnTop)
|
||
case .openFile, .openStream: return canOpen
|
||
case .closeFile: return canClose
|
||
case .quit: return true
|
||
case .showBufferInfo: return !showsBufferInfo
|
||
case .hideBufferInfo: return showsBufferInfo
|
||
case .enableCompactSnapping: return !compactWindow.snappingEnabled
|
||
case .disableCompactSnapping: return compactWindow.snappingEnabled
|
||
case .enableAutomaticQuality: return !automaticYouTubeQuality
|
||
case .disableAutomaticQuality: return automaticYouTubeQuality
|
||
case .qualityDefault:
|
||
return canChangeYouTubeQuality && (automaticYouTubeQuality || youtubeQuality != nil)
|
||
case let .quality(height):
|
||
return canChangeYouTubeQuality && youtubeHeights.contains(height) &&
|
||
(automaticYouTubeQuality || youtubeQuality != height)
|
||
}
|
||
}
|
||
|
||
@discardableResult
|
||
func perform(_ action: PlayerAction) -> Bool {
|
||
guard canPerform(action) else { return false }
|
||
switch action {
|
||
case .play: return setPlayback(playing: true)
|
||
case .pause: return setPlayback(playing: false)
|
||
case .mute: setVolume(0)
|
||
case .unmute: setVolume(lastAudibleVolume)
|
||
case .volumeUp: setVolume(min(1, volume + 0.05))
|
||
case .volumeDown: setVolume(max(0, volume - 0.05))
|
||
case .seekForward5s: return seekRelative(5)
|
||
case .seekBackward5s: return seekRelative(-5)
|
||
case .seekForward10s: return seekRelative(10)
|
||
case .seekBackward10s: return seekRelative(-10)
|
||
case .stepFrameForward: return stepFrame(1)
|
||
case .stepFrameBackward: return stepFrame(-1)
|
||
case .enterFullscreen, .exitFullscreen:
|
||
finishQueueTransition()
|
||
if isCompact {
|
||
compactIntent = false
|
||
compactWindow.restore(resumeFullScreen: false, animated: false)
|
||
}
|
||
fullScreenIntent = action == .enterFullscreen
|
||
window?.toggleFullScreen(nil)
|
||
revealControls()
|
||
case .enterCompact:
|
||
finishQueueTransition()
|
||
compactIntent = true
|
||
compactWindow.toggle(aspect: state.video_aspect_ratio)
|
||
controlsHovered = false
|
||
titleHovered = false
|
||
revealControls()
|
||
case .exitCompact:
|
||
compactIntent = false
|
||
compactWindow.restore()
|
||
revealControls()
|
||
case .enableAlwaysOnTop, .disableAlwaysOnTop:
|
||
isOnTop = action == .enableAlwaysOnTop
|
||
window?.level = isOnTop ? .floating : .normal
|
||
revealControls()
|
||
case .openFile: openPanel()
|
||
case .openStream: showsYouTubeInput.toggle()
|
||
case .closeFile: closeFile()
|
||
case .quit: NSApp.terminate(nil)
|
||
case .showBufferInfo: setShowsBufferInfo(true)
|
||
case .hideBufferInfo: setShowsBufferInfo(false)
|
||
case .enableCompactSnapping: compactWindow.snappingEnabled = true
|
||
case .disableCompactSnapping: compactWindow.snappingEnabled = false
|
||
case .enableAutomaticQuality: setAutomaticYouTubeQuality(true)
|
||
case .disableAutomaticQuality: setAutomaticYouTubeQuality(false)
|
||
case .qualityDefault: setYouTubeQuality(nil)
|
||
case let .quality(height): setYouTubeQuality(height)
|
||
}
|
||
return true
|
||
}
|
||
|
||
@discardableResult
|
||
func handleKey(_ chord: KeyChord, isRepeat: Bool = false) -> Bool {
|
||
let actions = keyBindings.actions(for: chord)
|
||
guard !actions.isEmpty else { return false }
|
||
for action in actions where canPerform(action) {
|
||
if !isRepeat || action.allowsRepeat { perform(action) }
|
||
break
|
||
}
|
||
return true
|
||
}
|
||
|
||
func setPointerInsideWindow(_ inside: Bool) {
|
||
pointerInsideWindow = inside
|
||
keyboardNavigation = false
|
||
if inside {
|
||
revealControls()
|
||
} else {
|
||
controlsHovered = false
|
||
titleHovered = false
|
||
hideControls(hideCursor: false)
|
||
}
|
||
}
|
||
|
||
func setControlsHovered(_ hovered: Bool) {
|
||
guard controlsHovered != hovered else { return }
|
||
controlsHovered = hovered
|
||
revealControls()
|
||
}
|
||
|
||
func setTitleHovered(_ hovered: Bool) {
|
||
guard titleHovered != hovered else { return }
|
||
titleHovered = hovered
|
||
revealControls()
|
||
}
|
||
|
||
func setControlsDragging(_ dragging: Bool) {
|
||
guard controlsDragging != dragging else { return }
|
||
controlsDragging = dragging
|
||
revealControls()
|
||
}
|
||
|
||
func hideControls(hideCursor: Bool = true) {
|
||
guard shouldHideControls else { return }
|
||
hideDeadline = nil
|
||
keyboardNavigation = false
|
||
controlsVisible = false
|
||
NSCursor.setHiddenUntilMouseMoves(hideCursor && pointerInsideWindow && windowActive && !isCompact)
|
||
}
|
||
|
||
func beginSeeking() {
|
||
if transition.resolvingAutomaticQuality { cancelStreamResolution() }
|
||
automaticQuality.resetHealth()
|
||
isSeeking = true
|
||
revealControls()
|
||
}
|
||
|
||
func revealControls(forKeyboardNavigation: Bool = false) {
|
||
hideDeadline = nil
|
||
NSCursor.setHiddenUntilMouseMoves(false)
|
||
if forKeyboardNavigation { keyboardNavigation = true }
|
||
if !keyboardNavigation && !pointerInsideWindow && shouldHideControls {
|
||
hideControls(hideCursor: false)
|
||
return
|
||
}
|
||
if !controlsVisible { controlsVisible = true }
|
||
guard shouldHideControls else { return }
|
||
hideDeadline = ContinuousClock.now.advanced(by: .milliseconds(2500))
|
||
}
|
||
|
||
private var shouldHideControls: Bool {
|
||
castDevice == nil && ((canTogglePlayback && fileName != nil && (state.has_video || !pointerInsideWindow)) ||
|
||
(!pointerInsideWindow && isBusy)) &&
|
||
!isSeeking && !transition.seekPending && !controlsDragging &&
|
||
(!pointerInsideWindow || (!controlsHovered && !titleHovered)) &&
|
||
(windowActive || isCompact || !pointerInsideWindow) && errorText == nil &&
|
||
!NSWorkspace.shared.isVoiceOverEnabled
|
||
}
|
||
|
||
private func fitVideoWindow() {
|
||
guard needsVideoFit, canTogglePlayback, let window, !isFullScreen,
|
||
!compactWindow.isTransitioning, !isQueueTransitioning else { return }
|
||
let aspect = state.video_aspect_ratio
|
||
if isCompact {
|
||
compactWindow.updateAspect(state.video_aspect_ratio)
|
||
return
|
||
}
|
||
needsVideoFit = false
|
||
guard state.has_video, aspect.isFinite, aspect > 0 else {
|
||
window.resizeIncrements = NSSize(width: 1, height: 1)
|
||
return
|
||
}
|
||
let screen = (window.screen ?? NSScreen.main)?.visibleFrame ?? window.frame
|
||
let sidebarWidth: CGFloat = showsQueue ? 260 : 0
|
||
let width = min(window.frame.width - sidebarWidth, screen.width - sidebarWidth, screen.height * aspect)
|
||
let size = NSSize(width: width + sidebarWidth, height: width / aspect)
|
||
// ponytail: extreme ratios retain letterboxing; compact controls if exact fitting is needed.
|
||
guard size.width >= window.minSize.width, size.height >= window.minSize.height else {
|
||
window.resizeIncrements = NSSize(width: 1, height: 1)
|
||
return
|
||
}
|
||
if showsQueue {
|
||
window.resizeIncrements = NSSize(width: 1, height: 1)
|
||
} else {
|
||
window.aspectRatio = NSSize(width: aspect, height: 1)
|
||
}
|
||
let frame = NSRect(
|
||
x: min(max(window.frame.midX - size.width / 2, screen.minX), screen.maxX - size.width),
|
||
y: min(max(window.frame.midY - size.height / 2, screen.minY), screen.maxY - size.height),
|
||
width: size.width, height: size.height)
|
||
window.setFrame(frame, display: true)
|
||
}
|
||
|
||
func savedPosition(for url: URL) -> Double? { playbackProgress.position(for: url) }
|
||
|
||
private func saveProgress(_ snapshot: PlayerState? = nil, force: Bool = true) {
|
||
guard let handle else { return }
|
||
if let checkpoint = casting.checkpoint {
|
||
saveCastProgress(checkpoint.state, source: checkpoint.source)
|
||
} else {
|
||
transition.saveProgress(snapshot, handle: handle, force: force, history: playbackProgress)
|
||
}
|
||
}
|
||
|
||
private func saveCastProgress(_ state: PlayerState, source: MediaSource) {
|
||
guard !source.live, state.has_video, state.can_seek, !state.seek_pending,
|
||
state.duration > 0, state.position.isFinite else { return }
|
||
playbackProgress.save(state.status == PLAYER_ENDED ? nil : state.position, for: source.url)
|
||
}
|
||
|
||
private func poll() {
|
||
guard let handle else { return }
|
||
if reportingEnabled && !reportingErrorShown {
|
||
if player_get_progress_error(handle, &reportingErrorBuffer) {
|
||
showReportingError(reportingErrorMessage)
|
||
}
|
||
}
|
||
if !casting.active { applyState(player_get_state(handle)) }
|
||
}
|
||
|
||
func applyState(_ next: PlayerState) {
|
||
guard !casting.active else { return }
|
||
if let revision = queuePlaybackRevision, next.revision < revision { return }
|
||
let statusChanged = state.status != next.status
|
||
let observation = transition.advance(next,
|
||
forceCheckpoint: statusChanged && (next.status == PLAYER_PAUSED || next.status == PLAYER_ENDED),
|
||
handle: handle, history: playbackProgress)
|
||
guard observation.ready else { return }
|
||
defer { systemPlayback?.update() }
|
||
state = next
|
||
updateSubtitle()
|
||
if shouldRecordRecentMedia, !isBusy, next.status == PLAYER_PLAYING || next.status == PLAYER_PAUSED,
|
||
let source = currentMedia, let title = fileName {
|
||
// Resolved sources need a fresh external handoff, not their expiring stream URL.
|
||
switch source {
|
||
case .file, .youtube:
|
||
playbackProgress.recordRecent(source.url, title: title)
|
||
recentMedia = playbackProgress.recentMedia
|
||
case .resolved:
|
||
break
|
||
}
|
||
shouldRecordRecentMedia = false
|
||
}
|
||
if let playing = observation.playback { setPlayback(playing: playing) }
|
||
if !isBusy && (next.status == PLAYER_EMPTY || next.status == PLAYER_ERROR || !next.has_video) {
|
||
compactWindow.restore(resumeFullScreen: false, animated: false)
|
||
}
|
||
if !isSeeking && !transition.seekPending {
|
||
seekPosition = min(max(0, next.position.isFinite ? next.position : 0), duration)
|
||
}
|
||
fitVideoWindow()
|
||
if statusChanged || (!pointerInsideWindow && !keyboardNavigation && controlsVisible) { revealControls() }
|
||
if let hideDeadline, ContinuousClock.now >= hideDeadline { hideControls() }
|
||
updateAutomaticQuality()
|
||
if let restored = observation.restored {
|
||
if let position = restored.position { seekPosition = position }
|
||
commandError = restored.error
|
||
}
|
||
if let device = queueCastDevice, !isBusy, !transition.seekPending, !state.seek_pending {
|
||
if state.status == PLAYER_ERROR {
|
||
queueCastDevice = nil
|
||
} else if canCast {
|
||
queueCastDevice = nil
|
||
cast(to: device, playing: true)
|
||
}
|
||
}
|
||
completeQueuePlaybackIfNeeded()
|
||
}
|
||
|
||
private func updateAutomaticQuality() {
|
||
guard castDevice == nil else { return }
|
||
guard automaticYouTubeQuality else { return }
|
||
let reserve = bufferedRange.map { max(0, $0.upperBound - state.position) }
|
||
let sample = AutomaticQuality.Sample(
|
||
enabled: automaticYouTubeQuality, network: isNetworkMedia,
|
||
playing: effectivePlaying, opening: transition.blocksAutomaticQuality(state.status),
|
||
seeking: isSeeking || transition.seekPending || state.seek_pending, live: isLiveStream,
|
||
buffering: state.buffering, position: state.position, duration: state.duration,
|
||
ahead: reserve, height: youtubeHeight, heights: youtubeHeights)
|
||
guard let height = automaticQuality.choose(sample, at: qualityNow), let url = transition.mediaURL else { return }
|
||
resolveYouTube(url, height: height, change: .quality(position: nil, playing: true, automatic: true))
|
||
}
|
||
|
||
private func showReportingError(_ message: String, terminating: Bool = false) {
|
||
guard !reportingErrorShown else { return }
|
||
reportingErrorShown = true
|
||
FileHandle.standardError.write(Data("terebi: \(message)\n".utf8))
|
||
let alert = NSAlert()
|
||
alert.alertStyle = .warning
|
||
alert.messageText = "Playback progress reporting failed"
|
||
alert.informativeText = message + "\n\nReporting has been disabled. Playback is unaffected."
|
||
if let window, !terminating {
|
||
alert.beginSheetModal(for: window)
|
||
} else {
|
||
alert.runModal()
|
||
}
|
||
}
|
||
|
||
func shutdown() {
|
||
finishQueueTransition()
|
||
saveProgress()
|
||
if castDevice != nil { stopCasting(resume: false) }
|
||
castDiscovery.stop()
|
||
cancelStreamResolution()
|
||
replaceMetadata(for: nil)
|
||
selectSubtitle(nil)
|
||
systemPlayback?.shutdown()
|
||
systemPlayback = nil
|
||
compactWindow.restore(resumeFullScreen: false, animated: false)
|
||
timer?.invalidate()
|
||
timer = nil
|
||
hideDeadline = nil
|
||
NSCursor.setHiddenUntilMouseMoves(false)
|
||
guard let player = handle else { return }
|
||
if reportingEnabled {
|
||
if !player_shutdown(player, &reportingErrorBuffer) {
|
||
showReportingError(reportingErrorMessage, terminating: true)
|
||
}
|
||
}
|
||
handle = nil
|
||
player_destroy(player)
|
||
}
|
||
|
||
@MainActor
|
||
private final class CastPlaybackTransition {
|
||
struct Projection {
|
||
var state: PlayerState
|
||
var volume: Double
|
||
var subtitle: Int?
|
||
}
|
||
|
||
struct Return {
|
||
let source: MediaSource
|
||
let checkpoint: PlayerState?
|
||
let suspendedRevision: UInt64?
|
||
let volume: Double
|
||
let audibleVolume: Double
|
||
let restore: (position: Double, playing: Bool)?
|
||
}
|
||
|
||
enum Event {
|
||
case changed
|
||
case activated
|
||
case status(Projection)
|
||
case stopped(Return)
|
||
case error(String)
|
||
}
|
||
|
||
private enum HandoffError: LocalizedError {
|
||
case pause, acknowledgement, readiness
|
||
var errorDescription: String? {
|
||
switch self {
|
||
case .pause: "Could not pause local playback for casting."
|
||
case .acknowledgement: "Local playback did not acknowledge the Cast handoff."
|
||
case .readiness: "Local playback is not ready to cast."
|
||
}
|
||
}
|
||
}
|
||
|
||
@MainActor
|
||
private final class Operation {
|
||
let device: CastDevice
|
||
let source: MediaSource
|
||
var title: String
|
||
let subtitle: Int?
|
||
let handle: OpaquePointer
|
||
let snapshot: PlayerState
|
||
let playing: Bool
|
||
let volume: Double
|
||
let audibleVolume: Double
|
||
let session = CastSession()
|
||
let server = CastMediaServer()
|
||
var task: Task<Void, Never>?
|
||
var initialStatus: CastStatus?
|
||
var projection: Projection?
|
||
var intent: Bool?
|
||
|
||
init(device: CastDevice, source: MediaSource, title: String, subtitle: Int?,
|
||
handle: OpaquePointer, snapshot: PlayerState, playing: Bool,
|
||
volume: Double, audibleVolume: Double) {
|
||
self.device = device
|
||
self.source = source
|
||
self.title = title
|
||
self.subtitle = subtitle
|
||
self.handle = handle
|
||
self.snapshot = snapshot
|
||
self.playing = playing
|
||
self.volume = volume
|
||
self.audibleVolume = audibleVolume
|
||
}
|
||
}
|
||
|
||
private var operation: Operation?
|
||
private let observe: (Event) -> Void
|
||
|
||
init(observe: @escaping (Event) -> Void) { self.observe = observe }
|
||
|
||
var device: CastDevice? { operation?.device }
|
||
var connecting: Bool { operation != nil && !active }
|
||
var active: Bool { operation?.projection != nil }
|
||
var playbackIntent: Bool? { operation?.intent }
|
||
var checkpoint: (source: MediaSource, state: PlayerState)? {
|
||
guard let operation, let projection = operation.projection else { return nil }
|
||
return (operation.source, projection.state)
|
||
}
|
||
|
||
func updateTitle(_ title: String) {
|
||
// The existing Cast interface accepts titles at LOAD; do not reload active playback.
|
||
operation?.title = title
|
||
}
|
||
|
||
func start(to device: CastDevice, source: MediaSource, title: String, subtitle: Int?,
|
||
handle: OpaquePointer, snapshot: PlayerState, playing: Bool,
|
||
volume: Double, audibleVolume: Double) {
|
||
guard operation == nil else { return }
|
||
let operation = Operation(device: device, source: source, title: title, subtitle: subtitle,
|
||
handle: handle, snapshot: snapshot, playing: playing,
|
||
volume: volume, audibleVolume: audibleVolume)
|
||
self.operation = operation
|
||
operation.session.onStatus = { [weak self, weak operation] status in
|
||
guard let self, let operation, self.operation === operation else { return }
|
||
if operation.projection == nil { operation.initialStatus = status }
|
||
else { self.receive(status, operation: operation) }
|
||
}
|
||
operation.session.onDisconnect = { [weak self, weak operation] message in
|
||
guard let self, let operation, self.operation === operation else { return }
|
||
self.stop()
|
||
if let message { self.observe(.error(message)) }
|
||
}
|
||
operation.session.onError = { [weak self, weak operation] message in
|
||
guard let self, let operation, self.operation === operation else { return }
|
||
if !operation.session.playbackPending { operation.intent = nil }
|
||
self.observe(.error(message))
|
||
}
|
||
observe(.changed)
|
||
operation.task = Task { [weak self] in await self?.connect(operation) }
|
||
}
|
||
|
||
private func connect(_ operation: Operation) async {
|
||
do {
|
||
try check(operation)
|
||
try await operation.session.connect(to: operation.device)
|
||
try check(operation)
|
||
guard player_pause(operation.handle) else { throw HandoffError.pause }
|
||
let deadline = ContinuousClock.now.advanced(by: .seconds(5))
|
||
while player_get_state(operation.handle).status == PLAYER_PLAYING {
|
||
guard ContinuousClock.now < deadline else { throw HandoffError.acknowledgement }
|
||
try await Task.sleep(for: .milliseconds(20))
|
||
try check(operation)
|
||
}
|
||
let snapshot = player_get_state(operation.handle)
|
||
guard snapshot.status == PLAYER_PAUSED || snapshot.status == PLAYER_ENDED else {
|
||
throw HandoffError.readiness
|
||
}
|
||
let position = snapshot.can_seek && !operation.source.live && snapshot.status != PLAYER_ENDED ?
|
||
snapshot.position : 0
|
||
let media = try await operation.server.start(input: input(operation.source, duration: snapshot.duration),
|
||
receiverHost: operation.device.host)
|
||
try check(operation)
|
||
try await operation.session.load(media, title: operation.title, position: position,
|
||
playing: operation.playing, subtitle: operation.subtitle)
|
||
try check(operation)
|
||
var state = operation.snapshot
|
||
state.buffered_start = 0
|
||
state.buffered_end = 0
|
||
operation.projection = Projection(state: state, volume: operation.volume, subtitle: operation.subtitle)
|
||
operation.task = nil
|
||
observe(.activated)
|
||
if let status = operation.initialStatus {
|
||
operation.initialStatus = nil
|
||
receive(status, operation: operation)
|
||
} else if let projection = operation.projection {
|
||
observe(.status(projection))
|
||
}
|
||
} catch {
|
||
guard self.operation === operation else { return }
|
||
stop()
|
||
if !(error is CancellationError) { observe(.error(error.localizedDescription)) }
|
||
}
|
||
}
|
||
|
||
private func check(_ operation: Operation) throws {
|
||
try Task.checkCancellation()
|
||
guard self.operation === operation else { throw CancellationError() }
|
||
}
|
||
|
||
func stop(resume: Bool = true) {
|
||
guard let operation else { return }
|
||
let projection = operation.projection
|
||
let playing = operation.intent ?? projection.map { $0.state.status == PLAYER_PLAYING } ?? operation.playing
|
||
let position = projection.map {
|
||
$0.state.can_seek && !operation.source.live && $0.state.status != PLAYER_ENDED ? $0.state.position : 0
|
||
} ?? 0
|
||
let revision = projection == nil ? nil : player_get_state(operation.handle).revision
|
||
// Invalidate before teardown callbacks or any suspended task resumes. All native
|
||
// accesses are main-actor synchronous, with check() after every suspension:
|
||
// shutdown can destroy the player immediately after stop(resume: false).
|
||
self.operation = nil
|
||
operation.task?.cancel()
|
||
operation.task = nil
|
||
operation.session.onStatus = nil
|
||
operation.session.onDisconnect = nil
|
||
operation.session.onError = nil
|
||
operation.session.disconnect()
|
||
operation.server.stop()
|
||
_ = player_set_volume(operation.handle, Float(operation.volume))
|
||
if resume && projection == nil {
|
||
_ = operation.playing ? player_play(operation.handle) : player_pause(operation.handle)
|
||
}
|
||
observe(.stopped(Return(source: operation.source, checkpoint: projection?.state,
|
||
suspendedRevision: revision, volume: operation.volume,
|
||
audibleVolume: operation.audibleVolume,
|
||
restore: resume && projection != nil ? (position, playing) : nil)))
|
||
}
|
||
|
||
func setPlaying(_ playing: Bool) -> Bool {
|
||
guard let operation, operation.projection != nil, operation.session.setPlaying(playing) else { return false }
|
||
operation.intent = playing
|
||
observe(.changed)
|
||
return true
|
||
}
|
||
|
||
func seek(_ position: Double) -> Bool {
|
||
guard let operation, operation.projection != nil else { return false }
|
||
return operation.session.seek(position)
|
||
}
|
||
|
||
func setVolume(_ volume: Double) -> Bool {
|
||
guard let operation, operation.projection != nil else { return false }
|
||
return operation.session.setVolume(volume)
|
||
}
|
||
|
||
func setSubtitle(_ index: Int?) -> Bool {
|
||
guard let operation, operation.projection != nil else { return false }
|
||
return operation.session.setSubtitle(index)
|
||
}
|
||
|
||
private func receive(_ status: CastStatus, operation: Operation) {
|
||
guard var projection = operation.projection else { return }
|
||
projection.state.status = status.ended ? PLAYER_ENDED : (status.playing ? PLAYER_PLAYING : PLAYER_PAUSED)
|
||
projection.state.position = status.position
|
||
projection.state.duration = status.duration.isFinite && status.duration > 0 ? status.duration : 0
|
||
projection.state.buffering = status.buffering
|
||
projection.state.can_seek = status.seekable && !operation.source.live
|
||
projection.state.seek_pending = status.seekPending
|
||
projection.state.buffered_start = 0
|
||
projection.state.buffered_end = 0
|
||
projection.state.revision &+= 1
|
||
projection.volume = status.volume
|
||
projection.subtitle = status.subtitle
|
||
if !status.playbackPending { operation.intent = nil }
|
||
operation.projection = projection
|
||
observe(.status(projection))
|
||
}
|
||
|
||
private func input(_ source: MediaSource, duration: Double) -> CastMediaInput {
|
||
switch source {
|
||
case .file(let url):
|
||
return CastMediaInput(videoURL: url.path, videoHeaders: "", audioURL: nil, audioHeaders: nil,
|
||
local: true, restricted: false, live: false, duration: duration, subtitles: [])
|
||
case .youtube(let video, _):
|
||
return CastMediaInput(videoURL: video.video.url, videoHeaders: video.video.headers,
|
||
audioURL: video.audio?.url, audioHeaders: video.audio?.headers,
|
||
local: false, restricted: false, live: video.isLive,
|
||
duration: duration, subtitles: [])
|
||
case .resolved(let resolved):
|
||
return CastMediaInput(videoURL: resolved.url, videoHeaders: resolved.headers,
|
||
audioURL: nil, audioHeaders: nil, local: false, restricted: true,
|
||
live: false, duration: duration, subtitles: resolved.subtitles)
|
||
}
|
||
}
|
||
}
|
||
|
||
private enum MediaChange {
|
||
case open
|
||
case queue(playing: Bool)
|
||
case quality(position: Double?, playing: Bool, automatic: Bool)
|
||
case restore(position: Double, playing: Bool)
|
||
|
||
var automatic: Bool {
|
||
if case .quality(_, _, let automatic) = self { return automatic }
|
||
return false
|
||
}
|
||
var playing: Bool {
|
||
if case .quality(_, let playing, _) = self { return playing }
|
||
if case .restore(_, let playing) = self { return playing }
|
||
if case .queue(let playing) = self { return playing }
|
||
return true
|
||
}
|
||
var position: Double? {
|
||
if case .quality(let position, _, _) = self { return position }
|
||
if case .restore(let position, _) = self { return position }
|
||
return nil
|
||
}
|
||
var isQuality: Bool {
|
||
if case .quality = self { return true }
|
||
return false
|
||
}
|
||
var preservesPosition: Bool {
|
||
if case .restore = self { return true }
|
||
return isQuality
|
||
}
|
||
}
|
||
|
||
private enum MediaSource {
|
||
case file(URL)
|
||
case youtube(YouTubeVideo, height: Int?)
|
||
case resolved(ResolvedSource)
|
||
|
||
var url: URL {
|
||
switch self {
|
||
case .file(let url): return url
|
||
case .youtube(let video, _): return video.webpageURL
|
||
case .resolved(let source): return URL(string: source.identity)!
|
||
}
|
||
}
|
||
var live: Bool {
|
||
if case .youtube(let video, _) = self { return video.isLive }
|
||
return false
|
||
}
|
||
}
|
||
|
||
private struct MetadataLifecycle {
|
||
private(set) var identity: String?
|
||
private(set) var metadata: MiruroMetadata?
|
||
private var episode: Int?
|
||
private var generation: UInt64 = 0
|
||
|
||
mutating func replace(identity: String?, episode: Int?) -> UInt64 {
|
||
generation &+= 1
|
||
self.identity = identity
|
||
self.episode = episode
|
||
metadata = nil
|
||
return generation
|
||
}
|
||
|
||
mutating func accept(_ metadata: MiruroMetadata, identity: String, generation: UInt64) -> Bool {
|
||
guard self.identity == identity, self.generation == generation else { return false }
|
||
self.metadata = metadata
|
||
return true
|
||
}
|
||
|
||
func visibleIntervals(duration: Double) -> [MiruroInterval] {
|
||
guard let episode, let metadata else { return [] }
|
||
return metadata.visibleIntervals(episode: episode, duration: duration)
|
||
}
|
||
}
|
||
|
||
#if PLAYER_CHECK
|
||
static func metadataLifecycleCheck() {
|
||
var lifecycle = MetadataLifecycle()
|
||
let a = "miruro:A:1"
|
||
let b = "miruro:B:2"
|
||
let metadata = MiruroMetadata(title: "Series — Episode 1", intervals: [
|
||
MiruroInterval(kind: .intro, start: 2.5, end: 10, episode: 1),
|
||
MiruroInterval(kind: .outro, start: 90, end: 100, episode: 1),
|
||
])
|
||
let first = lifecycle.replace(identity: a, episode: 1)
|
||
precondition(lifecycle.accept(metadata, identity: a, generation: first), "Current metadata is accepted")
|
||
precondition(lifecycle.visibleIntervals(duration: 0).isEmpty, "Unknown duration hides bands")
|
||
precondition(lifecycle.visibleIntervals(duration: 100).count == 2, "Actual duration permits exact-end bands")
|
||
precondition(lifecycle.visibleIntervals(duration: 95).count == 1, "Duration shrink revalidates raw bands")
|
||
precondition(lifecycle.visibleIntervals(duration: 100).count == 2, "Duration growth restores raw bands")
|
||
_ = lifecycle.replace(identity: b, episode: 2)
|
||
precondition(lifecycle.metadata == nil, "Replacement clears title and intervals")
|
||
precondition(!lifecycle.accept(metadata, identity: a, generation: first), "Different-identity result is stale")
|
||
let second = lifecycle.replace(identity: a, episode: 1)
|
||
precondition(!lifecycle.accept(metadata, identity: a, generation: first), "A-B-A result is stale")
|
||
precondition(lifecycle.accept(metadata, identity: a, generation: second), "Fresh same-identity result is accepted")
|
||
precondition(lifecycle.accept(MiruroMetadata(title: nil, intervals: []), identity: a, generation: second),
|
||
"Empty optional metadata is accepted")
|
||
precondition(lifecycle.metadata?.title == nil && lifecycle.visibleIntervals(duration: 100).isEmpty,
|
||
"Absent title and intervals do not retain enrichment")
|
||
_ = lifecycle.replace(identity: "file:///replacement.mp4", episode: nil)
|
||
precondition(lifecycle.metadata == nil && lifecycle.visibleIntervals(duration: 100).isEmpty,
|
||
"Local replacement has no title enrichment or bands")
|
||
_ = lifecycle.replace(identity: "https://www.youtube.com/watch?v=fixture", episode: nil)
|
||
precondition(!lifecycle.accept(metadata, identity: a, generation: second), "YouTube rejects earlier metadata")
|
||
_ = lifecycle.replace(identity: nil, episode: nil)
|
||
precondition(lifecycle.metadata == nil, "Close clears metadata")
|
||
}
|
||
#endif
|
||
|
||
private struct PlaybackTransition {
|
||
enum PlaybackSubmission {
|
||
case unchanged
|
||
case deferred
|
||
case submitted(Bool)
|
||
}
|
||
|
||
private struct PlaybackRequest {
|
||
var desired: Bool
|
||
let submitted: Bool
|
||
let revision: UInt64
|
||
}
|
||
private struct Resolution {
|
||
let id: UUID
|
||
let change: MediaChange
|
||
var task: Task<Void, Never>?
|
||
}
|
||
|
||
private(set) var pendingRevision: UInt64?
|
||
private(set) var seekPending = false
|
||
private(set) var mediaURL: URL?
|
||
private(set) var isLive = false
|
||
private(set) var isNetwork = false
|
||
private var playback: PlaybackRequest?
|
||
private var resolution: Resolution?
|
||
private var restoration: (position: Double, playing: Bool)?
|
||
private var lastSavedPosition: Double?
|
||
|
||
var playbackIntent: Bool? { playback?.desired }
|
||
var isResolving: Bool { resolution != nil }
|
||
var resolvingAutomaticQuality: Bool { resolution?.change.automatic == true }
|
||
|
||
func isBusy(_ status: PlayerStatus) -> Bool {
|
||
(isResolving && !resolvingAutomaticQuality) || pendingRevision != nil || status == PLAYER_OPENING
|
||
}
|
||
|
||
func qualityChange(_ state: PlayerState, playing: Bool, automatic: Bool = false) -> MediaChange {
|
||
.quality(position: !isLive && state.can_seek ? state.position : nil,
|
||
playing: playing, automatic: automatic)
|
||
}
|
||
|
||
mutating func beginResolution(_ change: MediaChange) -> UUID {
|
||
if !change.automatic { playback = nil }
|
||
let id = UUID()
|
||
resolution = Resolution(id: id, change: change)
|
||
return id
|
||
}
|
||
|
||
mutating func attachResolution(_ task: Task<Void, Never>, request: UUID) {
|
||
guard resolution?.id == request else { task.cancel(); return }
|
||
resolution?.task = task
|
||
}
|
||
|
||
mutating func finishResolution(_ request: UUID) -> MediaChange? {
|
||
guard let resolution, resolution.id == request else { return nil }
|
||
let change = resolution.change
|
||
cancelResolution()
|
||
return change
|
||
}
|
||
|
||
mutating func cancelResolution() {
|
||
resolution?.task?.cancel()
|
||
resolution = nil
|
||
}
|
||
|
||
func canApplyAutomaticQuality(_ state: PlayerState, enabled: Bool, previewingSeek: Bool) -> Bool {
|
||
enabled && state.status == PLAYER_PLAYING && !previewingSeek && !seekPending &&
|
||
!state.seek_pending && pendingRevision == nil &&
|
||
(isLive || !state.duration.isFinite || state.duration <= 0 || state.duration - state.position > 15)
|
||
}
|
||
|
||
func blocksAutomaticQuality(_ status: PlayerStatus) -> Bool {
|
||
isBusy(status) || isResolving || restoration != nil
|
||
}
|
||
|
||
mutating func acceptOpen(revision: UInt64, source: MediaSource, change: MediaChange,
|
||
savedPosition: Double?) {
|
||
playback = nil
|
||
pendingRevision = revision
|
||
isLive = source.live
|
||
isNetwork = !source.url.isFileURL
|
||
mediaURL = source.url
|
||
// Network restoration is acknowledged by the backend open transaction.
|
||
restoration = isNetwork ? nil : savedPosition.map { ($0, change.playing) }
|
||
lastSavedPosition = nil
|
||
seekPending = false
|
||
}
|
||
|
||
mutating func advance(_ next: PlayerState, forceCheckpoint: Bool, handle: OpaquePointer?,
|
||
history: PlaybackProgress)
|
||
-> (ready: Bool, playback: Bool?, restored: (position: Double?, error: String?)?) {
|
||
if let revision = pendingRevision {
|
||
guard next.revision != revision, next.status != PLAYER_OPENING else { return (false, nil, nil) }
|
||
pendingRevision = nil
|
||
}
|
||
var followUp: Bool?
|
||
if next.status == PLAYER_EMPTY || next.status == PLAYER_ERROR {
|
||
playback = nil
|
||
} else if let request = playback, next.revision != request.revision,
|
||
next.status == PLAYER_ENDED || (next.status == PLAYER_PLAYING) == request.submitted,
|
||
next.status == PLAYER_PLAYING || next.status == PLAYER_PAUSED || next.status == PLAYER_ENDED {
|
||
playback = nil
|
||
if request.desired != request.submitted { followUp = request.desired }
|
||
}
|
||
if !next.seek_pending || next.status == PLAYER_ERROR { seekPending = false }
|
||
// Checkpoint before consuming local restoration: ready is not yet resumed.
|
||
let restored: (position: Double?, error: String?)?
|
||
if let handle {
|
||
saveProgress(next, handle: handle, force: forceCheckpoint, history: history)
|
||
restored = restoreLocalPosition(next, handle: handle, history: history)
|
||
} else {
|
||
restored = nil
|
||
}
|
||
return (true, followUp, restored)
|
||
}
|
||
|
||
mutating func setPlayback(_ playing: Bool, status: PlayerStatus, handle: OpaquePointer) -> PlaybackSubmission {
|
||
if playback != nil {
|
||
playback?.desired = playing
|
||
return .deferred
|
||
}
|
||
guard (status == PLAYER_PLAYING) != playing else { return .unchanged }
|
||
let revision = player_get_state(handle).revision
|
||
let accepted = playing ? player_play(handle) : player_pause(handle)
|
||
if accepted { playback = PlaybackRequest(desired: playing, submitted: playing, revision: revision) }
|
||
return .submitted(accepted)
|
||
}
|
||
|
||
mutating func seek(_ position: Double, handle: OpaquePointer) -> Bool {
|
||
let accepted = player_seek(handle, position)
|
||
if accepted { seekPending = true }
|
||
return accepted
|
||
}
|
||
|
||
mutating func stepFrame(_ direction: Int32, handle: OpaquePointer) -> Bool {
|
||
let accepted = player_step_frame(handle, direction)
|
||
if accepted {
|
||
seekPending = false
|
||
playback = nil
|
||
}
|
||
return accepted
|
||
}
|
||
|
||
mutating func acceptCast() {
|
||
playback = nil
|
||
pendingRevision = nil
|
||
restoration = nil
|
||
seekPending = false
|
||
}
|
||
mutating func suspendHistory(revision: UInt64) {
|
||
pendingRevision = revision
|
||
}
|
||
mutating func closeMedia() {
|
||
playback = nil
|
||
isLive = false
|
||
isNetwork = false
|
||
mediaURL = nil
|
||
restoration = nil
|
||
lastSavedPosition = nil
|
||
seekPending = false
|
||
}
|
||
|
||
mutating func saveProgress(_ snapshot: PlayerState?, handle: OpaquePointer,
|
||
force: Bool, history: PlaybackProgress) {
|
||
guard let url = mediaURL, pendingRevision == nil, restoration == nil, !isLive else { return }
|
||
let state = snapshot ?? player_get_state(handle)
|
||
guard state.status == PLAYER_PAUSED || state.status == PLAYER_PLAYING || state.status == PLAYER_ENDED,
|
||
state.has_video, state.can_seek, !state.seek_pending,
|
||
state.duration.isFinite, state.duration > 0,
|
||
state.position.isFinite, state.position >= 0 else { return }
|
||
let position = state.status == PLAYER_ENDED ? 0 : min(state.position, state.duration)
|
||
guard force || lastSavedPosition == nil || abs(position - lastSavedPosition!) >= 5 else { return }
|
||
history.save(position > 0 ? position : nil, for: url)
|
||
lastSavedPosition = position
|
||
}
|
||
|
||
mutating func restoreLocalPosition(_ state: PlayerState, handle: OpaquePointer,
|
||
history: PlaybackProgress) -> (position: Double?, error: String?)? {
|
||
guard let saved = restoration else { return nil }
|
||
restoration = nil
|
||
guard state.status == PLAYER_PAUSED || state.status == PLAYER_PLAYING else { return nil }
|
||
var position: Double?
|
||
if state.has_video && state.can_seek && state.duration.isFinite && state.duration > 0 {
|
||
if saved.position < state.duration {
|
||
guard seek(saved.position, handle: handle) else {
|
||
mediaURL = nil
|
||
return (nil, "Could not resume saved progress. Please try opening the video again.")
|
||
}
|
||
position = saved.position
|
||
} else if let url = mediaURL {
|
||
history.save(nil, for: url)
|
||
}
|
||
}
|
||
let accepted = !saved.playing || player_play(handle)
|
||
return (position, accepted ? nil : "Could not queue playback. Please try again.")
|
||
}
|
||
|
||
var needsLocalRestoration: Bool { restoration != nil }
|
||
}
|
||
}
|
||
|
||
@MainActor
|
||
private enum SunsetPalette {
|
||
static let background = NSColor(srgbRed: 0.12, green: 0.09, blue: 0.08, alpha: 0.35)
|
||
static let text = NSColor(srgbRed: 1.00, green: 0.89, blue: 0.71, alpha: 1)
|
||
static let secondaryText = NSColor(srgbRed: 0.85, green: 0.78, blue: 0.65, alpha: 1)
|
||
static let accent = NSColor(srgbRed: 0.95, green: 0.58, blue: 0.23, alpha: 1)
|
||
static let ember = NSColor(srgbRed: 0.76, green: 0.27, blue: 0.18, alpha: 1)
|
||
static let outline = secondaryText.withAlphaComponent(0.22)
|
||
static let actionFill = accent.withAlphaComponent(0.08)
|
||
static let track = secondaryText.withAlphaComponent(0.18)
|
||
static let bufferedTrack = secondaryText.withAlphaComponent(0.4)
|
||
static let disabledThumb = secondaryText.withAlphaComponent(0.25)
|
||
}
|
||
|
||
@MainActor
|
||
private final class PlayerBackdrop: NSView {
|
||
var ambientGlows = false
|
||
private static let amber = NSGradient(colors: [
|
||
SunsetPalette.accent.withAlphaComponent(0.18),
|
||
SunsetPalette.accent.withAlphaComponent(0.065),
|
||
SunsetPalette.accent.withAlphaComponent(0),
|
||
])!
|
||
private static let ember = NSGradient(colors: [
|
||
SunsetPalette.ember.withAlphaComponent(0.26),
|
||
SunsetPalette.ember.withAlphaComponent(0.10),
|
||
SunsetPalette.ember.withAlphaComponent(0),
|
||
])!
|
||
|
||
override func hitTest(_ point: NSPoint) -> NSView? { nil }
|
||
|
||
override func draw(_ dirtyRect: NSRect) {
|
||
SunsetPalette.background.setFill()
|
||
dirtyRect.fill()
|
||
guard ambientGlows else { return }
|
||
let upperGlow = NSPoint(x: bounds.width * 0.58, y: bounds.height * 0.94)
|
||
Self.ember.draw(fromCenter: upperGlow, radius: 0, toCenter: upperGlow,
|
||
radius: bounds.width * 0.40, options: [])
|
||
let lowerGlow = NSPoint(x: bounds.width * 0.88, y: bounds.height * 0.10)
|
||
Self.amber.draw(fromCenter: lowerGlow, radius: 0, toCenter: lowerGlow,
|
||
radius: bounds.width * 0.55, options: [])
|
||
let leftGlow = NSPoint(x: bounds.width * 0.08, y: bounds.height * 0.65)
|
||
NSGraphicsContext.saveGraphicsState()
|
||
NSGraphicsContext.current?.cgContext.setAlpha(0.45)
|
||
Self.amber.draw(fromCenter: leftGlow, radius: 0, toCenter: leftGlow,
|
||
radius: bounds.width * 0.32, options: [])
|
||
NSGraphicsContext.restoreGraphicsState()
|
||
}
|
||
}
|
||
|
||
@MainActor
|
||
private struct PlayerGlass: NSViewRepresentable {
|
||
var blendingMode: NSVisualEffectView.BlendingMode = .withinWindow
|
||
var cornerRadius: CGFloat = 12
|
||
var ambientGlows = false
|
||
|
||
func makeCoordinator() -> PlayerBackdrop { PlayerBackdrop(frame: .zero) }
|
||
|
||
func makeNSView(context: Context) -> NSVisualEffectView {
|
||
let view = NSVisualEffectView()
|
||
view.blendingMode = blendingMode
|
||
view.state = .active
|
||
view.appearance = NSAppearance(named: .vibrantDark)
|
||
view.material = .hudWindow
|
||
view.wantsLayer = true
|
||
view.layer?.cornerRadius = cornerRadius
|
||
view.layer?.masksToBounds = true
|
||
view.layer?.borderWidth = cornerRadius > 0 ? 0.5 : 0
|
||
view.layer?.borderColor = SunsetPalette.outline.cgColor
|
||
let backdrop = context.coordinator
|
||
backdrop.frame = view.bounds
|
||
backdrop.autoresizingMask = [.width, .height]
|
||
backdrop.ambientGlows = ambientGlows
|
||
backdrop.setAccessibilityElement(false)
|
||
view.addSubview(backdrop)
|
||
return view
|
||
}
|
||
|
||
func updateNSView(_ view: NSVisualEffectView, context: Context) {
|
||
view.blendingMode = blendingMode
|
||
if context.coordinator.ambientGlows != ambientGlows {
|
||
context.coordinator.ambientGlows = ambientGlows
|
||
context.coordinator.needsDisplay = true
|
||
}
|
||
}
|
||
}
|
||
|
||
@MainActor
|
||
private struct PlaybackSlider: NSViewRepresentable {
|
||
@Binding var value: Double
|
||
let range: ClosedRange<Double>
|
||
var volume = false
|
||
var enabled = true
|
||
var bufferedRange: ClosedRange<Double>?
|
||
var intervals: [MiruroInterval] = []
|
||
var onEditingChanged: (Bool) -> Void = { _ in }
|
||
|
||
func makeCoordinator() -> Coordinator { Coordinator(self) }
|
||
|
||
func makeNSView(context: Context) -> Slider {
|
||
let slider = Slider()
|
||
let cell = Cell()
|
||
cell.volume = volume
|
||
slider.cell = cell
|
||
slider.isContinuous = true
|
||
slider.target = context.coordinator
|
||
slider.action = #selector(Coordinator.changed(_:))
|
||
return slider
|
||
}
|
||
|
||
func updateNSView(_ slider: Slider, context: Context) {
|
||
context.coordinator.parent = self
|
||
slider.minValue = range.lowerBound
|
||
slider.maxValue = range.upperBound
|
||
slider.setModelValue(value)
|
||
slider.isEnabled = enabled
|
||
slider.editingChanged = onEditingChanged
|
||
if let cell = slider.cell as? Cell,
|
||
cell.bufferedRange != bufferedRange || cell.intervals != intervals {
|
||
cell.bufferedRange = bufferedRange
|
||
cell.intervals = intervals
|
||
slider.needsDisplay = true
|
||
}
|
||
slider.setAccessibilityLabel(volume ? "Volume" : "Playback Position")
|
||
}
|
||
|
||
@MainActor
|
||
final class Coordinator: NSObject {
|
||
var parent: PlaybackSlider
|
||
init(_ parent: PlaybackSlider) { self.parent = parent }
|
||
@objc func changed(_ slider: Slider) {
|
||
if !slider.tracking { parent.onEditingChanged(true) }
|
||
parent.value = slider.doubleValue
|
||
if !slider.tracking { parent.onEditingChanged(false) }
|
||
}
|
||
}
|
||
|
||
final class Slider: NSSlider {
|
||
var editingChanged: (Bool) -> Void = { _ in }
|
||
private(set) var tracking = false
|
||
func setModelValue(_ value: Double) { if !tracking { doubleValue = value } }
|
||
#if PLAYER_CHECK
|
||
static func feedbackCheck() -> Bool {
|
||
let slider = Slider()
|
||
slider.minValue = 0
|
||
slider.maxValue = 1
|
||
slider.doubleValue = 0.8
|
||
slider.tracking = true
|
||
slider.setModelValue(0.79)
|
||
let heldUserValue = slider.doubleValue == 0.8
|
||
slider.tracking = false
|
||
slider.setModelValue(0.79)
|
||
return heldUserValue && slider.doubleValue == 0.79
|
||
}
|
||
#endif
|
||
override func mouseDown(with event: NSEvent) {
|
||
guard isEnabled else { return }
|
||
tracking = true
|
||
editingChanged(true)
|
||
super.mouseDown(with: event)
|
||
tracking = false
|
||
editingChanged(false)
|
||
}
|
||
}
|
||
|
||
final class Cell: NSSliderCell {
|
||
var volume = false
|
||
var bufferedRange: ClosedRange<Double>?
|
||
var intervals: [MiruroInterval] = []
|
||
private static let labelAttributes: [NSAttributedString.Key: Any] = [
|
||
.font: NSFont.systemFont(ofSize: 9, weight: .semibold),
|
||
.foregroundColor: SunsetPalette.text,
|
||
]
|
||
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)
|
||
if !volume, maxValue > minValue {
|
||
let scale = bar.width / (maxValue - minValue)
|
||
for interval in intervals {
|
||
let band = NSRect(x: bar.minX + (interval.start - minValue) * scale,
|
||
y: bar.minY - 2, width: (interval.end - interval.start) * scale, height: 7)
|
||
let color = interval.kind == .intro ? SunsetPalette.accent : SunsetPalette.ember
|
||
color.withAlphaComponent(0.3).setFill()
|
||
NSBezierPath(rect: band).fill()
|
||
SunsetPalette.secondaryText.withAlphaComponent(0.8).setStroke()
|
||
let outline = NSBezierPath(rect: band)
|
||
outline.lineWidth = 0.5
|
||
outline.stroke()
|
||
let full = (interval.kind == .intro ? "Opening" : "Ending") as NSString
|
||
let label = full.size(withAttributes: Self.labelAttributes).width <= band.width ? full :
|
||
(interval.kind == .intro ? "OP" : "ED") as NSString
|
||
let size = label.size(withAttributes: Self.labelAttributes)
|
||
let x = min(max(bar.minX, band.midX - size.width / 2), bar.maxX - size.width)
|
||
let above = interval.kind == .intro
|
||
let y = above != flipped ? bar.maxY + 3 : bar.minY - size.height - 3
|
||
label.draw(at: NSPoint(x: x, y: y), withAttributes: Self.labelAttributes)
|
||
}
|
||
}
|
||
SunsetPalette.track.setFill()
|
||
NSBezierPath(roundedRect: bar, xRadius: 1.5, yRadius: 1.5).fill()
|
||
if volume {
|
||
let fraction = (doubleValue - minValue) / (maxValue - minValue)
|
||
let fill = NSRect(x: bar.minX, y: bar.minY, width: bar.width * fraction, height: bar.height)
|
||
SunsetPalette.accent.setFill()
|
||
NSBezierPath(roundedRect: fill, xRadius: 1.5, yRadius: 1.5).fill()
|
||
} else if let bufferedRange, maxValue > minValue {
|
||
let lower = min(maxValue, max(minValue, bufferedRange.lowerBound))
|
||
let upper = min(maxValue, max(minValue, bufferedRange.upperBound))
|
||
let scale = bar.width / (maxValue - minValue)
|
||
let fill = NSRect(x: bar.minX + (lower - minValue) * scale, y: bar.minY,
|
||
width: (upper - lower) * scale, height: bar.height)
|
||
SunsetPalette.bufferedTrack.setFill()
|
||
NSBezierPath(roundedRect: fill, xRadius: 1.5, yRadius: 1.5).fill()
|
||
}
|
||
}
|
||
override func drawKnob(_ rect: NSRect) {
|
||
let width: CGFloat = volume ? 16 : 3
|
||
let height: CGFloat = volume ? 12 : 15
|
||
let knob = NSRect(x: rect.midX - width / 2, y: rect.midY - height / 2, width: width, height: height)
|
||
let color = isEnabled ? (volume ? SunsetPalette.text : SunsetPalette.secondaryText)
|
||
: SunsetPalette.disabledThumb
|
||
color.setFill()
|
||
NSBezierPath(roundedRect: knob, xRadius: volume ? 6 : 1, yRadius: volume ? 6 : 1).fill()
|
||
}
|
||
}
|
||
}
|
||
|
||
#if PLAYER_CHECK
|
||
@MainActor
|
||
func castVolumeSliderFeedbackCheck() -> Bool { PlaybackSlider.Slider.feedbackCheck() }
|
||
#endif
|
||
|
||
@MainActor
|
||
private struct CaptionSelector: NSViewRepresentable {
|
||
let model: PlayerModel
|
||
|
||
func makeNSView(context: Context) -> Selector { Selector(model: model) }
|
||
func updateNSView(_ view: Selector, context: Context) { view.updateTracks() }
|
||
|
||
final class Selector: NSPopUpButton {
|
||
private let model: PlayerModel
|
||
private var displayedTracks: [ResolvedSubtitle] = []
|
||
private var displayedSelection: Int?
|
||
private let captionItem = NSMenuItem(title: "", action: nil, keyEquivalent: "")
|
||
|
||
init(model: PlayerModel) {
|
||
self.model = model
|
||
super.init(frame: NSRect(x: 0, y: 0, width: 24, height: 24), pullsDown: true)
|
||
usesItemFromMenu = false
|
||
preferredEdge = .maxY
|
||
isBordered = false
|
||
imagePosition = .imageOnly
|
||
imageScaling = .scaleProportionallyUpOrDown
|
||
(cell as? NSPopUpButtonCell)?.arrowPosition = .noArrow
|
||
let symbol = NSImage(systemSymbolName: "captions.bubble", accessibilityDescription: "Subtitles")?
|
||
.withSymbolConfiguration(.init(pointSize: 13, weight: .regular))
|
||
captionItem.image = symbol
|
||
contentTintColor = SunsetPalette.text
|
||
toolTip = "Subtitles"
|
||
setAccessibilityLabel("Subtitles")
|
||
updateTracks()
|
||
}
|
||
|
||
required init?(coder: NSCoder) { nil }
|
||
|
||
func updateTracks() {
|
||
guard menu == nil || displayedSelection != model.selectedSubtitle ||
|
||
displayedTracks.count != model.subtitleTracks.count ||
|
||
zip(displayedTracks, model.subtitleTracks).contains(where: {
|
||
$0.label != $1.label || $0.language != $1.language
|
||
}) else { return }
|
||
displayedTracks = model.subtitleTracks
|
||
displayedSelection = model.selectedSubtitle
|
||
let tracks = NSMenu()
|
||
tracks.autoenablesItems = false
|
||
// AppKit reserves the first pull-down item for the button title.
|
||
let title = NSMenuItem(title: "Subtitles", action: nil, keyEquivalent: "")
|
||
title.isHidden = true
|
||
tracks.addItem(title)
|
||
func add(_ label: String, index: Int?) {
|
||
let item = NSMenuItem(title: label, action: #selector(selectTrack(_:)), keyEquivalent: "")
|
||
item.target = self
|
||
item.tag = index ?? -1
|
||
item.state = model.selectedSubtitle == index ? .on : .off
|
||
tracks.addItem(item)
|
||
}
|
||
add("Off", index: nil)
|
||
for (index, track) in model.subtitleTracks.enumerated() {
|
||
add(track.label + " (" + track.language + ")", index: index)
|
||
}
|
||
menu = tracks
|
||
(cell as? NSPopUpButtonCell)?.menuItem = captionItem
|
||
isEnabled = !model.subtitleTracks.isEmpty
|
||
}
|
||
|
||
@objc private func selectTrack(_ item: NSMenuItem) {
|
||
model.selectSubtitle(item.tag < 0 ? nil : item.tag)
|
||
model.revealControls()
|
||
}
|
||
}
|
||
}
|
||
|
||
@MainActor
|
||
struct PlaybackSurface: NSViewRepresentable {
|
||
@ObservedObject var model: PlayerModel
|
||
|
||
func makeNSView(context: Context) -> DragView { DragView(model: model) }
|
||
func updateNSView(_ view: DragView, context: Context) { view.updateVideo() }
|
||
|
||
final class DragView: NSView {
|
||
private let model: PlayerModel
|
||
private var hoverTracking: NSTrackingArea?
|
||
private var mouseDownEvent: NSEvent?
|
||
|
||
init(model: PlayerModel) {
|
||
self.model = model
|
||
super.init(frame: .zero)
|
||
identifier = NSUserInterfaceItemIdentifier("PlaybackSurface")
|
||
if let video = model.videoView {
|
||
video.frame = bounds
|
||
video.autoresizingMask = [.width, .height]
|
||
addSubview(video)
|
||
}
|
||
updateVideo()
|
||
}
|
||
required init?(coder: NSCoder) { nil }
|
||
|
||
func updateVideo() {
|
||
model.videoView?.isHidden = model.fileName == nil || model.castDevice != nil
|
||
}
|
||
|
||
override var mouseDownCanMoveWindow: Bool { false }
|
||
|
||
override func hitTest(_ point: NSPoint) -> NSView? {
|
||
guard super.hitTest(point) != nil else { return nil }
|
||
// Leave the outer native resize border alone, not the internal queue boundary.
|
||
if let content = window?.contentView,
|
||
!content.bounds.insetBy(dx: 5, dy: 5).contains(content.convert(point, from: superview)) {
|
||
return nil
|
||
}
|
||
return self
|
||
}
|
||
|
||
override func updateTrackingAreas() {
|
||
super.updateTrackingAreas()
|
||
if let hoverTracking { removeTrackingArea(hoverTracking) }
|
||
let area = NSTrackingArea(rect: .zero,
|
||
options: [.inVisibleRect, .activeAlways, .mouseEnteredAndExited, .mouseMoved],
|
||
owner: self, userInfo: nil)
|
||
hoverTracking = area
|
||
addTrackingArea(area)
|
||
}
|
||
|
||
override func mouseEntered(with event: NSEvent) { model.revealControls() }
|
||
override func mouseMoved(with event: NSEvent) { model.revealControls() }
|
||
override func mouseExited(with event: NSEvent) {
|
||
if model.isCompact { model.hideControls(hideCursor: false) }
|
||
}
|
||
|
||
override func mouseDown(with event: NSEvent) {
|
||
mouseDownEvent = nil
|
||
if event.clickCount == 2 {
|
||
if model.isCompact { model.toggleCompact() } else { model.toggleFullScreen() }
|
||
} else {
|
||
mouseDownEvent = event
|
||
}
|
||
}
|
||
|
||
override func mouseDragged(with event: NSEvent) {
|
||
guard let down = mouseDownEvent, let window else { return }
|
||
mouseDownEvent = nil
|
||
model.setControlsDragging(true)
|
||
window.performDrag(with: down)
|
||
if model.isCompact { model.compactWindow.snapAfterDrag() }
|
||
model.setControlsDragging(false)
|
||
}
|
||
|
||
override func mouseUp(with event: NSEvent) {
|
||
guard mouseDownEvent != nil else { return }
|
||
mouseDownEvent = nil
|
||
if !model.isCompact { model.hideControls() }
|
||
}
|
||
}
|
||
}
|
||
|
||
// Select the property wrapper, not the macOS 27 macro missing from Command Line Tools.
|
||
private typealias ViewState<Value> = SwiftUI.State<Value>
|
||
|
||
private struct StartScreenButtonStyle: ButtonStyle {
|
||
var prominent = false
|
||
@ViewState private var hovered = false
|
||
|
||
func makeBody(configuration: Configuration) -> some View {
|
||
configuration.label
|
||
.padding(.horizontal, 14)
|
||
.frame(minHeight: 40)
|
||
.contentShape(RoundedRectangle(cornerRadius: 7))
|
||
.background {
|
||
RoundedRectangle(cornerRadius: 7)
|
||
.fill(Color.white.opacity(configuration.isPressed ? 0.16 : (hovered ? 0.09 : (prominent ? 0.045 : 0))))
|
||
}
|
||
.onHover { hovered = $0 }
|
||
}
|
||
}
|
||
|
||
@MainActor
|
||
private struct PlayerContent: View {
|
||
@ObservedObject var model: PlayerModel
|
||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||
@ViewState private var showsRemainingTime = false
|
||
@ViewState private var dropTargeted = false
|
||
private var showsYouTubeInput: Bool { model.showsYouTubeInput }
|
||
@ViewState private var youTubeURL = ""
|
||
@FocusState private var youTubeInputFocused: Bool
|
||
@ViewState private var controlSize = CGSize(width: 440, height: 68)
|
||
@ViewState private var queueFrames: [UUID: CGRect] = [:]
|
||
@ViewState private var queueDrag: PlaybackQueueDrag?
|
||
@GestureState private var queueDragging = false
|
||
@GestureState private var windowDragging = false
|
||
@ViewState private var queueScroll = ScrollPosition(y: 0)
|
||
@ViewState private var queueScrollOffset: CGFloat = 0
|
||
@ViewState private var queueViewportHeight: CGFloat = 0
|
||
@ViewState private var queueContentHeight: CGFloat = 0
|
||
|
||
private func compactTitleLabel(progress: String?, constrained: Bool) -> some View {
|
||
HStack(spacing: 0) {
|
||
Text(model.fileName ?? "Terebi")
|
||
.lineLimit(1)
|
||
.truncationMode(.tail)
|
||
.frame(minWidth: 0, maxWidth: constrained ? .infinity : nil, alignment: .leading)
|
||
.clipped()
|
||
if let progress {
|
||
HStack(alignment: .top, spacing: 0) {
|
||
if model.compactTitleProgress == .percentage, let decimal = progress.firstIndex(of: ".") {
|
||
Text(" · \(progress[..<decimal])")
|
||
Text(String(progress[decimal...]))
|
||
.font(.system(size: 11 * 2 / 3, weight: .medium))
|
||
} else {
|
||
Text(" · \(progress)")
|
||
}
|
||
}
|
||
.monospacedDigit()
|
||
.accessibilityElement(children: .ignore)
|
||
.accessibilityLabel(model.compactTitleProgress == .percentage ? "\(progress) percent" : progress)
|
||
.fixedSize(horizontal: true, vertical: false)
|
||
}
|
||
}
|
||
}
|
||
|
||
private func toggleYouTubeInput() {
|
||
model.perform(.openStream)
|
||
}
|
||
|
||
private func startScreen(in size: CGSize) -> some View {
|
||
let sidebarWidth = min(260, max(112, size.width * 0.28))
|
||
let openKeys = model.keyBindings.shortcuts(for: .openFile)
|
||
let urlKeys = model.keyBindings.shortcuts(for: .openStream)
|
||
let latest = model.recentMedia.first
|
||
let resumePosition = latest.flatMap { model.savedPosition(for: $0.url) }
|
||
return HStack(spacing: 0) {
|
||
VStack(spacing: 0) {
|
||
Image(systemName: "play.rectangle.fill")
|
||
.font(.system(size: sidebarWidth < 160 ? 48 : 72, weight: .light))
|
||
.foregroundStyle(Color(SunsetPalette.accent))
|
||
.accessibilityHidden(true)
|
||
.padding(.top, max(48, size.height * 0.16) + 20)
|
||
VStack(spacing: 6) {
|
||
Text("Terebi").font(.system(size: 16, weight: .semibold))
|
||
if let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String {
|
||
Text(version).font(.system(size: 12)).foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
.padding(.top, min(80, size.height * 0.14))
|
||
Spacer(minLength: 24)
|
||
}
|
||
.frame(width: sidebarWidth)
|
||
.frame(maxHeight: .infinity)
|
||
.background(Color.black.opacity(0.28))
|
||
.overlay(alignment: .trailing) {
|
||
Rectangle().fill(Color(SunsetPalette.outline)).frame(width: 1)
|
||
}
|
||
.allowsHitTesting(false)
|
||
ScrollView {
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
Button(action: model.openPanel) {
|
||
HStack {
|
||
Text("Open…")
|
||
Spacer(minLength: 8)
|
||
Text((openKeys.first { $0.modifiers.contains(.command) } ?? openKeys.first)?.displayText ?? "")
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
.buttonStyle(StartScreenButtonStyle())
|
||
.accessibilityLabel("Open File…")
|
||
Button(action: toggleYouTubeInput) {
|
||
HStack {
|
||
Text("Open Stream…")
|
||
Spacer(minLength: 8)
|
||
Text((urlKeys.first { $0.modifiers.contains(.command) } ?? urlKeys.first)?.displayText ?? "")
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
.buttonStyle(StartScreenButtonStyle())
|
||
.accessibilityLabel("Open Stream…")
|
||
if showsYouTubeInput {
|
||
startURLInput.padding(.horizontal, 14)
|
||
.transition(.opacity.combined(with: .scale(scale: 0.96, anchor: .top)))
|
||
}
|
||
if let latest, let resumePosition {
|
||
Button { model.open(latest.url) } label: {
|
||
HStack(spacing: 8) {
|
||
Image(systemName: "clock.arrow.circlepath").foregroundStyle(.secondary)
|
||
Text("Resume \(latest.title)").lineLimit(1).truncationMode(.tail)
|
||
Spacer(minLength: 0)
|
||
Text(time(resumePosition)).monospacedDigit().foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
.buttonStyle(StartScreenButtonStyle(prominent: true))
|
||
.help("Resume \(latest.title) at \(time(resumePosition))")
|
||
.padding(.vertical, 8)
|
||
}
|
||
ForEach(model.recentMedia.prefix(4).filter { resumePosition == nil || $0.id != latest?.id }) { item in
|
||
Button { model.open(item.url) } label: {
|
||
HStack(spacing: 10) {
|
||
Image(systemName: item.url.isFileURL ? "doc.richtext" : "link")
|
||
.foregroundStyle(Color(SunsetPalette.accent))
|
||
.frame(width: 18)
|
||
Text(item.title).lineLimit(1).truncationMode(.tail)
|
||
Spacer(minLength: 0)
|
||
}
|
||
}
|
||
.buttonStyle(StartScreenButtonStyle())
|
||
.help(item.url.isFileURL ? item.url.path : item.url.absoluteString)
|
||
}
|
||
if model.recentMedia.isEmpty {
|
||
Text("Drop a media file or YouTube link here")
|
||
.font(.system(size: 13))
|
||
.foregroundStyle(.secondary)
|
||
.padding(.horizontal, 14)
|
||
.padding(.top, 20)
|
||
}
|
||
}
|
||
.animation(reduceMotion ? nil : .easeInOut(duration: 0.2), value: showsYouTubeInput)
|
||
.font(.system(size: 14, weight: .medium))
|
||
.disabled(!model.canOpen)
|
||
.padding(.horizontal, size.width < 600 ? 14 : 30)
|
||
.padding(.top, 64)
|
||
.padding(.bottom, 24)
|
||
.frame(maxWidth: .infinity, minHeight: size.height, alignment: .topLeading)
|
||
.background(windowDragBackground)
|
||
}
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
}
|
||
.foregroundStyle(Color(SunsetPalette.text))
|
||
}
|
||
|
||
private var startURLInput: some View {
|
||
HStack(spacing: 8) {
|
||
TextField("Stream URL", text: $youTubeURL,
|
||
prompt: Text("Stream URL")
|
||
.foregroundStyle(Color(SunsetPalette.secondaryText).opacity(0.6)))
|
||
.textFieldStyle(.plain)
|
||
.focused($youTubeInputFocused)
|
||
.accessibilityLabel("Stream URL")
|
||
.onAppear { youTubeInputFocused = true }
|
||
.onSubmit { model.openStream(youTubeURL) }
|
||
Button { model.openStream(youTubeURL) } label: {
|
||
Image(systemName: "arrow.right").frame(width: 24, height: 24).contentShape(Rectangle())
|
||
}
|
||
.buttonStyle(.plain)
|
||
.accessibilityLabel("Open Stream")
|
||
.help("Open Stream")
|
||
.disabled(youTubeURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||
}
|
||
.font(.system(size: 12))
|
||
.padding(.horizontal, 12)
|
||
.padding(.vertical, 4)
|
||
.background(Color(SunsetPalette.actionFill), in: RoundedRectangle(cornerRadius: 8))
|
||
.overlay(RoundedRectangle(cornerRadius: 8)
|
||
.strokeBorder(Color(SunsetPalette.outline), lineWidth: 0.5).allowsHitTesting(false))
|
||
}
|
||
|
||
private var showsBufferReadout: Bool { model.isNetworkMedia && model.showsBufferInfo }
|
||
private var controlHeight: CGFloat { showsBufferReadout ? 88 : 68 }
|
||
private var compactControlHeight: CGFloat { showsBufferReadout ? 52 : 40 }
|
||
|
||
private var compactChromeAnimation: Animation? {
|
||
reduceMotion || !model.compactWindow.isTransitioning
|
||
? nil : .easeInOut(duration: CompactWindowController.transitionDuration)
|
||
}
|
||
|
||
private var controlsVisibilityAnimation: Animation? {
|
||
reduceMotion ? nil : (model.controlsVisible ? .easeOut(duration: 0.25) : .easeIn(duration: 0.25))
|
||
}
|
||
|
||
private var bufferedStatus: String? {
|
||
model.bufferedRange.map {
|
||
let behind = max(0, model.state.position - $0.lowerBound)
|
||
let ahead = max(0, $0.upperBound - model.state.position)
|
||
return "Reusable buffer: \(String(format: "%.1f", behind)) seconds behind, " +
|
||
"\(String(format: "%.1f", ahead)) seconds ahead; buffered through \(time($0.upperBound))."
|
||
}
|
||
}
|
||
|
||
private var bufferReadout: some View {
|
||
Text(model.bufferedRange.map {
|
||
let behind = max(0, model.state.position - $0.lowerBound)
|
||
let ahead = max(0, $0.upperBound - model.state.position)
|
||
return (model.isCompact ? "" : "Buffer: ") + String(format: "−%.0fs +%.0fs", behind, ahead)
|
||
} ?? (model.isCompact ? "Buffer —" : "Buffer: —"))
|
||
.font(.system(size: 10).monospacedDigit())
|
||
.foregroundStyle(Color(SunsetPalette.secondaryText))
|
||
.lineLimit(1)
|
||
.frame(height: 12)
|
||
.help(bufferedStatus ?? "Reusable buffer unavailable.")
|
||
.accessibilityLabel("Reusable Buffer")
|
||
.accessibilityValue(bufferedStatus ?? "Unavailable")
|
||
}
|
||
|
||
private func time(_ seconds: Double) -> String {
|
||
let value = Int(min(max(0, seconds.isFinite ? seconds : 0), Double(Int.max / 2)))
|
||
let hours = value / 3600
|
||
return hours > 0
|
||
? String(format: "%d:%02d:%02d", hours, value / 60 % 60, value % 60)
|
||
: String(format: "%02d:%02d", value / 60, value % 60)
|
||
}
|
||
|
||
private func iconButton(_ symbol: String, _ label: String, size: CGFloat = 17, action: @escaping () -> Void) -> some View {
|
||
Button(action: action) {
|
||
Image(systemName: symbol).font(.system(size: size)).frame(width: 24, height: 24)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.help(label)
|
||
.accessibilityLabel(label)
|
||
}
|
||
|
||
private var titlebar: some View {
|
||
HStack(spacing: 4) {
|
||
HStack(spacing: 4) {
|
||
if let icon = model.fileIcon {
|
||
Image(nsImage: icon).resizable().frame(width: 16, height: 16)
|
||
} else {
|
||
Image(systemName: "play.rectangle.fill").font(.system(size: 14))
|
||
}
|
||
Text(model.fileName ?? "Terebi")
|
||
.font(.system(size: 13, weight: .semibold))
|
||
.lineLimit(1)
|
||
.truncationMode(.tail)
|
||
.help(model.fileName ?? "Terebi")
|
||
}
|
||
.padding(.horizontal, 8)
|
||
.frame(height: 24)
|
||
.background(PlayerGlass(cornerRadius: 8).clipShape(RoundedRectangle(cornerRadius: 8)).allowsHitTesting(false))
|
||
Spacer(minLength: 12)
|
||
}
|
||
.padding(.leading, model.isFullScreen ? 16 : 86)
|
||
.padding(.trailing, 92 - 88 * model.queueWidth / 260)
|
||
.frame(height: 32)
|
||
.foregroundStyle(Color(SunsetPalette.text))
|
||
.background(windowDragBackground)
|
||
.onHover(perform: model.setTitleHovered)
|
||
.animation(controlsVisibilityAnimation) { content in
|
||
content.opacity(model.controlsVisible ? 1 : 0)
|
||
}
|
||
.allowsHitTesting(model.controlsVisible)
|
||
.accessibilityHidden(!model.controlsVisible)
|
||
}
|
||
|
||
private var titleControls: some View {
|
||
HStack(spacing: 4) {
|
||
iconButton(model.isOnTop ? "pin.fill" : "pin",
|
||
model.isOnTop ? "Stop Keeping Window on Top" : "Keep Window on Top", action: model.toggleOnTop)
|
||
.imageScale(.small)
|
||
.foregroundStyle(Color(model.isOnTop ? SunsetPalette.accent : SunsetPalette.secondaryText))
|
||
.disabled(model.isFullScreen || model.compactWindow.isTransitioning)
|
||
CastDeviceMenu(model: model)
|
||
actionsMenu
|
||
}
|
||
.padding(.horizontal, 4 * (1 - model.queueWidth / 260))
|
||
.frame(height: 24)
|
||
.background(windowDragBackground)
|
||
.background {
|
||
PlayerGlass(cornerRadius: 8)
|
||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||
.opacity(1 - model.queueWidth / 260)
|
||
.allowsHitTesting(false)
|
||
}
|
||
}
|
||
|
||
private var captionsMenu: some View {
|
||
CaptionSelector(model: model)
|
||
.frame(width: 24, height: 24)
|
||
}
|
||
|
||
private var actionsMenu: some View {
|
||
Menu {
|
||
PlayerActionButton(model: model, action: .openFile, title: "Open File…")
|
||
Button("Add to Queue…", action: model.addQueuePanel).disabled(!model.canOpen)
|
||
Button(model.showsQueue ? "Hide Queue" : "Show Queue") { model.showsQueue.toggle() }
|
||
Button("Previous Queue Item") { model.advanceQueue(-1) }.disabled(!model.canPreviousQueueItem)
|
||
Button("Next Queue Item") { model.advanceQueue(1) }.disabled(!model.canNextQueueItem)
|
||
PlayerActionButton(model: model, action: .openStream, title: "Open Stream…")
|
||
PlayerActionButton(model: model, action: .closeFile, title: "Close File")
|
||
Divider()
|
||
PlayerQualityMenu(model: model)
|
||
Divider()
|
||
if model.isCompact {
|
||
Menu("Subtitles") {
|
||
Button("Off") { model.selectSubtitle(nil) }
|
||
ForEach(model.subtitleTracks.indices, id: \.self) { index in
|
||
Button {
|
||
model.selectSubtitle(index)
|
||
} label: {
|
||
Label(model.subtitleTracks[index].label + " (" + model.subtitleTracks[index].language + ")",
|
||
systemImage: model.selectedSubtitle == index ? "checkmark" : "text.bubble")
|
||
}
|
||
}
|
||
}
|
||
.disabled(model.subtitleTracks.isEmpty)
|
||
Divider()
|
||
}
|
||
PlayerActionButton(model: model, action: model.bufferInfoAction,
|
||
title: model.showsBufferInfo ? "Hide Buffer Info" : "Show Buffer Info")
|
||
Divider()
|
||
PlayerActionButton(model: model, action: model.compactAction,
|
||
title: model.compactAction == .exitCompact ? "Exit Compact Mode" : "Enter Compact Mode")
|
||
PlayerActionButton(model: model, action: model.snappingAction,
|
||
title: model.compactWindow.snappingEnabled ? "Disable Compact Snapping" : "Enable Compact Snapping")
|
||
Divider()
|
||
PlayerActionButton(model: model, action: model.onTopAction,
|
||
title: model.isOnTop ? "Stop Keeping Window on Top" : "Keep Window on Top")
|
||
} label: {
|
||
Image(systemName: "gearshape").font(.system(size: model.isCompact ? 17 : 14)).frame(width: 24, height: 24)
|
||
}
|
||
.menuStyle(.button)
|
||
.buttonStyle(.plain)
|
||
.menuIndicator(.hidden)
|
||
.fixedSize()
|
||
.accessibilityLabel("Playback Actions")
|
||
.help("Playback Actions")
|
||
}
|
||
|
||
private var compactControls: some View {
|
||
VStack(spacing: 4) {
|
||
HStack(spacing: 16) {
|
||
iconButton(model.effectivePlaying ? "pause.fill" : "play.fill",
|
||
model.effectivePlaying ? "Pause" : "Play", action: model.togglePlayback)
|
||
.disabled(!model.canTogglePlayback)
|
||
iconButton("pip.exit", "Exit Compact Mode", action: model.toggleCompact)
|
||
.disabled(!model.canToggleCompact)
|
||
CastDeviceMenu(model: model)
|
||
actionsMenu
|
||
}
|
||
if showsBufferReadout { bufferReadout }
|
||
}
|
||
.padding(.horizontal, 12)
|
||
.padding(.vertical, showsBufferReadout ? 6 : 8)
|
||
.foregroundStyle(Color(SunsetPalette.text))
|
||
.background(windowDragBackground)
|
||
.background(PlayerGlass(cornerRadius: 10).clipShape(RoundedRectangle(cornerRadius: 10)))
|
||
.onHover(perform: model.setControlsHovered)
|
||
.animation(controlsVisibilityAnimation) { content in
|
||
content.opacity(model.controlsVisible ? 1 : 0)
|
||
}
|
||
.allowsHitTesting(model.controlsVisible)
|
||
.accessibilityHidden(!model.controlsVisible)
|
||
}
|
||
|
||
private var playbackControls: some View {
|
||
let buffered = model.bufferedRange
|
||
let intervals = model.visibleIntervals
|
||
let intervalStatus = intervals.map {
|
||
"\($0.kind == .intro ? "Opening (OP)" : "Ending (ED)") \(time($0.start))–\(time($0.end))"
|
||
}.joined(separator: ", ")
|
||
let timelineStatus = [bufferedStatus, intervalStatus.isEmpty ? nil : intervalStatus].compactMap { $0 }
|
||
.joined(separator: ", ")
|
||
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)
|
||
// Avoid the actor-isolated method reabstraction implicated in a Swift 6.3.3 IRGen crash.
|
||
PlaybackSlider(value: Binding(get: { model.volume }, set: { model.setVolume($0) }),
|
||
range: 0...1, volume: true, enabled: model.canSetVolume,
|
||
onEditingChanged: model.setControlsDragging)
|
||
.frame(width: 70, height: 14)
|
||
.accessibilityLabel("Volume")
|
||
.accessibilityValue("\(Int((model.volume * 100).rounded())) percent")
|
||
}
|
||
.disabled(!model.canSetVolume)
|
||
.frame(width: 106, alignment: .leading)
|
||
Spacer(minLength: 8)
|
||
HStack(spacing: 24) {
|
||
iconButton("backward.fill", "Seek Back 5 Seconds") { model.seekRelative(-5) }
|
||
.disabled(!model.canSeek)
|
||
Button(action: model.togglePlayback) {
|
||
Image(systemName: model.effectivePlaying ? "pause.fill" : "play.fill")
|
||
.font(.system(size: 24))
|
||
.frame(width: 24, height: 24)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.accessibilityLabel(model.effectivePlaying ? "Pause" : "Play")
|
||
.help(model.effectivePlaying ? "Pause" : "Play")
|
||
.disabled(!model.canTogglePlayback)
|
||
iconButton("forward.fill", "Seek Forward 5 Seconds") { model.seekRelative(5) }
|
||
.disabled(!model.canSeek)
|
||
}
|
||
Spacer(minLength: 8)
|
||
HStack(spacing: 12) {
|
||
if !model.subtitleTracks.isEmpty { captionsMenu }
|
||
iconButton("list.bullet", model.showsQueue ? "Hide Queue" : "Show Queue") {
|
||
model.showsQueue.toggle()
|
||
}
|
||
iconButton("pip.enter", "Enter Compact Mode", action: model.toggleCompact)
|
||
.disabled(!model.canToggleCompact)
|
||
}
|
||
.frame(width: 106, alignment: .trailing)
|
||
}
|
||
.padding(.horizontal, 16)
|
||
|
||
HStack(spacing: 10) {
|
||
Text(time(model.seekPosition)).frame(minWidth: 32, alignment: .trailing)
|
||
PlaybackSlider(value: $model.seekPosition, range: 0...max(model.duration, 0.001),
|
||
enabled: model.canSeek, bufferedRange: buffered, intervals: intervals, onEditingChanged: { editing in
|
||
if editing { model.beginSeeking() } else { model.finishSeeking() }
|
||
})
|
||
.frame(height: intervals.isEmpty ? 14 : 32)
|
||
.accessibilityLabel("Playback Position")
|
||
.accessibilityValue(time(model.seekPosition) + (timelineStatus.isEmpty ? "" : ", " + timelineStatus))
|
||
.help(timelineStatus.isEmpty ? "Playback Position" : timelineStatus)
|
||
Button {
|
||
showsRemainingTime.toggle()
|
||
model.revealControls()
|
||
} label: {
|
||
Text(model.isLiveStream ? "LIVE" :
|
||
(showsRemainingTime ? "−" + time(model.duration - model.seekPosition) : time(model.duration)))
|
||
.frame(minWidth: 32, alignment: .leading)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.disabled(model.isLiveStream)
|
||
.help(model.isLiveStream ? "Live Stream" : (showsRemainingTime ? "Show Duration" : "Show Remaining Time"))
|
||
.accessibilityLabel(model.isLiveStream ? "Live Stream" : (showsRemainingTime ? "Remaining Time" : "Duration"))
|
||
.accessibilityValue(model.isLiveStream ? "Live" :
|
||
time(showsRemainingTime ? model.duration - model.seekPosition : model.duration))
|
||
}
|
||
.font(.system(size: 11).monospacedDigit())
|
||
.foregroundStyle(Color(SunsetPalette.secondaryText))
|
||
.padding(.horizontal, 16)
|
||
if showsBufferReadout { bufferReadout }
|
||
}
|
||
.padding(.vertical, 11)
|
||
// Optical centering accounts for symbol insets and the native seek thumb.
|
||
.offset(y: -1.5)
|
||
.foregroundStyle(Color(SunsetPalette.text))
|
||
.onHover(perform: model.setControlsHovered)
|
||
}
|
||
|
||
private func controllerPosition(in size: CGSize) -> CGPoint {
|
||
let x = size.width / 2
|
||
let y = size.height * 0.96 - controlSize.height / 2
|
||
return CGPoint(
|
||
x: min(max(x, controlSize.width / 2 + 12), size.width - controlSize.width / 2 - 12),
|
||
y: min(max(y, 32 + controlSize.height / 2 + 12), size.height - controlSize.height / 2 - 12)
|
||
)
|
||
}
|
||
|
||
private var queueBackground: Color { Color(nsColor: .textBackgroundColor) }
|
||
|
||
private func queueChromeBackground(top: Bool) -> some View {
|
||
VStack(spacing: 0) {
|
||
if !top {
|
||
LinearGradient(colors: [queueBackground.opacity(0), queueBackground],
|
||
startPoint: .top, endPoint: .bottom)
|
||
.frame(height: 20)
|
||
}
|
||
queueBackground
|
||
if top {
|
||
LinearGradient(colors: [queueBackground, queueBackground.opacity(0)],
|
||
startPoint: .top, endPoint: .bottom)
|
||
.frame(height: 20)
|
||
}
|
||
}
|
||
.padding(top ? .bottom : .top, -20)
|
||
.allowsHitTesting(false)
|
||
.accessibilityHidden(true)
|
||
}
|
||
private var queueReorderAnimation: Animation? {
|
||
reduceMotion ? nil : .easeOut(duration: 0.16)
|
||
}
|
||
|
||
private func updateQueueDrag(_ value: DragGesture.Value, item: PlaybackQueue.Item) {
|
||
NSLog("[DEBUG-queue-drag] changed %@ translation %@ frames %d", item.url.lastPathComponent, String(describing: value.translation), queueFrames.count)
|
||
if queueDrag == nil {
|
||
let frames = model.queue.items.compactMap { queueFrames[$0.id] }
|
||
guard frames.count == model.queue.items.count,
|
||
let source = model.queue.items.firstIndex(where: { $0.id == item.id }) else { return }
|
||
queueDrag = PlaybackQueueDrag(source: source, frames: frames, scrollOffset: queueScrollOffset)
|
||
}
|
||
queueDrag?.pointerY = value.location.y
|
||
queueDrag?.update(translation: value.translation.height, scrollOffset: queueScrollOffset)
|
||
}
|
||
|
||
private func queueRowDrag(_ item: PlaybackQueue.Item) -> some Gesture {
|
||
DragGesture(minimumDistance: 4, coordinateSpace: .named("queueViewport"))
|
||
.updating($queueDragging) { _, active, _ in active = true }
|
||
.onChanged { updateQueueDrag($0, item: item) }
|
||
.onEnded { value in
|
||
NSLog("[DEBUG-queue-drag] ended %@", String(describing: queueDrag))
|
||
updateQueueDrag(value, item: item)
|
||
guard let drag = queueDrag else { return }
|
||
withAnimation(queueReorderAnimation) {
|
||
model.moveQueueItems(fromOffsets: IndexSet(integer: drag.source), toOffset: drag.insertionOffset)
|
||
queueDrag = nil
|
||
}
|
||
}
|
||
}
|
||
|
||
private var queueSidebar: some View {
|
||
VStack(spacing: 0) {
|
||
ScrollView {
|
||
VStack(spacing: 0) {
|
||
Color.clear
|
||
.frame(height: 52)
|
||
.allowsHitTesting(false)
|
||
.accessibilityHidden(true)
|
||
ForEach(Array(model.queue.items.enumerated()), id: \.element.id) { index, item in
|
||
let isCurrent = item.id == model.queue.currentID
|
||
HStack(spacing: 8) {
|
||
VStack(spacing: 4) {
|
||
Image(systemName: isCurrent ? "play.fill" : "film")
|
||
.font(.system(size: 12))
|
||
.foregroundStyle(isCurrent ? Color(SunsetPalette.accent) : Color(SunsetPalette.secondaryText).opacity(0.6))
|
||
.frame(width: 20, alignment: .center)
|
||
.accessibilityLabel(isCurrent ? "Current item" : "Queued item")
|
||
Button { model.removeQueueItem(item.id) } label: {
|
||
Image(systemName: "minus.circle")
|
||
.font(.system(size: 12))
|
||
.foregroundStyle(.secondary)
|
||
.frame(width: 20, height: 24)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.help("Remove from Queue")
|
||
.accessibilityLabel("Remove \(item.url.lastPathComponent) from Queue")
|
||
}
|
||
VStack(alignment: .leading, spacing: 3) {
|
||
Text(item.url.deletingPathExtension().lastPathComponent)
|
||
.font(.system(size: 13, weight: .medium))
|
||
.lineLimit(2)
|
||
.lineSpacing(2)
|
||
.truncationMode(.middle)
|
||
if !item.url.pathExtension.isEmpty {
|
||
Text(item.url.pathExtension.uppercased())
|
||
.font(.system(size: 10, weight: .medium))
|
||
.tracking(0.6)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
}
|
||
.padding(.vertical, 8)
|
||
.padding(.horizontal, 4)
|
||
.frame(minHeight: 56)
|
||
.background {
|
||
if isCurrent {
|
||
RoundedRectangle(cornerRadius: 8)
|
||
.fill(Color(SunsetPalette.actionFill))
|
||
}
|
||
}
|
||
.contentShape(Rectangle())
|
||
.onTapGesture { model.selectQueueItem(item.id) }
|
||
.help(item.url.path + "\nClick to play; drag to reorder.")
|
||
.contextMenu {
|
||
Button("Play") { model.selectQueueItem(item.id) }
|
||
Button("Remove from Queue") { model.removeQueueItem(item.id) }
|
||
if let index = model.queue.items.firstIndex(where: { $0.id == item.id }) {
|
||
Button("Move Up") {
|
||
model.moveQueueItems(fromOffsets: IndexSet(integer: index), toOffset: index - 1)
|
||
}.disabled(index == 0)
|
||
Button("Move Down") {
|
||
model.moveQueueItems(fromOffsets: IndexSet(integer: index), toOffset: index + 2)
|
||
}.disabled(index + 1 == model.queue.items.count)
|
||
}
|
||
}
|
||
.accessibilityAction(named: Text("Play")) { model.selectQueueItem(item.id) }
|
||
.accessibilityAction(named: Text("Move Up")) {
|
||
if index > 0 {
|
||
model.moveQueueItems(fromOffsets: IndexSet(integer: index), toOffset: index - 1)
|
||
}
|
||
}
|
||
.accessibilityAction(named: Text("Move Down")) {
|
||
if index + 1 < model.queue.items.count {
|
||
model.moveQueueItems(fromOffsets: IndexSet(integer: index), toOffset: index + 2)
|
||
}
|
||
}
|
||
.padding(.vertical, 2)
|
||
.onGeometryChange(for: CGRect.self) { $0.frame(in: .named("queueContent")) } action: {
|
||
NSLog("[DEBUG-queue-drag] frame %@ %@", item.url.lastPathComponent, String(describing: $0))
|
||
queueFrames[item.id] = $0
|
||
}
|
||
.offset(y: queueDrag?.offset(at: index) ?? 0)
|
||
.zIndex(queueDrag?.source == index ? 1 : 0)
|
||
.animation(queueDrag?.source == index ? nil : queueReorderAnimation, value: queueDrag?.destination)
|
||
.highPriorityGesture(queueRowDrag(item))
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, minHeight: queueViewportHeight, alignment: .top)
|
||
.background(windowDragBackground)
|
||
.coordinateSpace(name: "queueContent")
|
||
.onGeometryChange(for: CGFloat.self) { $0.size.height } action: { queueContentHeight = $0 }
|
||
}
|
||
.coordinateSpace(name: "queueViewport")
|
||
.scrollPosition($queueScroll)
|
||
.onGeometryChange(for: CGFloat.self) { $0.size.height } action: { queueViewportHeight = $0 }
|
||
.onScrollGeometryChange(for: CGFloat.self) { $0.contentOffset.y } action: { _, offset in
|
||
queueScrollOffset = offset
|
||
if let drag = queueDrag {
|
||
queueDrag?.update(translation: drag.translation, scrollOffset: offset)
|
||
}
|
||
}
|
||
.task(id: queueDrag?.source) {
|
||
guard queueDrag != nil else { return }
|
||
while !Task.isCancelled {
|
||
do { try await Task.sleep(for: .milliseconds(16)) } catch { return }
|
||
guard let drag = queueDrag else { return }
|
||
let step: CGFloat = drag.pointerY < 64 ? -6 : (drag.pointerY > queueViewportHeight - 32 ? 6 : 0)
|
||
if step != 0 {
|
||
let offset = min(max(0, queueScrollOffset + step), max(0, queueContentHeight - queueViewportHeight))
|
||
if offset != queueScrollOffset { queueScroll.scrollTo(y: offset) }
|
||
}
|
||
}
|
||
}
|
||
.onChange(of: queueDragging) { _, active in
|
||
NSLog("[DEBUG-queue-drag] active %d", active)
|
||
if !active { withAnimation(queueReorderAnimation) { queueDrag = nil } }
|
||
}
|
||
.onChange(of: model.queue.items.map(\.id)) { _, ids in
|
||
queueDrag = nil
|
||
queueFrames = queueFrames.filter { ids.contains($0.key) }
|
||
}
|
||
.onDisappear { queueDrag = nil }
|
||
.overlay {
|
||
if model.queue.items.isEmpty {
|
||
Text("Drop local files here to add them to the queue.")
|
||
.font(.callout)
|
||
.foregroundStyle(.secondary)
|
||
.multilineTextAlignment(.center)
|
||
.padding()
|
||
.allowsHitTesting(false)
|
||
}
|
||
}
|
||
HStack(spacing: 4) {
|
||
HStack(spacing: 4) {
|
||
Text("Queue")
|
||
Text("\(model.queue.items.count)").foregroundStyle(.secondary)
|
||
}
|
||
.font(.system(size: 13, weight: .medium))
|
||
.accessibilityElement(children: .combine)
|
||
Button(action: model.addQueuePanel) {
|
||
Image(systemName: "plus")
|
||
.font(.system(size: 12))
|
||
.frame(width: 20, height: 20)
|
||
.background(Color(SunsetPalette.accent).opacity(0.1), in: RoundedRectangle(cornerRadius: 6))
|
||
}
|
||
.buttonStyle(.plain)
|
||
.foregroundStyle(Color(SunsetPalette.accent))
|
||
.padding(.leading, 4)
|
||
.help("Add to Queue…")
|
||
.accessibilityLabel("Add to Queue…")
|
||
.disabled(!model.canOpen)
|
||
Spacer()
|
||
iconButton("backward.end.fill", "Previous Queue Item", size: 12) { model.advanceQueue(-1) }
|
||
.disabled(!model.canPreviousQueueItem)
|
||
iconButton("forward.end.fill", "Next Queue Item", size: 12) { model.advanceQueue(1) }
|
||
.disabled(!model.canNextQueueItem)
|
||
}
|
||
.padding(.horizontal, 12)
|
||
.padding(.vertical, 8)
|
||
.background(windowDragBackground)
|
||
.background { queueChromeBackground(top: false) }
|
||
}
|
||
.frame(width: 260)
|
||
.background(queueBackground)
|
||
.overlay(alignment: .top) {
|
||
queueChromeBackground(top: true)
|
||
.frame(height: 32)
|
||
}
|
||
.dropDestination(for: URL.self) { urls, _ in
|
||
guard model.canOpen, urls.contains(where: \.isFileURL) else { return false }
|
||
model.addToQueue(urls)
|
||
return true
|
||
}
|
||
.onHover(perform: model.setControlsHovered)
|
||
}
|
||
|
||
var body: some View {
|
||
let titleControlsVisible = model.showsQueue || model.isQueueTransitioning || model.controlsVisible
|
||
let startScreen = model.fileName == nil && !model.isCompact && !model.compactWindow.isTransitioning
|
||
HStack(spacing: 0) {
|
||
videoContent
|
||
if !model.isCompact && !model.compactWindow.isTransitioning && model.queueWidth > 0 {
|
||
queueSidebar
|
||
.frame(width: model.queueWidth, alignment: .leading)
|
||
.clipped()
|
||
.allowsHitTesting(model.showsQueue)
|
||
.accessibilityHidden(!model.showsQueue)
|
||
}
|
||
}
|
||
.overlay(alignment: .topTrailing) {
|
||
if !model.isCompact && !model.compactWindow.isTransitioning {
|
||
titleControls
|
||
.padding(.trailing, 4)
|
||
.frame(height: 32)
|
||
.onHover(perform: model.setTitleHovered)
|
||
.animation(model.isQueueTransitioning ? nil : controlsVisibilityAnimation) { content in
|
||
content.opacity(titleControlsVisible ? 1 : 0)
|
||
}
|
||
.allowsHitTesting(titleControlsVisible)
|
||
.accessibilityHidden(!titleControlsVisible)
|
||
}
|
||
}
|
||
.ignoresSafeArea()
|
||
.frame(minWidth: startScreen ? 640 : (model.isCompact || model.compactWindow.isTransitioning ? 160 : 400 + model.queueWidth),
|
||
maxWidth: startScreen ? 640 : .infinity,
|
||
minHeight: startScreen ? model.startContentHeight : (model.isCompact || model.compactWindow.isTransitioning ? model.compactContentMinimumHeight : 240),
|
||
maxHeight: startScreen ? model.startContentHeight : .infinity)
|
||
.preferredColorScheme(.dark)
|
||
.foregroundStyle(Color(SunsetPalette.text))
|
||
.tint(Color(SunsetPalette.accent))
|
||
.background(WindowChrome(model: model))
|
||
.onChange(of: windowDragging) { _, active in model.setControlsDragging(active) }
|
||
.onChange(of: showsYouTubeInput) { _, shown in youTubeInputFocused = shown }
|
||
.overlay(alignment: .top) {
|
||
if model.fileName != nil && showsYouTubeInput {
|
||
startURLInput
|
||
.padding(12)
|
||
.background(PlayerGlass().allowsHitTesting(false))
|
||
.frame(maxWidth: 420)
|
||
.padding(.top, 44)
|
||
.transition(.opacity)
|
||
}
|
||
}
|
||
.onContinuousHover { phase in
|
||
switch phase {
|
||
case .active:
|
||
model.setPointerInsideWindow(true)
|
||
case .ended: model.setPointerInsideWindow(false)
|
||
}
|
||
}
|
||
}
|
||
|
||
private var windowDragBackground: some View {
|
||
Color.clear.contentShape(Rectangle())
|
||
.gesture(WindowDragGesture()
|
||
.updating($windowDragging) { _, active, _ in active = true })
|
||
.padding(5) // Preserve the native resize border.
|
||
}
|
||
|
||
private var videoContent: some View {
|
||
GeometryReader { geometry in
|
||
ZStack(alignment: .top) {
|
||
if model.fileName == nil || model.castDevice != nil {
|
||
PlayerGlass(blendingMode: .behindWindow, cornerRadius: 0,
|
||
ambientGlows: !model.isBusy && model.errorText == nil)
|
||
.allowsHitTesting(false)
|
||
.accessibilityHidden(true)
|
||
} else {
|
||
Color.black
|
||
}
|
||
PlaybackSurface(model: model)
|
||
.accessibilityLabel("Video")
|
||
if !model.subtitleText.isEmpty {
|
||
Text(model.subtitleText)
|
||
.font(.system(size: max(16, min(28, geometry.size.width / 32)), weight: .semibold))
|
||
.foregroundStyle(.white)
|
||
.multilineTextAlignment(.center)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
.padding(.horizontal, 24)
|
||
.padding(.vertical, 12)
|
||
.background(PlayerGlass().opacity(0.6)
|
||
.clipShape(RoundedRectangle(cornerRadius: 12)).allowsHitTesting(false))
|
||
.frame(maxWidth: geometry.size.width * 0.8)
|
||
.padding(.horizontal, 8)
|
||
.animation(controlsVisibilityAnimation) { content in
|
||
content.padding(.bottom, model.controlsVisible
|
||
? (model.isCompact ? compactControlHeight + 20 : 110)
|
||
: (model.isCompact ? 12 : 24))
|
||
}
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
|
||
.allowsHitTesting(false)
|
||
}
|
||
if let error = model.subtitleError {
|
||
Text(error)
|
||
.font(.caption)
|
||
.foregroundStyle(.yellow)
|
||
.padding(8)
|
||
.background(PlayerGlass().clipShape(RoundedRectangle(cornerRadius: 12)).allowsHitTesting(false))
|
||
.padding(.top, 44).padding(.trailing, 16)
|
||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||
.allowsHitTesting(false)
|
||
}
|
||
if let error = model.errorText {
|
||
VStack(spacing: 12) {
|
||
Image(systemName: "exclamationmark.triangle").font(.system(size: 32)).foregroundStyle(.yellow)
|
||
Text("Unable to Play").font(.headline)
|
||
Text(error).font(.callout).multilineTextAlignment(.center).textSelection(.enabled)
|
||
Button("Open Another File…", action: model.openPanel).disabled(!model.canOpen)
|
||
}
|
||
.padding(24)
|
||
.frame(maxWidth: 420)
|
||
.background(PlayerGlass().clipShape(RoundedRectangle(cornerRadius: 12)).allowsHitTesting(false))
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
.background(windowDragBackground)
|
||
} else if model.isBusy {
|
||
VStack(spacing: 12) {
|
||
ProgressView().controlSize(.large)
|
||
Text(model.castConnecting ? "Connecting to \(model.castDevice?.name ?? "Cast device")…" :
|
||
(model.resolvingStream ? "Resolving stream…" : "Opening…"))
|
||
.font(.callout).foregroundStyle(Color(SunsetPalette.secondaryText))
|
||
}
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
.allowsHitTesting(false)
|
||
} else if model.fileName == nil {
|
||
startScreen(in: geometry.size)
|
||
} else if let device = model.castDevice {
|
||
VStack(spacing: 16) {
|
||
Image(systemName: "airplayvideo").font(.system(size: 48))
|
||
Text("Casting to \(device.name)").font(.headline)
|
||
Button("Play on This Mac") { model.stopCasting() }
|
||
}
|
||
.foregroundStyle(Color(SunsetPalette.text))
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
.background(windowDragBackground)
|
||
} else if !model.state.has_video && model.state.has_audio {
|
||
VStack(spacing: 16) {
|
||
Image(systemName: "waveform").font(.system(size: 64, weight: .ultraLight))
|
||
Text(model.fileName ?? "").font(.headline).lineLimit(1)
|
||
}
|
||
.foregroundStyle(Color(SunsetPalette.secondaryText))
|
||
.padding(40)
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
.allowsHitTesting(false)
|
||
}
|
||
if model.state.buffering && !model.isBusy && model.errorText == nil {
|
||
ProgressView("Buffering…")
|
||
.padding(12)
|
||
.background(PlayerGlass(cornerRadius: 10).clipShape(RoundedRectangle(cornerRadius: 10)))
|
||
.position(x: geometry.size.width / 2, y: geometry.size.height / 2)
|
||
.allowsHitTesting(false)
|
||
}
|
||
|
||
let progress = model.isCompact || model.compactWindow.isTransitioning ? model.compactProgress : nil
|
||
ZStack(alignment: .top) {
|
||
HStack(spacing: 8) {
|
||
ViewThatFits(in: .horizontal) {
|
||
compactTitleLabel(progress: progress, constrained: false)
|
||
.fixedSize(horizontal: true, vertical: false)
|
||
compactTitleLabel(progress: progress, constrained: true)
|
||
}
|
||
.font(.system(size: 11, weight: .medium))
|
||
.accessibilityElement(children: .combine)
|
||
.help((model.fileName ?? "Terebi") + (progress.map { " · \($0)" } ?? ""))
|
||
.padding(.horizontal, 8)
|
||
.frame(height: 28)
|
||
.background(PlayerGlass(cornerRadius: 10).clipShape(RoundedRectangle(cornerRadius: 10)).allowsHitTesting(false))
|
||
.allowsHitTesting(false)
|
||
Spacer(minLength: 0)
|
||
}
|
||
.padding(8)
|
||
.foregroundStyle(Color(SunsetPalette.text))
|
||
.onHover(perform: model.setTitleHovered)
|
||
.animation(controlsVisibilityAnimation) { content in
|
||
content.opacity(model.controlsVisible ? 1 : 0)
|
||
}
|
||
.allowsHitTesting(model.controlsVisible)
|
||
.accessibilityHidden(!model.controlsVisible)
|
||
compactControls
|
||
.position(x: geometry.size.width / 2, y: geometry.size.height - compactControlHeight / 2 - 8)
|
||
}
|
||
.animation(compactChromeAnimation) { content in
|
||
content.opacity(model.isCompact ? 1 : 0)
|
||
}
|
||
.allowsHitTesting(model.isCompact)
|
||
.accessibilityHidden(!model.isCompact)
|
||
|
||
if model.fileName != nil {
|
||
titlebar
|
||
.frame(width: geometry.size.width)
|
||
.opacity(model.isCompact || model.compactWindow.isTransitioning ? 0 : 1)
|
||
.allowsHitTesting(!model.isCompact && !model.compactWindow.isTransitioning && model.controlsVisible)
|
||
.accessibilityHidden(model.isCompact || model.compactWindow.isTransitioning || !model.controlsVisible)
|
||
}
|
||
if model.canTogglePlayback {
|
||
playbackControls
|
||
.frame(width: 440, height: controlHeight)
|
||
.background(windowDragBackground)
|
||
.background {
|
||
PlayerGlass()
|
||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||
}
|
||
.scaleEffect(min(1, (geometry.size.width - 32) / 440))
|
||
.frame(width: min(440, geometry.size.width - 32),
|
||
height: controlHeight * min(1, (geometry.size.width - 32) / 440))
|
||
.onGeometryChange(for: CGSize.self) { $0.size } action: { controlSize = $0 }
|
||
.position(controllerPosition(in: geometry.size))
|
||
.animation(controlsVisibilityAnimation) { content in
|
||
content.opacity(model.controlsVisible ? 1 : 0)
|
||
}
|
||
.animation(compactChromeAnimation) { content in
|
||
content.opacity(model.isCompact ? 0 : 1)
|
||
}
|
||
.allowsHitTesting(!model.isCompact && model.controlsVisible)
|
||
.accessibilityHidden(model.isCompact || !model.controlsVisible)
|
||
}
|
||
if dropTargeted {
|
||
RoundedRectangle(cornerRadius: 10)
|
||
.strokeBorder(Color(SunsetPalette.accent), lineWidth: 2)
|
||
.padding(8)
|
||
.allowsHitTesting(false)
|
||
}
|
||
}
|
||
.dropDestination(for: URL.self) { urls, _ in
|
||
guard model.canOpen, !urls.isEmpty else { return false }
|
||
if urls.allSatisfy(\.isFileURL) {
|
||
model.openFiles(urls)
|
||
} else {
|
||
guard urls.count == 1, let url = urls.first,
|
||
(try? YouTubeResolver.validateURL(url.absoluteString)) != nil else { return false }
|
||
model.open(url)
|
||
}
|
||
return true
|
||
} isTargeted: {
|
||
dropTargeted = $0
|
||
model.revealControls()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
@MainActor
|
||
private struct WindowChrome: NSViewRepresentable {
|
||
@ObservedObject var model: PlayerModel
|
||
|
||
func makeNSView(context: Context) -> ChromeView { ChromeView(model: model) }
|
||
func updateNSView(_ view: ChromeView, context: Context) { view.updateButtons() }
|
||
static func dismantleNSView(_ view: ChromeView, coordinator: ()) { view.detach() }
|
||
|
||
final class ChromeView: NSView {
|
||
private let model: PlayerModel
|
||
private weak var configuredWindow: NSWindow?
|
||
private var monitor: Any?
|
||
private var observers: [NSObjectProtocol] = []
|
||
private var dragOrigin: NSPoint?
|
||
|
||
init(model: PlayerModel) {
|
||
self.model = model
|
||
super.init(frame: .zero)
|
||
}
|
||
required init?(coder: NSCoder) { nil }
|
||
|
||
func handleKeyEvent(_ event: NSEvent) -> Bool {
|
||
guard let window = configuredWindow else { return false }
|
||
return model.handleKeyEvent(event, in: window)
|
||
}
|
||
|
||
override func viewDidMoveToWindow() {
|
||
super.viewDidMoveToWindow()
|
||
guard let window else { detach(); return }
|
||
guard window !== configuredWindow else { return }
|
||
detach()
|
||
configuredWindow = window
|
||
window.styleMask.insert(.fullSizeContentView)
|
||
window.titleVisibility = .hidden
|
||
window.titlebarAppearsTransparent = true
|
||
window.titlebarSeparatorStyle = .none
|
||
window.isOpaque = false
|
||
window.backgroundColor = .clear
|
||
window.acceptsMouseMovedEvents = true
|
||
window.collectionBehavior.insert(.fullScreenPrimary)
|
||
window.appearance = NSAppearance(named: .darkAqua)
|
||
for name in [NSWindow.willEnterFullScreenNotification, NSWindow.willStartLiveResizeNotification,
|
||
NSWindow.willCloseNotification] {
|
||
observers.append(NotificationCenter.default.addObserver(forName: name, object: window, queue: .main) { [weak self] _ in
|
||
MainActor.assumeIsolated { self?.model.finishQueueTransition() }
|
||
})
|
||
}
|
||
for name in [NSWindow.didBecomeKeyNotification, NSWindow.didResignKeyNotification,
|
||
NSWindow.didEnterFullScreenNotification, NSWindow.didExitFullScreenNotification] {
|
||
observers.append(NotificationCenter.default.addObserver(forName: name, object: window, queue: .main) { [weak self] _ in
|
||
MainActor.assumeIsolated {
|
||
self?.model.updateWindowState()
|
||
self?.updateButtons()
|
||
}
|
||
})
|
||
}
|
||
monitor = NSEvent.addLocalMonitorForEvents(matching: [.mouseMoved, .leftMouseDown, .leftMouseUp, .keyDown]) { [weak self] event in
|
||
let consumed = MainActor.assumeIsolated {
|
||
guard let self, let window = self.configuredWindow else { return false }
|
||
if event.type == .leftMouseUp {
|
||
let origin = self.dragOrigin
|
||
self.dragOrigin = nil
|
||
if let origin {
|
||
Task { @MainActor [weak self, weak window] in
|
||
guard let self, let window, self.configuredWindow === window,
|
||
self.model.isCompact, window.frame.origin != origin else { return }
|
||
self.model.compactWindow.snapAfterDrag()
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
guard event.window === window else { return false }
|
||
if event.type == .leftMouseDown { self.dragOrigin = window.frame.origin }
|
||
if event.type == .keyDown && event.keyCode == 48 {
|
||
self.model.revealControls(forKeyboardNavigation: true)
|
||
}
|
||
if event.type == .mouseMoved || event.type == .leftMouseDown {
|
||
self.model.revealControls()
|
||
} else if event.type == .keyDown {
|
||
return self.handleKeyEvent(event)
|
||
}
|
||
return false
|
||
}
|
||
return consumed ? nil : event
|
||
}
|
||
Task { @MainActor [weak self, weak window] in
|
||
guard let self, let window else { return }
|
||
self.model.attachWindow(window)
|
||
self.updateButtons()
|
||
window.makeFirstResponder(nil)
|
||
}
|
||
}
|
||
|
||
func updateButtons() {
|
||
let hidden = !model.controlsVisible || model.isFullScreen || model.isCompact
|
||
for button in [NSWindow.ButtonType.closeButton, .miniaturizeButton, .zoomButton] {
|
||
if let view = configuredWindow?.standardWindowButton(button), view.isHidden != hidden {
|
||
view.isHidden = hidden
|
||
}
|
||
}
|
||
}
|
||
|
||
override func hitTest(_ point: NSPoint) -> NSView? { nil }
|
||
|
||
func detach() {
|
||
if let monitor { NSEvent.removeMonitor(monitor) }
|
||
monitor = nil
|
||
dragOrigin = nil
|
||
observers.forEach(NotificationCenter.default.removeObserver)
|
||
observers.removeAll()
|
||
configuredWindow = nil
|
||
}
|
||
}
|
||
}
|
||
|
||
@MainActor
|
||
final class AppDelegate: NSObject, NSApplicationDelegate {
|
||
func applicationWillFinishLaunching(_ notification: Notification) {
|
||
// Own external opens: SwiftUI's document routing can close the singleton Window.
|
||
let events = NSAppleEventManager.shared()
|
||
events.setEventHandler(self, andSelector: #selector(openInternetURL(_:withReplyEvent:)),
|
||
forEventClass: AEEventClass(kInternetEventClass), andEventID: AEEventID(kAEGetURL))
|
||
}
|
||
|
||
@objc private func openInternetURL(_ event: NSAppleEventDescriptor, withReplyEvent reply: NSAppleEventDescriptor) {
|
||
guard let text = event.paramDescriptor(forKeyword: keyDirectObject)?.stringValue else { return }
|
||
PlayerModel.shared.openStream(text)
|
||
}
|
||
|
||
@objc private func openDocuments(_ event: NSAppleEventDescriptor, withReplyEvent reply: NSAppleEventDescriptor) {
|
||
guard let files = event.paramDescriptor(forKeyword: keyDirectObject), files.numberOfItems > 0 else { return }
|
||
let urls = (1...files.numberOfItems).compactMap { files.atIndex($0)?.fileURLValue }
|
||
PlayerModel.shared.openFiles(urls)
|
||
}
|
||
|
||
func applicationDidFinishLaunching(_ notification: Notification) {
|
||
NSApp.setActivationPolicy(.regular)
|
||
NSApp.activate()
|
||
NSAppleEventManager.shared().setEventHandler(self, andSelector: #selector(openDocuments(_:withReplyEvent:)),
|
||
forEventClass: AEEventClass(kCoreEventClass), andEventID: AEEventID(kAEOpenDocuments))
|
||
let arguments = Array(CommandLine.arguments.dropFirst())
|
||
if let index = arguments.firstIndex(of: "--open-media"), index + 1 < arguments.count,
|
||
let url = URL(string: arguments[index + 1]) {
|
||
PlayerModel.shared.open(url)
|
||
}
|
||
if let index = arguments.firstIndex(of: "--source-file"), index + 1 < arguments.count {
|
||
PlayerModel.shared.openResolvedSource(URL(fileURLWithPath: arguments[index + 1]))
|
||
}
|
||
}
|
||
|
||
// Let SwiftUI create the initial window for a Finder/document launch.
|
||
func application(_ application: NSApplication, open urls: [URL]) {
|
||
if urls.allSatisfy(\.isFileURL) { PlayerModel.shared.openFiles(urls) }
|
||
else if let url = urls.first { PlayerModel.shared.open(url) }
|
||
}
|
||
|
||
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { true }
|
||
|
||
func applicationWillTerminate(_ notification: Notification) {
|
||
PlayerModel.shared.shutdown()
|
||
}
|
||
}
|
||
|
||
@MainActor
|
||
struct TerebiApp: App {
|
||
@NSApplicationDelegateAdaptor(AppDelegate.self) private var delegate
|
||
@StateObject private var model = PlayerModel.shared
|
||
|
||
var body: some Scene {
|
||
Window("\(model.fileName ?? "Terebi")", id: "terebi") {
|
||
PlayerContent(model: model)
|
||
.focusedSceneObject(model)
|
||
}
|
||
.defaultSize(width: 640, height: 400)
|
||
.windowResizability(.contentSize)
|
||
.windowStyle(.hiddenTitleBar)
|
||
.commands { PlayerCommands() }
|
||
}
|
||
}
|
||
|
||
#if !PLAYER_CHECK
|
||
@_cdecl("terebi_app_main")
|
||
#endif
|
||
@MainActor
|
||
public func terebiAppMain() {
|
||
do {
|
||
PlayerModel.startupConfiguration = try PlayerConfiguration.loadForApplication(
|
||
arguments: Array(CommandLine.arguments.dropFirst()),
|
||
environment: ProcessInfo.processInfo.environment,
|
||
homeDirectory: FileManager.default.homeDirectoryForCurrentUser)
|
||
} catch {
|
||
FileHandle.standardError.write(Data("terebi: \(error.localizedDescription)\n".utf8))
|
||
NSApplication.shared.setActivationPolicy(.regular)
|
||
NSApp.activate()
|
||
let alert = NSAlert()
|
||
alert.alertStyle = .critical
|
||
alert.messageText = "Could not load Terebi configuration"
|
||
alert.informativeText = error.localizedDescription
|
||
alert.runModal()
|
||
exit(78)
|
||
}
|
||
let arguments = Array(CommandLine.arguments.dropFirst())
|
||
if let index = arguments.firstIndex(of: "--progress-file") {
|
||
guard index + 1 < arguments.count, arguments[index + 1].hasPrefix("/"),
|
||
arguments.filter({ $0 == "--progress-file" }).count == 1 else {
|
||
let alert = NSAlert()
|
||
alert.alertStyle = .critical
|
||
alert.messageText = "Could not initialize playback progress reporting"
|
||
alert.informativeText = "--progress-file requires one absolute filesystem path."
|
||
alert.runModal()
|
||
exit(73)
|
||
}
|
||
PlayerModel.startupProgressFile = arguments[index + 1]
|
||
_ = NSApplication.shared
|
||
if let error = PlayerModel.shared.progressInitializationError {
|
||
PlayerModel.shared.shutdown()
|
||
FileHandle.standardError.write(Data("terebi: \(error)\n".utf8))
|
||
NSApp.setActivationPolicy(.regular)
|
||
NSApp.activate()
|
||
let alert = NSAlert()
|
||
alert.alertStyle = .critical
|
||
alert.messageText = "Could not initialize playback progress reporting"
|
||
alert.informativeText = error
|
||
alert.runModal()
|
||
exit(73)
|
||
}
|
||
}
|
||
TerebiApp.main()
|
||
}
|