19a1de625b
Remove the knobRect override: native slider geometry also affects drag positioning. Progress pointer tracking now shares the drawn thumb bounds, preserves the initial grab offset, and clamps to the track endpoints. Expanded native event regression covers adjacent clicks, thumb drags, track drags, and endpoint clamping; application build passes.
3479 lines
159 KiB
Swift
3479 lines
159 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)
|
||
defer {
|
||
tracking = false
|
||
editingChanged(false)
|
||
}
|
||
guard let cell = cell as? Cell, !cell.volume else {
|
||
super.mouseDown(with: event)
|
||
return
|
||
}
|
||
let bar = cell.barRect(flipped: isFlipped)
|
||
let start = bar.minX + cell.knobThickness / 2
|
||
let width = bar.width - cell.knobThickness
|
||
guard width > 0, maxValue > minValue else { return }
|
||
let point = convert(event.locationInWindow, from: nil)
|
||
let knob = cell.visibleKnobRect(cell.knobRect(flipped: isFlipped))
|
||
let grabbed = NSBezierPath(roundedRect: knob, xRadius: 1, yRadius: 1).contains(point)
|
||
let offset = grabbed ? point.x - knob.midX : 0
|
||
func seek(_ x: CGFloat) {
|
||
let fraction = min(1, max(0, (x - offset - start) / width))
|
||
let value = minValue + fraction * (maxValue - minValue)
|
||
guard value != doubleValue else { return }
|
||
doubleValue = value
|
||
if let action { sendAction(action, to: target) }
|
||
}
|
||
if !grabbed { seek(point.x) }
|
||
while let next = window?.nextEvent(matching: [.leftMouseDragged, .leftMouseUp]) {
|
||
let nextPoint = convert(next.locationInWindow, from: nil)
|
||
if next.type == .leftMouseDragged || nextPoint.x != point.x { seek(nextPoint.x) }
|
||
if next.type == .leftMouseUp { break }
|
||
}
|
||
}
|
||
}
|
||
|
||
final class Cell: NSSliderCell {
|
||
var volume = false
|
||
var bufferedRange: ClosedRange<Double>?
|
||
var intervals: [MiruroInterval] = []
|
||
func visibleKnobRect(_ rect: NSRect) -> NSRect {
|
||
let width: CGFloat = volume ? 16 : 3
|
||
let height: CGFloat = volume ? 12 : 15
|
||
return NSRect(x: rect.midX - width / 2, y: rect.midY - height / 2,
|
||
width: width, height: height)
|
||
}
|
||
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)
|
||
NSGraphicsContext.saveGraphicsState()
|
||
defer { NSGraphicsContext.restoreGraphicsState() }
|
||
if !volume, maxValue > minValue, bar.width > 0 {
|
||
let scale = bar.width / (maxValue - minValue)
|
||
let boundaries = Array(Set([minValue, maxValue] + intervals.flatMap {
|
||
[$0.start, $0.end]
|
||
}.filter { $0 > minValue && $0 < maxValue })).sorted()
|
||
let track = NSBezierPath()
|
||
for index in 0..<(boundaries.count - 1) {
|
||
let start = bar.minX + (boundaries[index] - minValue) * scale
|
||
let end = bar.minX + (boundaries[index + 1] - minValue) * scale
|
||
let inset = min(1.5, (end - start) / 4)
|
||
let left = index == 0 ? start : start + inset
|
||
let right = index == boundaries.count - 2 ? end : end - inset
|
||
track.append(NSBezierPath(roundedRect:
|
||
NSRect(x: left, y: bar.minY, width: right - left, height: bar.height),
|
||
xRadius: 1.5, yRadius: 1.5))
|
||
}
|
||
track.addClip()
|
||
}
|
||
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 knob = visibleKnobRect(rect)
|
||
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() }
|
||
@MainActor
|
||
func playbackSliderHitCheck() -> Bool {
|
||
let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 320, height: 80),
|
||
styleMask: [.borderless], backing: .buffered, defer: false)
|
||
let slider = PlaybackSlider.Slider(frame: NSRect(x: 20, y: 20, width: 280, height: 24))
|
||
slider.cell = PlaybackSlider.Cell()
|
||
slider.minValue = 0
|
||
slider.maxValue = 100
|
||
slider.isContinuous = true
|
||
window.contentView!.addSubview(slider)
|
||
window.display()
|
||
let cell = slider.cell as! NSSliderCell
|
||
slider.doubleValue = slider.minValue
|
||
let left = cell.knobRect(flipped: slider.isFlipped).midX
|
||
slider.doubleValue = slider.maxValue
|
||
let right = cell.knobRect(flipped: slider.isFlipped).midX
|
||
for (initial, offset, movement): (Double, CGFloat, CGFloat) in [
|
||
(50, -4, 0), (50, -2, 0), (50, -1, 0), (50, 0, 0), (50, 1, 0), (50, 2, 0), (50, 4, 0),
|
||
(20, -1, -20), (80, 1, 20), (20, -4, -20), (80, 4, 20),
|
||
(20, 0, -500), (80, 0, 500)
|
||
] {
|
||
slider.doubleValue = initial
|
||
let knob = cell.knobRect(flipped: slider.isFlipped)
|
||
let point = slider.convert(NSPoint(x: knob.midX + offset, y: knob.midY), to: nil)
|
||
let end = NSPoint(x: point.x + movement, y: point.y)
|
||
let up = NSEvent.mouseEvent(with: .leftMouseUp, location: end, modifierFlags: [],
|
||
timestamp: 0, windowNumber: window.windowNumber, context: nil,
|
||
eventNumber: 1, clickCount: 1, pressure: 0)!
|
||
let down = NSEvent.mouseEvent(with: .leftMouseDown, location: point, modifierFlags: [],
|
||
timestamp: 0, windowNumber: window.windowNumber, context: nil,
|
||
eventNumber: 0, clickCount: 1, pressure: 1)!
|
||
NSApp.postEvent(up, atStart: true)
|
||
if movement != 0 {
|
||
let drag = NSEvent.mouseEvent(with: .leftMouseDragged, location: end, modifierFlags: [],
|
||
timestamp: 0, windowNumber: window.windowNumber, context: nil,
|
||
eventNumber: 1, clickCount: 1, pressure: 1)!
|
||
NSApp.postEvent(drag, atStart: true)
|
||
}
|
||
slider.mouseDown(with: down)
|
||
let movedKnob = cell.knobRect(flipped: slider.isFlipped)
|
||
let expectedMovement = movement + (abs(offset) <= 1 ? 0 : offset)
|
||
let expectedX = min(right, max(left, knob.midX + expectedMovement))
|
||
guard abs(movedKnob.midX - expectedX) < 0.5 else { return false }
|
||
if abs(offset) <= 1 && movement == 0 {
|
||
guard slider.doubleValue == initial else { return false }
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
#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: 14)
|
||
.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()
|
||
}
|