Add unified page-based details panel on existing layouts

Phase 1 of the player panels redesign:

- New PanelSettings model (pages, regions, layout mode, default
  visibility) stored as an optional field on PlayerControlsLayout with
  an effectivePanelSettings accessor; lenient Codable so future page
  types and settings payloads never corrupt saved presets.
- New Views/Player/Panels content engine: PlayerPanelContentView
  renders a page bar plus the selected page (info, comments, related,
  chapters, queue, tracks), with runtime hiding of empty pages.
- PortraitDetailsPanel and FloatingDetailsPanel now host the shared
  content instead of their duplicated info bodies; drag machinery,
  pills, and fades are untouched.
- Comments list extracted into CommentsListContent shared by the
  overlay and the comments page; comments pill routes to the comments
  page (expanding the portrait panel) when the page is enabled, and
  falls back to the overlay when it is not.
- Tracks page embeds QualitySelectorView via a new isEmbedded mode.
- Deleted the unused video info bodies in ExpandedPlayerSheet+VideoInfo
  and the dead PlayerInfoTab model.
This commit is contained in:
Arkadiusz Fal
2026-09-21 23:20:45 +02:00
parent 8bb6137417
commit d5285b2b83
21 changed files with 1358 additions and 473 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<Content: View>: 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -360,11 +360,6 @@ struct WideScreenPlayerLayout<PlayerContent: View>: 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