feat: match IINA screenshot geometry and controls
This commit is contained in:
+167
-38
@@ -7,6 +7,7 @@ final class PlayerModel: ObservableObject {
|
||||
|
||||
@Published private(set) var state = PlayerState()
|
||||
@Published private(set) var fileName: String?
|
||||
@Published private(set) var fileIcon: NSImage?
|
||||
@Published private(set) var commandError: String?
|
||||
@Published private(set) var pendingRevision: UInt64?
|
||||
@Published var seekPosition = 0.0
|
||||
@@ -26,6 +27,7 @@ final class PlayerModel: ObservableObject {
|
||||
private var controlsDragging = false
|
||||
private var windowActive = true
|
||||
private var lastAudibleVolume = 1.0
|
||||
private var needsVideoFit = false
|
||||
|
||||
private init() {
|
||||
let player = player_create()
|
||||
@@ -94,6 +96,8 @@ final class PlayerModel: ObservableObject {
|
||||
// Open is always paused; never queue play against the previous file.
|
||||
pendingRevision = revision
|
||||
fileName = url.lastPathComponent
|
||||
fileIcon = NSWorkspace.shared.icon(forFile: url.path)
|
||||
needsVideoFit = true
|
||||
commandError = nil
|
||||
isSeeking = false
|
||||
seekPosition = 0
|
||||
@@ -124,6 +128,9 @@ final class PlayerModel: ObservableObject {
|
||||
return
|
||||
}
|
||||
fileName = nil
|
||||
fileIcon = nil
|
||||
needsVideoFit = false
|
||||
window?.aspectRatio = .zero
|
||||
commandError = nil
|
||||
isSeeking = false
|
||||
window?.representedURL = nil
|
||||
@@ -157,6 +164,7 @@ final class PlayerModel: ObservableObject {
|
||||
self.window = window
|
||||
window.title = fileName ?? "Player"
|
||||
updateWindowState()
|
||||
fitVideoWindow()
|
||||
}
|
||||
|
||||
func updateWindowState() {
|
||||
@@ -164,6 +172,7 @@ final class PlayerModel: ObservableObject {
|
||||
isFullScreen = window.styleMask.contains(.fullScreen)
|
||||
windowActive = window.isKeyWindow
|
||||
window.level = isOnTop && !isFullScreen ? .floating : .normal
|
||||
if !isFullScreen { fitVideoWindow() }
|
||||
revealControls()
|
||||
}
|
||||
|
||||
@@ -230,6 +239,30 @@ final class PlayerModel: ObservableObject {
|
||||
!NSWorkspace.shared.isVoiceOverEnabled
|
||||
}
|
||||
|
||||
private func fitVideoWindow() {
|
||||
guard needsVideoFit, canTogglePlayback, let window, !isFullScreen else { return }
|
||||
needsVideoFit = false
|
||||
let aspect = state.video_aspect_ratio
|
||||
guard state.has_video, aspect.isFinite, aspect > 0 else {
|
||||
window.aspectRatio = .zero
|
||||
return
|
||||
}
|
||||
let screen = (window.screen ?? NSScreen.main)?.visibleFrame ?? window.frame
|
||||
let width = min(window.frame.width, screen.width, screen.height * aspect)
|
||||
let size = NSSize(width: width, height: width / aspect)
|
||||
// ponytail: extreme ratios retain letterboxing; compact controls if exact fitting is needed.
|
||||
guard size.width >= window.minSize.width, size.height >= window.minSize.height else {
|
||||
window.aspectRatio = .zero
|
||||
return
|
||||
}
|
||||
window.aspectRatio = NSSize(width: aspect, height: 1)
|
||||
let frame = NSRect(
|
||||
x: min(max(window.frame.midX - size.width / 2, screen.minX), screen.maxX - size.width),
|
||||
y: min(max(window.frame.midY - size.height / 2, screen.minY), screen.maxY - size.height),
|
||||
width: size.width, height: size.height)
|
||||
window.setFrame(frame, display: true)
|
||||
}
|
||||
|
||||
private func poll() {
|
||||
guard let handle else { return }
|
||||
let next = player_get_state(handle)
|
||||
@@ -242,6 +275,7 @@ final class PlayerModel: ObservableObject {
|
||||
if !isSeeking {
|
||||
seekPosition = min(max(0, next.position.isFinite ? next.position : 0), duration)
|
||||
}
|
||||
fitVideoWindow()
|
||||
if statusChanged { revealControls() }
|
||||
if let hideDeadline, ContinuousClock.now >= hideDeadline { hideControls() }
|
||||
}
|
||||
@@ -257,6 +291,100 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private struct PlayerGlass: NSViewRepresentable {
|
||||
var titlebar = false
|
||||
|
||||
func makeNSView(context: Context) -> NSVisualEffectView {
|
||||
let view = NSVisualEffectView()
|
||||
view.blendingMode = .withinWindow
|
||||
view.state = .active
|
||||
view.appearance = NSAppearance(named: .vibrantDark)
|
||||
view.material = titlebar ? .titlebar : .hudWindow
|
||||
return view
|
||||
}
|
||||
|
||||
func updateNSView(_ view: NSVisualEffectView, context: Context) {}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private struct PlaybackSlider: NSViewRepresentable {
|
||||
@Binding var value: Double
|
||||
let range: ClosedRange<Double>
|
||||
var volume = false
|
||||
var enabled = true
|
||||
var onEditingChanged: (Bool) -> Void = { _ in }
|
||||
|
||||
func makeCoordinator() -> Coordinator { Coordinator(self) }
|
||||
|
||||
func makeNSView(context: Context) -> Slider {
|
||||
let slider = Slider()
|
||||
let cell = Cell()
|
||||
cell.volume = volume
|
||||
slider.cell = cell
|
||||
slider.isContinuous = true
|
||||
slider.target = context.coordinator
|
||||
slider.action = #selector(Coordinator.changed(_:))
|
||||
return slider
|
||||
}
|
||||
|
||||
func updateNSView(_ slider: Slider, context: Context) {
|
||||
context.coordinator.parent = self
|
||||
slider.minValue = range.lowerBound
|
||||
slider.maxValue = range.upperBound
|
||||
slider.doubleValue = value
|
||||
slider.isEnabled = enabled
|
||||
slider.editingChanged = onEditingChanged
|
||||
slider.setAccessibilityLabel(volume ? "Volume" : "Playback Position")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class Coordinator: NSObject {
|
||||
var parent: PlaybackSlider
|
||||
init(_ parent: PlaybackSlider) { self.parent = parent }
|
||||
@objc func changed(_ slider: Slider) {
|
||||
if !slider.tracking { parent.onEditingChanged(true) }
|
||||
parent.value = slider.doubleValue
|
||||
if !slider.tracking { parent.onEditingChanged(false) }
|
||||
}
|
||||
}
|
||||
|
||||
final class Slider: NSSlider {
|
||||
var editingChanged: (Bool) -> Void = { _ in }
|
||||
private(set) var tracking = false
|
||||
override func mouseDown(with event: NSEvent) {
|
||||
guard isEnabled else { return }
|
||||
tracking = true
|
||||
editingChanged(true)
|
||||
super.mouseDown(with: event)
|
||||
tracking = false
|
||||
editingChanged(false)
|
||||
}
|
||||
}
|
||||
|
||||
final class Cell: NSSliderCell {
|
||||
var volume = false
|
||||
override func drawBar(inside rect: NSRect, flipped: Bool) {
|
||||
let bar = NSRect(x: rect.minX, y: rect.midY - 1.5, width: rect.width, height: 3)
|
||||
NSColor.white.withAlphaComponent(0.12).setFill()
|
||||
NSBezierPath(roundedRect: bar, xRadius: 1.5, yRadius: 1.5).fill()
|
||||
if volume {
|
||||
let fraction = (doubleValue - minValue) / (maxValue - minValue)
|
||||
let fill = NSRect(x: bar.minX, y: bar.minY, width: bar.width * fraction, height: bar.height)
|
||||
NSColor.systemOrange.withAlphaComponent(0.85).setFill()
|
||||
NSBezierPath(roundedRect: fill, xRadius: 1.5, yRadius: 1.5).fill()
|
||||
}
|
||||
}
|
||||
override func drawKnob(_ rect: NSRect) {
|
||||
let width: CGFloat = volume ? 16 : 3
|
||||
let height: CGFloat = volume ? 12 : 15
|
||||
let knob = NSRect(x: rect.midX - width / 2, y: rect.midY - height / 2, width: width, height: height)
|
||||
NSColor.white.withAlphaComponent(isEnabled ? 0.65 : 0.25).setFill()
|
||||
NSBezierPath(roundedRect: knob, xRadius: volume ? 6 : 1, yRadius: volume ? 6 : 1).fill()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private struct VideoSurface: NSViewRepresentable {
|
||||
let view: NSView
|
||||
@@ -270,7 +398,7 @@ private struct PlayerContent: View {
|
||||
@ObservedObject var model: PlayerModel
|
||||
@State private var showsRemainingTime = false
|
||||
@State private var dropTargeted = false
|
||||
@State private var controlSize = CGSize(width: 460, height: 82)
|
||||
@State private var controlSize = CGSize(width: 440, height: 68)
|
||||
// Relative offsets preserve the dragged position across resize/fullscreen.
|
||||
@State private var controlOffset = CGSize.zero
|
||||
@State private var dragOrigin = CGSize.zero
|
||||
@@ -280,7 +408,7 @@ private struct PlayerContent: View {
|
||||
let hours = value / 3600
|
||||
return hours > 0
|
||||
? String(format: "%d:%02d:%02d", hours, value / 60 % 60, value % 60)
|
||||
: String(format: "%d:%02d", value / 60, value % 60)
|
||||
: String(format: "%02d:%02d", value / 60, value % 60)
|
||||
}
|
||||
|
||||
private func iconButton(_ symbol: String, _ label: String, action: @escaping () -> Void) -> some View {
|
||||
@@ -294,27 +422,27 @@ private struct PlayerContent: View {
|
||||
|
||||
private var titlebar: some View {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: model.fileName == nil ? "play.rectangle.fill" : "doc.fill")
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(.secondary)
|
||||
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 ?? "Player")
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
.help(model.fileName ?? "Player")
|
||||
Spacer(minLength: 12)
|
||||
iconButton(model.isFullScreen ? "arrow.down.right.and.arrow.up.left" : "arrow.up.left.and.arrow.down.right",
|
||||
model.isFullScreen ? "Exit Full Screen" : "Enter Full Screen", action: model.toggleFullScreen)
|
||||
iconButton(model.isOnTop ? "pin.fill" : "pin",
|
||||
model.isOnTop ? "Stop Keeping Window on Top" : "Keep Window on Top", action: model.toggleOnTop)
|
||||
.foregroundStyle(model.isOnTop ? .white : .secondary)
|
||||
.foregroundStyle(.white.opacity(model.isOnTop ? 1 : 0.6))
|
||||
.disabled(model.isFullScreen)
|
||||
}
|
||||
.padding(.leading, model.isFullScreen ? 16 : 86)
|
||||
.padding(.trailing, 12)
|
||||
.frame(height: 40)
|
||||
.background(.ultraThinMaterial)
|
||||
.overlay(alignment: .bottom) { Rectangle().fill(.white.opacity(0.08)).frame(height: 1) }
|
||||
.frame(height: 32)
|
||||
.foregroundStyle(.white.opacity(0.6))
|
||||
.background(PlayerGlass(titlebar: true).allowsHitTesting(false))
|
||||
.background(WindowChrome(model: model))
|
||||
.onHover(perform: model.setTitleHovered)
|
||||
.opacity(model.controlsVisible ? 1 : 0)
|
||||
@@ -328,22 +456,23 @@ private struct PlayerContent: View {
|
||||
HStack(spacing: 4) {
|
||||
iconButton(model.volume == 0 ? "speaker.slash.fill" : "speaker.wave.2.fill",
|
||||
model.volume == 0 ? "Unmute" : "Mute", action: model.toggleMute)
|
||||
Slider(value: Binding(get: { model.volume }, set: model.setVolume), in: 0...1)
|
||||
.controlSize(.mini)
|
||||
.frame(width: 70)
|
||||
PlaybackSlider(value: Binding(get: { model.volume }, set: model.setVolume),
|
||||
range: 0...1, volume: true, enabled: model.canSetVolume,
|
||||
onEditingChanged: model.setControlsDragging)
|
||||
.frame(width: 70, height: 14)
|
||||
.accessibilityLabel("Volume")
|
||||
.accessibilityValue("\(Int((model.volume * 100).rounded())) percent")
|
||||
}
|
||||
.disabled(!model.canSetVolume)
|
||||
.frame(width: 106, alignment: .leading)
|
||||
Spacer(minLength: 8)
|
||||
HStack(spacing: 20) {
|
||||
HStack(spacing: 24) {
|
||||
iconButton("backward.fill", "Seek Back 5 Seconds") { model.seekRelative(-5) }
|
||||
.disabled(!model.canSeek)
|
||||
Button(action: model.togglePlayback) {
|
||||
Image(systemName: model.state.status == PLAYER_PLAYING ? "pause.fill" : "play.fill")
|
||||
.font(.system(size: 28))
|
||||
.frame(width: 32, height: 32)
|
||||
.font(.system(size: 24))
|
||||
.frame(width: 24, height: 24)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(model.state.status == PLAYER_PLAYING ? "Pause" : "Play")
|
||||
@@ -363,7 +492,7 @@ private struct PlayerContent: View {
|
||||
Button(model.isOnTop ? "Stop Keeping Window on Top" : "Keep Window on Top", action: model.toggleOnTop)
|
||||
.disabled(model.isFullScreen)
|
||||
} label: {
|
||||
Image(systemName: "ellipsis.circle").font(.system(size: 18)).frame(width: 24, height: 24)
|
||||
Image(systemName: "gearshape").font(.system(size: 17)).frame(width: 24, height: 24)
|
||||
}
|
||||
.menuStyle(.borderlessButton)
|
||||
.menuIndicator(.hidden)
|
||||
@@ -373,16 +502,16 @@ private struct PlayerContent: View {
|
||||
}
|
||||
.frame(width: 106, alignment: .trailing)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 14)
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Text(time(model.seekPosition)).frame(width: 46, alignment: .trailing)
|
||||
Slider(value: $model.seekPosition, in: 0...max(model.duration, 0.001), onEditingChanged: { editing in
|
||||
HStack(spacing: 10) {
|
||||
Text(time(model.seekPosition)).frame(minWidth: 32, alignment: .trailing)
|
||||
PlaybackSlider(value: $model.seekPosition, range: 0...max(model.duration, 0.001),
|
||||
enabled: model.canSeek, onEditingChanged: { editing in
|
||||
if editing { model.beginSeeking() } else { model.finishSeeking() }
|
||||
})
|
||||
.controlSize(.mini)
|
||||
.disabled(!model.canSeek)
|
||||
.frame(height: 14)
|
||||
.accessibilityLabel("Playback Position")
|
||||
.accessibilityValue(time(model.seekPosition))
|
||||
Button {
|
||||
@@ -390,7 +519,7 @@ private struct PlayerContent: View {
|
||||
model.revealControls()
|
||||
} label: {
|
||||
Text(showsRemainingTime ? "−" + time(model.duration - model.seekPosition) : time(model.duration))
|
||||
.frame(width: 46, alignment: .leading)
|
||||
.frame(minWidth: 32, alignment: .leading)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help(showsRemainingTime ? "Show Duration" : "Show Remaining Time")
|
||||
@@ -398,20 +527,19 @@ private struct PlayerContent: View {
|
||||
.accessibilityValue(time(showsRemainingTime ? model.duration - model.seekPosition : model.duration))
|
||||
}
|
||||
.font(.system(size: 11).monospacedDigit())
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
.tint(.white)
|
||||
.foregroundStyle(.white.opacity(0.6))
|
||||
.onHover(perform: model.setControlsHovered)
|
||||
}
|
||||
|
||||
private func controllerPosition(in size: CGSize) -> CGPoint {
|
||||
let x = size.width / 2 + controlOffset.width * size.width
|
||||
let y = size.height * 0.9 - controlSize.height / 2 + controlOffset.height * size.height
|
||||
let y = size.height * 0.96 - controlSize.height / 2 + controlOffset.height * size.height
|
||||
return CGPoint(
|
||||
x: min(max(x, controlSize.width / 2 + 12), size.width - controlSize.width / 2 - 12),
|
||||
y: min(max(y, 40 + controlSize.height / 2 + 12), size.height - controlSize.height / 2 - 12)
|
||||
y: min(max(y, 32 + controlSize.height / 2 + 12), size.height - controlSize.height / 2 - 12)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -468,10 +596,10 @@ private struct PlayerContent: View {
|
||||
|
||||
if model.canTogglePlayback {
|
||||
playbackControls
|
||||
.frame(width: min(460, geometry.size.width - 32))
|
||||
.frame(width: 440, height: 68)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(.ultraThinMaterial)
|
||||
PlayerGlass()
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.gesture(DragGesture(minimumDistance: 4).onChanged { value in
|
||||
model.setControlsDragging(true)
|
||||
controlOffset = CGSize(width: dragOrigin.width + value.translation.width / geometry.size.width,
|
||||
@@ -479,13 +607,14 @@ private struct PlayerContent: View {
|
||||
}.onEnded { _ in
|
||||
let position = controllerPosition(in: geometry.size)
|
||||
controlOffset = CGSize(width: (position.x - geometry.size.width / 2) / geometry.size.width,
|
||||
height: (position.y - (geometry.size.height * 0.9 - controlSize.height / 2)) / geometry.size.height)
|
||||
height: (position.y - (geometry.size.height * 0.96 - controlSize.height / 2)) / geometry.size.height)
|
||||
dragOrigin = controlOffset
|
||||
model.setControlsDragging(false)
|
||||
})
|
||||
}
|
||||
.overlay { RoundedRectangle(cornerRadius: 12).strokeBorder(.white.opacity(0.13), lineWidth: 1).allowsHitTesting(false) }
|
||||
.shadow(color: .black.opacity(0.3), radius: 16, y: 6)
|
||||
.scaleEffect(min(1, (geometry.size.width - 32) / 440))
|
||||
.frame(width: min(440, geometry.size.width - 32),
|
||||
height: 68 * min(1, (geometry.size.width - 32) / 440))
|
||||
.onGeometryChange(for: CGSize.self) { $0.size } action: { controlSize = $0 }
|
||||
.position(controllerPosition(in: geometry.size))
|
||||
.opacity(model.controlsVisible ? 1 : 0)
|
||||
@@ -515,7 +644,7 @@ private struct PlayerContent: View {
|
||||
}
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
.frame(minWidth: 560, minHeight: 360)
|
||||
.frame(minWidth: 400, minHeight: 240)
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user