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
+22
View File
@@ -1,6 +1,12 @@
import Foundation
struct PlaybackProgress {
struct RecentMedia: Equatable, Identifiable {
let url: URL
let title: String
var id: URL { url }
}
private let defaults: UserDefaults
init(defaults: UserDefaults = .standard) {
@@ -28,6 +34,22 @@ struct PlaybackProgress {
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 {
// YouTube callers supply the canonical webpage URL, never an expiring media URL.
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) }
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 {
+167 -90
View File
@@ -32,6 +32,7 @@ final class PlayerModel: ObservableObject {
@Published private(set) var fileIcon: NSImage?
@Published private(set) var commandError: String?
@Published private(set) var queue = PlaybackQueue()
@Published private(set) var recentMedia: [PlaybackProgress.RecentMedia] = []
@Published var showsQueue = false {
didSet {
if showsQueue != oldValue { transitionQueue() }
@@ -53,6 +54,7 @@ final class PlayerModel: ObservableObject {
var castDevice: CastDevice? { casting.device }
var castConnecting: Bool { casting.connecting }
private var currentMedia: MediaSource?
private var shouldRecordRecentMedia = false
var pendingRevision: UInt64? { transition.pendingRevision }
var resolvingYouTube: Bool { transition.isResolving }
var isLiveStream: Bool { transition.isLive }
@@ -141,6 +143,7 @@ final class PlayerModel: ObservableObject {
showsBufferInfo = configuration.showBufferInfo
compactWindow = CompactWindowController(snappingEnabled: configuration.snapToEdges, defaults: defaults)
playbackProgress = PlaybackProgress(defaults: defaults)
recentMedia = playbackProgress.recentMedia
let player = player_create()
handle = 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
queueCompletionHandled = false
currentMedia = source
shouldRecordRecentMedia = !change.isQuality
if change.automatic { automaticQuality.resetHealth() } else { automaticQuality = AutomaticQuality() }
switch source {
case .file(let url):
@@ -690,6 +694,7 @@ final class PlayerModel: ObservableObject {
queueCompletionHandled = true
queueCastDevice = nil
currentMedia = nil
shouldRecordRecentMedia = false
selectSubtitle(nil)
subtitleTracks = []
compactIntent = nil
@@ -1064,6 +1069,8 @@ final class PlayerModel: ObservableObject {
window.setFrame(frame, display: true)
}
func savedPosition(for url: URL) -> Double? { playbackProgress.position(for: url) }
private func saveProgress(_ snapshot: PlayerState? = nil, force: Bool = true) {
guard let handle else { return }
if let checkpoint = casting.checkpoint {
@@ -1100,6 +1107,18 @@ final class PlayerModel: ObservableObject {
defer { systemPlayback?.update() }
state = next
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 !isBusy && (next.status == PLAYER_EMPTY || next.status == PLAYER_ERROR || !next.has_video) {
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
private struct PlayerContent: View {
@ObservedObject var model: PlayerModel
@@ -2077,6 +2113,129 @@ private struct PlayerContent: View {
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 controlHeight: CGFloat { showsBufferReadout ? 88 : 68 }
private var compactControlHeight: CGFloat { showsBufferReadout ? 52 : 40 }
@@ -2672,91 +2831,7 @@ private struct PlayerContent: View {
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if model.fileName == nil {
VStack(spacing: 18) {
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)
startScreen(in: geometry.size)
} else if let device = model.castDevice {
VStack(spacing: 16) {
Image(systemName: "airplayvideo").font(.system(size: 48))
@@ -2816,11 +2891,13 @@ private struct PlayerContent: View {
.allowsHitTesting(model.isCompact)
.accessibilityHidden(!model.isCompact)
titlebar
.frame(width: geometry.size.width)
.opacity(model.isCompact || model.compactWindow.isTransitioning ? 0 : 1)
.allowsHitTesting(!model.isCompact && !model.compactWindow.isTransitioning && model.controlsVisible)
.accessibilityHidden(model.isCompact || model.compactWindow.isTransitioning || !model.controlsVisible)
if model.fileName != nil {
titlebar
.frame(width: geometry.size.width)
.opacity(model.isCompact || model.compactWindow.isTransitioning ? 0 : 1)
.allowsHitTesting(!model.isCompact && !model.compactWindow.isTransitioning && model.controlsVisible)
.accessibilityHidden(model.isCompact || model.compactWindow.isTransitioning || !model.controlsVisible)
}
if model.canTogglePlayback {
playbackControls
.frame(width: 440, height: controlHeight)