Compare commits

...

34 Commits

Author SHA1 Message Date
hl-valdemar 67b38394d3 fix: fill the fixed start window through its safe area 2026-10-18 23:29:20 +02:00
hl-valdemar 47395491b7 fix: enforce start size through SwiftUI window constraints 2026-10-18 23:23:36 +02:00
hl-valdemar 4748e3ea2f feat: fix start screen window dimensions 2026-10-18 23:12:04 +02:00
hl-valdemar a6fa54fc42 feat: adopt a two-pane start screen 2026-10-18 23:02:22 +02:00
hl-valdemar 4b9436c04a fix: play playlist entries on single click 2026-10-18 22:50:14 +02:00
hl-valdemar ef3aae842d fix: keep playlist reordering active while dragging 2026-10-18 22:39:43 +02:00
hl-valdemar 2e5255e194 fix: anchor title controls outside the animated queue clip 2026-10-18 22:12:23 +02:00
hl-valdemar 22f13dd08d feat: animate queue sidebar expansion and collapse 2026-10-18 21:56:24 +02:00
hl-valdemar e4504ea5c4 fix: reserve queue header clearance in native list content 2026-10-18 21:47:34 +02:00
hl-valdemar 82f88c8cab fix: let queue rows scroll beneath header fade 2026-10-18 21:31:32 +02:00
hl-valdemar c551203af2 style: clear queue fade and stack row actions 2026-10-18 21:29:40 +02:00
hl-valdemar 0084609a00 style: fade playlist chrome into queue background 2026-10-18 21:08:56 +02:00
hl-valdemar b8275635f0 style: match queue footer typography to media rows 2026-10-18 21:06:43 +02:00
hl-valdemar ddf53ea606 fix: skip disposable pictures during local seek preroll 2026-10-18 20:18:14 +02:00
hl-valdemar 2e61bd7864 fix: capitalize Terebi application name 2026-10-18 20:07:34 +02:00
hl-valdemar 0e860b2ef6 fix: install app in user Applications for discovery 2026-10-18 20:04:33 +02:00
hl-valdemar 015da6a33e fix: minimize queue row horizontal padding 2026-10-18 20:02:14 +02:00
hl-valdemar 2e690b1a3a fix: remove extra sidebar list row margins 2026-10-18 19:57:48 +02:00
hl-valdemar 44d800f387 style: clarify queue rows with format labels and current-item highlight 2026-10-18 19:51:33 +02:00
hl-valdemar 4ca6e80cb0 fix: restore sidebar header and balance footer sizing 2026-10-18 19:48:42 +02:00
hl-valdemar 09d659d2cc fix: move queue controls into sidebar footer 2026-10-18 19:37:22 +02:00
hl-valdemar 547fdf6c1f fix: reduce queue add button padding 2026-10-18 19:30:14 +02:00
hl-valdemar 4dcc5414e9 fix: square queue add button and increase header spacing 2026-10-18 19:24:05 +02:00
hl-valdemar a17b2fc7d7 fix: restore bordered styling for queue add button 2026-10-18 19:20:18 +02:00
hl-valdemar 804d946970 fix: replace queue add label with header plus button 2026-10-18 19:11:34 +02:00
hl-valdemar c2261ad2f0 fix: scope control fades to preserve responsive window animation 2026-10-18 18:59:08 +02:00
hl-valdemar 29a70678a4 fix: stabilize native window layout transitions 2026-10-18 18:37:36 +02:00
hl-valdemar 9670645b9a fix: align titlebar control padding with title container 2026-10-18 18:12:02 +02:00
hl-valdemar 6b792b87af fix: match titlebar control top and right margins 2026-10-18 18:08:33 +02:00
hl-valdemar 126adc925f fix: truncate normal window titles at the end 2026-10-18 17:59:36 +02:00
hl-valdemar ffef863277 fix: move queue toggle beside compact mode control 2026-10-18 16:10:50 +02:00
hl-valdemar 6f58cd91e9 fix: match playlist outer and inner icon spacing 2026-10-18 16:09:01 +02:00
hl-valdemar b499b3f871 fix: balance queue row spacing and icon margins 2026-10-18 15:59:45 +02:00
hl-valdemar b43152da4c fix: balance sidebar control edge insets 2026-10-18 15:50:30 +02:00
17 changed files with 1199 additions and 211 deletions
+40
View File
@@ -36,6 +36,42 @@ class StartupStreamingServer(StreamingServer):
super()._get(request)
def check_queue_drag(root, app, fixtures):
result_path = root / "queue-drag.json"
diagnostics = root / "queue-drag.log"
result = subprocess.run(
["open", "-n", "-W", "--stderr", str(diagnostics), "-a", str(app),
str(fixtures / "cache.mp4"), "--args", "--no-config", "--check-queue-drag", str(result_path)],
capture_output=True, text=True, timeout=30,
)
report = json.loads(result_path.read_text()) if result_path.exists() else {}
if result.returncode != 0 or not report.get("passed"):
log = diagnostics.read_text(errors="replace") if diagnostics.exists() else ""
raise RuntimeError(f"native queue drag failed: {report}; {result.stdout}{result.stderr}{log}")
print("PASS native queue: drag down/up without changing playback, release to nearest slot, single click plays without reordering")
def check_window_layout(root, app, fixtures):
result_path = root / "window-layout.json"
diagnostics = root / "window-layout.log"
result = subprocess.run(
["open", "-g", "-n", "-W", "--stderr", str(diagnostics), "-a", str(app),
str(fixtures / "cache.mp4"), "--args", "--no-config", "--check-window-layout", str(result_path)],
capture_output=True, text=True, timeout=40,
)
log = diagnostics.read_text(errors="replace") if diagnostics.exists() else ""
report = json.loads(result_path.read_text()) if result_path.exists() else {}
expected_animations = 0 if report.get("reduceMotion") else 16
expected_queue_animations = 0 if report.get("reduceMotion") else 9
if (result.returncode != 0 or not report.get("passed") or report.get("steps") != 36
or report.get("animatedTransitions") != expected_animations
or report.get("animatedQueueTransitions") != expected_queue_animations):
raise RuntimeError(f"native window layout failed: {report}; {result.stdout}{result.stderr}{log}")
print(f"PASS native SwiftUI window layout: {report['animatedTransitions']} compact and "
f"{report['animatedQueueTransitions']} queue animations, stable video size, responsive reversals, "
"screen-edge fitting, fullscreen fixed bounds, minimum sizes and exact normal geometry restored")
def check_cli_instances(root, app, fixtures):
cli = root / "terebi"
cli.symlink_to(app / "Contents/MacOS/terebi-cli")
@@ -635,6 +671,10 @@ with tempfile.TemporaryDirectory(prefix="terebi-startup-") as directory:
if launch.returncode != 0 or not result["passed"] or result["bundleIdentifier"] != identifier:
raise RuntimeError(f"packaged startup failed: {result}; {stdout}{stderr}")
print("PASS packaged bootstrap: registered app PID/identity, in-process player load, actual playback and Now Playing")
check_queue_drag(root, app, fixtures)
if os.environ.get("TEREBI_CHECK_QUEUE_ONLY") != "1":
check_window_layout(root, app, fixtures)
if os.environ.get("TEREBI_CHECK_LAYOUT_ONLY") != "1":
check_cli_instances(root, app, fixtures)
finally:
os.close(descriptor)
+355
View File
@@ -5,6 +5,14 @@ import MediaPlayer
@_cdecl("terebi_app_main")
@MainActor
public func checkAppStartup() {
if let index = CommandLine.arguments.firstIndex(of: "--check-queue-drag") {
QueueDragCheck.run(result: URL(fileURLWithPath: CommandLine.arguments[index + 1]))
return
}
if let index = CommandLine.arguments.firstIndex(of: "--check-window-layout") {
WindowLayoutCheck.run(result: URL(fileURLWithPath: CommandLine.arguments[index + 1]))
return
}
if let configuration = Bundle.main.url(forResource: "cli-check", withExtension: "json") {
CLIInstanceCheck.run(configuration)
return
@@ -12,6 +20,353 @@ public func checkAppStartup() {
AppStartupCheck.run()
}
@MainActor
private enum QueueDragCheck {
static var timer: Timer?
static var step = 0
static var resultURL: URL!
static var original: [UUID] = []
static var current: UUID?
static var start = NSPoint.zero
static var end = NSPoint.zero
static var previousPoint = NSPoint.zero
// Deadline only; ordering assertions do not depend on the wall clock.
static let deadline = ContinuousClock.now.advanced(by: .seconds(20))
static func run(result: URL) {
resultURL = result
_ = NSApplication.shared
_ = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDown, .leftMouseDragged, .leftMouseUp]) {
NSLog("[DEBUG-queue-drag] received %@", String(describing: $0))
return $0
}
timer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { _ in
MainActor.assumeIsolated { advance() }
}
// Keep injecting mouse events while SwiftUI tracks a pressed row.
RunLoop.main.add(timer!, forMode: .common)
terebiAppMain()
}
static func finish(_ error: String? = nil) {
timer?.invalidate()
try! JSONSerialization.data(withJSONObject: ["passed": error == nil, "error": error ?? ""])
.write(to: resultURL, options: .atomic)
PlayerModel.shared.shutdown()
NSApp.terminate(nil)
}
static func mouse(_ type: NSEvent.EventType, at point: NSPoint, in window: NSWindow) {
let event = NSEvent.mouseEvent(with: type, location: point, modifierFlags: [],
timestamp: ProcessInfo.processInfo.systemUptime, windowNumber: window.windowNumber, context: nil,
eventNumber: step, clickCount: 1, pressure: type == .leftMouseUp ? 0 : 1)!
let native = event.cgEvent!
if type == .leftMouseDragged {
native.setDoubleValueField(.mouseEventDeltaX, value: point.x - previousPoint.x)
native.setDoubleValueField(.mouseEventDeltaY, value: previousPoint.y - point.y)
}
previousPoint = point
NSApp.postEvent(NSEvent(cgEvent: native)!, atStart: false)
}
static func advance() {
let model = PlayerModel.shared
guard ContinuousClock.now < deadline else {
finish("Queue interaction did not complete at step \(step): status \(model.state.status), media \(model.fileName ?? "none"), windows \(NSApp.windows.count)")
return
}
guard model.canTogglePlayback,
let window = NSApp.windows.first(where: { $0.canBecomeMain && !($0 is NSPanel) }),
let content = window.contentView else { return }
if step == 0 {
model.setVolume(0)
let fixtures = model.queue.currentURL!.deletingLastPathComponent()
model.addToQueue(["av.mp4", "cache.mp4"].map { fixtures.appendingPathComponent($0) })
current = model.queue.currentID
original = model.queue.items.map(\.id)
NSApp.activate(ignoringOtherApps: true)
window.makeKeyAndOrderFront(nil)
} else if step == 1 {
guard !model.isQueueTransitioning else { return }
guard NSApp.isActive && window.isKeyWindow else {
NSApp.activate(ignoringOtherApps: true)
window.makeKeyAndOrderFront(nil)
return
}
window.layoutIfNeeded()
if let bitmap = content.bitmapImageRepForCachingDisplay(in: content.bounds) {
content.cacheDisplay(in: content.bounds, to: bitmap)
try! bitmap.representation(using: .png, properties: [:])!.write(to: URL(fileURLWithPath: "/tmp/terebi-queue-debug.png"))
}
let top = content.isFlipped ? content.bounds.minY : content.bounds.maxY
let sign: CGFloat = content.isFlipped ? 1 : -1
start = content.convert(NSPoint(x: content.bounds.maxX - 100, y: top + sign * 82), to: nil)
end = NSPoint(x: start.x, y: start.y - 120)
NSLog("[DEBUG-queue-drag] start %@ end %@ hit %@", String(describing: start), String(describing: end),
String(describing: content.hitTest(content.convert(start, from: nil))))
mouse(.leftMouseDown, at: start, in: window)
} else if step == 2 {
mouse(.leftMouseDragged, at: NSPoint(x: start.x, y: start.y - 10), in: window)
} else if step == 3 {
mouse(.leftMouseDragged, at: end, in: window)
} else if step == 12 {
guard model.queue.items.map(\.id) == original else {
finish("Queue committed before release"); return
}
// Several event-loop turns without a mouseDragged event, then release in place.
mouse(.leftMouseUp, at: end, in: window)
} else if step == 14 {
guard model.queue.items.map(\.id) == [original[1], original[2], original[0]],
model.queue.currentID == current else {
finish("Stationary release order: \(model.queue.items.map(\.url.lastPathComponent))"); return
}
mouse(.leftMouseDown, at: end, in: window)
} else if step == 15 {
mouse(.leftMouseDragged, at: NSPoint(x: end.x, y: end.y + 10), in: window)
} else if step == 16 {
mouse(.leftMouseDragged, at: start, in: window)
} else if step == 25 {
mouse(.leftMouseUp, at: start, in: window)
} else if step == 27 {
guard model.queue.items.map(\.id) == original, model.queue.currentID == current else {
finish("Stationary upward release did not restore the original order"); return
}
let secondRow = NSPoint(x: start.x, y: start.y - 60)
mouse(.leftMouseDown, at: secondRow, in: window)
} else if step == 28 {
mouse(.leftMouseUp, at: NSPoint(x: start.x, y: start.y - 60), in: window)
} else if step == 35 {
guard model.queue.currentID == original[1],
model.queue.items.map(\.id) == original,
model.state.status == PLAYER_PLAYING else {
finish("Single click did not play the second row without reordering"); return
}
finish()
return
}
step += 1
}
}
@MainActor
private enum WindowLayoutCheck {
static var timer: Timer?
static var resultURL: URL!
static var step = 0
static var windowIdentity: ObjectIdentifier?
static var normalFrame = NSRect.zero
static var animationStart: NSRect?
static var animationTarget = NSRect.zero
static var sawIntermediateFrame = false
static var animatedTransitions = 0
static var animatedQueueTransitions = 0
static var queueVideoSize: NSSize?
static var observingQueue = false
static var animationQueueWidth: CGFloat = 0
static var expectsAnimation: Bool { !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion }
// Infrastructure timeout only; assertions depend on native transition completion.
static let deadline = ContinuousClock.now.advanced(by: .seconds(30))
static func run(result: URL) {
resultURL = result
_ = NSApplication.shared
_ = NotificationCenter.default.addObserver(forName: NSApplication.willFinishLaunchingNotification,
object: nil, queue: .main) { _ in
MainActor.assumeIsolated { PlayerModel.shared.setVolume(0) }
}
_ = NotificationCenter.default.addObserver(forName: NSWindow.didUpdateNotification,
object: nil, queue: .main) { _ in
MainActor.assumeIsolated {
for window in NSApp.windows { window.alphaValue = 0; window.ignoresMouseEvents = true }
}
}
timer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { _ in
MainActor.assumeIsolated { advance() }
}
terebiAppMain()
}
static func finish(_ error: String? = nil) {
timer?.invalidate()
let result: [String: Any] = ["passed": error == nil, "error": error ?? "", "steps": step,
"animatedTransitions": animatedTransitions,
"animatedQueueTransitions": animatedQueueTransitions,
"reduceMotion": !expectsAnimation]
try! JSONSerialization.data(withJSONObject: result).write(to: resultURL, options: .atomic)
PlayerModel.shared.shutdown()
NSApp.terminate(nil)
}
static func sameFrame(_ lhs: NSRect, _ rhs: NSRect) -> Bool {
abs(lhs.minX - rhs.minX) < 1 && abs(lhs.minY - rhs.minY) < 1 &&
abs(lhs.width - rhs.width) < 1 && abs(lhs.height - rhs.height) < 1
}
static func observeAnimation(from window: NSWindow, to target: NSRect) {
animationStart = window.frame
animationTarget = target
sawIntermediateFrame = false
animationQueueWidth = PlayerModel.shared.queueWidth
}
static func sidebarHeaderIsVisible(in window: NSWindow) -> Bool {
guard let content = window.contentView else { return false }
let header = NSRect(x: content.bounds.maxX - 84,
y: content.isFlipped ? content.bounds.minY : content.bounds.maxY - 32,
width: 84, height: 32)
guard let bitmap = content.bitmapImageRepForCachingDisplay(in: header) else { return false }
content.cacheDisplay(in: header, to: bitmap)
let scale = CGFloat(bitmap.pixelsWide) / header.width
// Each control must paint a visible glyph, not merely exist in the view tree.
return (0..<3).allSatisfy { index in
var visiblePixels = 0
// Fullscreen disables pinning; its glyph uses the native dimmed tint.
let minimumBrightness = index == 0 && window.styleMask.contains(.fullScreen) ? 0.25 : 0.5
for x in Int(CGFloat(index * 28) * scale)..<Int(CGFloat(index * 28 + 24) * scale) {
for y in 0..<bitmap.pixelsHigh {
if let color = bitmap.colorAt(x: x, y: y)?.usingColorSpace(.deviceRGB),
color.alphaComponent > 0.5,
min(color.redComponent, color.greenComponent, color.blueComponent) > minimumBrightness {
visiblePixels += 1
}
}
}
return visiblePixels >= 8
}
}
static func advance() {
let model = PlayerModel.shared
guard ContinuousClock.now < deadline else { finish("Window layout did not settle at step \(step)"); return }
guard model.canTogglePlayback, model.state.has_video,
let window = NSApp.windows.first(where: { $0.canBecomeMain && !($0 is NSPanel) }) else { return }
if model.compactWindow.isTransitioning || model.isQueueTransitioning {
window.layoutIfNeeded()
if model.isQueueTransitioning && !sidebarHeaderIsVisible(in: window) {
finish("Top-right controls left the window corner during queue animation at step \(step)"); return
}
if let size = queueVideoSize, let video = model.videoView {
guard abs(video.bounds.width - size.width) < 2, abs(video.bounds.height - size.height) < 2 else {
finish("Queue animation changed video size at step \(step): \(video.bounds.size), expected \(size)"); return
}
}
if expectsAnimation, let start = animationStart, !sawIntermediateFrame,
(!sameFrame(window.frame, start) && !sameFrame(window.frame, animationTarget) ||
observingQueue && abs(model.queueWidth - animationQueueWidth) > 1 &&
model.queueWidth > 1 && model.queueWidth < 259) {
sawIntermediateFrame = true
// These run-loop-delivered requests must remain responsive mid-animation.
if model.isQueueTransitioning {
let frame = window.frame
model.showsQueue.toggle()
model.showsQueue.toggle()
guard sameFrame(window.frame, frame) else { finish("Queue reversal jumped the window"); return }
} else {
model.toggleCompact()
model.toggleCompact()
}
}
return
}
if animationStart != nil {
if expectsAnimation {
guard sawIntermediateFrame else { finish("No nonblocking animation frames at step \(step)"); return }
if observingQueue { animatedQueueTransitions += 1 }
else { animatedTransitions += 1 }
}
animationStart = nil
guard sameFrame(window.frame, animationTarget) else {
finish("Animation missed target at step \(step): \(window.frame), expected \(animationTarget)"); return
}
queueVideoSize = nil
observingQueue = false
}
if windowIdentity == nil { windowIdentity = ObjectIdentifier(window) }
guard ObjectIdentifier(window) == windowIdentity else { finish("Transition replaced the playback window"); return }
if model.fullScreenIntent != nil { return }
window.layoutIfNeeded()
if model.showsQueue && !model.isCompact && !sidebarHeaderIsVisible(in: window) {
finish("Sidebar pin, Cast or settings glyph is not visible at step \(step)"); return
}
if step >= 33 {
switch step {
case 33, 34:
guard model.isFullScreen else { finish("Fullscreen entry failed"); return }
normalFrame = window.frame
observeAnimation(from: window, to: normalFrame)
observingQueue = true
model.showsQueue.toggle()
case 35:
guard sameFrame(window.frame, normalFrame), model.showsQueue,
let video = model.videoView, let content = window.contentView,
abs(video.bounds.width - (content.bounds.width - 260)) < 2 else {
finish("Fullscreen queue did not retain fixed window bounds and fit the video"); return
}
model.toggleFullScreen()
default:
guard !model.isFullScreen else { finish("Fullscreen exit failed"); return }
finish(); return
}
step += 1
return
}
switch step % 8 {
case 0, 4:
if step > 0 {
let actual = window.frame
guard abs(actual.minX - normalFrame.minX) < 1, abs(actual.minY - normalFrame.minY) < 1,
abs(actual.width - normalFrame.width) < 1, abs(actual.height - normalFrame.height) < 1,
!model.isCompact else { finish("Normal geometry was not restored at step \(step): \(actual)"); return }
}
if step == 32 {
model.toggleFullScreen()
step += 1
return
}
let showsQueue = step % 8 == 4
if showsQueue != model.showsQueue {
if showsQueue, let screen = window.screen?.visibleFrame {
window.setFrameOrigin(NSPoint(x: screen.maxX - window.frame.width, y: window.frame.minY))
}
var target = window.frame
target.size.width += showsQueue ? 260 : -260
if let screen = window.screen?.visibleFrame {
target.origin.x = max(screen.minX, min(target.minX, screen.maxX - target.width))
}
observeAnimation(from: window, to: target)
observingQueue = true
queueVideoSize = model.videoView?.bounds.size
model.showsQueue = showsQueue
}
case 1, 5:
window.setFrame(NSRect(x: 100, y: 200, width: 763, height: 368), display: true)
normalFrame = window.frame
guard window.minSize.width >= (model.showsQueue ? 660 : 400), window.minSize.height >= 240 else {
finish("Normal minimum size was lost: \(window.minSize)"); return
}
case 2, 6:
// Repeated intent changes must keep the original restoration frame.
observeAnimation(from: window, to: CompactWindowController.initialFrame(
aspect: model.state.video_aspect_ratio,
visibleFrame: (window.screen ?? NSScreen.main!).visibleFrame, width: 320))
model.toggleCompact()
model.toggleCompact()
model.toggleCompact()
default:
guard model.isCompact, window.frame.width >= 160, window.frame.height >= 90,
abs(window.frame.width / window.frame.height - model.state.video_aspect_ratio) < 0.01 else {
finish("Compact geometry did not settle at step \(step): \(window.frame)"); return
}
guard window.minSize.width >= 160, window.minSize.height >= 90 else {
finish("Compact minimum size was lost: \(window.minSize)"); return
}
observeAnimation(from: window, to: normalFrame)
model.toggleCompact()
}
step += 1
}
}
@MainActor
private enum AppStartupCheck {
static var model: PlayerModel?
+68
View File
@@ -0,0 +1,68 @@
// Exercise the real local seek/decode/presentation loop without worker races.
#include "../Sources/Core/player.cpp"
#include <cstdio>
#include <cstdlib>
static void require(bool condition, const char *message) {
if (!condition) { std::fprintf(stderr, "FAIL: %s\n", message); std::exit(1); }
}
static std::vector<uint8_t> pixels(const AVFrame *frame) {
const auto format = static_cast<AVPixelFormat>(frame->format);
const int size = av_image_get_buffer_size(format, frame->width, frame->height, 1);
require(size > 0, "preview has readable pixels");
std::vector<uint8_t> bytes(size);
require(av_image_copy_to_buffer(bytes.data(), size, frame->data, frame->linesize,
format, frame->width, frame->height, 1) == size, "copy preview pixels");
return bytes;
}
int main(int argc, char **argv) {
require(argc > 1, "provide local media fixtures");
for (int i = 1; i < argc; ++i) {
av_log_set_level(AV_LOG_ERROR);
Player player;
player.shutdown();
player.stopping.store(false);
player.activeEpoch = player.requestedEpoch.load();
player.open(Command{Action::Open, argv[i]});
require(player.tracks[1].codec && player.canSeek, "seekable local video");
player.output.set_volume(0);
for (bool playing : {false, true}) {
for (double fraction : {.01, .79, .21, .59}) {
const double target = player.duration * fraction;
// Full reference decoding supplies an independent expected picture,
// including disposable B-frames at or immediately after the target.
player.seek(target, false);
VideoFrame expected{};
const auto referenceStart = Clock::now();
while (!expected.frame && Clock::now() - referenceStart < std::chrono::seconds(5)) {
player.decodeOne(true);
while (!player.video.empty()) {
auto frame = std::move(player.video.front());
player.videoBytes -= frame.bytes;
player.video.pop_front();
if (frame.pts + sampleTime * .5 >= target) {
expected = std::move(frame);
break;
}
}
}
require(bool(expected.frame), "full decode supplies reference picture");
const auto start = Clock::now();
player.seek(target, playing);
while ((!player.presented.frame || player.presented.pts < target - .002) &&
Clock::now() - start < std::chrono::seconds(5))
player.tick();
const double elapsed = std::chrono::duration<double, std::milli>(Clock::now() - start).count();
std::printf("local-seek fixture=%s playing=%d target=%.3f preview=%.3f latency_ms=%.3f\n",
argv[i], playing, target, player.presented.pts, elapsed);
require(player.presented.frame && player.presented.pts == expected.pts &&
pixels(player.presented.frame.get()) == pixels(expected.frame.get()),
"seek presents the exact fully decoded picture, including B-frame targets");
require(player.status == (playing ? PLAYER_PLAYING : PLAYER_PAUSED),
"seek preserves playback intent");
}
}
}
}
+13
View File
@@ -63,6 +63,19 @@ struct PlaybackProgressChecks {
let migratedAgain = PlaybackProgress(defaults: isolated)
require(migratedAgain.position(for: file) == 43 && migratedAgain.position(for: youtube) == 15,
"opening another player preserves both migrated bookmarks and newer progress")
progress.recordRecent(file, title: "Original")
progress.recordRecent(youtube, title: "YouTube title")
independent.recordRecent(alias, title: "Renamed")
require(reopened.recentMedia == [
.init(url: file.resolvingSymlinksInPath(), title: "Renamed"), .init(url: youtube, title: "YouTube title")
], "recent media persists titles, shares file identities, and moves reopened items to the front")
for index in 0..<6 {
progress.recordRecent(directory.appendingPathComponent("\(index).mp4"), title: "Video \(index)")
}
require(reopened.recentMedia.map(\.title) == (0..<6).reversed().map { "Video \($0)" },
"recent media retains only the six most recently opened items in order")
require(PlaybackProgress(defaults: isolated).recentMedia.isEmpty,
"recent media respects the injected preference domain")
print("Playback progress checks passed")
}
}
+39
View File
@@ -1,3 +1,4 @@
import CoreGraphics
import Foundation
@main
@@ -10,7 +11,45 @@ struct PlaybackQueueChecks {
URL(fileURLWithPath: "/queue-check/" + name + ".mp4")
}
static func dragReordering() {
// Unequal heights catch snapping to a row's old center instead of the vacated slot.
let frames = [
CGRect(x: 0, y: 52, width: 260, height: 60),
CGRect(x: 0, y: 112, width: 260, height: 80),
CGRect(x: 0, y: 192, width: 260, height: 60),
]
var queue = PlaybackQueue()
queue.replace([file("A"), file("B"), file("C")])
let current = queue.currentID
var drag = PlaybackQueueDrag(source: 0, frames: frames, scrollOffset: 0)
drag.update(translation: 39, scrollOffset: 0)
require(drag.destination == 0, "release before the nearest-slot midpoint keeps the original position")
drag.update(translation: 41, scrollOffset: 0)
require(drag.destination == 1 && drag.offset(at: 1) == -60 && drag.offset(at: 2) == 0,
"crossing the midpoint slides only the displaced neighbor")
drag.update(translation: 140, scrollOffset: 0)
require(drag.destination == 2 && drag.offset(at: 0) == 140,
"dragged row follows the pointer continuously to the final slot")
// No further pointer update: release consumes the retained gesture state.
queue.move(fromOffsets: IndexSet(integer: drag.source), toOffset: drag.insertionOffset)
require(queue.items.map(\.url) == [file("B"), file("C"), file("A")] && queue.currentID == current,
"stationary release commits the nearest slot without changing playback identity")
drag = PlaybackQueueDrag(source: 2, frames: frames, scrollOffset: 100)
drag.update(translation: -20, scrollOffset: 0)
require(drag.destination == 0 && drag.offset(at: 0) == 60 && drag.offset(at: 1) == 60,
"scrolling under a stationary pointer moves the row and shifts earlier neighbors down")
drag.update(translation: 0, scrollOffset: 100)
require(drag.destination == 2 && drag.offset(at: 1) == 0,
"reversing a drag restores the original gap")
drag.update(translation: -1_000, scrollOffset: 100)
require(drag.destination == 0 && drag.insertionOffset == 0, "release above the list selects the first slot")
drag.update(translation: 1_000, scrollOffset: 100)
require(drag.destination == 2 && drag.insertionOffset == 2, "release below the list selects the last slot")
}
static func main() {
dragReordering()
let first = file("Episode1"), second = file("Episode2"), tenth = file("Episode10")
var queue = PlaybackQueue()
require(queue.items.isEmpty && queue.currentURL == nil && !queue.canPrevious && !queue.canNext,
+4
View File
@@ -299,10 +299,12 @@ struct SystemPlaybackChecks {
model.attachWindow(window)
let videoFrame = window.frame
model.showsQueue = true
until("queue opening settles") { !model.isQueueTransitioning }
require(abs(window.frame.width - videoFrame.width - 260) < 0.01 &&
abs(window.frame.height - videoFrame.height) < 0.01,
"opening queue grows window without shrinking video")
model.showsQueue = false
until("queue closing settles") { !model.isQueueTransitioning }
require(abs(window.frame.width - videoFrame.width) < 0.01 &&
abs(window.frame.height - videoFrame.height) < 0.01,
"closing queue restores video-only window size")
@@ -327,6 +329,7 @@ struct SystemPlaybackChecks {
}
until("open starts authoritative playback") { model.canTogglePlayback && model.state.status == PLAYER_PLAYING }
model.showsQueue = true
until("queue opens before live resize") { !model.isQueueTransitioning }
for delta in [NSSize(width: 160, height: 0), NSSize(width: 0, height: 90),
NSSize(width: -100, height: 0), NSSize(width: 0, height: -60)] {
let proposal = NSSize(width: window.frame.width + delta.width, height: window.frame.height + delta.height)
@@ -343,6 +346,7 @@ struct SystemPlaybackChecks {
"aspect-locked queue window follows the dragged axis")
}
model.showsQueue = false
until("queue closes after live resize") { !model.isQueueTransitioning }
require(center.playbackState == .playing, "playing publishes macOS playback ownership")
requirePlaybackPower(system: true, display: true, "local video keeps the Mac and display awake")
var bufferingState = model.state
+19 -5
View File
@@ -10,6 +10,7 @@ FF_LIBS = $(shell PKG_CONFIG_LIBDIR="$(DEPENDENCY_ROOT)/ffmpeg/lib/pkgconfig" pk
BUILD_DIR ?= .build
DEBUG_FLAGS ?= -g
PREFIX ?= $(HOME)/.local
APPDIR ?= $(HOME)/Applications
CXXFLAGS = -std=c++20 -mmacosx-version-min=15.0 -O2 $(DEBUG_FLAGS) -Wall -Wextra -Wno-unused-parameter $(FF_CFLAGS)
FRAMEWORKS := -framework AppKit -framework AudioToolbox -framework CoreAudio -framework Metal -framework MetalKit -framework QuartzCore -framework MediaPlayer
OBJECTS := $(BUILD_DIR)/player.o $(BUILD_DIR)/progress_reporter.o $(BUILD_DIR)/Output.o $(BUILD_DIR)/cast_media.o
@@ -44,11 +45,13 @@ release:
$(MAKE) BUILD_DIR=.build/release DEBUG_FLAGS= all
install: release
mkdir -p "$(PREFIX)/lib/terebi" "$(PREFIX)/bin"
mkdir -p "$(APPDIR)" "$(PREFIX)/bin"
rm -f "$(PREFIX)/lib/terebi/terebi"
rm -rf "$(PREFIX)/lib/terebi/terebi.app"
cp -R ".build/release/terebi.app" "$(PREFIX)/lib/terebi/terebi.app"
ln -sfn "$$(cd "$(PREFIX)" && pwd)/lib/terebi/terebi.app/Contents/MacOS/terebi-cli" "$(PREFIX)/bin/terebi"
rm -rf "$(PREFIX)/lib/terebi/terebi.app" "$(APPDIR)/terebi.app" "$(APPDIR)/Terebi.app"
cp -R ".build/release/terebi.app" "$(APPDIR)/Terebi.app"
ln -sfn "$$(cd "$(APPDIR)" && pwd)/Terebi.app/Contents/MacOS/terebi-cli" "$(PREFIX)/bin/terebi"
/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister -f "$(APPDIR)/Terebi.app"
mdimport "$(APPDIR)/Terebi.app"
$(BUILD_DIR):
mkdir -p "$@"
@@ -140,7 +143,18 @@ $(BUILD_DIR)/seek-queue-check: Checks/seek-queue.cpp Sources/Core/player.cpp Sou
check-seek-queue: $(BUILD_DIR)/seek-queue-check
$(BUILD_DIR)/seek-queue-check
check: check-dependencies $(BUILD_DIR)/playback-check check-seek-queue check-video-decoding check-fixtures check-audio check-window check-config check-youtube check-quality check-adaptive check-network check-progress check-reporting check-cli check-runtime check-media check-startup check-bundle check-subtitles check-resolved check-cast check-queue
$(BUILD_DIR)/local-seek-check: Checks/local-seek.mm Sources/Core/player.cpp Sources/Core/player.h Sources/Core/player_macos.h Sources/Core/output.hpp Sources/Core/video_decoder.hpp Sources/Core/network_input.hpp Sources/Core/network_policy.hpp Sources/Core/progress_reporter.hpp $(filter-out $(BUILD_DIR)/player.o,$(OBJECTS)) Makefile | check-dependencies
$(CXX) $(CXXFLAGS) $< $(filter-out $(BUILD_DIR)/player.o,$(OBJECTS)) $(FF_LIBS) $(FRAMEWORKS) -o $@
$(BUILD_DIR)/fixtures/local-seek.mp4: Makefile | check-dependencies
mkdir -p $(BUILD_DIR)/fixtures
ffmpeg -hide_banner -loglevel error -y -f lavfi -i testsrc2=size=1920x1080:rate=30:duration=12 -f lavfi -i sine=frequency=440:sample_rate=48000:duration=12 -c:v libx264 -preset ultrafast -threads 2 -bf 2 -g 300 -keyint_min 300 -sc_threshold 0 -c:a aac $@
.PHONY: check-local-seek
check-local-seek: $(BUILD_DIR)/local-seek-check $(BUILD_DIR)/fixtures/local-seek.mp4 $(DECODE_FIXTURES)
$(BUILD_DIR)/local-seek-check $(BUILD_DIR)/fixtures/local-seek.mp4 $(DECODE_FIXTURES)
check: check-dependencies $(BUILD_DIR)/playback-check check-seek-queue check-local-seek check-video-decoding check-fixtures check-audio check-window check-config check-youtube check-quality check-adaptive check-network check-progress check-reporting check-cli check-runtime check-media check-startup check-bundle check-subtitles check-resolved check-cast check-queue
$(BUILD_DIR)/playback-check $(BUILD_DIR)/fixtures
check-fixtures: | check-dependencies
+23 -7
View File
@@ -20,12 +20,14 @@ The resulting `.build/terebi.app` is self-contained: copy the whole app to a com
### Install
```sh
make install # builds a release and installs under ~/.local
make install # app: ~/Applications; CLI: ~/.local/bin
export PATH="$HOME/.local/bin:$PATH"
terebi --help
```
Use `make install PREFIX="/custom/path"` to change the prefix, or `make release` for just `.build/release/terebi.app`. Installation places the app in `lib/terebi/terebi.app` and its CLI symlink in `bin/terebi`. Quit running players before updating.
Installation builds a release, places `Terebi.app` in `~/Applications`, registers it with Launch Services, and requests Spotlight indexing. The application display name is **Terebi**; the CLI remains `~/.local/bin/terebi`. Any old app under `~/.local/lib/terebi` or lowercase installed bundle is removed. Quit running players before updating.
Use `make install APPDIR="/Applications"` to change the app directory (it must be writable), `PREFIX="/custom/path"` to change the CLI prefix (`bin/terebi`), or both. Use `make release` for just `.build/release/terebi.app`.
Builds are ad-hoc signed for personal use, not notarized. Bundled licenses and source archives are in the app's Resources; public binary redistribution requires a separate dependency/license audit, including the GPLv3+ standalone yt-dlp bundle.
@@ -33,6 +35,8 @@ Builds are ad-hoc signed for personal use, not notarized. Bundled licenses and s
Open one or more local files with **⌘O**, drag them onto the video, or use Finder's **Open With**. A selection replaces the session's queue and plays in natural filename order (for example, episode 2 before episode 10). Open YouTube with **⇧⌘O** or drop a video link. Media starts automatically and resumes its saved position; finished videos restart from the beginning. Live and audio-only media do not save progress.
The start screen uses a fixed 640 × 400-point two-pane window: Terebi's identity and version on the left; **Open…**, **Open URL…**, and the six most recently played local files or YouTube videos on the right. Opening media restores normal resizing; closing media returns to the fixed start size. Shortcut hints follow your configured bindings. **Open URL…** reveals a focused inline YouTube field. When the most recent item has saved progress, it appears once as a **Resume** row with its saved timestamp. Recent titles and ordering survive relaunches; failed opens are not added. Resolved streams are excluded because reopening them requires a fresh external handoff. The action pane scrolls, and drag-and-drop remains available.
| Action | Default shortcut |
| --- | --- |
| Play / pause | Space or K |
@@ -47,7 +51,11 @@ Open one or more local files with **⌘O**, drag them onto the video, or use Fin
| Close media | ⇧⌘W |
| Quit | Q or ⌘Q |
Compact mode keeps playback in the same floating window; drag to move it and double-click to restore. The titlebar pin keeps the normal window on top. macOS media keys also control playback; previous/next seek five seconds rather than changing tracks.
Compact mode keeps playback in the same floating window; drag to move it and double-click to restore. Long window titles truncate at the end in both normal and compact mode; hover the title to see the full name. The titlebar pin keeps the normal window on top. macOS media keys also control playback; previous/next seek five seconds rather than changing tracks.
Compact entry and exit animate asynchronously over 0.25 seconds, preserving the original window frame and queue visibility even with rapid toggles. Control fades animate only their opacity rather than the whole window update, avoiding competing SwiftUI window-size animations. Reduce Motion disables these animations.
The normal titlebar control container uses matching 4-point top and right margins. Its horizontal padding accounts for the buttons' internal spacing to visually match the title container.
Drag the video or unused playback background to move the window, including with the queue open. A single video click hides the controls; double-click toggles fullscreen. Playback controls stay anchored at the bottom center of the video and cannot be dragged. Native window edges remain available for resizing; the open queue allows free resizing with letterboxed video.
@@ -55,13 +63,19 @@ Active playback prevents idle system sleep. Local video also keeps the display a
### Local-file queue
Use the titlebar's **Queue** icon or **View → Show Queue** to show the editable sidebar. **Add to Queue…** and dropping files onto the sidebar append without replacing the queue or interrupting the current item. Drag rows to reorder, remove them with the minus button, and double-click a row to play it. The current item is marked; context menus also offer Play, Remove and Move Up/Down.
Use the **Queue** icon immediately left of the compact-mode button in the playback controls, or **View → Show Queue**, to show the editable sidebar. The **+** button beside the queue count opens **Add to Queue…**; it and dropping files onto the sidebar append without replacing the queue or interrupting the current item. Drag rows to reorder, remove them with the minus button, and click a row to play it. Dragging does not change playback. The current item is marked; context menus also offer Play, Remove and Move Up/Down.
In a normal window, showing the queue expands the window outward while preserving the video's size; hiding it removes that extra width. The sidebar meets the video directly, without a separate divider gap. The window shifts left when necessary near the screen's right edge. Fullscreen keeps its fixed bounds.
The fixed bottom row holds the queue label/count and **+** on the left, with previous/next controls on the right. Its text matches the rows' 13-point medium-weight filenames, and its icons match the rows' 12-point icons. The **+** is a 20-point rounded-square button with an accent-tinted background and an 8-point gap after the queue count; navigation buttons retain their 24-point hit areas.
Queue rows show a medium-weight, two-line filename without its extension, with a smaller format label beneath it. Rows have a 56-point minimum height, 8-point vertical and 4-point horizontal internal padding, and no horizontal outer insets. The subdued remove button sits beneath the media icon in the left column, leaving the remaining width for the filename. The current item has an accent play marker and a subtle rounded highlight. Full filenames and paths remain available in row tooltips.
The queue expands and collapses with a 250 ms ease-in/out reveal. In a normal window, the sidebar and window resize together while preserving the video's size; hiding it removes that extra width. Repeated toggles reverse from the current width without jumping. The sidebar meets the video directly, without a separate divider gap, and the window shifts left when necessary near the screen's right edge. Fullscreen animates the sidebar within its fixed bounds. Reduce Motion makes the change immediate; entering compact/fullscreen mode or starting a native resize settles an in-flight reveal first.
Resizing with the queue open preserves the video's aspect ratio while keeping the sidebar at a fixed width; closing the queue retains ordinary aspect-locked video resizing.
While the sidebar is open, its persistent top header holds the queue count, queue toggle, pin, Cast and settings controls without a surrounding glass container; these do not fade with the video controls. Closing the sidebar returns those controls to the auto-hiding video titlebar with its glass container. Space and configured playback shortcuts continue working with queue-list focus; Tab navigation, deliberately focused buttons/sliders and text entry keep their native keyboard behavior.
One window-level top-right overlay holds the pin, Cast and settings controls, keeping them visible at the moving window corner throughout queue expansion, collapse and reversals. Their glass container fades out as the sidebar opens and back in as it closes. With the sidebar open, the controls sit above the list with matching top and right button insets; these and the bottom queue controls do not fade with the video controls. Once the sidebar closes, the top-right controls resume auto-hiding with the video titlebar. The native layout check samples all three rendered header glyphs at the window corner during transitions as well as after opening. The queue toggle stays in the playback controls. Space and configured playback shortcuts continue working with queue-list focus; Tab navigation, deliberately focused buttons/sliders and text entry keep their native keyboard behavior.
The sidebar, top controls and queue footer share an opaque playlist background. The top backing fades downward over 20 points. A noninteractive 52-point blank row at the start of the list clears the 32-point control row and its fade for the first entry, while the list viewport extends behind the controls so scrolling rows pass beneath the gradient rather than a hard clipping edge. The footer backing fades upward into the list by the same amount, keeping scrolled filenames from interfering with the controls without hard edges.
Playback advances automatically and stops at the end, without wrapping. **Previous Queue Item** and **Next Queue Item** are available in the sidebar and Playback menu and are disabled at their respective ends. Reordering or removing another item does not interrupt playback; removing the current item plays its next neighbor, or stops if there is none. A playback error stays on that item rather than skipping it.
@@ -144,7 +158,7 @@ Settings load once at startup. Menu changes are session-only and never write the
## Limits
- SDR output only; PQ/HLG HDR is rejected. No stream playlists, playlist-file import, embedded/YouTube captions, manual audio-track selection, rotation, or deinterlacing. The session-only local-file queue and external resolved-source WebVTT subtitles are supported.
- H.264, HEVC, and VP9 prefer VideoToolbox decoding with software fallback; other codecs, including AV1, use software. Backward frame stepping can be slow on long-GOP or unindexed media.
- H.264, HEVC, and VP9 prefer VideoToolbox decoding with software fallback; other codecs, including AV1, use software. Local seeks remain frame-accurate: disposable non-reference pictures strictly before the target are skipped, but reference pictures still require decoding. Seeking and backward frame stepping can remain slow on long-GOP or unindexed media.
- Network buffering is bounded and memory-only; live/non-seekable sources disable seeking and frame stepping. Expired stream URLs are not automatically refreshed.
- Audio-device changes during playback are not handled.
@@ -159,6 +173,8 @@ The full suite requires a graphical macOS session and a working audio device; it
The startup checks verify that playing video remains attached, unhidden and sized to its native drag surface, including with the queue open; playback-state checks alone do not establish renderer visibility.
`make check-local-seek` measures paused/playing local seek-to-picture latency and compares the resulting timestamps and pixels against full decoding, including B-frame targets. It covers a 1080p H.264 long-GOP file with audio and the six hardware/software decoder fixtures; timing is diagnostic, not a machine-dependent pass threshold.
Queue-open resize checks require positive native resize increments and exercise continuous horizontal/vertical resize-delegate sequences, including reversals, to preserve video aspect without snap-back. The driving axis is fixed for each live resize gesture so aspect corrections do not feed back into axis selection. Physical edge-drag resizing still needs manual verification.
`make benchmark-playback` measures cold source startup, cached/uncached seeks, and sustained native/Cast playback with repeatable loopback fixtures. It requires the same macOS session/audio device and fixture-generation FFmpeg; results exclude live extraction, WAN/TLS, and physical-receiver latency.
+2 -1
View File
@@ -4,7 +4,8 @@
<dict>
<key>CFBundleExecutable</key><string>terebi</string>
<key>CFBundleIdentifier</key><string>local.personal.terebi</string>
<key>CFBundleName</key><string>terebi</string>
<key>CFBundleName</key><string>Terebi</string>
<key>CFBundleDisplayName</key><string>Terebi</string>
<key>CFBundlePackageType</key><string>APPL</string>
<key>CFBundleShortVersionString</key><string>0.1</string>
<key>LSMinimumSystemVersion</key><string>15.0</string>
+3 -3
View File
@@ -12,10 +12,10 @@ enum TerebiBootstrap {
let payload = app.appendingPathComponent("Contents/Frameworks/libterebi-player.dylib")
guard let library = dlopen(payload.path, RTLD_NOW | RTLD_LOCAL) else {
let detail = dlerror().map { String(cString: $0) } ?? "The dynamic loader could not load the player."
fail("Could not load the player: \(detail)\n\nPlayer library: \(payload.path)\nRebuild and reinstall terebi.")
fail("Could not load the player: \(detail)\n\nPlayer library: \(payload.path)\nRebuild and reinstall Terebi.")
}
guard let entry = dlsym(library, "terebi_app_main") else {
fail("The player library is missing its application entry point. Rebuild and reinstall terebi.")
fail("The player library is missing its application entry point. Rebuild and reinstall Terebi.")
}
// Keep the declared app executable alive so LaunchServices retains its identity.
typealias PlayerMain = @convention(c) () -> Void
@@ -29,7 +29,7 @@ enum TerebiBootstrap {
application.activate(ignoringOtherApps: true)
let alert = NSAlert()
alert.alertStyle = .critical
alert.messageText = "terebi could not start"
alert.messageText = "Terebi could not start"
alert.informativeText = message
alert.addButton(withTitle: "Quit")
alert.runModal()
-1
View File
@@ -398,7 +398,6 @@ final class CompactWindowController: NSObject {
frameAnimating = false
frameAnimationGeneration &+= 1
guard let window else { return }
// A zero-duration animator update cancels the native in-flight frame animation.
NSAnimationContext.runAnimationGroup { context in
context.duration = 0
window.animator().setFrame(window.frame, display: true)
+22
View File
@@ -1,6 +1,12 @@
import Foundation
struct PlaybackProgress {
struct RecentMedia: Equatable, Identifiable {
let url: URL
let title: String
var id: URL { url }
}
private let defaults: UserDefaults
init(defaults: UserDefaults = .standard) {
@@ -28,6 +34,22 @@ struct PlaybackProgress {
defaults.set(position, forKey: key)
}
var recentMedia: [RecentMedia] {
(defaults.array(forKey: "recentMedia") as? [[String: String]] ?? []).compactMap {
guard let text = $0["url"], let url = URL(string: text), let title = $0["title"] else { return nil }
return RecentMedia(url: url, title: title)
}
}
func recordRecent(_ url: URL, title: String) {
let url = url.isFileURL ? url.standardizedFileURL.resolvingSymlinksInPath() : url
var entries = recentMedia
entries.removeAll { $0.url == url }
entries.insert(RecentMedia(url: url, title: title), at: 0)
defaults.set(entries.prefix(6).map { ["url": $0.url.absoluteString, "title": $0.title] },
forKey: "recentMedia")
}
private func key(for url: URL) -> String {
// YouTube callers supply the canonical webpage URL, never an expiring media URL.
return "playbackPosition:" + (url.isFileURL ? url.standardizedFileURL.resolvingSymlinksInPath().absoluteString : url.absoluteString)
+44
View File
@@ -60,3 +60,47 @@ struct PlaybackQueue {
}
}
}
// Geometry stays in the unshifted content coordinate space throughout a gesture.
struct PlaybackQueueDrag {
let source: Int
let frames: [CGRect]
let initialScrollOffset: CGFloat
private(set) var scrollOffset: CGFloat
private(set) var translation: CGFloat = 0
private(set) var destination: Int
var pointerY: CGFloat = 0
init(source: Int, frames: [CGRect], scrollOffset: CGFloat) {
self.source = source
self.frames = frames
initialScrollOffset = scrollOffset
self.scrollOffset = scrollOffset
destination = source
}
var offset: CGFloat { translation + scrollOffset - initialScrollOffset }
func center(at index: Int) -> CGFloat {
if index < source { return frames[index].minY + frames[source].height / 2 }
if index > source { return frames[index].maxY - frames[source].height / 2 }
return frames[source].midY
}
mutating func update(translation: CGFloat, scrollOffset: CGFloat) {
self.translation = translation
self.scrollOffset = scrollOffset
let centerY = frames[source].midY + offset
destination = frames.indices.min { abs(center(at: $0) - centerY) < abs(center(at: $1) - centerY) }!
}
var insertionOffset: Int { destination > source ? destination + 1 : destination }
func offset(at index: Int) -> CGFloat {
if index == source { return offset }
let target = destination
if source < index && index <= target { return -frames[source].height }
if target <= index && index < source { return frames[source].height }
return 0
}
}
+14
View File
@@ -64,6 +64,20 @@ extension KeyChord {
if modifiers.contains(.shift) { flags.insert(.shift) }
return KeyboardShortcut(equivalent, modifiers: flags)
}
private static let displayNames = [
"space": "Space", "escape": "⎋", "return": "↩", "tab": "⇥",
"backspace": "⌫", "delete": "⌦", "left": "←", "right": "→",
"up": "↑", "down": "↓", "home": "↖", "end": "↘",
"pageup": "⇞", "pagedown": "⇟"
]
var displayText: String {
return (modifiers.contains(.control) ? "⌃" : "") +
(modifiers.contains(.option) ? "⌥" : "") +
(modifiers.contains(.shift) ? "⇧" : "") +
(modifiers.contains(.command) ? "⌘" : "") + (Self.displayNames[key] ?? key.uppercased())
}
}
extension PlayerModel {
File diff suppressed because it is too large Load Diff
+12
View File
@@ -468,6 +468,7 @@ struct Player {
for (auto &track : tracks) {
if (!track.codec) continue;
avcodec_flush_buffers(track.codec);
track.codec->skip_frame = AVDISCARD_DEFAULT;
track.drainSent = track.drained = false;
track.firstPacket = true;
av_packet_unref(track.packet);
@@ -1151,6 +1152,17 @@ struct Player {
"Preparing video decoder preroll");
track.firstPacket = false;
}
if (slot == 1) {
// Preroll needs reference pictures, not disposable pictures before
// the target. Restore full decoding before the target packet and
// for exact frame stepping; never guess when timestamps are absent.
const auto *packet = track.packet;
const bool preroll = !network && !stepping && !previewShown && seekTarget > 0 &&
packet->pts != AV_NOPTS_VALUE &&
packet->duration > 0 &&
(double(packet->pts) + packet->duration) * av_q2d(track.stream()->time_base) - origin < seekTarget;
track.codec->skip_frame = preroll ? AVDISCARD_NONREF : AVDISCARD_DEFAULT;
}
const int result = avcodec_send_packet(track.codec, track.packet);
if (result != AVERROR(EAGAIN)) {
check(result, "Sending media packet to decoder");
+6
View File
@@ -4,5 +4,11 @@
- native playback activity prevents idle system sleep; local video keeps the display awake, casting allows screensaver/display sleep; native assertion checks and loopback Cast transitions passed, physical idle-TV verification remains unverified
- [x] add session-only local-file playlist support
- multi-file Open/Finder/drop, natural filename order, editable queue sidebar, explicit append, previous/next and automatic advance; Cast navigation retains the receiver; UI/drag-drop and physical-Cast verification remain unverified
- [x] make sure the `make install` command installs the application (i can't find it in spotlight)
- installs to ~/Applications, migrates the old hidden app, registers with Launch Services and requests Spotlight indexing; actual install, CLI, code signature, registration and Spotlight query passed; Spotlight UI remains unverified
- [x] takes an awfully long time seek in local videos which i find weird, since seeking in streamed youtube videos is pretty much instant
- local seek preroll skips disposable non-reference pictures without sacrificing exact target frames; measured 1080p/10-second-GOP paused seek improved from 541 ms to 400 ms and playing seek from 682 ms to 488 ms; timestamp/pixel checks passed across six decoder formats; long-GOP reference decoding remains necessary, and the originally affected files still need manual verification
- [x] make the sidebar expand/collapse with a nice transition
- synchronized 250 ms sidebar/window reveal, reversible mid-transition, with immediate Reduce Motion changes; top-right controls stay anchored to the moving window corner; native checks passed for rendered controls during transitions, stable video size, reversals, screen-edge fitting, fullscreen and compact restoration; animation feel and Reduce Motion still need manual verification
- [ ] in the "choose what to play" screen, we should change the youtube button to general streaming
- we can figure out the stream and how to handle it from the url (i.e. if youtube url, handle as youtube stream; if miruro url, handle accordingly)