diff --git a/Checks/playback.mm b/Checks/playback.mm index 60fc380..9ef6443 100644 --- a/Checks/playback.mm +++ b/Checks/playback.mm @@ -46,13 +46,16 @@ int main(int argc, char **argv) { window.contentView = view; [window orderBack:nil]; - for (const char *fixture : {"av.mp4", "video.mp4", "audio.wav"}) { + for (const char *fixture : {"av.mp4", "video.mp4", "audio.wav", "anamorphic.mp4"}) { std::string path = std::string(argv[1]) + "/" + fixture; require(player_open(player.get(), path.c_str()), "open accepted"); auto opened = until(player.get(), [](auto s) { return s.status == PLAYER_PAUSED; }); require(opened.duration > 3.9 && opened.duration < 4.2, "discovered fixture duration"); require(opened.has_video == (std::string(fixture) != "audio.wav"), "video track selection"); - require(opened.has_audio == (std::string(fixture) != "video.mp4"), "audio track selection"); + require(opened.has_audio == (std::string(fixture) == "av.mp4" || std::string(fixture) == "audio.wav"), + "audio track selection"); + double aspect = !opened.has_video ? 0 : std::string(fixture) == "anamorphic.mp4" ? 8.0 / 3 : 16.0 / 9; + require(std::abs(opened.video_aspect_ratio - aspect) < .0001, "display aspect includes non-square pixels"); require(!player_seek(player.get(), NAN), "non-finite seek rejected"); require(player_play(player.get()), "play accepted"); until(player.get(), [](auto s) { return s.status == PLAYER_PLAYING && s.position > .35; }); diff --git a/Makefile b/Makefile index 15f23d1..e061a22 100644 --- a/Makefile +++ b/Makefile @@ -39,6 +39,7 @@ check: .build/playback-check check-audio mkdir -p .build/fixtures ffmpeg -hide_banner -loglevel error -y -f lavfi -i testsrc2=size=320x180:rate=24 -f lavfi -i sine=frequency=440:sample_rate=44100 -t 4 -c:v mpeg4 -bf 2 -c:a aac .build/fixtures/av.mp4 ffmpeg -hide_banner -loglevel error -y -i .build/fixtures/av.mp4 -an -c:v copy .build/fixtures/video.mp4 + ffmpeg -hide_banner -loglevel error -y -i .build/fixtures/av.mp4 -an -vf setsar=3/2 -c:v mpeg4 .build/fixtures/anamorphic.mp4 ffmpeg -hide_banner -loglevel error -y -i .build/fixtures/av.mp4 -vn -c:a pcm_s16le .build/fixtures/audio.wav ffmpeg -hide_banner -loglevel error -y -f lavfi -i testsrc2=size=160x90:rate=12 -t 1 -vf 'format=yuv420p10le,setparams=color_primaries=bt2020:color_trc=smpte2084:colorspace=bt2020nc' -c:v ffv1 .build/fixtures/hdr.mkv .build/playback-check .build/fixtures diff --git a/README.md b/README.md index 3bf326a..93102f6 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,9 @@ Open a local file with **⌘O**, drop it onto the window, or use Finder's **Open ## Playback UI -The starting layout follows [IINA's video-first window](https://iina.io/img/screenshot.png): dark, edge-to-edge video, a slim titlebar with native traffic lights, and a draggable translucent floating controller. The controller starts centered near the bottom and keeps its relative position when the window changes size. +The layout follows [IINA's video-first window](https://iina.io/img/screenshot.png) and the supplied screenshots: a 32-point frosted titlebar with native traffic lights and file icon, plus a dark frosted 440 × 68-point floating controller. AppKit materials blur the video beneath the chrome; muted icons, an orange volume track, a narrow seek thumb, and zero-padded time labels follow the reference. The controller starts centered with a four-percent bottom inset and keeps its relative position when the window changes size. + +Opening video fits the window to its display aspect ratio, including non-square pixels, and constrains subsequent window resizing. Fullscreen retains the normal letterboxed renderer; audio-only playback and close release the aspect constraint. Extreme ratios that cannot fit both the screen and usable controls keep a freely resizable letterboxed window. - **Space:** play/pause. **←/→:** seek five seconds. The timeline slider seeks on release. - **↑/↓:** adjust volume by five percent. **M:** mute/unmute, restoring the previous audible level. @@ -33,7 +35,7 @@ Only implemented actions have controls; there are no placeholder playlist, subti - `Sources/Core/player.h`: the C interface and authoritative state snapshot. Playback commands enqueue work; poll state for completion. Volume set/get is synchronous and uses a lock-free target gain. Callers own the opaque handle and must stop using it before destruction. - `Sources/Core/player.cpp`: single demux/decode worker, bounded buffers, timestamps, audio-master synchronization, paused previews, decoder/resampler drain, seeking, errors, and shutdown. A seek stops output and flushes all old timeline state before resuming. - `Sources/Core/output.hpp` and `Sources/macOS/Output.mm`: native output contract, allocation-free/nonblocking Core Audio callback and bounded audio ring, latency-aware presentation clock, retained frame mailbox, and Metal YUV conversion. The view can outlive the player safely. -- `Sources/App/PlayerApp.swift`: MainActor state polling, file panel/Finder/drop handling, video host, floating controls, visibility, and native window integration. +- `Sources/App/PlayerApp.swift`: MainActor state polling, file panel/Finder/drop handling, video host, AppKit glass and native slider cells, floating controls, visibility, video-fitted sizing, and native window integration. - `Makefile`: compiler/linker commands and app bundle construction. No generated Xcode project or additional build dependency. ## Runnable check @@ -42,7 +44,7 @@ Only implemented actions have controls; there are no placeholder playlist, subti make check ``` -Generates short fixtures and exercises the real playback interface and native outputs: audio/video, video-only, audio-only, pause, paused/playing seek, completion, replay, close, HDR rejection, failed-open recovery, and destruction while playing. It initializes the actual Metal shaders and uses an offscreen native window. It requires a working audio device and graphical macOS session, and plays short sine-wave audio. It is a runtime smoke check, not a mocked clock/device test. +Generates short fixtures and exercises the real playback interface and native outputs: audio/video, video-only, audio-only, non-square-pixel display aspect, pause, paused/playing seek, completion, replay, close, HDR rejection, failed-open recovery, and destruction while playing. It initializes the actual Metal shaders and uses an offscreen native window. It requires a working audio device and graphical macOS session, and plays short sine-wave audio. It is a runtime smoke check, not a mocked clock/device test. `make check-audio` runs just the audio-quality check. A check-only hook captures actual Core Audio callback PCM into preallocated storage and compares every sample against FFmpeg's reference output for PCM/AAC, 44.1/48 kHz, and coarse container timestamps. It checks fractional gain, silent muted startup, smooth live volume changes, and unchanged playback completion. A separate gapped fixture verifies that real timestamp gaps remain silent. Release builds contain no capture hook. diff --git a/Sources/App/PlayerApp.swift b/Sources/App/PlayerApp.swift index 2649859..a479eb1 100644 --- a/Sources/App/PlayerApp.swift +++ b/Sources/App/PlayerApp.swift @@ -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 + 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) } } diff --git a/Sources/Core/player.cpp b/Sources/Core/player.cpp index 4f56aa6..c9902b3 100644 --- a/Sources/Core/player.cpp +++ b/Sources/Core/player.cpp @@ -179,6 +179,12 @@ struct Player { state.duration = duration; state.has_audio = tracks[0].codec != nullptr; state.has_video = tracks[1].codec != nullptr; + state.video_aspect_ratio = 0; + if (const auto *video = tracks[1].codec; video && video->width > 0 && video->height > 0) { + const auto aspect = av_guess_sample_aspect_ratio(format, format->streams[tracks[1].index], nullptr); + state.video_aspect_ratio = double(video->width) / video->height * + (aspect.num > 0 && aspect.den > 0 ? av_q2d(aspect) : 1); + } std::memcpy(state.error, error.data(), error.size()); ++state.revision; } diff --git a/Sources/Core/player.h b/Sources/Core/player.h index e39b00a..c4a3f58 100644 --- a/Sources/Core/player.h +++ b/Sources/Core/player.h @@ -17,6 +17,8 @@ typedef struct PlayerState { double duration; bool has_audio; bool has_video; + // Display width / height, including sample aspect ratio; zero without video. + double video_aspect_ratio; uint64_t revision; char error[512]; } PlayerState;