feat: configure duration-aware progress in compact titles

This commit is contained in:
2026-10-12 15:45:22 +02:00
parent 1f5e9541e7
commit 35fa89e492
5 changed files with 96 additions and 8 deletions
+8
View File
@@ -116,6 +116,12 @@ struct ConfigurationChecks {
"printed TOML is directly usable and preserves all default behavior and bindings") "printed TOML is directly usable and preserves all default behavior and bindings")
try require(!defaults.showBufferInfo && !defaults.automaticQuality && defaults.snapToEdges, try require(!defaults.showBufferInfo && !defaults.automaticQuality && defaults.snapToEdges,
"built-in defaults hide buffer info, disable automatic quality, and enable snapping") "built-in defaults hide buffer info, disable automatic quality, and enable snapping")
for (value, expected) in [("time", CompactTitleProgress.time), ("percentage", .percentage), ("none", .none)] {
let progress = root.appendingPathComponent("compact-progress-\(value).toml")
try Data("[ui]\ncompact_title_progress = \"\(value)\"\n".utf8).write(to: progress)
try require(try PlayerConfiguration.load(from: progress).compactTitleProgress == expected,
"compact title format \(value) loads from a partial UI section")
}
try require(PlayerConfiguration.fileURL(environment: ["XDG_CONFIG_HOME": xdg.path], homeDirectory: home) == config, try require(PlayerConfiguration.fileURL(environment: ["XDG_CONFIG_HOME": xdg.path], homeDirectory: home) == config,
"absolute XDG_CONFIG_HOME selects terebi/config.toml") "absolute XDG_CONFIG_HOME selects terebi/config.toml")
for environment in [[:], ["XDG_CONFIG_HOME": ""], ["XDG_CONFIG_HOME": "relative/config"]] { for environment in [[:], ["XDG_CONFIG_HOME": ""], ["XDG_CONFIG_HOME": "relative/config"]] {
@@ -219,6 +225,8 @@ struct ConfigurationChecks {
("string", "[ui]\nbuffer_info = \"true\"\n", []), ("string", "[ui]\nbuffer_info = \"true\"\n", []),
("integer", "[playback]\nautomatic_quality = 1\n", []), ("integer", "[playback]\nautomatic_quality = 1\n", []),
("array", "[ui]\ncompact_snap_to_edges = [true]\n", []), ("array", "[ui]\ncompact_snap_to_edges = [true]\n", []),
("progress-value", "[ui]\ncompact_title_progress = \"unknown\"\n", ["compact_title_progress", "unknown"]),
("progress-bool", "[ui]\ncompact_title_progress = false\n", ["compact_title_progress"]),
("table", "[playback.automatic_quality]\nenabled = true\n", ["automatic_quality"]), ("table", "[playback.automatic_quality]\nenabled = true\n", ["automatic_quality"]),
("ui-scalar", "ui = true\n", ["ui"]), ("ui-scalar", "ui = true\n", ["ui"]),
("keys-scalar", "keys = true\n", ["keys"]), ("keys-scalar", "keys = true\n", ["keys"]),
+33
View File
@@ -17,7 +17,40 @@ struct SystemPlaybackChecks {
require(condition(), message) require(condition(), message)
} }
static func checkCompactProgress() {
let times: [(Double, Double, String)] = [
(12.9, 45.9, "12 / 45 s"),
(59, 59.999, "59 / 59 s"),
(0, 60, "00:00 / 01:00 min"),
(312, 2528, "05:12 / 42:08 min"),
(3599, 3599.999, "59:59 / 59:59 min"),
(59, 3600, "00:00 / 01:00 h"),
(4980, 7800, "01:23 / 02:10 h"),
(-5, 45, "0 / 45 s"),
(80, 45, "45 / 45 s"),
]
for (position, duration, expected) in times {
require(compactProgressText(position: position, duration: duration, format: .time) == expected,
"compact time uses total-duration units, truncation and bounded progress: \(expected)")
}
for (position, expected) in [(59.9, "59"), (100.0, "100"), (105.0, "100"), (-1.0, "0")] {
require(compactProgressText(position: position, duration: 100, format: .percentage) == expected,
"compact percentage is a bounded integer: \(expected)")
}
for duration in [0.0, -1.0, .nan, .infinity] {
require(compactProgressText(position: 10, duration: duration, format: .time) == nil &&
compactProgressText(position: 10, duration: duration, format: .percentage) == nil,
"unknown/invalid duration has no compact progress")
}
require(compactProgressText(position: .nan, duration: 100, format: .time) == nil &&
compactProgressText(position: .infinity, duration: 100, format: .percentage) == nil,
"nonfinite positions never appear as compact progress")
require(compactProgressText(position: 59, duration: 100, format: .none) == nil,
"disabled compact progress leaves only the title")
}
static func main() { static func main() {
checkCompactProgress()
_ = NSApplication.shared _ = NSApplication.shared
NSApp.setActivationPolicy(.prohibited) NSApp.setActivationPolicy(.prohibited)
let suite = "terebi.system-playback-check.\(UUID().uuidString)" let suite = "terebi.system-playback-check.\(UUID().uuidString)"
+5 -2
View File
@@ -79,6 +79,7 @@ automatic_quality = false
[ui] [ui]
buffer_info = false buffer_info = false
compact_snap_to_edges = true compact_snap_to_edges = true
compact_title_progress = "time"
[keys] [keys]
play = ["k", "space"] play = ["k", "space"]
@@ -120,7 +121,7 @@ Bindings name explicit actions, not toggles. The same chord may be shared only b
For YouTube, bind `quality_<height>p` (for example, `quality_2160p = "ctrl+4"`) to select an available resolution, or `quality_default` to restore the capped default. Resolution actions are unavailable for local media and absent resolutions; selecting manual/default quality disables automatic quality. Captions are not implemented, so caption and `toggle_*` action names are rejected. For YouTube, bind `quality_<height>p` (for example, `quality_2160p = "ctrl+4"`) to select an available resolution, or `quality_default` to restore the capped default. Resolution actions are unavailable for local media and absent resolutions; selecting manual/default quality disables automatic quality. Captions are not implemented, so caption and `toggle_*` action names are rejected.
Behavior/UI values must be TOML booleans. Unknown keys, malformed TOML, wrong types, unreadable files, and a directory in place of the file are errors, not silent defaults. Top-level `automatic_quality` is no longer accepted: move it under `[playback]`. The old flat `show_buffer_info` and `snap_to_edges` settings are also rejected: move them to `[ui]` as `buffer_info` and `compact_snap_to_edges`. GUI startup errors show a native alert. Settings are read once at startup; control/menu changes are **session-only** and never write the file. Edit it yourself and start a fresh player to apply changes. Behavior/UI switches must be TOML booleans; `[ui].compact_title_progress` is a string: `"time"` (default), `"percentage"`, or `"none"`. Unknown keys, unsupported values, malformed TOML, wrong types, unreadable files, and a directory in place of the file are errors, not silent defaults. Top-level `automatic_quality` is no longer accepted: move it under `[playback]`. The old flat `show_buffer_info` and `snap_to_edges` settings are also rejected: move them to `[ui]` as `buffer_info` and `compact_snap_to_edges`. GUI startup errors show a native alert. Settings are read once at startup; control/menu changes are **session-only** and never write the file. Edit it yourself and start a fresh player to apply changes.
Put **`--no-config` before the media arguments** to skip reading the file entirely and use built-in settings. Combine it with `--new-instance` in either order to guarantee a fresh session. Reusing a running player neither reloads its file nor resets its current settings, even with `--no-config`. Playback history remains separate in UserDefaults; legacy UserDefaults settings are no longer read or written. Put **`--no-config` before the media arguments** to skip reading the file entirely and use built-in settings. Combine it with `--new-instance` in either order to guarantee a fresh session. Reusing a running player neither reloads its file nor resets its current settings, even with `--no-config`. Playback history remains separate in UserDefaults; legacy UserDefaults settings are no longer read or written.
@@ -192,7 +193,9 @@ The inline URL field, including its placeholder, uses the same 12-point system f
With no media open, the full window uses native behind-window HUD glass with a 35%-opaque brown tint. Static, cached amber/rust radial gradients add ambient color only to the empty screen; they disappear during loading or errors, and are never drawn over video or playback chrome. Glass title/control/error surfaces use the same brown tint with half-point sand outlines. Decorative drawing does not intercept input. The idle Metal surface stays attached but hidden; opening media restores a solid black background and letterboxing without tinting the video, and closing it restores the ambient frosted background. macOS controls the material’s appearance, including Reduce Transparency. With no media open, the full window uses native behind-window HUD glass with a 35%-opaque brown tint. Static, cached amber/rust radial gradients add ambient color only to the empty screen; they disappear during loading or errors, and are never drawn over video or playback chrome. Glass title/control/error surfaces use the same brown tint with half-point sand outlines. Decorative drawing does not intercept input. The idle Metal surface stays attached but hidden; opening media restores a solid black background and letterboxing without tinting the video, and closing it restores the ambient frosted background. macOS controls the material’s appearance, including Reduce Transparency.
Normal and compact titlebars have no full-width glass strip or separator: content extends beneath AppKit’s transparent titlebar using `fullSizeContentView`, `titlebarAppearsTransparent`, and `titlebarSeparatorStyle = .none`. Normal-mode titles and their file icons have a small rounded HUD-glass backdrop for legibility; the pin stays over the existing background. Compact titles sit in a controls-style frosted box with 8-point top/left insets, matching the controller’s bottom inset. Long titles truncate in the middle and fit the 160-point minimum width. Native traffic lights and window dragging are preserved. Normal and compact titlebars have no full-width glass strip or separator: content extends beneath AppKit’s transparent titlebar using `fullSizeContentView`, `titlebarAppearsTransparent`, and `titlebarSeparatorStyle = .none`. Normal-mode titles and their file icons have a small rounded HUD-glass backdrop for legibility; the pin stays over the existing background. Compact titles sit in a controls-style frosted box with 8-point top/left insets, matching the controller’s bottom inset. The title truncates in the middle before the progress does, and the box fits the 160-point minimum width. Native traffic lights and window dragging are preserved.
Compact titles default to `<title> · <position> / <duration> <unit>`, using acknowledged playback position rather than slider previews. Total duration selects the format: below one minute, `12 / 45 s`; from one minute to below one hour, `05:12 / 42:08 min`; from one hour onward, `01:23 / 02:10 h`. Values truncate to the displayed precision. Set `[ui].compact_title_progress = "percentage"` to append only a whole percentage number (`<title> · 59`, without a percent sign), or `"none"` for title-only display. Percentages truncate and stay within 0–100. Loading, failed, live, unknown-duration and closed media have no progress suffix. Normal-mode titles remain unchanged.
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. 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.
+37 -4
View File
@@ -2,6 +2,21 @@ import AppKit
import SwiftUI import SwiftUI
import TerebiConfiguration import TerebiConfiguration
func compactProgressText(position: Double, duration: Double, format: CompactTitleProgress) -> String? {
guard format != .none, duration.isFinite, duration > 0, position.isFinite else { return nil }
let position = min(max(0, position), duration)
if format == .percentage { return String(Int((position / duration * 100).rounded(.down))) }
let total = Int(min(duration, Double(Int.max / 2)))
let current = Int(min(position, Double(Int.max / 2)))
if duration < 60 { return "\(current) / \(total) s" }
let divisor = duration < 3600 ? 1 : 60
let currentUnits = current / divisor
let totalUnits = total / divisor
let currentTime = String(format: "%02lld:%02lld", Int64(currentUnits / 60), Int64(currentUnits % 60))
let totalTime = String(format: "%02lld:%02lld", Int64(totalUnits / 60), Int64(totalUnits % 60))
return "\(currentTime) / \(totalTime) \(duration < 3600 ? "min" : "h")"
}
@MainActor @MainActor
final class PlayerModel: ObservableObject { final class PlayerModel: ObservableObject {
static var startupConfiguration = PlayerConfiguration() static var startupConfiguration = PlayerConfiguration()
@@ -30,6 +45,11 @@ final class PlayerModel: ObservableObject {
@Published private(set) var isOnTop = false @Published private(set) var isOnTop = false
let compactWindow: CompactWindowController let compactWindow: CompactWindowController
let keyBindings: KeyBindings let keyBindings: KeyBindings
let compactTitleProgress: CompactTitleProgress
var compactProgress: String? {
guard !isBusy, !isLiveStream, fileName != nil, state.status != PLAYER_ERROR else { return nil }
return compactProgressText(position: state.position, duration: state.duration, format: compactTitleProgress)
}
var playbackIntent: Bool? { transition.playbackIntent } var playbackIntent: Bool? { transition.playbackIntent }
@Published private(set) var fullScreenIntent: Bool? @Published private(set) var fullScreenIntent: Bool?
@Published private(set) var compactIntent: Bool? @Published private(set) var compactIntent: Bool?
@@ -85,6 +105,7 @@ final class PlayerModel: ObservableObject {
qualityClock: @escaping () -> Double = { ProcessInfo.processInfo.systemUptime }) { qualityClock: @escaping () -> Double = { ProcessInfo.processInfo.systemUptime }) {
self.qualityClock = qualityClock self.qualityClock = qualityClock
keyBindings = configuration.keys keyBindings = configuration.keys
compactTitleProgress = configuration.compactTitleProgress
automaticYouTubeQuality = configuration.automaticQuality automaticYouTubeQuality = configuration.automaticQuality
showsBufferInfo = configuration.showBufferInfo showsBufferInfo = configuration.showBufferInfo
compactWindow = CompactWindowController(snappingEnabled: configuration.snapToEdges) compactWindow = CompactWindowController(snappingEnabled: configuration.snapToEdges)
@@ -1639,11 +1660,23 @@ private struct PlayerContent: View {
} }
ZStack(alignment: .top) { ZStack(alignment: .top) {
Text(model.fileName ?? "terebi") let progress = model.isCompact || model.compactWindow.isTransitioning ? model.compactProgress : nil
HStack(spacing: 0) {
Text(model.fileName ?? "terebi")
.lineLimit(1)
.truncationMode(.middle)
.frame(minWidth: 0, maxWidth: progress == nil ? nil : .infinity, alignment: .leading)
.clipped()
if let progress {
Text(" · \(progress)")
.monospacedDigit()
.accessibilityLabel(model.compactTitleProgress == .percentage ? "\(progress) percent" : progress)
.fixedSize(horizontal: true, vertical: false)
}
}
.font(.system(size: 11, weight: .medium)) .font(.system(size: 11, weight: .medium))
.lineLimit(1) .accessibilityElement(children: .combine)
.truncationMode(.middle) .help((model.fileName ?? "terebi") + (progress.map { " · \($0)" } ?? ""))
.help(model.fileName ?? "terebi")
.padding(8) .padding(8)
.background(PlayerGlass(cornerRadius: 10).clipShape(RoundedRectangle(cornerRadius: 10)).allowsHitTesting(false)) .background(PlayerGlass(cornerRadius: 10).clipShape(RoundedRectangle(cornerRadius: 10)).allowsHitTesting(false))
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
@@ -1,17 +1,23 @@
import Foundation import Foundation
import TOMLDecoder import TOMLDecoder
public enum CompactTitleProgress: String, Decodable, Sendable {
case time, percentage, none
}
public struct PlayerConfiguration: Equatable, Sendable { public struct PlayerConfiguration: Equatable, Sendable {
public var showBufferInfo: Bool public var showBufferInfo: Bool
public var automaticQuality: Bool public var automaticQuality: Bool
public var snapToEdges: Bool public var snapToEdges: Bool
public var compactTitleProgress: CompactTitleProgress
public var keys: KeyBindings public var keys: KeyBindings
public init(showBufferInfo: Bool = false, automaticQuality: Bool = false, snapToEdges: Bool = true, public init(showBufferInfo: Bool = false, automaticQuality: Bool = false, snapToEdges: Bool = true,
keys: KeyBindings = KeyBindings()) { compactTitleProgress: CompactTitleProgress = .time, keys: KeyBindings = KeyBindings()) {
self.showBufferInfo = showBufferInfo self.showBufferInfo = showBufferInfo
self.automaticQuality = automaticQuality self.automaticQuality = automaticQuality
self.snapToEdges = snapToEdges self.snapToEdges = snapToEdges
self.compactTitleProgress = compactTitleProgress
self.keys = keys self.keys = keys
} }
@@ -25,6 +31,7 @@ public struct PlayerConfiguration: Equatable, Sendable {
"", "[ui]", "", "[ui]",
"buffer_info = \(defaults.showBufferInfo)", "buffer_info = \(defaults.showBufferInfo)",
"compact_snap_to_edges = \(defaults.snapToEdges)", "compact_snap_to_edges = \(defaults.snapToEdges)",
"compact_title_progress = \"\(defaults.compactTitleProgress.rawValue)\"",
"", "[keys]" "", "[keys]"
] ]
for (name, action) in PlayerAction.namedActions { for (name, action) in PlayerAction.namedActions {
@@ -77,6 +84,7 @@ public struct PlayerConfiguration: Equatable, Sendable {
return Self(showBufferInfo: values.ui?.bufferInfo ?? false, return Self(showBufferInfo: values.ui?.bufferInfo ?? false,
automaticQuality: values.playback?.automaticQuality ?? false, automaticQuality: values.playback?.automaticQuality ?? false,
snapToEdges: values.ui?.compactSnapToEdges ?? true, snapToEdges: values.ui?.compactSnapToEdges ?? true,
compactTitleProgress: values.ui?.compactTitleProgress ?? .time,
keys: try KeyBindings(overrides: values.keys?.overrides ?? [:])) keys: try KeyBindings(overrides: values.keys?.overrides ?? [:]))
} catch { } catch {
throw ConfigurationError(message: "\(url.path): \(diagnostic(for: error, data: data))") throw ConfigurationError(message: "\(url.path): \(diagnostic(for: error, data: data))")
@@ -163,11 +171,14 @@ public struct PlayerConfiguration: Equatable, Sendable {
private struct UIConfiguration { private struct UIConfiguration {
let bufferInfo: Bool? let bufferInfo: Bool?
let compactSnapToEdges: Bool? let compactSnapToEdges: Bool?
let compactTitleProgress: CompactTitleProgress?
init(container: KeyedDecodingContainer<SettingKey>) throws { init(container: KeyedDecodingContainer<SettingKey>) throws {
try validate(container, allowed: ["buffer_info", "compact_snap_to_edges"]) try validate(container, allowed: ["buffer_info", "compact_snap_to_edges", "compact_title_progress"])
bufferInfo = try container.decodeIfPresent(Bool.self, forKey: SettingKey("buffer_info")) bufferInfo = try container.decodeIfPresent(Bool.self, forKey: SettingKey("buffer_info"))
compactSnapToEdges = try container.decodeIfPresent(Bool.self, forKey: SettingKey("compact_snap_to_edges")) compactSnapToEdges = try container.decodeIfPresent(Bool.self, forKey: SettingKey("compact_snap_to_edges"))
compactTitleProgress = try container.decodeIfPresent(CompactTitleProgress.self,
forKey: SettingKey("compact_title_progress"))
} }
} }