diff --git a/Yattee/Localizable.xcstrings b/Yattee/Localizable.xcstrings index 026ef4d3..85b9edc5 100644 --- a/Yattee/Localizable.xcstrings +++ b/Yattee/Localizable.xcstrings @@ -7434,6 +7434,50 @@ } } }, + "panel.page.info" : { + "comment" : "Details panel page name", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Info" + } + } + } + }, + "panel.page.queue" : { + "comment" : "Details panel page name", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Queue" + } + } + } + }, + "panel.page.related" : { + "comment" : "Details panel page name", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Related" + } + } + } + }, + "panel.page.tracks" : { + "comment" : "Details panel page name", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tracks" + } + } + } + }, "peertube.explore.added" : { "localizations" : { "en" : { diff --git a/Yattee/Models/Navigation/PlayerInfoTab.swift b/Yattee/Models/Navigation/PlayerInfoTab.swift deleted file mode 100644 index 73f1bf45..00000000 --- a/Yattee/Models/Navigation/PlayerInfoTab.swift +++ /dev/null @@ -1,20 +0,0 @@ -// -// PlayerInfoTab.swift -// Yattee -// -// Player info tab definitions. -// - -import Foundation - -enum PlayerInfoTab: String, CaseIterable { - case description - case comments - - var title: String { - switch self { - case .description: return String(localized: "player.description") - case .comments: return String(localized: "player.comments") - } - } -} diff --git a/Yattee/Models/PlayerControls/PanelSettings.swift b/Yattee/Models/PlayerControls/PanelSettings.swift new file mode 100644 index 00000000..7dc037b5 --- /dev/null +++ b/Yattee/Models/PlayerControls/PanelSettings.swift @@ -0,0 +1,379 @@ +// +// PanelSettings.swift +// Yattee +// +// Preset-backed configuration for the player details panel pages. +// + +import Foundation + +// MARK: - Panel Page Type + +/// A page that can be shown inside the player details panel. +enum PanelPageType: String, Codable, CaseIterable, Sendable { + case info + case comments + case related + case chapters + case queue + case tracks + + /// Localized display name shown in the page bar and settings. + var displayName: String { + switch self { + case .info: + return String(localized: "panel.page.info") + case .comments: + return String(localized: "player.comments") + case .related: + return String(localized: "panel.page.related") + case .chapters: + return String(localized: "player.chapters") + case .queue: + return String(localized: "panel.page.queue") + case .tracks: + return String(localized: "panel.page.tracks") + } + } + + /// SF Symbol used in the page bar and settings. + var systemImage: String { + switch self { + case .info: + return "info.circle" + case .comments: + return "bubble.left.and.bubble.right" + case .related: + return "rectangle.stack.badge.play" + case .chapters: + return "list.bullet.rectangle" + case .queue: + return "list.bullet" + case .tracks: + return "slider.horizontal.3" + } + } +} + +// MARK: - Panel Region + +/// Region a page is assigned to in the corner layout. +/// `primary` is the right column in corner mode and the only panel elsewhere; +/// the assignment is ignored outside corner mode. +enum PanelRegion: String, Codable, Sendable { + case primary + case belowVideo +} + +// MARK: - Layout Mode + +/// How the details panel is laid out on iPad/macOS. +/// Corner mode automatically falls back to stacked when the window is narrow. +enum PanelLayoutMode: String, Codable, Sendable { + case stacked + case corner +} + +// MARK: - Default Visibility + +/// Default panel visibility when the player opens on iPad/macOS. +enum PanelDefaultVisibility: String, Codable, Sendable { + case hidden + case visible + case remember +} + +// MARK: - Per-Page Settings + +/// Element toggles for the info page. Optionals fall back to defaults so +/// future fields stay compatible with old saved presets. +struct InfoPageSettings: Codable, Hashable, Sendable { + var showStatsRow: Bool? + var showChannelRow: Bool? + var showDescription: Bool? + var descriptionCollapsedLines: Int? + + var effectiveShowStatsRow: Bool { showStatsRow ?? true } + var effectiveShowChannelRow: Bool { showChannelRow ?? true } + var effectiveShowDescription: Bool { showDescription ?? true } +} + +/// Element toggles for the comments page. +struct CommentsPageSettings: Codable, Hashable, Sendable { + var showAvatars: Bool? + + var effectiveShowAvatars: Bool { showAvatars ?? true } +} + +/// Element toggles for the related videos page. +struct RelatedPageSettings: Codable, Hashable, Sendable { + var showThumbnails: Bool? + + var effectiveShowThumbnails: Bool { showThumbnails ?? true } +} + +/// Element toggles for the queue page. +struct QueuePageSettings: Codable, Hashable, Sendable { + var showHistory: Bool? + + var effectiveShowHistory: Bool { showHistory ?? true } +} + +/// Element toggles for the chapters page. +struct ChaptersPageSettings: Codable, Hashable, Sendable { + var showThumbnails: Bool? + + var effectiveShowThumbnails: Bool { showThumbnails ?? true } +} + +/// Element toggles for the tracks page. +struct TracksPageSettings: Codable, Hashable, Sendable { + var showQuality: Bool? + var showAudio: Bool? + var showSubtitles: Bool? + var showRate: Bool? +} + +/// Typed per-page settings payload. Uses the same discriminator pattern as +/// `ButtonSettings`; an unknown discriminator from a future app version +/// decodes to `nil` at the configuration level so the pages array never +/// fails to decode as a whole. +enum PanelPageSettings: Codable, Hashable, Sendable { + case info(InfoPageSettings) + case comments(CommentsPageSettings) + case related(RelatedPageSettings) + case chapters(ChaptersPageSettings) + case queue(QueuePageSettings) + case tracks(TracksPageSettings) + + private enum CodingKeys: String, CodingKey { + case type + case info + case comments + case related + case chapters + case queue + case tracks + } + + private enum SettingsType: String, Codable { + case info + case comments + case related + case chapters + case queue + case tracks + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decode(SettingsType.self, forKey: .type) + + switch type { + case .info: + self = .info(try container.decode(InfoPageSettings.self, forKey: .info)) + case .comments: + self = .comments(try container.decode(CommentsPageSettings.self, forKey: .comments)) + case .related: + self = .related(try container.decode(RelatedPageSettings.self, forKey: .related)) + case .chapters: + self = .chapters(try container.decode(ChaptersPageSettings.self, forKey: .chapters)) + case .queue: + self = .queue(try container.decode(QueuePageSettings.self, forKey: .queue)) + case .tracks: + self = .tracks(try container.decode(TracksPageSettings.self, forKey: .tracks)) + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + switch self { + case .info(let settings): + try container.encode(SettingsType.info, forKey: .type) + try container.encode(settings, forKey: .info) + case .comments(let settings): + try container.encode(SettingsType.comments, forKey: .type) + try container.encode(settings, forKey: .comments) + case .related(let settings): + try container.encode(SettingsType.related, forKey: .type) + try container.encode(settings, forKey: .related) + case .chapters(let settings): + try container.encode(SettingsType.chapters, forKey: .type) + try container.encode(settings, forKey: .chapters) + case .queue(let settings): + try container.encode(SettingsType.queue, forKey: .type) + try container.encode(settings, forKey: .queue) + case .tracks(let settings): + try container.encode(SettingsType.tracks, forKey: .type) + try container.encode(settings, forKey: .tracks) + } + } +} + +// MARK: - Page Configuration + +/// One page entry in the ordered panel pages list. +struct PanelPageConfiguration: Codable, Hashable, Sendable, Identifiable { + var id: PanelPageType { pageType } + + var pageType: PanelPageType + var isEnabled: Bool + var region: PanelRegion + var settings: PanelPageSettings? + + init( + pageType: PanelPageType, + isEnabled: Bool = true, + region: PanelRegion = .primary, + settings: PanelPageSettings? = nil + ) { + self.pageType = pageType + self.isEnabled = isEnabled + self.region = region + self.settings = settings + } + + /// Info page settings when this is an info page, defaults otherwise. + var infoSettings: InfoPageSettings { + if case .info(let settings) = settings { return settings } + return InfoPageSettings() + } + + private enum CodingKeys: String, CodingKey { + case pageType + case isEnabled + case region + case settings + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + pageType = try container.decode(PanelPageType.self, forKey: .pageType) + isEnabled = try container.decodeIfPresent(Bool.self, forKey: .isEnabled) ?? true + region = try container.decodeIfPresent(PanelRegion.self, forKey: .region) ?? .primary + // Unknown discriminators from future versions decode to nil (defaults) + // instead of corrupting the pages array. + settings = try? container.decodeIfPresent(PanelPageSettings.self, forKey: .settings) + } +} + +// MARK: - Panel Settings + +/// Complete details panel configuration stored on a `PlayerControlsLayout`. +struct PanelSettings: Codable, Hashable, Sendable { + /// One ordered list of pages: the single source of truth for order, + /// enablement, and (corner mode) region assignment. + var pages: [PanelPageConfiguration] + + /// Layout mode on iPad/macOS. Effective default: `.stacked`. + var layoutMode: PanelLayoutMode? + + /// Default panel visibility when the player opens. Effective default: + /// `.hidden` (today's behavior). + var defaultVisibility: PanelDefaultVisibility? + + /// Whether the page bar shows labels next to icons. + var showPageBarLabels: Bool? + + init( + pages: [PanelPageConfiguration], + layoutMode: PanelLayoutMode? = nil, + defaultVisibility: PanelDefaultVisibility? = nil, + showPageBarLabels: Bool? = nil + ) { + self.pages = pages + self.layoutMode = layoutMode + self.defaultVisibility = defaultVisibility + self.showPageBarLabels = showPageBarLabels + } + + static let `default` = PanelSettings(pages: [ + PanelPageConfiguration(pageType: .info, region: .belowVideo), + PanelPageConfiguration(pageType: .comments, region: .belowVideo), + PanelPageConfiguration(pageType: .related, region: .primary), + PanelPageConfiguration(pageType: .chapters, region: .primary), + PanelPageConfiguration(pageType: .queue, region: .primary), + PanelPageConfiguration(pageType: .tracks, isEnabled: false, region: .primary), + ]) + + var effectiveLayoutMode: PanelLayoutMode { layoutMode ?? .stacked } + var effectiveDefaultVisibility: PanelDefaultVisibility { defaultVisibility ?? .hidden } + var effectiveShowPageBarLabels: Bool { showPageBarLabels ?? true } + + /// Pages the panel should offer, in order. Pass a region to filter for + /// corner mode; `nil` returns enabled pages from all regions. + func enabledPages(in region: PanelRegion? = nil) -> [PanelPageConfiguration] { + normalizedPages.filter { page in + guard page.isEnabled else { return false } + if let region { return page.region == region } + return true + } + } + + /// The pages list with any page types this app version knows about but + /// the stored list is missing appended as disabled, so newly introduced + /// pages surface in settings after an update. + var normalizedPages: [PanelPageConfiguration] { + var result = pages + let known = Set(pages.map(\.pageType)) + for pageType in PanelPageType.allCases where !known.contains(pageType) { + result.append(PanelPageConfiguration(pageType: pageType, isEnabled: false)) + } + return result + } + + /// Whether a page type is enabled in this configuration. + func isPageEnabled(_ pageType: PanelPageType) -> Bool { + enabledPages().contains { $0.pageType == pageType } + } + + private enum CodingKeys: String, CodingKey { + case pages + case layoutMode + case defaultVisibility + case showPageBarLabels + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + // Lenient element decoding: a page entry with an unknown pageType + // (from a future app version) is dropped instead of failing the + // whole settings decode. + var pagesContainer = try container.nestedUnkeyedContainer(forKey: .pages) + var decodedPages: [PanelPageConfiguration] = [] + while !pagesContainer.isAtEnd { + if let page = try? pagesContainer.decode(PanelPageConfiguration.self) { + decodedPages.append(page) + } else { + // Skip the undecodable element to keep the container advancing. + _ = try? pagesContainer.decode(AnyDecodableValue.self) + } + } + pages = decodedPages + layoutMode = try? container.decodeIfPresent(PanelLayoutMode.self, forKey: .layoutMode) + defaultVisibility = try? container.decodeIfPresent(PanelDefaultVisibility.self, forKey: .defaultVisibility) + showPageBarLabels = try container.decodeIfPresent(Bool.self, forKey: .showPageBarLabels) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(pages, forKey: .pages) + try container.encodeIfPresent(layoutMode, forKey: .layoutMode) + try container.encodeIfPresent(defaultVisibility, forKey: .defaultVisibility) + try container.encodeIfPresent(showPageBarLabels, forKey: .showPageBarLabels) + } +} + +/// Consumes any JSON value during lenient array decoding. +private struct AnyDecodableValue: Decodable { + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { return } + if (try? container.decode(Bool.self)) != nil { return } + if (try? container.decode(Double.self)) != nil { return } + if (try? container.decode(String.self)) != nil { return } + if (try? container.decode([String: AnyDecodableValue].self)) != nil { return } + if (try? container.decode([AnyDecodableValue].self)) != nil { return } + } +} diff --git a/Yattee/Models/PlayerControls/PlayerControlsLayout.swift b/Yattee/Models/PlayerControls/PlayerControlsLayout.swift index 1eebc296..cf975e21 100644 --- a/Yattee/Models/PlayerControls/PlayerControlsLayout.swift +++ b/Yattee/Models/PlayerControls/PlayerControlsLayout.swift @@ -38,6 +38,9 @@ struct PlayerControlsLayout: Codable, Hashable, Sendable { /// field still decode. var wideLayoutPanelAlignment: FloatingPanelSide? + /// Details panel pages configuration. Optional for backward compatibility. + var panelSettings: PanelSettings? + // MARK: - Initialization @@ -59,7 +62,8 @@ struct PlayerControlsLayout: Codable, Hashable, Sendable { progressBarSettings: ProgressBarSettings = .default, gesturesSettings: GesturesSettings? = nil, playerPillSettings: PlayerPillSettings? = nil, - miniPlayerSettings: MiniPlayerSettings? = nil + miniPlayerSettings: MiniPlayerSettings? = nil, + panelSettings: PanelSettings? = nil ) { self.topSection = topSection self.centerSettings = centerSettings @@ -69,6 +73,7 @@ struct PlayerControlsLayout: Codable, Hashable, Sendable { self.gesturesSettings = gesturesSettings self.playerPillSettings = playerPillSettings self.miniPlayerSettings = miniPlayerSettings + self.panelSettings = panelSettings } // MARK: - Gesture Settings @@ -92,6 +97,13 @@ struct PlayerControlsLayout: Codable, Hashable, Sendable { miniPlayerSettings ?? .default } + // MARK: - Panel Settings + + /// Returns the effective details panel settings, using defaults if not set. + var effectivePanelSettings: PanelSettings { + panelSettings ?? .default + } + // MARK: - Defaults /// Default layout matching the current hardcoded player controls. diff --git a/Yattee/Views/Components/ExpandedCommentsView.swift b/Yattee/Views/Components/ExpandedCommentsView.swift index c6cad167..53d69621 100644 --- a/Yattee/Views/Components/ExpandedCommentsView.swift +++ b/Yattee/Views/Components/ExpandedCommentsView.swift @@ -223,6 +223,48 @@ struct ExpandedCommentsView: View { @ViewBuilder private var commentsContent: some View { + CommentsListContent(videoID: videoID) + } + + @ViewBuilder + private var closeButton: some View { + Button(action: onClose) { + Image(systemName: isDismissThresholdReached ? "chevron.down" : "xmark") + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(isDismissThresholdReached ? .white : .primary) + .frame(width: 32, height: 32) + .padding(10) + .contentShape(Circle()) + } + .buttonStyle(.plain) + .glassBackground( + isDismissThresholdReached ? .tinted(.blue) : .regular, + in: .circle, + fallback: isDismissThresholdReached ? .ultraThinMaterial : .thinMaterial + ) + .contentShape(Circle()) + .animation(.easeInOut(duration: 0.15), value: isDismissThresholdReached) + } +} + +// MARK: - Comments List Content + +/// Comments list states and rows driven by `PlayerState`, shared between the +/// expanded comments overlay and the details panel comments page. +struct CommentsListContent: View { + @Environment(\.appEnvironment) private var appEnvironment + + let videoID: String + + private var playerState: PlayerState? { appEnvironment?.playerService.state } + private var contentService: ContentService? { appEnvironment?.contentService } + private var instancesManager: InstancesManager? { appEnvironment?.instancesManager } + + private var comments: [Comment] { playerState?.comments ?? [] } + private var commentsState: CommentsLoadState { playerState?.commentsState ?? .idle } + private var videoSource: ContentSource? { playerState?.currentVideo?.id.source } + + var body: some View { switch commentsState { case .idle, .loading: HStack { @@ -307,26 +349,6 @@ struct ExpandedCommentsView: View { } } - @ViewBuilder - private var closeButton: some View { - Button(action: onClose) { - Image(systemName: isDismissThresholdReached ? "chevron.down" : "xmark") - .font(.system(size: 18, weight: .semibold)) - .foregroundStyle(isDismissThresholdReached ? .white : .primary) - .frame(width: 32, height: 32) - .padding(10) - .contentShape(Circle()) - } - .buttonStyle(.plain) - .glassBackground( - isDismissThresholdReached ? .tinted(.blue) : .regular, - in: .circle, - fallback: isDismissThresholdReached ? .ultraThinMaterial : .thinMaterial - ) - .contentShape(Circle()) - .animation(.easeInOut(duration: 0.15), value: isDismissThresholdReached) - } - private func loadComments() async { guard let playerState, let contentService, let instancesManager else { return } await playerState.loadComments(contentService: contentService, instancesManager: instancesManager) diff --git a/Yattee/Views/Player/ExpandedPlayerSheet+Layouts.swift b/Yattee/Views/Player/ExpandedPlayerSheet+Layouts.swift index 867d5f8f..d1ef6882 100644 --- a/Yattee/Views/Player/ExpandedPlayerSheet+Layouts.swift +++ b/Yattee/Views/Player/ExpandedPlayerSheet+Layouts.swift @@ -512,6 +512,17 @@ extension ExpandedPlayerSheet { panelExpandOffset = 0 videoYOffset = 0 } + }, + onRequestExpand: { [self] in + // Comments pill routed to the comments page: + // expand the panel to fullscreen + guard !isPanelExpanded else { return } + withAnimation(.spring(response: 0.3, dampingFraction: 1.0)) { + isPanelExpanded = true + panelExpandOffset = 0 + panelDragOffset = 0 + videoYOffset = 0 + } } ) .frame(height: { diff --git a/Yattee/Views/Player/ExpandedPlayerSheet+VideoInfo.swift b/Yattee/Views/Player/ExpandedPlayerSheet+VideoInfo.swift index dc85cfdd..4498ee51 100644 --- a/Yattee/Views/Player/ExpandedPlayerSheet+VideoInfo.swift +++ b/Yattee/Views/Player/ExpandedPlayerSheet+VideoInfo.swift @@ -83,120 +83,6 @@ extension ExpandedPlayerSheet { await playerState.loadComments(contentService: contentService, instancesManager: instancesManager) } - // MARK: - DeArrow Title Helpers - - /// Returns the DeArrow title if available and enabled. - func deArrowTitle(for video: Video) -> String? { - appEnvironment?.deArrowBrandingProvider.title(for: video) - } - - /// Returns the display title based on toggle state. - /// Shows DeArrow title by default when available, original when toggled. - func displayTitle(for video: Video) -> String { - if let deArrow = deArrowTitle(for: video) { - return showOriginalTitle ? video.title : deArrow - } - return video.title - } - - /// Whether the title can be toggled (DeArrow title is available). - func canToggleTitle(for video: Video) -> Bool { - deArrowTitle(for: video) != nil - } - - // MARK: - Video Info Views - - /// Video info section with title, stats, and channel. - @ViewBuilder - func videoInfo(_ video: Video) -> some View { - VStack(alignment: .leading, spacing: 8) { - // Title - full width - Text(displayTitle(for: video)) - .font(.title3) - .fontWeight(.semibold) - .lineLimit(3) - .onTapGesture { - if canToggleTitle(for: video) { - showOriginalTitle.toggle() - } - } - - // Stats row - only show for non-media-source videos - if !video.isFromMediaSource { - VideoStatsRow( - playerState: playerState, - showFormattedDate: $showFormattedDate, - returnYouTubeDislikeEnabled: appEnvironment?.settingsManager.returnYouTubeDislikeEnabled ?? false - ) - } - - // Channel row with context menu - VideoChannelRow( - author: video.author, - source: video.authorSource, - yatteeServerURL: yatteeServerURL, - onChannelTap: video.author.hasRealChannelInfo ? { - navigationCoordinator?.navigateToChannel(for: video, collapsePlayer: true) - } : nil, - video: video, - accentColor: accentColor, - showSubscriberCount: !video.isFromMediaSource, - isLoadingDetails: playerState?.videoDetailsState == .loading - ) - } - .padding() - } - - /// Returns the first enabled Yattee Server instance URL, if any. - var yatteeServerURL: URL? { - appEnvironment?.instancesManager.yatteeServerInstances.first { $0.isEnabled }?.url - } - - // MARK: - Info Tab Section - - /// Info tab section with video description. - @ViewBuilder - func infoTabSection(_ video: Video) -> some View { - VStack(alignment: .leading, spacing: 12) { - // Description only (no picker) - descriptionContent(video.description ?? "") - - // Extra space at bottom so content can scroll above the comments pill - Spacer() - .frame(height: 80) - } - .padding(.vertical) - } - - /// Description content view. - @ViewBuilder - func descriptionContent(_ description: String) -> some View { - let isLoadingDetails = playerState?.videoDetailsState == .loading - - VStack(alignment: .leading, spacing: 8) { - if !description.isEmpty { - Text(DescriptionText.attributed(description, linkColor: accentColor)) - .font(.subheadline) - .foregroundStyle(.secondary) - .tint(accentColor) - .padding(.horizontal) - .handleTimestampLinks(using: playerService) - } else if isLoadingDetails { - HStack { - Spacer() - ProgressView() - Spacer() - } - .padding() - } else { - Text(String(localized: "player.noDescription")) - .font(.subheadline) - .foregroundStyle(.secondary) - .padding(.horizontal) - } - } - } - // MARK: - Actions /// Closes the current video and dismisses the player. diff --git a/Yattee/Views/Player/ExpandedPlayerSheet.swift b/Yattee/Views/Player/ExpandedPlayerSheet.swift index 80432a2c..0680c4e0 100644 --- a/Yattee/Views/Player/ExpandedPlayerSheet.swift +++ b/Yattee/Views/Player/ExpandedPlayerSheet.swift @@ -51,8 +51,6 @@ struct ExpandedPlayerSheet: View { @State var panelExpandOffset: CGFloat = 0 /// Whether to use compact panel (no description) - animated state @State var useCompactPanel: Bool = false - @State var showFormattedDate = false - @State var showOriginalTitle = false /// Track if we're in widescreen layout for status bar hiding @State private var isInWideScreenLayout = false /// Active player controls layout from preset (loaded once, updated on notification) @@ -190,7 +188,6 @@ struct ExpandedPlayerSheet: View { scrollPosition: $scrollPosition, isPanelExpanded: $isPanelExpanded, panelExpandOffset: $panelExpandOffset, - showOriginalTitle: $showOriginalTitle, isThumbnailFrozen: $isThumbnailFrozen, displayedThumbnailURL: $displayedThumbnailURL, displayedThumbnailImage: $displayedThumbnailImage, @@ -765,7 +762,6 @@ private struct PlayerEventHandlersModifier: ViewModifier { @Binding var scrollPosition: ScrollPosition @Binding var isPanelExpanded: Bool @Binding var panelExpandOffset: CGFloat - @Binding var showOriginalTitle: Bool @Binding var isThumbnailFrozen: Bool @Binding var displayedThumbnailURL: URL? @Binding var displayedThumbnailImage: Image? @@ -835,7 +831,6 @@ private struct PlayerEventHandlersModifier: ViewModifier { isPanelExpanded = false panelExpandOffset = 0 } - showOriginalTitle = false stopAutoplayCountdown() isAutoplayCancelled = false diff --git a/Yattee/Views/Player/FloatingDetailsPanel.swift b/Yattee/Views/Player/FloatingDetailsPanel.swift index 26ec612d..170134fe 100644 --- a/Yattee/Views/Player/FloatingDetailsPanel.swift +++ b/Yattee/Views/Player/FloatingDetailsPanel.swift @@ -37,8 +37,7 @@ struct FloatingDetailsPanel: View { // Player controls layout for pill settings let playerControlsLayout: PlayerControlsLayout @State private var isCommentsExpanded: Bool = false - @State private var showFormattedDate = false - @State private var showOriginalTitle = false + @State private var selectedPage: PanelPageType = .info @State private var scrollOffset: CGFloat = 0 @State private var panelHeight: CGFloat = 0 @State private var scrollToTopTrigger: Bool = false @@ -51,17 +50,13 @@ struct FloatingDetailsPanel: View { private var playerService: PlayerService? { appEnvironment?.playerService } private var playerState: PlayerState? { playerService?.state } - // Read video and dislikeCount from playerState for reactive updates + // Read video from playerState for reactive updates private var video: Video? { playerState?.currentVideo } - private var dislikeCount: Int? { playerState?.dislikeCount } // Comments helpers private var comments: [Comment] { playerState?.comments ?? [] } private var commentsState: CommentsLoadState { playerState?.commentsState ?? .idle } - // Video details helpers - private var videoDetailsState: VideoDetailsLoadState { playerState?.videoDetailsState ?? .idle } - // Queue helpers private var queue: [QueuedVideo] { playerState?.queue ?? [] } @@ -89,6 +84,17 @@ struct FloatingDetailsPanel: View { !(video?.description ?? "").isEmpty } + // Panel pages configuration + private var panelSettings: PanelSettings { + playerControlsLayout.effectivePanelSettings + } + + /// Whether the comments pill should route to the comments page instead of + /// the overlay. + private var isCommentsPageAvailable: Bool { + panelSettings.isPageEnabled(.comments) && !(video?.isFromMediaSource ?? true) + } + /// Whether any pills are currently visible private var hasPillsVisible: Bool { let hasCommentsPill = commentsState == .loaded && !comments.isEmpty && shouldShowCommentsPill @@ -101,29 +107,6 @@ struct FloatingDetailsPanel: View { return hasCommentsPill && shouldShowPlayerPill && !isCommentsPillAlwaysCollapsed && hasVideoDescription } - /// Returns the first enabled Yattee Server instance URL, if any. - private var yatteeServerURL: URL? { - appEnvironment?.instancesManager.yatteeServerInstances.first { $0.isEnabled }?.url - } - - /// Returns the DeArrow title if available and enabled. - private func deArrowTitle(for video: Video) -> String? { - appEnvironment?.deArrowBrandingProvider.title(for: video) - } - - /// Returns the display title based on toggle state. - private func displayTitle(for video: Video) -> String { - if let deArrow = deArrowTitle(for: video) { - return showOriginalTitle ? video.title : deArrow - } - return video.title - } - - /// Whether the title can be toggled (DeArrow title is available). - private func canToggleTitle(for video: Video) -> Bool { - deArrowTitle(for: video) != nil - } - /// Minimum panel width static let minPanelWidth: CGFloat = 400 /// Default panel width @@ -162,7 +145,6 @@ struct FloatingDetailsPanel: View { .animation(.easeInOut(duration: 0.25), value: isPinned) .onChange(of: video.id) { _, _ in // Comments state is managed by parent - just reset local state - showOriginalTitle = false isCommentsExpanded = false } .onChange(of: isCommentsExpanded) { _, newValue in @@ -222,38 +204,26 @@ struct FloatingDetailsPanel: View { @ViewBuilder private func panelContent(video: Video) -> some View { VStack(spacing: 0) { - // Scrollable content - ScrollViewReader { proxy in - ScrollView { - VStack(alignment: .leading, spacing: 0) { - // Scroll offset tracker at top - Color.clear - .frame(height: 0) - .id("panelTop") - .background( - GeometryReader { geo in - Color.clear - .onChange(of: geo.frame(in: .named("panelScroll")).origin.y) { _, newValue in - scrollOffset = -newValue - } - } - ) - - videoInfo(video) - - Divider() - .padding(.horizontal) - - infoTabSection(video) - } - } - .coordinateSpace(name: "panelScroll") - .onChange(of: scrollToTopTrigger) { _, _ in - withAnimation(.easeInOut(duration: 0.3)) { - proxy.scrollTo("panelTop", anchor: .top) - } - } - } + // Unified page content (page bar + selected page) + PlayerPanelContentView( + panelSettings: panelSettings, + selectedPage: $selectedPage, + context: PanelPageContext( + appEnvironment: appEnvironment, + video: video, + accentColor: accentColor, + onChannelTap: onChannelTap + ), + scrollConfig: PanelScrollConfig( + topInset: 8, + bottomInset: 80, + scrollOffsetChanged: { offset in + scrollOffset = offset + }, + scrollToTopTrigger: scrollToTopTrigger + ), + pageBarTopPadding: 12 + ) } .frame(width: clampWidth(panelWidth)) .background { panelBackground } @@ -277,7 +247,11 @@ struct FloatingDetailsPanel: View { bottomOverlay(video: video) } .animation(.spring(response: 0.3, dampingFraction: 0.7), value: commentsState) - .overlay { expandedCommentsOverlay(video: video) } + .overlay { + if !isCommentsPageAvailable { + expandedCommentsOverlay(video: video) + } + } .animation(.smooth(duration: 0.3), value: isCommentsExpanded) } @@ -514,60 +488,14 @@ struct FloatingDetailsPanel: View { ) } - // MARK: - Video Info - - private func videoInfo(_ video: Video) -> some View { - VStack(alignment: .leading, spacing: 8) { - // Title - full width - Text(displayTitle(for: video)) - .font(.title3) - .fontWeight(.semibold) - .lineLimit(3) - .onTapGesture { - if canToggleTitle(for: video) { - showOriginalTitle.toggle() - } - } - - // Stats row - only show for non-media-source videos - if !video.isFromMediaSource { - VideoStatsRow( - playerState: playerState, - showFormattedDate: $showFormattedDate, - returnYouTubeDislikeEnabled: settingsManager?.returnYouTubeDislikeEnabled ?? false - ) - } - - // Channel row with context menu - VideoChannelRow( - author: video.author, - source: video.id.source, - yatteeServerURL: yatteeServerURL, - onChannelTap: onChannelTap, - video: video, - accentColor: accentColor, - showSubscriberCount: !video.isFromMediaSource, - isLoadingDetails: playerState?.videoDetailsState == .loading - ) - } - .padding() - } - - // MARK: - Info Tab Section - - private func infoTabSection(_ video: Video) -> some View { - VStack(alignment: .leading, spacing: 12) { - // Description only (no picker) - descriptionContent(video.description ?? "") - - // Extra space at bottom so content can scroll above the comments pill - Spacer() - .frame(height: 80) - } - .padding(.vertical) - } - private func expandComments() { + if isCommentsPageAvailable { + // Route to the comments page instead of the overlay + withAnimation(.smooth(duration: 0.3)) { + selectedPage = .comments + } + return + } // Use same animation as player sheet expand (0.3s, no bounce) withAnimation(.smooth(duration: 0.3)) { isCommentsExpanded = true @@ -589,31 +517,6 @@ struct FloatingDetailsPanel: View { } // Below threshold - scroll view will rubber-band back naturally } - - private func descriptionContent(_ description: String) -> some View { - VStack(alignment: .leading, spacing: 8) { - if !description.isEmpty { - Text(DescriptionText.attributed(description, linkColor: accentColor)) - .font(.subheadline) - .foregroundStyle(.secondary) - .tint(accentColor) - .padding(.horizontal) - .handleTimestampLinks(using: playerService) - } else if videoDetailsState == .loading { - HStack { - Spacer() - ProgressView() - Spacer() - } - .padding() - } else { - Text(String(localized: "player.noDescription")) - .font(.subheadline) - .foregroundStyle(.secondary) - .padding(.horizontal) - } - } - } } #endif diff --git a/Yattee/Views/Player/Panels/ChaptersPanelPage.swift b/Yattee/Views/Player/Panels/ChaptersPanelPage.swift new file mode 100644 index 00000000..553c3129 --- /dev/null +++ b/Yattee/Views/Player/Panels/ChaptersPanelPage.swift @@ -0,0 +1,63 @@ +// +// ChaptersPanelPage.swift +// Yattee +// +// Details panel page listing video chapters; tapping seeks. +// + +import SwiftUI + +#if os(iOS) || os(macOS) + +struct ChaptersPanelPage: View { + let context: PanelPageContext + var scrollConfig: PanelScrollConfig = PanelScrollConfig() + + private var appEnvironment: AppEnvironment? { context.appEnvironment } + private var playerService: PlayerService? { appEnvironment?.playerService } + private var playerState: PlayerState? { playerService?.state } + + private var chapters: [VideoChapter] { playerState?.chapters ?? [] } + private var currentTime: TimeInterval { playerState?.currentTime ?? 0 } + private var storyboard: Storyboard? { playerState?.preferredStoryboard } + + var body: some View { + PanelPageScrollView(config: scrollConfig) { + LazyVStack(spacing: 0) { + ForEach(chapters) { chapter in + ChapterRow( + chapter: chapter, + isActive: isChapterActive(chapter), + storyboard: storyboard, + onTap: { + Task { + await playerService?.seek(to: chapter.startTime) + } + } + ) + } + } + .padding(.horizontal) + .padding(.top, 8) + } + .task(id: context.video.id) { + await preloadChapterSheets() + } + } + + private func isChapterActive(_ chapter: VideoChapter) -> Bool { + let nextChapter = chapters.first { $0.startTime > chapter.startTime } + let endTime = nextChapter?.startTime ?? .infinity + return currentTime >= chapter.startTime && currentTime < endTime + } + + private func preloadChapterSheets() async { + guard let storyboard else { return } + let service = StoryboardService.shared + for chapter in chapters { + await service.loadSheet(for: chapter.startTime, from: storyboard) + } + } +} + +#endif diff --git a/Yattee/Views/Player/Panels/CommentsPanelPage.swift b/Yattee/Views/Player/Panels/CommentsPanelPage.swift new file mode 100644 index 00000000..981e51e5 --- /dev/null +++ b/Yattee/Views/Player/Panels/CommentsPanelPage.swift @@ -0,0 +1,36 @@ +// +// CommentsPanelPage.swift +// Yattee +// +// Details panel page with the inline comments list. +// + +import SwiftUI + +#if os(iOS) || os(macOS) + +struct CommentsPanelPage: View { + let context: PanelPageContext + var scrollConfig: PanelScrollConfig = PanelScrollConfig() + + private var appEnvironment: AppEnvironment? { context.appEnvironment } + private var playerState: PlayerState? { appEnvironment?.playerService.state } + + var body: some View { + PanelPageScrollView(config: scrollConfig) { + CommentsListContent(videoID: context.video.id.videoID) + .padding(.horizontal) + } + .task(id: context.video.id) { + // Opening the page is an explicit request; load even when the + // buffer-ready preload hasn't fired yet. + guard let playerState, + playerState.commentsState == .idle, + let contentService = appEnvironment?.contentService, + let instancesManager = appEnvironment?.instancesManager else { return } + await playerState.loadComments(contentService: contentService, instancesManager: instancesManager) + } + } +} + +#endif diff --git a/Yattee/Views/Player/Panels/InfoPanelPage.swift b/Yattee/Views/Player/Panels/InfoPanelPage.swift new file mode 100644 index 00000000..b7a340b4 --- /dev/null +++ b/Yattee/Views/Player/Panels/InfoPanelPage.swift @@ -0,0 +1,134 @@ +// +// InfoPanelPage.swift +// Yattee +// +// Details panel page with title, stats, channel row, and description. +// Consolidates the previously duplicated video info bodies. +// + +import SwiftUI + +#if os(iOS) || os(macOS) + +struct InfoPanelPage: View { + let context: PanelPageContext + var settings: InfoPageSettings = InfoPageSettings() + var scrollConfig: PanelScrollConfig = PanelScrollConfig() + + @State private var showFormattedDate = false + @State private var showOriginalTitle = false + + private var appEnvironment: AppEnvironment? { context.appEnvironment } + private var video: Video { context.video } + private var playerService: PlayerService? { appEnvironment?.playerService } + private var playerState: PlayerState? { playerService?.state } + private var videoDetailsState: VideoDetailsLoadState { playerState?.videoDetailsState ?? .idle } + + /// Returns the first enabled Yattee Server instance URL, if any. + private var yatteeServerURL: URL? { + appEnvironment?.instancesManager.yatteeServerInstances.first { $0.isEnabled }?.url + } + + /// Returns the DeArrow title if available and enabled. + private var deArrowTitle: String? { + appEnvironment?.deArrowBrandingProvider.title(for: video) + } + + /// Returns the display title based on toggle state. + private var displayTitle: String { + if let deArrowTitle { + return showOriginalTitle ? video.title : deArrowTitle + } + return video.title + } + + var body: some View { + PanelPageScrollView(config: scrollConfig) { + videoInfo + + if settings.effectiveShowDescription { + Divider() + .padding(.horizontal) + + descriptionContent + .padding(.vertical) + } + } + .onChange(of: video.id) { _, _ in + showOriginalTitle = false + } + } + + // MARK: - Video Info + + private var videoInfo: some View { + VStack(alignment: .leading, spacing: 8) { + // Title - full width (shows DeArrow title if available, tap toggles) + Text(displayTitle) + .font(.title3) + .fontWeight(.semibold) + .lineLimit(3) + .onTapGesture { + if deArrowTitle != nil { + showOriginalTitle.toggle() + } + } + + // Stats row - only show for non-media-source videos + if settings.effectiveShowStatsRow, !video.isFromMediaSource { + VideoStatsRow( + playerState: playerState, + showFormattedDate: $showFormattedDate, + returnYouTubeDislikeEnabled: appEnvironment?.settingsManager.returnYouTubeDislikeEnabled ?? false + ) + } + + // Channel row with context menu + if settings.effectiveShowChannelRow { + VideoChannelRow( + author: video.author, + source: video.id.source, + yatteeServerURL: yatteeServerURL, + onChannelTap: context.onChannelTap, + video: video, + accentColor: context.accentColor, + showSubscriberCount: !video.isFromMediaSource, + isLoadingDetails: videoDetailsState == .loading + ) + } + } + .padding(.horizontal) + .padding(.vertical, 8) + } + + // MARK: - Description + + private var descriptionContent: some View { + let description = video.description ?? "" + + return VStack(alignment: .leading, spacing: 8) { + if !description.isEmpty { + Text(DescriptionText.attributed(description, linkColor: context.accentColor)) + .font(.subheadline) + .foregroundStyle(.secondary) + .tint(context.accentColor) + .padding(.horizontal) + .handleTimestampLinks(using: playerService) + } else if videoDetailsState == .loading { + HStack { + Spacer() + ProgressView() + Spacer() + } + .padding() + } else { + Text(String(localized: "player.noDescription")) + .font(.subheadline) + .foregroundStyle(.secondary) + .padding(.horizontal) + } + } + } +} + +#endif diff --git a/Yattee/Views/Player/Panels/PanelPageBar.swift b/Yattee/Views/Player/Panels/PanelPageBar.swift new file mode 100644 index 00000000..055fe49c --- /dev/null +++ b/Yattee/Views/Player/Panels/PanelPageBar.swift @@ -0,0 +1,72 @@ +// +// PanelPageBar.swift +// Yattee +// +// Scrolling pill row for switching details panel pages. +// + +import SwiftUI + +#if os(iOS) || os(macOS) + +struct PanelPageBar: View { + let pages: [PanelPageConfiguration] + @Binding var selectedPage: PanelPageType + let accentColor: Color + var showLabels: Bool = true + + var body: some View { + ScrollViewReader { proxy in + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(pages) { page in + pageButton(page.pageType) + .id(page.pageType) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + } + .onChange(of: selectedPage) { _, newValue in + withAnimation(.easeInOut(duration: 0.2)) { + proxy.scrollTo(newValue, anchor: .center) + } + } + } + } + + @ViewBuilder + private func pageButton(_ pageType: PanelPageType) -> some View { + let isSelected = pageType == selectedPage + + Button { + withAnimation(.easeInOut(duration: 0.2)) { + selectedPage = pageType + } + } label: { + HStack(spacing: 5) { + Image(systemName: pageType.systemImage) + .font(.system(size: 13, weight: .medium)) + + if showLabels || isSelected { + Text(pageType.displayName) + .font(.subheadline) + .fontWeight(.medium) + .lineLimit(1) + .fixedSize() + } + } + .foregroundStyle(isSelected ? Color.white : Color.primary) + .padding(.horizontal, 12) + .padding(.vertical, 7) + .background { + Capsule() + .fill(isSelected ? AnyShapeStyle(accentColor) : AnyShapeStyle(.quaternary.opacity(0.6))) + } + .contentShape(Capsule()) + } + .buttonStyle(.plain) + } +} + +#endif diff --git a/Yattee/Views/Player/Panels/PanelPageContext.swift b/Yattee/Views/Player/Panels/PanelPageContext.swift new file mode 100644 index 00000000..a4ae5dd0 --- /dev/null +++ b/Yattee/Views/Player/Panels/PanelPageContext.swift @@ -0,0 +1,109 @@ +// +// PanelPageContext.swift +// Yattee +// +// Shared context and scroll plumbing for the details panel pages. +// + +import SwiftUI + +#if os(iOS) || os(macOS) + +// MARK: - Page Context + +/// Lightweight context handed to every panel page. Pages reach services via +/// `appEnvironment`; only host-specific callbacks travel explicitly. +struct PanelPageContext { + let appEnvironment: AppEnvironment? + let video: Video + let accentColor: Color + var onChannelTap: (() -> Void)? +} + +// MARK: - Scroll Config + +/// Scroll behavior the host wants applied to scrollable pages: content +/// insets, scroll-offset reporting (pills collapse / scroll-to-top button), +/// and the portrait overscroll-to-collapse gesture. +struct PanelScrollConfig { + /// Extra space above the content (e.g. below the drag handle). + var topInset: CGFloat = 10 + + /// Extra space below the content so it can scroll above pills/fades. + var bottomInset: CGFloat = 80 + + /// Reports the current scroll offset (0 at top, positive when scrolled). + var scrollOffsetChanged: ((CGFloat) -> Void)? + + /// Toggling this scrolls the page back to the top. + var scrollToTopTrigger: Bool = false + + /// Disables scrolling (while the portrait drag handle is active). + var isScrollDisabled: Bool = false + + /// Portrait overscroll-to-collapse drag callbacks (iOS only). + var onOverscrollDragChanged: ((CGFloat) -> Void)? + var onOverscrollDragEnded: ((CGFloat, CGFloat) -> Void)? +} + +// MARK: - Page Scroll View + +/// Shared scroll container used by the scrollable panel pages. Applies the +/// host's insets, reports scroll offset, handles scroll-to-top, and attaches +/// the portrait overscroll gesture when the host provides callbacks. +struct PanelPageScrollView: View { + let config: PanelScrollConfig + @ViewBuilder let content: Content + + var body: some View { + ScrollViewReader { proxy in + ScrollView { + VStack(alignment: .leading, spacing: 0) { + Color.clear + .frame(height: 0) + .id("panelPageTop") + .background( + GeometryReader { geo in + Color.clear + .onChange(of: geo.frame(in: .named("panelPageScroll")).origin.y) { _, newValue in + config.scrollOffsetChanged?(max(0, -newValue)) + } + } + ) + + Color.clear + .frame(height: config.topInset) + + content + + Spacer() + .frame(height: config.bottomInset) + } + } + .coordinateSpace(name: "panelPageScroll") + #if os(iOS) + .background { + if config.onOverscrollDragChanged != nil || config.onOverscrollDragEnded != nil { + OverscrollGestureView( + isEnabled: !config.isScrollDisabled, + onDragChanged: { offset in + config.onOverscrollDragChanged?(offset) + }, + onDragEnded: { offset, predicted in + config.onOverscrollDragEnded?(offset, predicted) + } + ) + } + } + #endif + .scrollDisabled(config.isScrollDisabled) + .onChange(of: config.scrollToTopTrigger) { _, _ in + withAnimation(.easeInOut(duration: 0.3)) { + proxy.scrollTo("panelPageTop", anchor: .top) + } + } + } + } +} + +#endif diff --git a/Yattee/Views/Player/Panels/PlayerPanelContentView.swift b/Yattee/Views/Player/Panels/PlayerPanelContentView.swift new file mode 100644 index 00000000..71ec83ea --- /dev/null +++ b/Yattee/Views/Player/Panels/PlayerPanelContentView.swift @@ -0,0 +1,128 @@ +// +// PlayerPanelContentView.swift +// Yattee +// +// Unified details panel content: page bar plus the selected page. Hosted by +// the portrait panel, the floating panel, and (later) the corner layout +// regions. +// + +import SwiftUI + +#if os(iOS) || os(macOS) + +struct PlayerPanelContentView: View { + let panelSettings: PanelSettings + + /// Region filter for corner mode; `nil` shows pages from all regions. + var region: PanelRegion? = nil + + @Binding var selectedPage: PanelPageType + let context: PanelPageContext + var scrollConfig: PanelScrollConfig = PanelScrollConfig() + + /// Extra space above the page bar (e.g. below the portrait drag handle). + var pageBarTopPadding: CGFloat = 0 + + private var appEnvironment: AppEnvironment? { context.appEnvironment } + private var playerState: PlayerState? { appEnvironment?.playerService.state } + + // MARK: - Page Visibility + + /// Config enables a page; runtime hides pages with nothing to show. + private func isRuntimeVisible(_ pageType: PanelPageType) -> Bool { + switch pageType { + case .info: + return true + case .comments: + return !context.video.isFromMediaSource + && playerState?.commentsState != .disabled + case .related: + return !(context.video.relatedVideos ?? []).isEmpty + case .chapters: + return !(playerState?.chapters ?? []).isEmpty + case .queue: + return appEnvironment?.settingsManager.queueEnabled ?? true + case .tracks: + return true + } + } + + /// Enabled pages that currently have content, in configured order. + private var visiblePages: [PanelPageConfiguration] { + panelSettings.enabledPages(in: region).filter { isRuntimeVisible($0.pageType) } + } + + /// The page to render: the selection when visible, else the first page. + private var effectivePage: PanelPageConfiguration? { + visiblePages.first { $0.pageType == selectedPage } ?? visiblePages.first + } + + // MARK: - Body + + var body: some View { + let pages = visiblePages + + VStack(spacing: 0) { + if pages.count >= 2 { + PanelPageBar( + pages: pages, + selectedPage: $selectedPage, + accentColor: context.accentColor, + showLabels: panelSettings.effectiveShowPageBarLabels + ) + .padding(.top, pageBarTopPadding) + } + + if let page = effectivePage { + pageContent(page) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + Spacer() + } + } + .onChange(of: selectedPage) { _, _ in + // Each page owns its scroll position; reset the host's offset so + // pills and the scroll-to-top button start from the top state. + scrollConfig.scrollOffsetChanged?(0) + } + .onChange(of: context.video.id) { _, _ in + // New video: fall back to the first configured page. + if let first = visiblePages.first?.pageType { + selectedPage = first + } + } + } + + // MARK: - Pages + + @ViewBuilder + private func pageContent(_ page: PanelPageConfiguration) -> some View { + switch page.pageType { + case .info: + InfoPanelPage( + context: context, + settings: page.infoSettings, + scrollConfig: scrollConfig + ) + .id(PanelPageType.info) + case .comments: + CommentsPanelPage(context: context, scrollConfig: scrollConfig) + .id(PanelPageType.comments) + case .related: + RelatedPanelPage(context: context, scrollConfig: scrollConfig) + .id(PanelPageType.related) + case .chapters: + ChaptersPanelPage(context: context, scrollConfig: scrollConfig) + .id(PanelPageType.chapters) + case .queue: + QueuePanelPage(context: context, bottomInset: scrollConfig.bottomInset) + .id(PanelPageType.queue) + case .tracks: + TracksPanelPage(context: context, bottomInset: scrollConfig.bottomInset) + .id(PanelPageType.tracks) + } + } +} + +#endif diff --git a/Yattee/Views/Player/Panels/QueuePanelPage.swift b/Yattee/Views/Player/Panels/QueuePanelPage.swift new file mode 100644 index 00000000..1676e14e --- /dev/null +++ b/Yattee/Views/Player/Panels/QueuePanelPage.swift @@ -0,0 +1,22 @@ +// +// QueuePanelPage.swift +// Yattee +// +// Details panel page hosting the shared queue list. +// + +import SwiftUI + +#if os(iOS) || os(macOS) + +struct QueuePanelPage: View { + let context: PanelPageContext + var bottomInset: CGFloat = 0 + + var body: some View { + QueueListView() + .safeAreaPadding(.bottom, bottomInset) + } +} + +#endif diff --git a/Yattee/Views/Player/Panels/RelatedPanelPage.swift b/Yattee/Views/Player/Panels/RelatedPanelPage.swift new file mode 100644 index 00000000..aa6f2a22 --- /dev/null +++ b/Yattee/Views/Player/Panels/RelatedPanelPage.swift @@ -0,0 +1,61 @@ +// +// RelatedPanelPage.swift +// Yattee +// +// Details panel page listing videos related to the current one. +// + +import SwiftUI + +#if os(iOS) || os(macOS) + +struct RelatedPanelPage: View { + let context: PanelPageContext + var scrollConfig: PanelScrollConfig = PanelScrollConfig() + + private var relatedVideos: [Video] { context.video.relatedVideos ?? [] } + + var body: some View { + PanelPageScrollView(config: scrollConfig) { + if relatedVideos.isEmpty { + emptyState + } else { + LazyVStack(spacing: 12) { + ForEach(Array(relatedVideos.enumerated()), id: \.element.id) { index, relatedVideo in + VideoRowView(video: relatedVideo, style: .regular) + .tappableVideo( + relatedVideo, + queueSource: .manual, + sourceLabel: String(localized: "videoInfo.section.relatedVideos"), + videoList: relatedVideos, + videoIndex: index, + loadMoreVideos: nil + ) + + if index < relatedVideos.count - 1 { + Divider() + .padding(.leading, VideoRowStyle.regular.thumbnailWidth + 12) + } + } + } + .padding(.horizontal) + .padding(.top, 8) + } + } + } + + private var emptyState: some View { + VStack(spacing: 8) { + Image(systemName: "rectangle.stack.badge.play") + .font(.title2) + .foregroundStyle(.secondary) + Text(String(localized: "videoInfo.section.relatedVideos")) + .font(.subheadline) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 32) + } +} + +#endif diff --git a/Yattee/Views/Player/Panels/TracksPanelPage.swift b/Yattee/Views/Player/Panels/TracksPanelPage.swift new file mode 100644 index 00000000..ae1b5ac8 --- /dev/null +++ b/Yattee/Views/Player/Panels/TracksPanelPage.swift @@ -0,0 +1,94 @@ +// +// TracksPanelPage.swift +// Yattee +// +// Details panel page embedding the quality/audio/subtitles/rate selector. +// + +import SwiftUI + +#if os(iOS) || os(macOS) + +struct TracksPanelPage: View { + let context: PanelPageContext + var bottomInset: CGFloat = 0 + + private var appEnvironment: AppEnvironment? { context.appEnvironment } + private var playerService: PlayerService? { appEnvironment?.playerService } + private var playerState: PlayerState? { playerService?.state } + + var body: some View { + if let playerService { + let supportedFormats = playerService.currentBackendType.supportedFormats + QualitySelectorView( + streams: playerService.availableStreams.filter { stream in + let format = StreamFormat.detect(from: stream) + // DASH is never offered for manual selection + if format == .dash { + return false + } + return supportedFormats.contains(format) + }, + captions: playerService.availableCaptions, + currentStream: playerState?.currentStream, + currentAudioStream: playerState?.currentAudioStream, + currentCaption: playerService.currentCaption, + isLoading: playerService.availableStreams.isEmpty && playerState?.playbackState == .loading, + currentDownload: playerService.currentDownload, + isLoadingOnlineStreams: playerService.isLoadingOnlineStreams, + localCaptionURL: playerService.currentDownload.flatMap { download in + guard let path = download.localCaptionPath else { return nil } + return appEnvironment?.downloadManager.downloadsDirectory().appendingPathComponent(path) + }, + currentRate: playerState?.rate ?? .x1, + isControlsLocked: playerState?.isControlsLocked ?? false, + isAudioMode: appEnvironment?.settingsManager.audioOnlyModeEnabled ?? false, + embeddedAudioTracks: playerService.embeddedAudioTracks, + embeddedSubtitleTracks: playerService.embeddedSubtitleTracks, + currentEmbeddedAudioTrackID: playerService.selectedEmbeddedAudioTrackID, + currentEmbeddedSubtitleTrackID: playerService.selectedEmbeddedSubtitleTrackID, + embeddedVideoTrack: playerService.primaryEmbeddedVideoTrack, + onStreamSelected: { stream, audioStream in + Task { + await playerService.selectStreamManually(stream, audioStream: audioStream) + } + }, + onCaptionSelected: { caption in + playerService.loadCaption(caption) + }, + onLoadOnlineStreams: { + Task { + await playerService.loadOnlineStreams() + } + }, + onSwitchToOnlineStream: { stream, audioStream in + Task { + await playerService.switchToOnlineStream(stream, audioStream: audioStream) + } + }, + onEmbeddedAudioTrackSelected: { trackID in + playerService.selectEmbeddedAudioTrack(trackID) + }, + onEmbeddedSubtitleTrackSelected: { trackID in + playerService.selectEmbeddedSubtitleTrack(trackID) + }, + onRateChanged: { rate in + playerState?.rate = rate + playerService.currentBackend?.rate = Float(rate.rawValue) + }, + onLockToggled: { locked in + playerState?.isControlsLocked = locked + }, + onAudioModeToggled: { enabled in + Task { + await playerService.setAudioMode(enabled) + } + }, + isEmbedded: true + ) + .safeAreaPadding(.bottom, bottomInset) + } + } +} + +#endif diff --git a/Yattee/Views/Player/PortraitDetailsPanel.swift b/Yattee/Views/Player/PortraitDetailsPanel.swift index bce7caf3..f587f4dc 100644 --- a/Yattee/Views/Player/PortraitDetailsPanel.swift +++ b/Yattee/Views/Player/PortraitDetailsPanel.swift @@ -22,9 +22,11 @@ struct PortraitDetailsPanel: View { var onDragChanged: ((CGFloat) -> Void)? var onDragEnded: ((CGFloat, CGFloat) -> Void)? var onDragCancelled: (() -> Void)? + /// Asks the host to expand the panel (comments pill → comments page). + var onRequestExpand: (() -> Void)? @GestureState private var isDraggingHandle: Bool = false @State private var isCommentsExpanded: Bool = false - @State private var showFormattedDate = false + @State private var selectedPage: PanelPageType = .info @State private var scrollOffset: CGFloat = 0 @State private var scrollToTopTrigger: Bool = false @State private var showQueueSheet: Bool = false @@ -36,17 +38,13 @@ struct PortraitDetailsPanel: View { private var playerState: PlayerState? { playerService?.state } private var isPanelDragging: Bool { appEnvironment?.navigationCoordinator.isPanelDragging ?? false } - // Read video and dislikeCount from playerState for reactive updates + // Read video from playerState for reactive updates private var video: Video? { playerState?.currentVideo } - private var dislikeCount: Int? { playerState?.dislikeCount } // Comments helpers private var comments: [Comment] { playerState?.comments ?? [] } private var commentsState: CommentsLoadState { playerState?.commentsState ?? .idle } - // Video details helpers - private var videoDetailsState: VideoDetailsLoadState { playerState?.videoDetailsState ?? .idle } - // Queue helpers private var queue: [QueuedVideo] { playerState?.queue ?? [] } private var history: [QueuedVideo] { playerState?.history ?? [] } @@ -76,6 +74,17 @@ struct PortraitDetailsPanel: View { !(video?.description ?? "").isEmpty } + // Panel pages configuration + private var panelSettings: PanelSettings { + playerControlsLayout.effectivePanelSettings + } + + /// Whether the comments pill should route to the comments page instead of + /// the overlay. + private var isCommentsPageAvailable: Bool { + panelSettings.isPageEnabled(.comments) && !(video?.isFromMediaSource ?? true) + } + /// Whether any pills are currently visible in the overlay private var hasPillsVisible: Bool { let hasCommentsPill = commentsState == .loaded && !comments.isEmpty && shouldShowCommentsPill @@ -88,16 +97,6 @@ struct PortraitDetailsPanel: View { return hasCommentsPill && shouldShowPlayerPill && !isCommentsPillAlwaysCollapsed && hasVideoDescription } - /// Returns the first enabled Yattee Server instance URL, if any. - private var yatteeServerURL: URL? { - appEnvironment?.instancesManager.yatteeServerInstances.first { $0.isEnabled }?.url - } - - /// Returns the DeArrow title if available and enabled, otherwise the original title. - private func displayTitle(for video: Video) -> String { - appEnvironment?.deArrowBrandingProvider.title(for: video) ?? video.title - } - // MARK: - Drag Handle private var dragHandle: some View { @@ -139,53 +138,34 @@ struct PortraitDetailsPanel: View { var body: some View { if let video { ZStack(alignment: .top) { - // Scrollable content (extends to top, scrolls under drag handle) - ScrollViewReader { proxy in - ScrollView { - VStack(alignment: .leading, spacing: 0) { - // Anchor for scroll-to-top - Color.clear - .frame(height: 0) - .id("panelTop") - - // Top padding so content isn't initially obscured by drag handle - Color.clear - .frame(height: 10) - - videoInfo(video) - - Divider() - .padding(.horizontal) - - infoTabSection(video) + // Unified page content (page bar + selected page) + PlayerPanelContentView( + panelSettings: panelSettings, + selectedPage: $selectedPage, + context: PanelPageContext( + appEnvironment: appEnvironment, + video: video, + accentColor: accentColor, + onChannelTap: onChannelTap + ), + scrollConfig: PanelScrollConfig( + topInset: 6, + bottomInset: hasVideoDescription ? 80 : 50, + scrollOffsetChanged: { offset in + scrollOffset = offset + }, + scrollToTopTrigger: scrollToTopTrigger, + isScrollDisabled: isDraggingHandle, + onOverscrollDragChanged: { offset in + onDragChanged?(offset) + }, + onOverscrollDragEnded: { offset, predicted in + onDragEnded?(offset, predicted) } - } - .background(Color(.systemBackground)) - .background { - // UIKit gesture handler for smooth overscroll-to-collapse - OverscrollGestureView( - isEnabled: !isDraggingHandle, - onDragChanged: { offset in - onDragChanged?(offset) - }, - onDragEnded: { offset, predicted in - onDragEnded?(offset, predicted) - } - ) - } - .scrollDisabled(isDraggingHandle) - .onScrollGeometryChange(for: CGFloat.self) { geometry in - geometry.contentOffset.y - } action: { _, newValue in - // Update scrollOffset for pills visibility - scrollOffset = max(0, newValue) - } - .onChange(of: scrollToTopTrigger) { _, _ in - withAnimation(.easeInOut(duration: 0.3)) { - proxy.scrollTo("panelTop", anchor: .top) - } - } - } + ), + pageBarTopPadding: 14 + ) + .background(Color(.systemBackground)) .overlay(alignment: .bottom) { bottomFadeOverlay } @@ -244,10 +224,35 @@ struct PortraitDetailsPanel: View { .allowsHitTesting(pillsOverlayOpacity > 0.01) } } - // Expanded comments overlay + // Expanded comments overlay (only when the comments page is not + // available; the pill then falls back to the overlay path) .overlay { - let expanded = isCommentsExpanded - ExpandedCommentsView( + if !isCommentsPageAvailable { + expandedCommentsOverlay(video: video) + } + } + .animation(.smooth(duration: 0.3), value: isCommentsExpanded) + .onChange(of: video.id) { _, _ in + isCommentsExpanded = false + } + .onChange(of: isCommentsExpanded) { _, newValue in + appEnvironment?.navigationCoordinator.isCommentsExpanded = newValue + } + .sheet(isPresented: $showQueueSheet) { + QueueManagementSheet() + } + .sheet(isPresented: $showPlaylistSheet) { + if let currentVideo = playerState?.currentVideo { + PlaylistSelectorSheet(video: currentVideo) + } + } + } + } + + @ViewBuilder + private func expandedCommentsOverlay(video: Video) -> some View { + let expanded = isCommentsExpanded + ExpandedCommentsView( videoID: video.id.videoID, onClose: collapseComments, onDismissGestureEnded: handleCommentsDismissGestureEnded, @@ -277,23 +282,6 @@ struct PortraitDetailsPanel: View { } } ) - } - .animation(.smooth(duration: 0.3), value: isCommentsExpanded) - .onChange(of: video.id) { _, _ in - isCommentsExpanded = false - } - .onChange(of: isCommentsExpanded) { _, newValue in - appEnvironment?.navigationCoordinator.isCommentsExpanded = newValue - } - .sheet(isPresented: $showQueueSheet) { - QueueManagementSheet() - } - .sheet(isPresented: $showPlaylistSheet) { - if let currentVideo = playerState?.currentVideo { - PlaylistSelectorSheet(video: currentVideo) - } - } - } } // MARK: - Pills Overlay @@ -479,57 +467,15 @@ struct PortraitDetailsPanel: View { } } - // MARK: - Video Info - - private func videoInfo(_ video: Video) -> some View { - VStack(alignment: .leading, spacing: 8) { - // Title - full width (shows DeArrow title if available) - Text(displayTitle(for: video)) - .font(.title3) - .fontWeight(.semibold) - .lineLimit(3) - - // Stats row - only show for non-media-source videos - if !video.isFromMediaSource { - VideoStatsRow( - playerState: playerState, - showFormattedDate: $showFormattedDate, - returnYouTubeDislikeEnabled: settingsManager?.returnYouTubeDislikeEnabled ?? false - ) - } - - // Channel row with context menu - VideoChannelRow( - author: video.author, - source: video.id.source, - yatteeServerURL: yatteeServerURL, - onChannelTap: onChannelTap, - video: video, - accentColor: accentColor, - showSubscriberCount: !video.isFromMediaSource, - isLoadingDetails: playerState?.videoDetailsState == .loading - ) - } - .padding(.horizontal, 12) - .padding(.vertical, 8) - } - - // MARK: - Info Tab Section - - private func infoTabSection(_ video: Video) -> some View { - let hasDescription = !(video.description ?? "").isEmpty - - return VStack(alignment: .leading, spacing: 12) { - descriptionContent(video.description ?? "") - - // Reduced bottom spacer when no description - Spacer() - .frame(height: hasDescription ? 80 : 50) - } - .padding(.vertical) - } - private func expandComments() { + if isCommentsPageAvailable { + // Route to the comments page and ask the host to expand the panel + withAnimation(.smooth(duration: 0.3)) { + selectedPage = .comments + } + onRequestExpand?() + return + } withAnimation(.smooth(duration: 0.3)) { isCommentsExpanded = true } @@ -548,27 +494,6 @@ struct PortraitDetailsPanel: View { collapseComments() } } - - private func descriptionContent(_ description: String) -> some View { - VStack(alignment: .leading, spacing: 8) { - if !description.isEmpty { - Text(DescriptionText.attributed(description, linkColor: accentColor)) - .font(.subheadline) - .foregroundStyle(.secondary) - .tint(accentColor) - .padding(.horizontal) - .handleTimestampLinks(using: playerService) - } else if videoDetailsState == .loading { - HStack { - Spacer() - ProgressView() - Spacer() - } - .padding() - } - // No description text removed - let panel shrink instead - } - } } #endif diff --git a/Yattee/Views/Player/QualitySelectorView.swift b/Yattee/Views/Player/QualitySelectorView.swift index 3e9a8c1f..eba902da 100644 --- a/Yattee/Views/Player/QualitySelectorView.swift +++ b/Yattee/Views/Player/QualitySelectorView.swift @@ -64,6 +64,10 @@ struct QualitySelectorView: View { /// the tvOS half-screen panel). When nil, falls back to `@Environment(\.dismiss)`. var onDismiss: (() -> Void)? + /// Embedded inside a container (details panel tracks page): no close + /// toolbar item, no sheet detents, and no minimum frame. + var isEmbedded: Bool = false + #if os(tvOS) /// Bound to the first focusable row so we can programmatically pull focus /// into the panel on appear (the system doesn't auto-focus an inline @@ -207,7 +211,8 @@ struct QualitySelectorView: View { onRateChanged: ((PlaybackRate) -> Void)? = nil, onLockToggled: ((Bool) -> Void)? = nil, onAudioModeToggled: ((Bool) -> Void)? = nil, - onDismiss: (() -> Void)? = nil + onDismiss: (() -> Void)? = nil, + isEmbedded: Bool = false ) { self.streams = streams self.captions = captions @@ -238,6 +243,7 @@ struct QualitySelectorView: View { self.onLockToggled = onLockToggled self.onAudioModeToggled = onAudioModeToggled self.onDismiss = onDismiss + self.isEmbedded = isEmbedded } // MARK: - Body @@ -272,18 +278,24 @@ struct QualitySelectorView: View { } } #else - NavigationStack { - stackRoot + if isEmbedded { + NavigationStack { + stackRoot + } + } else { + NavigationStack { + stackRoot + } + #if os(iOS) + // Rest at a taller-than-medium detent so the general section (playback + // speed / audio mode / lock) at the bottom of the scroll view is + // visible without dragging the sheet up to `.large`. + .presentationDetents([.fraction(0.6), .large]) + #endif + #if os(macOS) + .frame(minWidth: 450, minHeight: 500) + #endif } - #if os(iOS) - // Rest at a taller-than-medium detent so the general section (playback - // speed / audio mode / lock) at the bottom of the scroll view is - // visible without dragging the sheet up to `.large`. - .presentationDetents([.fraction(0.6), .large]) - #endif - #if os(macOS) - .frame(minWidth: 450, minHeight: 500) - #endif #endif } @@ -327,7 +339,9 @@ struct QualitySelectorView: View { #endif #if !os(tvOS) .toolbar { - sheetCloseToolbarItem { performDismiss() } + if !isEmbedded { + sheetCloseToolbarItem { performDismiss() } + } } #endif .navigationDestination(for: QualitySelectorDestination.self) { destination in diff --git a/Yattee/Views/Player/WideScreenPlayerLayout.swift b/Yattee/Views/Player/WideScreenPlayerLayout.swift index 0e9fe834..fae2abb1 100644 --- a/Yattee/Views/Player/WideScreenPlayerLayout.swift +++ b/Yattee/Views/Player/WideScreenPlayerLayout.swift @@ -360,11 +360,6 @@ struct WideScreenPlayerLayout: View { playerControlsLayout: playerControlsLayout ) } - - /// Returns the first enabled Yattee Server instance URL, if any. - private var yatteeServerURL: URL? { - appEnvironment?.instancesManager.yatteeServerInstances.first { $0.isEnabled }?.url - } } #endif