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