mirror of
https://github.com/yattee/yattee.git
synced 2026-11-14 07:47:16 +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:
@@ -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" : {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
379
Yattee/Models/PlayerControls/PanelSettings.swift
Normal file
379
Yattee/Models/PlayerControls/PanelSettings.swift
Normal 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 }
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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