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")
|
"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"]),
|
||||||
|
|||||||
@@ -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)"
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user