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

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

View File

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

View File

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

View File

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

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