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:
Arkadiusz Fal
2026-09-20 23:52:20 +02:00
parent ea57378b00
commit 5651f8d431
8 changed files with 381 additions and 2 deletions

View File

@@ -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)

View File

@@ -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.

View File

@@ -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

View File

@@ -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" : {

View File

@@ -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)

View 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

View File

@@ -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 {

View File

@@ -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()