mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 21:22:53 +00:00
Add a toolbar layout for the video info actions with a configurable primary action
Layout & Navigation gains a Video Info section on iOS/macOS: the actions (Play, Download, Share, Add to Playlist, Bookmark) can stay as inline button rows or move to the navigation bar, where the chosen primary action is pinned and the rest live in an ellipsis menu. The toolbar layout with Download pinned is the new default, since the hero thumbnail already plays the video on tap; a one-time TipKit popover on the thumbnail points that out. tvOS keeps the existing button layout.
This commit is contained in:
@@ -661,6 +661,39 @@ extension SettingsManager {
|
||||
localDefaults.set(newValue.rawValue, forKey: "textAreaTapAction")
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the video info view shows its actions. Default is the toolbar.
|
||||
var videoInfoActionsLayout: VideoInfoActionsLayout {
|
||||
get {
|
||||
if let cached = _videoInfoActionsLayout { return cached }
|
||||
guard let rawValue = localDefaults.string(forKey: "videoInfoActionsLayout"),
|
||||
let layout = VideoInfoActionsLayout(rawValue: rawValue) else {
|
||||
return .toolbar
|
||||
}
|
||||
return layout
|
||||
}
|
||||
set {
|
||||
_videoInfoActionsLayout = newValue
|
||||
localDefaults.set(newValue.rawValue, forKey: "videoInfoActionsLayout")
|
||||
}
|
||||
}
|
||||
|
||||
/// Action pinned to the video info toolbar (toolbar layout only). Default is download
|
||||
/// because the hero thumbnail already plays on tap.
|
||||
var videoInfoPrimaryAction: VideoInfoPrimaryAction {
|
||||
get {
|
||||
if let cached = _videoInfoPrimaryAction { return cached }
|
||||
guard let rawValue = localDefaults.string(forKey: "videoInfoPrimaryAction"),
|
||||
let action = VideoInfoPrimaryAction(rawValue: rawValue) else {
|
||||
return .download
|
||||
}
|
||||
return action
|
||||
}
|
||||
set {
|
||||
_videoInfoPrimaryAction = newValue
|
||||
localDefaults.set(newValue.rawValue, forKey: "videoInfoPrimaryAction")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: - Video Tap Action (tvOS only)
|
||||
|
||||
@@ -426,6 +426,49 @@ enum VideoTapAction: String, CaseIterable, Codable {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Video Info Actions
|
||||
|
||||
/// Where the video info view shows its actions (Play, Download, Share, Add to Playlist, Bookmark).
|
||||
enum VideoInfoActionsLayout: String, CaseIterable, Codable {
|
||||
/// Inline button rows below the header.
|
||||
case buttons
|
||||
/// Primary action in the toolbar; the rest in an ellipsis menu (default).
|
||||
case toolbar
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .buttons:
|
||||
return String(localized: "settings.videoInfo.actionsLayout.buttons")
|
||||
case .toolbar:
|
||||
return String(localized: "settings.videoInfo.actionsLayout.toolbar")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The action pinned to the video info toolbar when `VideoInfoActionsLayout.toolbar` is active.
|
||||
enum VideoInfoPrimaryAction: String, CaseIterable, Codable {
|
||||
case play
|
||||
case download
|
||||
case share
|
||||
case addToPlaylist
|
||||
case bookmark
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .play:
|
||||
return String(localized: "video.context.play")
|
||||
case .download:
|
||||
return String(localized: "video.download")
|
||||
case .share:
|
||||
return String(localized: "video.share")
|
||||
case .addToPlaylist:
|
||||
return String(localized: "video.context.addToPlaylist")
|
||||
case .bookmark:
|
||||
return String(localized: "video.bookmark")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Resume Action
|
||||
|
||||
/// Action to perform when starting a partially watched video.
|
||||
|
||||
@@ -217,6 +217,8 @@ final class SettingsManager {
|
||||
#if !os(tvOS)
|
||||
var _thumbnailTapAction: VideoTapAction?
|
||||
var _textAreaTapAction: VideoTapAction?
|
||||
var _videoInfoActionsLayout: VideoInfoActionsLayout?
|
||||
var _videoInfoPrimaryAction: VideoInfoPrimaryAction?
|
||||
#endif
|
||||
|
||||
// Video tap action (tvOS only)
|
||||
@@ -551,6 +553,8 @@ final class SettingsManager {
|
||||
#if !os(tvOS)
|
||||
_thumbnailTapAction = nil
|
||||
_textAreaTapAction = nil
|
||||
_videoInfoActionsLayout = nil
|
||||
_videoInfoPrimaryAction = nil
|
||||
#endif
|
||||
#if os(tvOS)
|
||||
_tvOSVideoTapAction = nil
|
||||
|
||||
@@ -16105,6 +16105,66 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings.videoInfo.actions.footer" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Toolbar keeps the primary action in the navigation bar and moves the remaining actions to the ⋯ menu."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings.videoInfo.actions.header" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Video Info"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings.videoInfo.actionsLayout" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Actions"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings.videoInfo.actionsLayout.buttons" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Buttons"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings.videoInfo.actionsLayout.toolbar" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Toolbar"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings.videoInfo.primaryAction" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Primary Action"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings.youtubeEnhancements.deArrow.footer" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
@@ -19482,6 +19542,26 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"videoInfo.playTip.message" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Tap the thumbnail to start playing this video."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"videoInfo.playTip.title" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Tap to Play"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"videoInfo.progress" : {
|
||||
"comment" : "Watch progress label",
|
||||
"localizations" : {
|
||||
@@ -19581,6 +19661,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"videoInfo.toolbar.moreActions" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "More Actions"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"videoInfo.viewAllComments" : {
|
||||
"comment" : "View all comments button",
|
||||
"localizations" : {
|
||||
|
||||
@@ -21,6 +21,7 @@ struct LayoutNavigationSettingsView: View {
|
||||
TVVideoActionsSection(settings: settings)
|
||||
#else
|
||||
VideoActionsSection(settings: settings)
|
||||
VideoInfoActionsSection(settings: settings)
|
||||
LinkActionSection(settings: settings)
|
||||
SharingSection(settings: settings)
|
||||
ClipboardSection(settings: settings)
|
||||
@@ -223,6 +224,38 @@ private struct VideoActionsSection: View {
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: - Video Info Actions Section
|
||||
|
||||
#if !os(tvOS)
|
||||
private struct VideoInfoActionsSection: View {
|
||||
@Bindable var settings: SettingsManager
|
||||
|
||||
var body: some View {
|
||||
SettingsFormSection("settings.videoInfo.actions.header", footer: "settings.videoInfo.actions.footer") {
|
||||
Picker(
|
||||
String(localized: "settings.videoInfo.actionsLayout"),
|
||||
selection: $settings.videoInfoActionsLayout
|
||||
) {
|
||||
ForEach(VideoInfoActionsLayout.allCases, id: \.self) { layout in
|
||||
Text(layout.displayName).tag(layout)
|
||||
}
|
||||
}
|
||||
|
||||
if settings.videoInfoActionsLayout == .toolbar {
|
||||
Picker(
|
||||
String(localized: "settings.videoInfo.primaryAction"),
|
||||
selection: $settings.videoInfoPrimaryAction
|
||||
) {
|
||||
ForEach(VideoInfoPrimaryAction.allCases, id: \.self) { action in
|
||||
Text(action.displayName).tag(action)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: - TV Video Actions Section
|
||||
|
||||
#if os(tvOS)
|
||||
|
||||
52
Yattee/Views/Video/VideoInfoPlayThumbnailTip.swift
Normal file
52
Yattee/Views/Video/VideoInfoPlayThumbnailTip.swift
Normal file
@@ -0,0 +1,52 @@
|
||||
//
|
||||
// VideoInfoPlayThumbnailTip.swift
|
||||
// Yattee
|
||||
//
|
||||
// TipKit hint that the hero thumbnail plays the video. Shown once, only while the
|
||||
// toolbar action layout is active (the inline layout has an explicit Play button).
|
||||
//
|
||||
|
||||
#if !os(tvOS)
|
||||
import SwiftUI
|
||||
import TipKit
|
||||
|
||||
struct VideoInfoPlayThumbnailTip: Tip {
|
||||
/// Mirrors `SettingsManager.videoInfoActionsLayout == .toolbar`; updated by VideoInfoView.
|
||||
@Parameter
|
||||
static var isToolbarLayout: Bool = false
|
||||
|
||||
var title: Text {
|
||||
Text(String(localized: "videoInfo.playTip.title"))
|
||||
}
|
||||
|
||||
var message: Text? {
|
||||
Text(String(localized: "videoInfo.playTip.message"))
|
||||
}
|
||||
|
||||
var image: Image? {
|
||||
Image(systemName: "play.circle")
|
||||
}
|
||||
|
||||
var rules: [Rule] {
|
||||
#Rule(Self.$isToolbarLayout) { $0 == true }
|
||||
}
|
||||
}
|
||||
|
||||
/// Attaches the play-thumbnail tip to the hero thumbnail when enabled.
|
||||
struct VideoInfoPlayThumbnailTipModifier: ViewModifier {
|
||||
let enabled: Bool
|
||||
private let tip = VideoInfoPlayThumbnailTip()
|
||||
|
||||
init(enabled: Bool) {
|
||||
self.enabled = enabled
|
||||
}
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
if enabled {
|
||||
content.popoverTip(tip, arrowEdge: .top)
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -7,6 +7,9 @@
|
||||
|
||||
import SwiftUI
|
||||
import NukeUI
|
||||
#if !os(tvOS)
|
||||
import TipKit
|
||||
#endif
|
||||
|
||||
/// Initialization mode for VideoInfoView - either a loaded video or just an ID to fetch.
|
||||
enum VideoInfoInitMode: Sendable {
|
||||
@@ -297,7 +300,33 @@ struct VideoInfoView: View {
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
#if !os(tvOS)
|
||||
.toolbar {
|
||||
if usesToolbarActions, displayedVideo != nil {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
toolbarActionView(primaryAction, inMenu: false)
|
||||
}
|
||||
#if os(iOS)
|
||||
if #available(iOS 26, *) {
|
||||
ToolbarSpacer(.fixed, placement: .primaryAction)
|
||||
}
|
||||
#endif
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Menu {
|
||||
ForEach(VideoInfoPrimaryAction.allCases.filter { $0 != primaryAction }, id: \.self) { action in
|
||||
toolbarActionView(action, inMenu: true)
|
||||
}
|
||||
} label: {
|
||||
Label(String(localized: "videoInfo.toolbar.moreActions"), systemImage: "ellipsis")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
.onAppear {
|
||||
#if !os(tvOS)
|
||||
VideoInfoPlayThumbnailTip.isToolbarLayout = showsThumbnailPlayTip
|
||||
#endif
|
||||
// Initialize current index from queue context
|
||||
if currentVideoIndex == nil, let context = videoQueueContext {
|
||||
currentVideoIndex = context.videoIndex
|
||||
@@ -503,8 +532,12 @@ struct VideoInfoView: View {
|
||||
// Header with thumbnail, title, and channel
|
||||
headerSection(cardWidth: geometry.size.width)
|
||||
|
||||
// Action buttons
|
||||
actionButtons
|
||||
// Action buttons (inline layout only; toolbar layout puts them in the nav bar)
|
||||
if !usesToolbarActions {
|
||||
actionButtons
|
||||
} else {
|
||||
Spacer().frame(height: 12)
|
||||
}
|
||||
|
||||
// Bookmark details section (only show if bookmarked)
|
||||
if isBookmarked, let bookmark = currentBookmark {
|
||||
@@ -1056,9 +1089,15 @@ struct VideoInfoView: View {
|
||||
.padding(.horizontal)
|
||||
.onTapGesture {
|
||||
if isCurrent {
|
||||
#if !os(tvOS)
|
||||
VideoInfoPlayThumbnailTip().invalidate(reason: .actionPerformed)
|
||||
#endif
|
||||
playVideo()
|
||||
}
|
||||
}
|
||||
#if !os(tvOS)
|
||||
.modifier(VideoInfoPlayThumbnailTipModifier(enabled: isCurrent && showsThumbnailPlayTip))
|
||||
#endif
|
||||
|
||||
// Title - full card width so long titles fit more characters per line.
|
||||
// Always reserves two lines of height so carousel cards don't shift
|
||||
@@ -1286,7 +1325,84 @@ struct VideoInfoView: View {
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
|
||||
// MARK: - Toolbar Actions (iOS/macOS)
|
||||
|
||||
#if !os(tvOS)
|
||||
/// True when the user chose the toolbar layout in Layout & Navigation settings.
|
||||
private var usesToolbarActions: Bool {
|
||||
appEnvironment?.settingsManager.videoInfoActionsLayout == .toolbar
|
||||
}
|
||||
|
||||
/// The action pinned to the toolbar; every other action lives in the ellipsis menu.
|
||||
private var primaryAction: VideoInfoPrimaryAction {
|
||||
appEnvironment?.settingsManager.videoInfoPrimaryAction ?? .play
|
||||
}
|
||||
|
||||
/// The hero thumbnail is the only play affordance when the toolbar layout is active
|
||||
/// and Play isn't the pinned action, so show the one-time "tap to play" tip.
|
||||
private var showsThumbnailPlayTip: Bool {
|
||||
usesToolbarActions && primaryAction != .play
|
||||
}
|
||||
|
||||
/// One toolbar action rendered either as a standalone toolbar item or as a menu row.
|
||||
/// The same Label works in both places: the toolbar shows the icon, the menu shows icon + text.
|
||||
@ViewBuilder
|
||||
private func toolbarActionView(_ action: VideoInfoPrimaryAction, inMenu: Bool) -> some View {
|
||||
switch action {
|
||||
case .play:
|
||||
Button(action: playVideo) {
|
||||
Label(inMenu ? playButtonLabel : String(localized: "video.context.play"), systemImage: "play.fill")
|
||||
}
|
||||
case .download:
|
||||
toolbarDownloadAction
|
||||
case .share:
|
||||
if let video = displayedVideo {
|
||||
ShareLink(item: appEnvironment?.shareURL(for: video) ?? video.shareURL) {
|
||||
Label(String(localized: "video.share"), systemImage: "square.and.arrow.up")
|
||||
}
|
||||
}
|
||||
case .addToPlaylist:
|
||||
Button {
|
||||
showingPlaylistSheet = true
|
||||
} label: {
|
||||
Label(String(localized: "video.context.addToPlaylist"), systemImage: "text.badge.plus")
|
||||
}
|
||||
case .bookmark:
|
||||
Button(action: toggleBookmark) {
|
||||
Label(
|
||||
isBookmarked ? String(localized: "video.removeBookmark") : String(localized: "video.bookmark"),
|
||||
systemImage: isBookmarked ? "bookmark.fill" : "bookmark"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Download action for the toolbar/menu, mirroring `verticalDownloadActionButton` states.
|
||||
@ViewBuilder
|
||||
private var toolbarDownloadAction: some View {
|
||||
let isDownloaded = download?.status == .completed
|
||||
let isDownloading = displayedVideo.flatMap { downloadManager?.isDownloading($0.id) } ?? false
|
||||
|
||||
if isDownloaded {
|
||||
Button(role: .destructive) {
|
||||
showingRemoveDownloadConfirmation = true
|
||||
} label: {
|
||||
Label(String(localized: "video.downloaded"), systemImage: "checkmark.circle.fill")
|
||||
}
|
||||
} else if isDownloading || isEnqueuingDownload {
|
||||
Button {} label: {
|
||||
Label(String(localized: "video.downloading"), systemImage: "arrow.down.circle.dotted")
|
||||
}
|
||||
.disabled(true)
|
||||
} else if let video = displayedVideo {
|
||||
Button {
|
||||
startDownload(for: video)
|
||||
} label: {
|
||||
Label(String(localized: "video.download"), systemImage: "arrow.down.circle")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Vertical download action button with four states: default, enqueueing, downloading, downloaded
|
||||
@ViewBuilder
|
||||
private var verticalDownloadActionButton: some View {
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
#if !os(tvOS)
|
||||
import TipKit
|
||||
#endif
|
||||
import Combine
|
||||
import CloudKit
|
||||
import Nuke
|
||||
@@ -60,6 +63,11 @@ struct YatteeApp: App {
|
||||
// Configure Nuke image loading pipeline
|
||||
ImageLoadingService.shared.configure()
|
||||
|
||||
#if !os(tvOS)
|
||||
// TipKit: one-time hints such as the video info "tap thumbnail to play" tip
|
||||
try? Tips.configure([.displayFrequency(.immediate), .datastoreLocation(.applicationDefault)])
|
||||
#endif
|
||||
|
||||
// Register background tasks early — Apple requires BGTaskScheduler.register()
|
||||
// to be called during the app launch sequence, before the run loop starts.
|
||||
appEnvironment.backgroundRefreshManager.registerBackgroundTasks()
|
||||
|
||||
Reference in New Issue
Block a user