feat: adopt a two-pane start screen
This commit is contained in:
@@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
+162
-85
@@ -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)
|
||||||
|
|
||||||
|
if model.fileName != nil {
|
||||||
titlebar
|
titlebar
|
||||||
.frame(width: geometry.size.width)
|
.frame(width: geometry.size.width)
|
||||||
.opacity(model.isCompact || model.compactWindow.isTransitioning ? 0 : 1)
|
.opacity(model.isCompact || model.compactWindow.isTransitioning ? 0 : 1)
|
||||||
.allowsHitTesting(!model.isCompact && !model.compactWindow.isTransitioning && model.controlsVisible)
|
.allowsHitTesting(!model.isCompact && !model.compactWindow.isTransitioning && model.controlsVisible)
|
||||||
.accessibilityHidden(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)
|
||||||
|
|||||||
Reference in New Issue
Block a user