feat: adopt a two-pane start screen

This commit is contained in:
2026-10-18 22:50:24 +02:00
parent 4b9436c04a
commit a6fa54fc42
5 changed files with 218 additions and 90 deletions
+13
View File
@@ -63,6 +63,19 @@ struct PlaybackProgressChecks {
let migratedAgain = PlaybackProgress(defaults: isolated) let migratedAgain = PlaybackProgress(defaults: isolated)
require(migratedAgain.position(for: file) == 43 && migratedAgain.position(for: youtube) == 15, require(migratedAgain.position(for: file) == 43 && migratedAgain.position(for: youtube) == 15,
"opening another player preserves both migrated bookmarks and newer progress") "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") print("Playback progress checks passed")
} }
} }
+2
View File
@@ -35,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. 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 two-pane layout: 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. 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 in small windows, and drag-and-drop remains available.
| Action | Default shortcut | | Action | Default shortcut |
| --- | --- | | --- | --- |
| Play / pause | Space or K | | Play / pause | Space or K |
+22
View File
@@ -1,6 +1,12 @@
import Foundation import Foundation
struct PlaybackProgress { struct PlaybackProgress {
struct RecentMedia: Equatable, Identifiable {
let url: URL
let title: String
var id: URL { url }
}
private let defaults: UserDefaults private let defaults: UserDefaults
init(defaults: UserDefaults = .standard) { init(defaults: UserDefaults = .standard) {
@@ -28,6 +34,22 @@ struct PlaybackProgress {
defaults.set(position, forKey: key) 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 { private func key(for url: URL) -> String {
// YouTube callers supply the canonical webpage URL, never an expiring media URL. // YouTube callers supply the canonical webpage URL, never an expiring media URL.
return "playbackPosition:" + (url.isFileURL ? url.standardizedFileURL.resolvingSymlinksInPath().absoluteString : url.absoluteString) return "playbackPosition:" + (url.isFileURL ? url.standardizedFileURL.resolvingSymlinksInPath().absoluteString : url.absoluteString)
+14
View File
@@ -64,6 +64,20 @@ extension KeyChord {
if modifiers.contains(.shift) { flags.insert(.shift) } if modifiers.contains(.shift) { flags.insert(.shift) }
return KeyboardShortcut(equivalent, modifiers: flags) 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 { extension PlayerModel {
+167 -90
View File
@@ -32,6 +32,7 @@ final class PlayerModel: ObservableObject {
@Published private(set) var fileIcon: NSImage? @Published private(set) var fileIcon: NSImage?
@Published private(set) var commandError: String? @Published private(set) var commandError: String?
@Published private(set) var queue = PlaybackQueue() @Published private(set) var queue = PlaybackQueue()
@Published private(set) var recentMedia: [PlaybackProgress.RecentMedia] = []
@Published var showsQueue = false { @Published var showsQueue = false {
didSet { didSet {
if showsQueue != oldValue { transitionQueue() } if showsQueue != oldValue { transitionQueue() }
@@ -53,6 +54,7 @@ final class PlayerModel: ObservableObject {
var castDevice: CastDevice? { casting.device } var castDevice: CastDevice? { casting.device }
var castConnecting: Bool { casting.connecting } var castConnecting: Bool { casting.connecting }
private var currentMedia: MediaSource? private var currentMedia: MediaSource?
private var shouldRecordRecentMedia = false
var pendingRevision: UInt64? { transition.pendingRevision } var pendingRevision: UInt64? { transition.pendingRevision }
var resolvingYouTube: Bool { transition.isResolving } var resolvingYouTube: Bool { transition.isResolving }
var isLiveStream: Bool { transition.isLive } var isLiveStream: Bool { transition.isLive }
@@ -141,6 +143,7 @@ final class PlayerModel: ObservableObject {
showsBufferInfo = configuration.showBufferInfo showsBufferInfo = configuration.showBufferInfo
compactWindow = CompactWindowController(snappingEnabled: configuration.snapToEdges, defaults: defaults) compactWindow = CompactWindowController(snappingEnabled: configuration.snapToEdges, defaults: defaults)
playbackProgress = PlaybackProgress(defaults: defaults) playbackProgress = PlaybackProgress(defaults: defaults)
recentMedia = playbackProgress.recentMedia
let player = player_create() let player = player_create()
handle = player handle = player
if let player, let view = player_macos_make_video_view(player) { if let player, let view = player_macos_make_video_view(player) {
@@ -514,6 +517,7 @@ final class PlayerModel: ObservableObject {
queuePlaybackRevision = queue.currentID == nil ? nil : player_get_state(handle).revision queuePlaybackRevision = queue.currentID == nil ? nil : player_get_state(handle).revision
queueCompletionHandled = false queueCompletionHandled = false
currentMedia = source currentMedia = source
shouldRecordRecentMedia = !change.isQuality
if change.automatic { automaticQuality.resetHealth() } else { automaticQuality = AutomaticQuality() } if change.automatic { automaticQuality.resetHealth() } else { automaticQuality = AutomaticQuality() }
switch source { switch source {
case .file(let url): case .file(let url):
@@ -690,6 +694,7 @@ final class PlayerModel: ObservableObject {
queueCompletionHandled = true queueCompletionHandled = true
queueCastDevice = nil queueCastDevice = nil
currentMedia = nil currentMedia = nil
shouldRecordRecentMedia = false
selectSubtitle(nil) selectSubtitle(nil)
subtitleTracks = [] subtitleTracks = []
compactIntent = nil compactIntent = nil
@@ -1064,6 +1069,8 @@ final class PlayerModel: ObservableObject {
window.setFrame(frame, display: true) window.setFrame(frame, display: true)
} }
func savedPosition(for url: URL) -> Double? { playbackProgress.position(for: url) }
private func saveProgress(_ snapshot: PlayerState? = nil, force: Bool = true) { private func saveProgress(_ snapshot: PlayerState? = nil, force: Bool = true) {
guard let handle else { return } guard let handle else { return }
if let checkpoint = casting.checkpoint { if let checkpoint = casting.checkpoint {
@@ -1100,6 +1107,18 @@ final class PlayerModel: ObservableObject {
defer { systemPlayback?.update() } defer { systemPlayback?.update() }
state = next state = next
updateSubtitle() updateSubtitle()
if shouldRecordRecentMedia, !isBusy, next.status == PLAYER_PLAYING || next.status == PLAYER_PAUSED,
let source = currentMedia, let title = fileName {
// Resolved sources need a fresh external handoff, not their expiring stream URL.
switch source {
case .file, .youtube:
playbackProgress.recordRecent(source.url, title: title)
recentMedia = playbackProgress.recentMedia
case .resolved:
break
}
shouldRecordRecentMedia = false
}
if let playing = observation.playback { setPlayback(playing: playing) } if let playing = observation.playback { setPlayback(playing: playing) }
if !isBusy && (next.status == PLAYER_EMPTY || next.status == PLAYER_ERROR || !next.has_video) { if !isBusy && (next.status == PLAYER_EMPTY || next.status == PLAYER_ERROR || !next.has_video) {
compactWindow.restore(resumeFullScreen: false, animated: false) compactWindow.restore(resumeFullScreen: false, animated: false)
@@ -2028,6 +2047,23 @@ struct PlaybackSurface: NSViewRepresentable {
} }
} }
private struct StartScreenButtonStyle: ButtonStyle {
var prominent = false
@State private var hovered = false
func makeBody(configuration: Configuration) -> some View {
configuration.label
.padding(.horizontal, 14)
.frame(minHeight: 40)
.contentShape(RoundedRectangle(cornerRadius: 7))
.background {
RoundedRectangle(cornerRadius: 7)
.fill(Color.white.opacity(configuration.isPressed ? 0.16 : (hovered ? 0.09 : (prominent ? 0.045 : 0))))
}
.onHover { hovered = $0 }
}
}
@MainActor @MainActor
private struct PlayerContent: View { private struct PlayerContent: View {
@ObservedObject var model: PlayerModel @ObservedObject var model: PlayerModel
@@ -2077,6 +2113,129 @@ private struct PlayerContent: View {
if showsYouTubeInput { youTubeInputFocused = true } if showsYouTubeInput { youTubeInputFocused = true }
} }
private func startScreen(in size: CGSize) -> some View {
let sidebarWidth = min(260, max(112, size.width * 0.28))
let openKeys = model.keyBindings.shortcuts(for: .openFile)
let urlKeys = model.keyBindings.shortcuts(for: .openYoutube)
let latest = model.recentMedia.first
let resumePosition = latest.flatMap { model.savedPosition(for: $0.url) }
return HStack(spacing: 0) {
VStack(spacing: 0) {
Image(systemName: "play.rectangle.fill")
.font(.system(size: sidebarWidth < 160 ? 48 : 72, weight: .light))
.foregroundStyle(Color(SunsetPalette.accent))
.accessibilityHidden(true)
.padding(.top, max(48, size.height * 0.16))
VStack(spacing: 6) {
Text("Terebi").font(.system(size: 23, weight: .semibold))
if let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String {
Text(version).font(.system(size: 17)).foregroundStyle(.secondary)
}
}
.padding(.top, min(80, size.height * 0.14))
Spacer(minLength: 24)
}
.frame(width: sidebarWidth)
.frame(maxHeight: .infinity)
.background(Color.black.opacity(0.28))
.overlay(alignment: .trailing) {
Rectangle().fill(Color(SunsetPalette.outline)).frame(width: 1)
}
.allowsHitTesting(false)
ScrollView {
VStack(alignment: .leading, spacing: 4) {
Button(action: model.openPanel) {
HStack {
Text("Open…")
Spacer(minLength: 8)
Text((openKeys.first { $0.modifiers.contains(.command) } ?? openKeys.first)?.displayText ?? "")
.foregroundStyle(.secondary)
}
}
.buttonStyle(StartScreenButtonStyle(prominent: true))
.accessibilityLabel("Open File…")
Button(action: toggleYouTubeInput) {
HStack {
Text("Open URL…")
Spacer(minLength: 8)
Text((urlKeys.first { $0.modifiers.contains(.command) } ?? urlKeys.first)?.displayText ?? "")
.foregroundStyle(.secondary)
}
}
.buttonStyle(StartScreenButtonStyle())
.accessibilityLabel("Open YouTube URL…")
if showsYouTubeInput { startURLInput.padding(.horizontal, 14) }
if let latest, let resumePosition {
Button { model.open(latest.url) } label: {
HStack(spacing: 8) {
Image(systemName: "clock.arrow.circlepath").foregroundStyle(.secondary)
Text("Resume \(latest.title)").lineLimit(1).truncationMode(.tail)
Spacer(minLength: 0)
Text(time(resumePosition)).monospacedDigit().foregroundStyle(.secondary)
}
}
.buttonStyle(StartScreenButtonStyle(prominent: true))
.help("Resume \(latest.title) at \(time(resumePosition))")
.padding(.vertical, 8)
}
ForEach(model.recentMedia.filter { resumePosition == nil || $0.id != latest?.id }) { item in
Button { model.open(item.url) } label: {
HStack(spacing: 10) {
Image(systemName: item.url.isFileURL ? "doc.richtext" : "link")
.foregroundStyle(Color(SunsetPalette.accent))
.frame(width: 18)
Text(item.title).lineLimit(1).truncationMode(.tail)
Spacer(minLength: 0)
}
}
.buttonStyle(StartScreenButtonStyle())
.help(item.url.isFileURL ? item.url.path : item.url.absoluteString)
}
if model.recentMedia.isEmpty {
Text("Drop a media file or YouTube link here")
.font(.system(size: 12))
.foregroundStyle(.secondary)
.padding(.horizontal, 14)
.padding(.top, 20)
}
}
.font(.system(size: 17, weight: .medium))
.disabled(!model.canOpen)
.padding(.horizontal, size.width < 600 ? 14 : 30)
.padding(.top, 44)
.padding(.bottom, 24)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.foregroundStyle(Color(SunsetPalette.text))
}
private var startURLInput: some View {
HStack(spacing: 8) {
TextField("YouTube video URL", text: $youTubeURL,
prompt: Text("YouTube video URL")
.foregroundStyle(Color(SunsetPalette.secondaryText).opacity(0.6)))
.textFieldStyle(.plain)
.focused($youTubeInputFocused)
.accessibilityLabel("YouTube video URL")
.onAppear { youTubeInputFocused = true }
.onSubmit { model.openYouTube(youTubeURL) }
Button { model.openYouTube(youTubeURL) } label: {
Image(systemName: "arrow.right").frame(width: 24, height: 24).contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityLabel("Open YouTube Video")
.help("Open YouTube Video")
.disabled(youTubeURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
.font(.system(size: 12))
.padding(.horizontal, 12)
.padding(.vertical, 4)
.background(Color(SunsetPalette.actionFill), in: RoundedRectangle(cornerRadius: 8))
.overlay(RoundedRectangle(cornerRadius: 8)
.strokeBorder(Color(SunsetPalette.outline), lineWidth: 0.5).allowsHitTesting(false))
}
private var showsBufferReadout: Bool { model.isNetworkMedia && model.showsBufferInfo } private var showsBufferReadout: Bool { model.isNetworkMedia && model.showsBufferInfo }
private var controlHeight: CGFloat { showsBufferReadout ? 88 : 68 } private var controlHeight: CGFloat { showsBufferReadout ? 88 : 68 }
private var compactControlHeight: CGFloat { showsBufferReadout ? 52 : 40 } private var compactControlHeight: CGFloat { showsBufferReadout ? 52 : 40 }
@@ -2672,91 +2831,7 @@ private struct PlayerContent: View {
} }
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
} else if model.fileName == nil { } else if model.fileName == nil {
VStack(spacing: 18) { startScreen(in: geometry.size)
HStack(spacing: 12) {
Image(systemName: "play.rectangle.fill")
.font(.system(size: 28))
.frame(width: 40, height: 40)
.foregroundStyle(Color(SunsetPalette.accent))
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 4) {
Text("Terebi").font(.system(size: 20, weight: .semibold))
.foregroundStyle(Color(SunsetPalette.text))
Text("Open media to get started")
.font(.callout)
.foregroundStyle(Color(SunsetPalette.secondaryText))
}
Spacer(minLength: 0)
}
HStack(spacing: 10) {
Button(action: model.openPanel) {
Label("Open File…", systemImage: "doc")
.frame(maxWidth: .infinity, minHeight: 40)
.contentShape(Rectangle())
}
.background(Color(SunsetPalette.actionFill), in: RoundedRectangle(cornerRadius: 8))
.overlay(RoundedRectangle(cornerRadius: 8)
.strokeBorder(Color(SunsetPalette.outline), lineWidth: 0.5)
.allowsHitTesting(false))
Button(action: toggleYouTubeInput) {
Label("YouTube URL…", systemImage: "link")
.frame(maxWidth: .infinity, minHeight: 40)
.contentShape(Rectangle())
}
.accessibilityLabel("Open YouTube URL…")
.background(Color(SunsetPalette.actionFill), in: RoundedRectangle(cornerRadius: 8))
.overlay(RoundedRectangle(cornerRadius: 8)
.strokeBorder(Color(SunsetPalette.outline), lineWidth: 0.5)
.allowsHitTesting(false))
}
.font(.system(size: 13, weight: .medium))
.foregroundStyle(Color(SunsetPalette.text))
.imageScale(.large)
.buttonStyle(.plain)
.disabled(!model.canOpen)
if showsYouTubeInput {
HStack(spacing: 8) {
TextField("YouTube video URL", text: $youTubeURL,
prompt: Text("YouTube video URL")
.foregroundStyle(Color(SunsetPalette.secondaryText).opacity(0.6)))
.textFieldStyle(.plain)
.focused($youTubeInputFocused)
.accessibilityLabel("YouTube video URL")
.onAppear { youTubeInputFocused = true }
.onSubmit { model.openYouTube(youTubeURL) }
Button {
model.openYouTube(youTubeURL)
} label: {
Image(systemName: "arrow.right")
.frame(width: 24, height: 24)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityLabel("Open YouTube Video")
.help("Open YouTube Video")
.disabled(youTubeURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
.font(.system(size: 12))
.foregroundStyle(Color(SunsetPalette.text))
.padding(.horizontal, 12)
.padding(.vertical, 4)
.background(Color(SunsetPalette.actionFill), in: RoundedRectangle(cornerRadius: 8))
.overlay(RoundedRectangle(cornerRadius: 8)
.strokeBorder(Color(SunsetPalette.outline), lineWidth: 0.5)
.allowsHitTesting(false))
.transition(.opacity.animation(reduceMotion ? nil : .easeInOut(duration: 0.22)))
.disabled(!model.canOpen)
}
Text("Drop a media file or YouTube link here")
.font(.system(size: 12))
.foregroundStyle(Color(SunsetPalette.secondaryText))
.multilineTextAlignment(.center)
}
.padding(20)
.frame(width: min(440, geometry.size.width - 48))
.background(PlayerGlass().clipShape(RoundedRectangle(cornerRadius: 12)).allowsHitTesting(false))
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(.top, 32)
} else if let device = model.castDevice { } else if let device = model.castDevice {
VStack(spacing: 16) { VStack(spacing: 16) {
Image(systemName: "airplayvideo").font(.system(size: 48)) Image(systemName: "airplayvideo").font(.system(size: 48))
@@ -2816,11 +2891,13 @@ private struct PlayerContent: View {
.allowsHitTesting(model.isCompact) .allowsHitTesting(model.isCompact)
.accessibilityHidden(!model.isCompact) .accessibilityHidden(!model.isCompact)
titlebar if model.fileName != nil {
.frame(width: geometry.size.width) titlebar
.opacity(model.isCompact || model.compactWindow.isTransitioning ? 0 : 1) .frame(width: geometry.size.width)
.allowsHitTesting(!model.isCompact && !model.compactWindow.isTransitioning && model.controlsVisible) .opacity(model.isCompact || model.compactWindow.isTransitioning ? 0 : 1)
.accessibilityHidden(model.isCompact || model.compactWindow.isTransitioning || !model.controlsVisible) .allowsHitTesting(!model.isCompact && !model.compactWindow.isTransitioning && model.controlsVisible)
.accessibilityHidden(model.isCompact || model.compactWindow.isTransitioning || !model.controlsVisible)
}
if model.canTogglePlayback { if model.canTogglePlayback {
playbackControls playbackControls
.frame(width: 440, height: controlHeight) .frame(width: 440, height: controlHeight)