feat: configure duration-aware progress in compact titles
This commit is contained in:
@@ -116,6 +116,12 @@ struct ConfigurationChecks {
|
||||
"printed TOML is directly usable and preserves all default behavior and bindings")
|
||||
try require(!defaults.showBufferInfo && !defaults.automaticQuality && defaults.snapToEdges,
|
||||
"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,
|
||||
"absolute XDG_CONFIG_HOME selects terebi/config.toml")
|
||||
for environment in [[:], ["XDG_CONFIG_HOME": ""], ["XDG_CONFIG_HOME": "relative/config"]] {
|
||||
@@ -219,6 +225,8 @@ struct ConfigurationChecks {
|
||||
("string", "[ui]\nbuffer_info = \"true\"\n", []),
|
||||
("integer", "[playback]\nautomatic_quality = 1\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"]),
|
||||
("ui-scalar", "ui = true\n", ["ui"]),
|
||||
("keys-scalar", "keys = true\n", ["keys"]),
|
||||
|
||||
@@ -17,7 +17,40 @@ struct SystemPlaybackChecks {
|
||||
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() {
|
||||
checkCompactProgress()
|
||||
_ = NSApplication.shared
|
||||
NSApp.setActivationPolicy(.prohibited)
|
||||
let suite = "terebi.system-playback-check.\(UUID().uuidString)"
|
||||
|
||||
@@ -79,6 +79,7 @@ automatic_quality = false
|
||||
[ui]
|
||||
buffer_info = false
|
||||
compact_snap_to_edges = true
|
||||
compact_title_progress = "time"
|
||||
|
||||
[keys]
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -2,6 +2,21 @@ import AppKit
|
||||
import SwiftUI
|
||||
import TerebiConfiguration
|
||||
|
||||
func compactProgressText(position: Double, duration: Double, format: CompactTitleProgress) -> String? {
|
||||
guard format != .none, duration.isFinite, duration > 0, position.isFinite else { return nil }
|
||||
let position = min(max(0, position), duration)
|
||||
if format == .percentage { 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
|
||||
final class PlayerModel: ObservableObject {
|
||||
static var startupConfiguration = PlayerConfiguration()
|
||||
@@ -30,6 +45,11 @@ final class PlayerModel: ObservableObject {
|
||||
@Published private(set) var isOnTop = false
|
||||
let compactWindow: CompactWindowController
|
||||
let keyBindings: KeyBindings
|
||||
let compactTitleProgress: CompactTitleProgress
|
||||
var compactProgress: String? {
|
||||
guard !isBusy, !isLiveStream, fileName != nil, state.status != PLAYER_ERROR else { return nil }
|
||||
return compactProgressText(position: state.position, duration: state.duration, format: compactTitleProgress)
|
||||
}
|
||||
var playbackIntent: Bool? { transition.playbackIntent }
|
||||
@Published private(set) var fullScreenIntent: Bool?
|
||||
@Published private(set) var compactIntent: Bool?
|
||||
@@ -85,6 +105,7 @@ final class PlayerModel: ObservableObject {
|
||||
qualityClock: @escaping () -> Double = { ProcessInfo.processInfo.systemUptime }) {
|
||||
self.qualityClock = qualityClock
|
||||
keyBindings = configuration.keys
|
||||
compactTitleProgress = configuration.compactTitleProgress
|
||||
automaticYouTubeQuality = configuration.automaticQuality
|
||||
showsBufferInfo = configuration.showBufferInfo
|
||||
compactWindow = CompactWindowController(snappingEnabled: configuration.snapToEdges)
|
||||
@@ -1639,11 +1660,23 @@ private struct PlayerContent: View {
|
||||
}
|
||||
|
||||
ZStack(alignment: .top) {
|
||||
let progress = model.isCompact || model.compactWindow.isTransitioning ? model.compactProgress : nil
|
||||
HStack(spacing: 0) {
|
||||
Text(model.fileName ?? "terebi")
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
.help(model.fileName ?? "terebi")
|
||||
.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))
|
||||
.accessibilityElement(children: .combine)
|
||||
.help((model.fileName ?? "terebi") + (progress.map { " · \($0)" } ?? ""))
|
||||
.padding(8)
|
||||
.background(PlayerGlass(cornerRadius: 10).clipShape(RoundedRectangle(cornerRadius: 10)).allowsHitTesting(false))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
import Foundation
|
||||
import TOMLDecoder
|
||||
|
||||
public enum CompactTitleProgress: String, Decodable, Sendable {
|
||||
case time, percentage, none
|
||||
}
|
||||
|
||||
public struct PlayerConfiguration: Equatable, Sendable {
|
||||
public var showBufferInfo: Bool
|
||||
public var automaticQuality: Bool
|
||||
public var snapToEdges: Bool
|
||||
public var compactTitleProgress: CompactTitleProgress
|
||||
public var keys: KeyBindings
|
||||
|
||||
public init(showBufferInfo: Bool = false, automaticQuality: Bool = false, snapToEdges: Bool = true,
|
||||
keys: KeyBindings = KeyBindings()) {
|
||||
compactTitleProgress: CompactTitleProgress = .time, keys: KeyBindings = KeyBindings()) {
|
||||
self.showBufferInfo = showBufferInfo
|
||||
self.automaticQuality = automaticQuality
|
||||
self.snapToEdges = snapToEdges
|
||||
self.compactTitleProgress = compactTitleProgress
|
||||
self.keys = keys
|
||||
}
|
||||
|
||||
@@ -25,6 +31,7 @@ public struct PlayerConfiguration: Equatable, Sendable {
|
||||
"", "[ui]",
|
||||
"buffer_info = \(defaults.showBufferInfo)",
|
||||
"compact_snap_to_edges = \(defaults.snapToEdges)",
|
||||
"compact_title_progress = \"\(defaults.compactTitleProgress.rawValue)\"",
|
||||
"", "[keys]"
|
||||
]
|
||||
for (name, action) in PlayerAction.namedActions {
|
||||
@@ -77,6 +84,7 @@ public struct PlayerConfiguration: Equatable, Sendable {
|
||||
return Self(showBufferInfo: values.ui?.bufferInfo ?? false,
|
||||
automaticQuality: values.playback?.automaticQuality ?? false,
|
||||
snapToEdges: values.ui?.compactSnapToEdges ?? true,
|
||||
compactTitleProgress: values.ui?.compactTitleProgress ?? .time,
|
||||
keys: try KeyBindings(overrides: values.keys?.overrides ?? [:]))
|
||||
} catch {
|
||||
throw ConfigurationError(message: "\(url.path): \(diagnostic(for: error, data: data))")
|
||||
@@ -163,11 +171,14 @@ public struct PlayerConfiguration: Equatable, Sendable {
|
||||
private struct UIConfiguration {
|
||||
let bufferInfo: Bool?
|
||||
let compactSnapToEdges: Bool?
|
||||
let compactTitleProgress: CompactTitleProgress?
|
||||
|
||||
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"))
|
||||
compactSnapToEdges = try container.decodeIfPresent(Bool.self, forKey: SettingKey("compact_snap_to_edges"))
|
||||
compactTitleProgress = try container.decodeIfPresent(CompactTitleProgress.self,
|
||||
forKey: SettingKey("compact_title_progress"))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user