mirror of
https://github.com/yattee/yattee.git
synced 2026-10-31 08:57:17 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
63
Yattee/Views/Player/Panels/ChaptersPanelPage.swift
Normal file
63
Yattee/Views/Player/Panels/ChaptersPanelPage.swift
Normal 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
|
||||
36
Yattee/Views/Player/Panels/CommentsPanelPage.swift
Normal file
36
Yattee/Views/Player/Panels/CommentsPanelPage.swift
Normal 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
|
||||
134
Yattee/Views/Player/Panels/InfoPanelPage.swift
Normal file
134
Yattee/Views/Player/Panels/InfoPanelPage.swift
Normal 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
|
||||
72
Yattee/Views/Player/Panels/PanelPageBar.swift
Normal file
72
Yattee/Views/Player/Panels/PanelPageBar.swift
Normal 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
|
||||
109
Yattee/Views/Player/Panels/PanelPageContext.swift
Normal file
109
Yattee/Views/Player/Panels/PanelPageContext.swift
Normal 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
|
||||
128
Yattee/Views/Player/Panels/PlayerPanelContentView.swift
Normal file
128
Yattee/Views/Player/Panels/PlayerPanelContentView.swift
Normal 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
|
||||
22
Yattee/Views/Player/Panels/QueuePanelPage.swift
Normal file
22
Yattee/Views/Player/Panels/QueuePanelPage.swift
Normal 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
|
||||
61
Yattee/Views/Player/Panels/RelatedPanelPage.swift
Normal file
61
Yattee/Views/Player/Panels/RelatedPanelPage.swift
Normal 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
|
||||
94
Yattee/Views/Player/Panels/TracksPanelPage.swift
Normal file
94
Yattee/Views/Player/Panels/TracksPanelPage.swift
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user