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)