diff --git a/Checks/playback-progress.swift b/Checks/playback-progress.swift index 3bebb03..7ce5413 100644 --- a/Checks/playback-progress.swift +++ b/Checks/playback-progress.swift @@ -63,6 +63,19 @@ struct PlaybackProgressChecks { let migratedAgain = PlaybackProgress(defaults: isolated) require(migratedAgain.position(for: file) == 43 && migratedAgain.position(for: youtube) == 15, "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") } } diff --git a/README.md b/README.md index f921a5f..f287f5f 100644 --- a/README.md +++ b/README.md @@ -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. +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 | | --- | --- | | Play / pause | Space or K | diff --git a/Sources/App/PlaybackProgress.swift b/Sources/App/PlaybackProgress.swift index d623e41..8c0d6e1 100644 --- a/Sources/App/PlaybackProgress.swift +++ b/Sources/App/PlaybackProgress.swift @@ -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) diff --git a/Sources/App/PlayerActions.swift b/Sources/App/PlayerActions.swift index fdcb6d9..1e1aee9 100644 --- a/Sources/App/PlayerActions.swift +++ b/Sources/App/PlayerActions.swift @@ -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 { diff --git a/Sources/App/TerebiApp.swift b/Sources/App/TerebiApp.swift index 0e50f7a..223bc87 100644 --- a/Sources/App/TerebiApp.swift +++ b/Sources/App/TerebiApp.swift @@ -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)