From 5651f8d43152d46111ab3d5a9b2323e153246c81 Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Sun, 20 Sep 2026 23:52:20 +0200 Subject: [PATCH] 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. --- .../Settings/SettingsManager+General.swift | 33 +++++ Yattee/Core/Settings/SettingsTypes.swift | 43 +++++++ Yattee/Core/SettingsManager.swift | 4 + Yattee/Localizable.xcstrings | 90 +++++++++++++ .../LayoutNavigationSettingsView.swift | 33 +++++ .../Video/VideoInfoPlayThumbnailTip.swift | 52 ++++++++ Yattee/Views/Video/VideoInfoView.swift | 120 +++++++++++++++++- Yattee/YatteeApp.swift | 8 ++ 8 files changed, 381 insertions(+), 2 deletions(-) create mode 100644 Yattee/Views/Video/VideoInfoPlayThumbnailTip.swift diff --git a/Yattee/Core/Settings/SettingsManager+General.swift b/Yattee/Core/Settings/SettingsManager+General.swift index 4ebca937..f5e118a1 100644 --- a/Yattee/Core/Settings/SettingsManager+General.swift +++ b/Yattee/Core/Settings/SettingsManager+General.swift @@ -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) diff --git a/Yattee/Core/Settings/SettingsTypes.swift b/Yattee/Core/Settings/SettingsTypes.swift index 530e657e..46b60482 100644 --- a/Yattee/Core/Settings/SettingsTypes.swift +++ b/Yattee/Core/Settings/SettingsTypes.swift @@ -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. diff --git a/Yattee/Core/SettingsManager.swift b/Yattee/Core/SettingsManager.swift index bafa8308..b701ec05 100644 --- a/Yattee/Core/SettingsManager.swift +++ b/Yattee/Core/SettingsManager.swift @@ -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 diff --git a/Yattee/Localizable.xcstrings b/Yattee/Localizable.xcstrings index e890dab5..026ef4d3 100644 --- a/Yattee/Localizable.xcstrings +++ b/Yattee/Localizable.xcstrings @@ -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" : { diff --git a/Yattee/Views/Settings/LayoutNavigationSettingsView.swift b/Yattee/Views/Settings/LayoutNavigationSettingsView.swift index 42273e7d..02bb12bc 100644 --- a/Yattee/Views/Settings/LayoutNavigationSettingsView.swift +++ b/Yattee/Views/Settings/LayoutNavigationSettingsView.swift @@ -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) diff --git a/Yattee/Views/Video/VideoInfoPlayThumbnailTip.swift b/Yattee/Views/Video/VideoInfoPlayThumbnailTip.swift new file mode 100644 index 00000000..d1ef9441 --- /dev/null +++ b/Yattee/Views/Video/VideoInfoPlayThumbnailTip.swift @@ -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 diff --git a/Yattee/Views/Video/VideoInfoView.swift b/Yattee/Views/Video/VideoInfoView.swift index d7ddde62..1c17f7c5 100644 --- a/Yattee/Views/Video/VideoInfoView.swift +++ b/Yattee/Views/Video/VideoInfoView.swift @@ -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 { diff --git a/Yattee/YatteeApp.swift b/Yattee/YatteeApp.swift index c668f78f..9a91d2a0 100644 --- a/Yattee/YatteeApp.swift +++ b/Yattee/YatteeApp.swift @@ -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()