diff --git a/Checks/configuration.swift b/Checks/configuration.swift index 392931a..c330f70 100644 --- a/Checks/configuration.swift +++ b/Checks/configuration.swift @@ -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"]), diff --git a/Checks/system-playback.swift b/Checks/system-playback.swift index ea5df66..f001ddd 100644 --- a/Checks/system-playback.swift +++ b/Checks/system-playback.swift @@ -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)" diff --git a/README.md b/README.md index 572110d..a26f029 100644 --- a/README.md +++ b/README.md @@ -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_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 ` · <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. diff --git a/Sources/App/TerebiApp.swift b/Sources/App/TerebiApp.swift index 0501adb..519295e 100644 --- a/Sources/App/TerebiApp.swift +++ b/Sources/App/TerebiApp.swift @@ -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) { - 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)) - .lineLimit(1) - .truncationMode(.middle) - .help(model.fileName ?? "terebi") + .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) diff --git a/Sources/Configuration/PlayerConfiguration.swift b/Sources/Configuration/PlayerConfiguration.swift index 9718485..c7476fd 100644 --- a/Sources/Configuration/PlayerConfiguration.swift +++ b/Sources/Configuration/PlayerConfiguration.swift @@ -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")) } }