mirror of
https://github.com/yattee/yattee.git
synced 2026-10-24 05:32:53 +00:00
Rework corner layout with portrait-style info panel below the video
The corner layout now always renders the info page as a portrait-style panel below the video (collapsible description, drag to hide/expand), while the side column holds tabs for the remaining pages. Key pieces: - Below-video panel reuses PortraitDetailsPanel (no comments pill, square corners, chapters/related stay in the side column tabs) - Drag down hides the panel with the video recentering in lockstep; swipe up on the video reveals it following the finger - Fullscreen button toggles the panels in corner layout (controls hide first so their resize never plays visibly); status bar and home indicator hide only while the panels are hidden - Panel visibility and column width are seeded at init from a warmed PlayerControlsLayout cache, so the first frame resolves corner mode without flashing the default layout or animating the panels in - Dismiss pan is blocked inside the below-video panel frame (cornerBelowPanelFrame in NavigationCoordinator) - Resize grabber slimmed to match the panel drag handle look
This commit is contained in:
@@ -740,6 +740,12 @@ final class AppEnvironment {
|
||||
func refreshActiveControlsSettings() async {
|
||||
let layout = await playerControlsLayoutService.activeLayout()
|
||||
activeControlsCenterSettings = layout.centerSettings
|
||||
// Warm the layout caches so the expanded player resolves the correct
|
||||
// layout (corner mode, panel settings) on its very first frame.
|
||||
PlayerControlsLayout.cached = layout
|
||||
GlobalLayoutSettings.cached = layout.globalSettings
|
||||
MiniPlayerSettings.cached = layout.effectiveMiniPlayerSettings
|
||||
PanelSettings.cached = layout.effectivePanelSettings
|
||||
}
|
||||
|
||||
/// Updates the HTTP client's User-Agent configuration from current settings.
|
||||
|
||||
@@ -6530,16 +6530,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"migration.accounts.prompt.review" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Review"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"migration.accounts.prompt.title" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
@@ -6550,26 +6540,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"migration.accounts.prompt.title" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Import from Previous App"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"migration.accounts.remove.confirm" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Remove"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"migration.accounts.remove.confirm" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
@@ -6590,16 +6560,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"migration.accounts.remove.message %@" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "This will remove \"%@\" from the legacy import list. It will not be imported."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"migration.accounts.remove.title" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
@@ -6610,26 +6570,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"migration.accounts.remove.title" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Remove Legacy Account?"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"migration.accounts.section" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Accounts"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"migration.accounts.section" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
@@ -6650,16 +6590,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"migration.accounts.title" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Legacy Accounts"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"migration.import" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
@@ -6670,26 +6600,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"migration.import" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Import"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"migration.importing" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Importing..."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"migration.importing" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
@@ -6710,16 +6620,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"migration.sources.footer" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Add these sources from the previous Yattee app. No sign-in is required."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"migration.sources.imported.title" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
@@ -6730,26 +6630,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"migration.sources.imported.title" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Source Added"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"migration.sources.remove.title" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Remove Legacy Source?"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"migration.sources.remove.title" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
@@ -6770,16 +6650,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"migration.sources.section" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Sources"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"miniPlayer.addButton.header" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
@@ -6790,26 +6660,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"miniPlayer.addButton.header" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Add Button"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"miniPlayer.buttons.empty" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "No buttons configured"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"miniPlayer.buttons.empty" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
@@ -6830,26 +6680,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"miniPlayer.buttons.footer" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Customize which buttons appear in the mini player."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"miniPlayer.buttons.header" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Buttons"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"miniPlayer.buttons.header" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
@@ -6862,19 +6692,6 @@
|
||||
},
|
||||
"my-server.example.com" : {
|
||||
|
||||
},
|
||||
"my-server.example.com" : {
|
||||
|
||||
},
|
||||
"navigation.sourceNotFound" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Source Not Found"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"navigation.sourceNotFound" : {
|
||||
"localizations" : {
|
||||
@@ -6896,27 +6713,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"navigation.sourceNotFound.description" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "This media source may have been removed."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"notification.action.watch" : {
|
||||
"comment" : "Action button on notification to watch video",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Watch"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"notification.action.watch" : {
|
||||
"comment" : "Action button on notification to watch video",
|
||||
"localizations" : {
|
||||
@@ -6939,17 +6735,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"notification.multipleChannels %@ %@" : {
|
||||
"comment" : "Multiple channels notification",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "New videos from %1$@ and %2$@"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"notification.multipleVideosOneChannel %lld %@" : {
|
||||
"comment" : "Multiple videos from one channel",
|
||||
"localizations" : {
|
||||
@@ -6961,28 +6746,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"notification.multipleVideosOneChannel %lld %@" : {
|
||||
"comment" : "Multiple videos from one channel",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "%1$lld new videos from %2$@"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"notification.newVideos.title" : {
|
||||
"comment" : "Notification title for new videos",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "New Videos"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"notification.newVideos.title" : {
|
||||
"comment" : "Notification title for new videos",
|
||||
"localizations" : {
|
||||
@@ -7005,28 +6768,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"notification.singleVideo %@" : {
|
||||
"comment" : "Notification body for single video",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "New video: %@"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"notification.twoChannels %@ %@" : {
|
||||
"comment" : "Two channels notification",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "New videos from %1$@ and %2$@"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"notification.twoChannels %@ %@" : {
|
||||
"comment" : "Two channels notification",
|
||||
"localizations" : {
|
||||
@@ -7049,17 +6790,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"notification.videoItem %@ %@" : {
|
||||
"comment" : "Video item: Author: Title",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "%1$@: %2$@"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"onboarding.cloud.syncing.preparing" : {
|
||||
"comment" : "Preparing sync text on iCloud onboarding",
|
||||
"localizations" : {
|
||||
@@ -7071,28 +6801,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"onboarding.cloud.syncing.preparing" : {
|
||||
"comment" : "Preparing sync text on iCloud onboarding",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Preparing sync..."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"onboarding.cloud.syncing.title" : {
|
||||
"comment" : "iCloud syncing title on onboarding",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Syncing Your Data"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"onboarding.cloud.syncing.title" : {
|
||||
"comment" : "iCloud syncing title on onboarding",
|
||||
"localizations" : {
|
||||
@@ -7566,6 +7274,17 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"panel.settings.comments.showAvatars" : {
|
||||
"comment" : "Comments page element toggle",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Author Avatars"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"panel.settings.defaultVisibility" : {
|
||||
"comment" : "Details panel settings picker label",
|
||||
"localizations" : {
|
||||
@@ -7599,17 +7318,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"panel.settings.comments.showAvatars" : {
|
||||
"comment" : "Comments page element toggle",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Author Avatars"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"panel.settings.elements.header" : {
|
||||
"comment" : "Details panel page editor section header",
|
||||
"localizations" : {
|
||||
@@ -7698,28 +7406,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"panel.settings.pageBarLabels" : {
|
||||
"comment" : "Details panel page bar labels toggle",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Page Bar Labels"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"panel.settings.pageBarLabels.footer" : {
|
||||
"comment" : "Details panel page bar labels footer",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Show a name next to each page icon. With labels off, only the selected page shows its name."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"panel.settings.page.enabled" : {
|
||||
"comment" : "Details panel page editor toggle",
|
||||
"localizations" : {
|
||||
@@ -7764,6 +7450,50 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"panel.settings.pageBarLabels" : {
|
||||
"comment" : "Details panel page bar labels toggle",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Page Bar Labels"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"panel.settings.pageBarLabels.footer" : {
|
||||
"comment" : "Details panel page bar labels footer",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Show a name next to each page icon. With labels off, only the selected page shows its name."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"panel.settings.pages.footer" : {
|
||||
"comment" : "Details panel settings section footer",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Pages appear in this order. The page bar shows when at least two pages are enabled; pages without content hide automatically."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"panel.settings.pages.header" : {
|
||||
"comment" : "Details panel settings section header",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Pages"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"panel.settings.queue.showHistory" : {
|
||||
"comment" : "Queue page element toggle",
|
||||
"localizations" : {
|
||||
@@ -7797,28 +7527,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"panel.settings.pages.header" : {
|
||||
"comment" : "Details panel settings section header",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Pages"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"panel.settings.pages.footer" : {
|
||||
"comment" : "Details panel settings section footer",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Pages appear in this order. The page bar shows when at least two pages are enabled; pages without content hide automatically."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"peertube.explore.added" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
@@ -8651,6 +8359,7 @@
|
||||
}
|
||||
},
|
||||
"player.tab.comments" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@@ -8661,6 +8370,7 @@
|
||||
}
|
||||
},
|
||||
"player.tab.info" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@@ -17345,17 +17055,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"sources.field.invidiousInstance" : {
|
||||
"comment" : "Field label for Invidious instance in Yattee Server info",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Invidious"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"sources.field.frontendURL" : {
|
||||
"comment" : "Field label for optional Piped web frontend URL",
|
||||
"localizations" : {
|
||||
@@ -17367,6 +17066,17 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"sources.field.invidiousInstance" : {
|
||||
"comment" : "Field label for Invidious instance in Yattee Server info",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Invidious"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"sources.field.name" : {
|
||||
"comment" : "Field label for source name",
|
||||
"localizations" : {
|
||||
|
||||
@@ -106,6 +106,12 @@ struct PlayerControlsLayout: Codable, Hashable, Sendable {
|
||||
|
||||
// MARK: - Defaults
|
||||
|
||||
/// Last loaded active layout — lets the expanded player seed its layout
|
||||
/// state synchronously before the async load, so the first frames resolve
|
||||
/// the correct layout (e.g. corner mode) instead of flashing the default.
|
||||
/// Warmed at app start and kept fresh by the expanded player's loads.
|
||||
nonisolated(unsafe) static var cached: PlayerControlsLayout = .default
|
||||
|
||||
/// Default layout matching the current hardcoded player controls.
|
||||
static let `default`: PlayerControlsLayout = {
|
||||
// Top section: spacer, brightness (widescreen), volume, airplay, debug, close
|
||||
|
||||
@@ -158,6 +158,10 @@ final class NavigationCoordinator {
|
||||
/// Portrait panel frame in screen coordinates (for gesture conflict resolution).
|
||||
var portraitPanelFrame: CGRect = .zero
|
||||
|
||||
/// Corner layout below-video panel frame in screen coordinates — touches
|
||||
/// inside it must not start the sheet dismiss pan (drag handle conflict).
|
||||
var cornerBelowPanelFrame: CGRect = .zero
|
||||
|
||||
/// Progress bar frame in screen coordinates (for gesture conflict resolution).
|
||||
var progressBarFrame: CGRect = .zero
|
||||
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
// CornerPlayerLayout.swift
|
||||
// Yattee
|
||||
//
|
||||
// Corner player layout for iPad/macOS: video in the top-left, a resizable
|
||||
// side column on the right, and an optional region below the video —
|
||||
// YouTube-style. Pages are assigned to regions in the preset's panel
|
||||
// settings; the layout resolver in ExpandedPlayerSheet falls back to the
|
||||
// stacked layout when the window is too narrow.
|
||||
// Corner player layout for iPad/macOS: video in the top-left, the portrait-
|
||||
// style info panel below it, and a resizable side column on the right with
|
||||
// tabs for the remaining pages (related/chapters/queue/comments) —
|
||||
// YouTube-style. The layout resolver in ExpandedPlayerSheet falls back to
|
||||
// the stacked layout when the window is too narrow.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
@@ -27,6 +27,34 @@ struct CornerPlayerLayout<PlayerContent: View>: View {
|
||||
) -> PlayerContent
|
||||
|
||||
let onChannelTap: (() -> Void)?
|
||||
var onFullscreen: (() -> Void)? = nil
|
||||
|
||||
/// Seed the panel visibility BEFORE the first frame — applying the
|
||||
/// preset's default visibility via onAppear/onChange animates the panels
|
||||
/// popping in over an initially full-screen video on player expand
|
||||
/// (same trap the portrait panel hit, fixed by init-time seeding).
|
||||
init(
|
||||
playerControlsLayout: PlayerControlsLayout,
|
||||
initialPanelVisible: Bool = true,
|
||||
initialColumnWidth: CGFloat? = nil,
|
||||
@ViewBuilder playerContent: @escaping (
|
||||
_ onTogglePanel: @escaping () -> Void,
|
||||
_ isPanelVisible: Bool,
|
||||
_ width: CGFloat,
|
||||
_ height: CGFloat
|
||||
) -> PlayerContent,
|
||||
onChannelTap: (() -> Void)?,
|
||||
onFullscreen: (() -> Void)? = nil
|
||||
) {
|
||||
self.playerControlsLayout = playerControlsLayout
|
||||
self.playerContent = playerContent
|
||||
self.onChannelTap = onChannelTap
|
||||
self.onFullscreen = onFullscreen
|
||||
_isPanelVisible = State(initialValue: initialPanelVisible)
|
||||
if let initialColumnWidth, initialColumnWidth > 0 {
|
||||
_columnWidth = State(initialValue: initialColumnWidth)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Layout Constants
|
||||
|
||||
@@ -44,10 +72,21 @@ struct CornerPlayerLayout<PlayerContent: View>: View {
|
||||
@State private var isPanelVisible = true
|
||||
@State private var columnWidth: CGFloat = SettingsManager.defaultCornerPanelColumnWidth
|
||||
@State private var primaryPage: PanelPageType = .related
|
||||
@State private var belowVideoPage: PanelPageType = .info
|
||||
@State private var isGrabberActive = false
|
||||
@State private var isBelowPanelVisible = true
|
||||
@State private var belowPanelDragOffset: CGFloat = 0
|
||||
@State private var isBelowPanelExpanded = false
|
||||
@State private var belowPanelExpandOffset: CGFloat = 0
|
||||
/// Swipe-up-on-video reveal in progress: the panel renders in the shown
|
||||
/// layout, positioned by `belowPanelDragOffset` counting down from full
|
||||
/// hide travel so it follows the finger.
|
||||
@State private var isRevealDragActive = false
|
||||
|
||||
private var settingsManager: SettingsManager? { appEnvironment?.settingsManager }
|
||||
/// Suppresses the leaf-level .animation modifiers while a panel drag
|
||||
/// tracks the finger (the host sheet strips withAnimation transactions,
|
||||
/// so these modifiers are what actually animate releases — like portrait).
|
||||
private var isPanelDragActive: Bool { appEnvironment?.navigationCoordinator.isPanelDragging ?? false }
|
||||
private var playerState: PlayerState? { appEnvironment?.playerService.state }
|
||||
private var video: Video? { playerState?.currentVideo }
|
||||
private var accentColor: Color { settingsManager?.resolvedAccentColor ?? .accentColor }
|
||||
@@ -56,15 +95,17 @@ struct CornerPlayerLayout<PlayerContent: View>: View {
|
||||
playerControlsLayout.effectivePanelSettings
|
||||
}
|
||||
|
||||
/// Region emptiness is decided at the config level: runtime-empty pages
|
||||
/// Column emptiness is decided at the config level: runtime-empty pages
|
||||
/// (no chapters, no related yet) are hidden inside the panel content, not
|
||||
/// by collapsing regions — layout jumps during loading would be jarring.
|
||||
private var hasPrimaryRegion: Bool {
|
||||
!panelSettings.enabledPages(in: .primary).isEmpty
|
||||
/// by collapsing the column — layout jumps during loading would be jarring.
|
||||
/// Info always renders below the video; the side column holds the rest.
|
||||
private var hasSideColumn: Bool {
|
||||
panelSettings.enabledPages().contains { $0.pageType != .info }
|
||||
}
|
||||
|
||||
private var hasBelowVideoRegion: Bool {
|
||||
!panelSettings.enabledPages(in: .belowVideo).isEmpty
|
||||
/// The info page config drives the below-video panel's element settings.
|
||||
private var infoPageSettings: InfoPageSettings {
|
||||
panelSettings.pages.first { $0.pageType == .info }?.infoSettings ?? InfoPageSettings()
|
||||
}
|
||||
|
||||
private var panelBackgroundColor: Color {
|
||||
@@ -90,47 +131,182 @@ struct CornerPlayerLayout<PlayerContent: View>: View {
|
||||
let width = max(1, geometry.size.width)
|
||||
let height = max(1, geometry.size.height)
|
||||
|
||||
let showsPrimary = isPanelVisible && hasPrimaryRegion
|
||||
let showsBelowVideo = isPanelVisible && hasBelowVideoRegion
|
||||
let showsSideColumn = isPanelVisible && hasSideColumn
|
||||
let showsBelowVideo = isPanelVisible && isBelowPanelVisible
|
||||
|
||||
let effectiveColumnWidth = clampColumnWidth(columnWidth, availableWidth: width)
|
||||
// Grabber sits on the column's leading edge, inside the column background.
|
||||
let totalColumnWidth = effectiveColumnWidth + 20
|
||||
// Grabber sits on the column's leading edge, inside the column
|
||||
// background — narrow strip matching the panel drag handle look.
|
||||
let grabberWidth: CGFloat = 12
|
||||
let totalColumnWidth = effectiveColumnWidth + grabberWidth
|
||||
|
||||
let videoWidth = showsPrimary ? max(1, width - totalColumnWidth) : width
|
||||
let videoHeight: CGFloat = {
|
||||
guard showsBelowVideo else { return height }
|
||||
let videoWidth = showsSideColumn ? max(1, width - totalColumnWidth) : width
|
||||
let shownVideoHeight: CGFloat = {
|
||||
let rawAspectRatio = playerState?.displayAspectRatio ?? (16.0 / 9.0)
|
||||
let aspectRatio = rawAspectRatio > 0 ? rawAspectRatio : (16.0 / 9.0)
|
||||
return max(1, min(videoWidth / aspectRatio, height * Self.maxVideoHeightFraction))
|
||||
}()
|
||||
// Reveal drag renders the shown layout so the panel can follow
|
||||
// the finger while `isBelowPanelVisible` is still false.
|
||||
let showsPanelLayout = showsBelowVideo || (isPanelVisible && isRevealDragActive)
|
||||
|
||||
// Panel travel needed to fully clear the screen (incl. bottom
|
||||
// safe area + the player window's extra PiP inset), and the video
|
||||
// recenter offset that follows the drag — like the portrait
|
||||
// layout's videoYOffset. At full travel the offset video matches
|
||||
// the centered full-height layout exactly, so the state flips
|
||||
// after the hide/reveal animations cause no visible jump.
|
||||
let bottomInset = geometry.safeAreaInsets.bottom
|
||||
let fullColumnHeight = height + bottomInset
|
||||
// With the below panel hidden the video (and its bottom controls)
|
||||
// extends to the physical bottom edge, not the safe-area line.
|
||||
let videoHeight = showsPanelLayout ? shownVideoHeight : fullColumnHeight
|
||||
let normalPanelHeight = max(1, fullColumnHeight - shownVideoHeight)
|
||||
let panelHideTravel = normalPanelHeight
|
||||
|
||||
// One shared duration for the whole panel toggle — both panels
|
||||
// and both video dimensions animate in lockstep so the video
|
||||
// corner grows diagonally and everything finishes together.
|
||||
// Scaled to the larger travel so the speed stays reasonable.
|
||||
let panelExitSpeed: CGFloat = 1400
|
||||
let panelToggleDuration = Double(min(max(max(totalColumnWidth, normalPanelHeight) / panelExitSpeed, 0.25), 0.45))
|
||||
let videoDragProgress = showsPanelLayout
|
||||
? min(1.0, max(0, belowPanelDragOffset) / panelHideTravel)
|
||||
: 0
|
||||
let videoDragYOffset = (fullColumnHeight - shownVideoHeight) / 2 * videoDragProgress
|
||||
|
||||
// Portrait-style expand: dragging up grows the panel over the
|
||||
// video (expandOffset is negative while dragging up; when
|
||||
// expanded, positive while dragging down to collapse).
|
||||
let panelHeight: CGFloat = {
|
||||
if isBelowPanelExpanded {
|
||||
return max(normalPanelHeight, fullColumnHeight - belowPanelExpandOffset)
|
||||
}
|
||||
return min(fullColumnHeight, normalPanelHeight - belowPanelExpandOffset)
|
||||
}()
|
||||
let panelSpacerHeight: CGFloat = {
|
||||
if isBelowPanelExpanded {
|
||||
return min(videoHeight, belowPanelExpandOffset)
|
||||
}
|
||||
return max(0, videoHeight + belowPanelExpandOffset)
|
||||
}()
|
||||
|
||||
ZStack {
|
||||
Color.black.ignoresSafeArea(.all)
|
||||
|
||||
HStack(spacing: 0) {
|
||||
VStack(spacing: 0) {
|
||||
playerContent(
|
||||
togglePanel,
|
||||
isPanelVisible,
|
||||
videoWidth,
|
||||
videoHeight
|
||||
)
|
||||
.frame(width: videoWidth, height: videoHeight)
|
||||
.clipped()
|
||||
|
||||
if showsBelowVideo, let video {
|
||||
regionPanel(
|
||||
region: .belowVideo,
|
||||
selectedPage: $belowVideoPage,
|
||||
video: video
|
||||
ZStack(alignment: .top) {
|
||||
// Sized from the live (animated) geometry, not the
|
||||
// target values — during panel toggles the video then
|
||||
// resizes smoothly through every animation frame
|
||||
// instead of snapping to the final size.
|
||||
GeometryReader { videoProxy in
|
||||
playerContent(
|
||||
togglePanel,
|
||||
isPanelVisible,
|
||||
max(1, videoProxy.size.width),
|
||||
max(1, videoProxy.size.height)
|
||||
)
|
||||
.frame(width: videoWidth)
|
||||
.frame(maxHeight: .infinity)
|
||||
}
|
||||
// Height animates with the below-panel timing, width
|
||||
// with the side-column timing — the inner .animation
|
||||
// scopes only the height frame. Keyed to gesture/
|
||||
// visibility state, NOT to derived geometry — appear
|
||||
// settling must not animate.
|
||||
.frame(height: videoHeight)
|
||||
.animation(isPanelDragActive ? nil : .easeInOut(duration: panelToggleDuration), value: isPanelVisible)
|
||||
.animation(isPanelDragActive ? nil : .easeInOut(duration: panelToggleDuration), value: isBelowPanelVisible)
|
||||
.frame(width: videoWidth)
|
||||
.animation(isPanelDragActive ? nil : .easeInOut(duration: panelToggleDuration), value: isPanelVisible)
|
||||
.clipped()
|
||||
.offset(y: videoDragYOffset)
|
||||
.animation(isPanelDragActive ? nil : .spring(response: 0.3, dampingFraction: 1.0), value: videoDragYOffset)
|
||||
// Aspect ratio changes (new video, stream switch)
|
||||
// resize the video/panel split — animate them
|
||||
.animation(isPanelDragActive ? nil : .spring(response: 0.3, dampingFraction: 1.0), value: playerState?.displayAspectRatio)
|
||||
// Swipe up on the video to bring the panel back —
|
||||
// the panel follows the finger (like the portrait
|
||||
// reveal) via belowPanelDragOffset counting down
|
||||
// from full hide travel.
|
||||
.simultaneousGesture(
|
||||
(isPanelVisible && !isBelowPanelVisible)
|
||||
? DragGesture()
|
||||
.onChanged { value in
|
||||
guard appEnvironment?.navigationCoordinator.isAdjustingPlayerSliders != true else { return }
|
||||
guard value.translation.height < 0 || isRevealDragActive else { return }
|
||||
if appEnvironment?.navigationCoordinator.isPanelDragging != true {
|
||||
appEnvironment?.navigationCoordinator.isPanelDragging = true
|
||||
}
|
||||
var transaction = Transaction()
|
||||
transaction.disablesAnimations = true
|
||||
withTransaction(transaction) {
|
||||
isRevealDragActive = true
|
||||
belowPanelDragOffset = min(panelHideTravel, max(0, panelHideTravel + value.translation.height))
|
||||
}
|
||||
}
|
||||
.onEnded { value in
|
||||
guard isRevealDragActive else { return }
|
||||
appEnvironment?.navigationCoordinator.isPanelDragging = false
|
||||
if value.translation.height < -80 || value.predictedEndTranslation.height < -160 {
|
||||
// Revealing: flip to visible at the
|
||||
// current position, then animate home
|
||||
var transaction = Transaction()
|
||||
transaction.disablesAnimations = true
|
||||
withTransaction(transaction) {
|
||||
isBelowPanelVisible = true
|
||||
isRevealDragActive = false
|
||||
}
|
||||
withAnimation(.spring(response: 0.3, dampingFraction: 1.0)) {
|
||||
belowPanelDragOffset = 0
|
||||
}
|
||||
} else {
|
||||
// Snap back off-screen, then drop the
|
||||
// reveal layout without animation
|
||||
withAnimation(.spring(response: 0.3, dampingFraction: 1.0), completionCriteria: .logicallyComplete) {
|
||||
belowPanelDragOffset = panelHideTravel
|
||||
} completion: {
|
||||
var transaction = Transaction()
|
||||
transaction.disablesAnimations = true
|
||||
withTransaction(transaction) {
|
||||
isRevealDragActive = false
|
||||
belowPanelDragOffset = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
: nil
|
||||
)
|
||||
.frame(maxHeight: .infinity, alignment: .top)
|
||||
|
||||
if showsPanelLayout, let video {
|
||||
VStack(spacing: 0) {
|
||||
Spacer(minLength: 0)
|
||||
.frame(height: panelSpacerHeight)
|
||||
|
||||
belowVideoInfoPanel(video: video, hideTravel: panelHideTravel)
|
||||
.frame(width: videoWidth)
|
||||
.frame(height: panelHeight)
|
||||
.offset(y: belowPanelDragOffset)
|
||||
}
|
||||
.frame(maxHeight: .infinity, alignment: .top)
|
||||
.animation(isPanelDragActive ? nil : .spring(response: 0.3, dampingFraction: 1.0), value: belowPanelDragOffset)
|
||||
.animation(isPanelDragActive ? nil : .spring(response: 0.3, dampingFraction: 0.8), value: isBelowPanelExpanded)
|
||||
.animation(isPanelDragActive ? nil : .spring(response: 0.3, dampingFraction: 0.8), value: belowPanelExpandOffset)
|
||||
.animation(isPanelDragActive ? nil : .spring(response: 0.3, dampingFraction: 1.0), value: playerState?.displayAspectRatio)
|
||||
// Pure slide, no fade — with a fade the panel
|
||||
// seems to vanish before the side column and the
|
||||
// video resize, breaking the one-transition feel
|
||||
.transition(.move(edge: .bottom).animation(.easeInOut(duration: panelToggleDuration)))
|
||||
}
|
||||
}
|
||||
.frame(width: videoWidth)
|
||||
.frame(maxHeight: .infinity, alignment: .top)
|
||||
// Extend into the bottom safe area (incl. the player
|
||||
// window's extra PiP inset) so the panel and its pills
|
||||
// reach the true bottom edge
|
||||
.ignoresSafeArea(edges: .bottom)
|
||||
|
||||
if showsPrimary, let video {
|
||||
if showsSideColumn, let video {
|
||||
HStack(spacing: 0) {
|
||||
PanelResizeGrabber(
|
||||
panelWidth: $columnWidth,
|
||||
@@ -138,19 +314,24 @@ struct CornerPlayerLayout<PlayerContent: View>: View {
|
||||
maxWidth: clampColumnWidth(.infinity, availableWidth: width),
|
||||
panelSide: .right,
|
||||
isPinned: true,
|
||||
isDragActive: $isGrabberActive
|
||||
isDragActive: $isGrabberActive,
|
||||
hitWidth: grabberWidth
|
||||
)
|
||||
|
||||
regionPanel(
|
||||
region: .primary,
|
||||
selectedPage: $primaryPage,
|
||||
video: video
|
||||
)
|
||||
.frame(width: effectiveColumnWidth)
|
||||
sideColumnPanel(video: video)
|
||||
.frame(width: effectiveColumnWidth)
|
||||
}
|
||||
.background(panelBackgroundColor.ignoresSafeArea(edges: .vertical))
|
||||
// Bottom only — the background must not run under
|
||||
// the system status bar at the top
|
||||
.background(panelBackgroundColor.ignoresSafeArea(edges: .bottom))
|
||||
.transition(.move(edge: .trailing).animation(.easeInOut(duration: panelToggleDuration)))
|
||||
}
|
||||
}
|
||||
// Container-level animation so panel insert/remove and the
|
||||
// video-area resize play as one transition (the host sheet
|
||||
// strips withAnimation transactions, so key it here)
|
||||
.animation(isPanelDragActive ? nil : .easeInOut(duration: panelToggleDuration), value: isPanelVisible)
|
||||
.animation(isPanelDragActive ? nil : .easeInOut(duration: panelToggleDuration), value: isBelowPanelVisible)
|
||||
}
|
||||
.onChange(of: width) { _, newWidth in
|
||||
// Re-clamp when the window shrinks (still above the corner
|
||||
@@ -159,9 +340,12 @@ struct CornerPlayerLayout<PlayerContent: View>: View {
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
isPanelVisible = settingsManager?.landscapeDetailsPanelVisible ?? true
|
||||
if let savedWidth = settingsManager?.cornerPanelColumnWidth, savedWidth > 0 {
|
||||
columnWidth = savedWidth
|
||||
// Only sync from the setting once the session's default
|
||||
// visibility was applied — before that the setting is stale
|
||||
// (the init seed already resolved the correct initial value).
|
||||
if appEnvironment?.navigationCoordinator.hasAppliedPanelDefaultVisibility == true,
|
||||
let visible = settingsManager?.landscapeDetailsPanelVisible {
|
||||
isPanelVisible = visible
|
||||
}
|
||||
}
|
||||
.onChange(of: columnWidth) { _, newWidth in
|
||||
@@ -177,18 +361,136 @@ struct CornerPlayerLayout<PlayerContent: View>: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Region Panel
|
||||
// MARK: - Panels
|
||||
|
||||
/// Portrait-style panel below the video: the actual PortraitDetailsPanel
|
||||
/// (drag handle, player pill, collapsible description) with the comments
|
||||
/// pill hidden and no inline chapters/related — those live in the side
|
||||
/// column tabs.
|
||||
@ViewBuilder
|
||||
private func regionPanel(
|
||||
region: PanelRegion,
|
||||
selectedPage: Binding<PanelPageType>,
|
||||
video: Video
|
||||
) -> some View {
|
||||
private func belowVideoInfoPanel(video: Video, hideTravel: CGFloat) -> some View {
|
||||
Group {
|
||||
#if os(iOS)
|
||||
PortraitDetailsPanel(
|
||||
onChannelTap: onChannelTap,
|
||||
playerControlsLayout: playerControlsLayout,
|
||||
onFullscreen: onFullscreen,
|
||||
pillsOverlayOpacity: 1,
|
||||
hidesCommentsPill: true,
|
||||
inlineInfoSections: false,
|
||||
panelCornerRadius: 0,
|
||||
onDragChanged: { offset in
|
||||
if appEnvironment?.navigationCoordinator.isPanelDragging != true {
|
||||
appEnvironment?.navigationCoordinator.isPanelDragging = true
|
||||
}
|
||||
var transaction = Transaction()
|
||||
transaction.disablesAnimations = true
|
||||
withTransaction(transaction) {
|
||||
if isBelowPanelExpanded {
|
||||
// When expanded, only track drag down to collapse
|
||||
belowPanelExpandOffset = max(0, offset)
|
||||
} else if offset < 0 {
|
||||
// Dragging UP - expand panel over the video
|
||||
belowPanelExpandOffset = offset
|
||||
belowPanelDragOffset = 0
|
||||
} else {
|
||||
// Dragging DOWN - dismiss tracking
|
||||
belowPanelDragOffset = offset
|
||||
belowPanelExpandOffset = 0
|
||||
}
|
||||
}
|
||||
},
|
||||
onDragEnded: { offset, predicted in
|
||||
appEnvironment?.navigationCoordinator.isPanelDragging = false
|
||||
if isBelowPanelExpanded {
|
||||
if offset > 80 || predicted > 160 {
|
||||
withAnimation(.spring(response: 0.3, dampingFraction: 1.0)) {
|
||||
isBelowPanelExpanded = false
|
||||
belowPanelExpandOffset = 0
|
||||
}
|
||||
} else {
|
||||
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
|
||||
belowPanelExpandOffset = 0
|
||||
}
|
||||
}
|
||||
} else if offset < -80 || predicted < -160 {
|
||||
// Expand panel to cover the video
|
||||
withAnimation(.spring(response: 0.3, dampingFraction: 1.0)) {
|
||||
isBelowPanelExpanded = true
|
||||
belowPanelExpandOffset = 0
|
||||
belowPanelDragOffset = 0
|
||||
}
|
||||
} else if offset > 80 || predicted > 160 {
|
||||
// Slide the panel fully off (video recenters via the
|
||||
// drag-progress offset), then flip the hidden state
|
||||
// without animation — the offset layout at full
|
||||
// travel matches the hidden layout exactly.
|
||||
withAnimation(.spring(response: 0.3, dampingFraction: 1.0), completionCriteria: .logicallyComplete) {
|
||||
belowPanelDragOffset = hideTravel
|
||||
} completion: {
|
||||
var transaction = Transaction()
|
||||
transaction.disablesAnimations = true
|
||||
withTransaction(transaction) {
|
||||
isBelowPanelVisible = false
|
||||
isBelowPanelExpanded = false
|
||||
belowPanelDragOffset = 0
|
||||
}
|
||||
}
|
||||
} else {
|
||||
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
|
||||
belowPanelDragOffset = 0
|
||||
belowPanelExpandOffset = 0
|
||||
}
|
||||
}
|
||||
},
|
||||
onDragCancelled: {
|
||||
appEnvironment?.navigationCoordinator.isPanelDragging = false
|
||||
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
|
||||
belowPanelDragOffset = 0
|
||||
belowPanelExpandOffset = 0
|
||||
}
|
||||
}
|
||||
)
|
||||
#else
|
||||
// macOS: static info panel, no drag-to-hide
|
||||
InfoPanelPage(
|
||||
context: PanelPageContext(
|
||||
appEnvironment: appEnvironment,
|
||||
video: video,
|
||||
accentColor: accentColor,
|
||||
onChannelTap: onChannelTap
|
||||
),
|
||||
settings: infoPageSettings,
|
||||
scrollConfig: PanelScrollConfig(
|
||||
topInset: 4,
|
||||
bottomInset: 16
|
||||
)
|
||||
)
|
||||
.background(panelBackgroundColor.ignoresSafeArea(edges: .vertical))
|
||||
#endif
|
||||
}
|
||||
// Publish the panel frame so the window-level dismiss pan gesture
|
||||
// doesn't begin from touches inside this panel (drag handle conflict)
|
||||
.background {
|
||||
GeometryReader { proxy in
|
||||
Color.clear
|
||||
.onChange(of: proxy.frame(in: .global), initial: true) { _, frame in
|
||||
appEnvironment?.navigationCoordinator.cornerBelowPanelFrame = frame
|
||||
}
|
||||
.onDisappear {
|
||||
appEnvironment?.navigationCoordinator.cornerBelowPanelFrame = .zero
|
||||
appEnvironment?.navigationCoordinator.isPanelDragging = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Side column with tabs for every enabled page except info.
|
||||
@ViewBuilder
|
||||
private func sideColumnPanel(video: Video) -> some View {
|
||||
PlayerPanelContentView(
|
||||
panelSettings: panelSettings,
|
||||
region: region,
|
||||
selectedPage: selectedPage,
|
||||
selectedPage: $primaryPage,
|
||||
context: PanelPageContext(
|
||||
appEnvironment: appEnvironment,
|
||||
video: video,
|
||||
@@ -199,9 +501,10 @@ struct CornerPlayerLayout<PlayerContent: View>: View {
|
||||
topInset: 4,
|
||||
bottomInset: 16
|
||||
),
|
||||
pageBarTopPadding: 8
|
||||
pageBarTopPadding: 8,
|
||||
excludedPages: [.info]
|
||||
)
|
||||
.background(panelBackgroundColor.ignoresSafeArea(edges: .vertical))
|
||||
.background(panelBackgroundColor.ignoresSafeArea(edges: .bottom))
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
@@ -1317,8 +1317,18 @@ extension ExpandedPlayerSheet {
|
||||
/// identical to the stacked layout — only the container size differs.
|
||||
@ViewBuilder
|
||||
func cornerContent(video: Video) -> some View {
|
||||
// Seed the panel visibility before the first frame: once the
|
||||
// session's default visibility is applied the setting is the truth;
|
||||
// until then resolve the preset default (same policy the sheet's
|
||||
// applyPanelDefaultVisibility writes moments later).
|
||||
let initialPanelVisible = appEnvironment?.navigationCoordinator.hasAppliedPanelDefaultVisibility == true
|
||||
? (appEnvironment?.settingsManager.landscapeDetailsPanelVisible ?? true)
|
||||
: Self.initialPortraitPanelVisibility(settingsManager: appEnvironment?.settingsManager)
|
||||
|
||||
CornerPlayerLayout(
|
||||
playerControlsLayout: playerControlsLayout,
|
||||
initialPanelVisible: initialPanelVisible,
|
||||
initialColumnWidth: appEnvironment?.settingsManager.cornerPanelColumnWidth,
|
||||
playerContent: { onTogglePanel, isPanelVisible, width, height in
|
||||
// isPanelPinned=true keeps the controls visible alongside the
|
||||
// panel regions (corner regions behave like a pinned panel).
|
||||
@@ -1336,7 +1346,12 @@ extension ExpandedPlayerSheet {
|
||||
},
|
||||
onChannelTap: video.author.hasRealChannelInfo ? {
|
||||
navigationCoordinator?.navigateToChannel(for: video, collapsePlayer: true)
|
||||
} : nil
|
||||
} : nil,
|
||||
onFullscreen: {
|
||||
#if os(iOS)
|
||||
self.toggleFullscreen()
|
||||
#endif
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1558,6 +1573,7 @@ extension ExpandedPlayerSheet {
|
||||
isPanelVisible: isPanelVisible,
|
||||
panelSide: panelSide,
|
||||
isPanelPinned: isPanelPinned,
|
||||
isCornerLayout: appEnvironment?.navigationCoordinator.isCornerLayoutActive == true,
|
||||
layoutLeadingSafeArea: 0.01, // Non-zero indicates safe areas handled externally
|
||||
layoutTrailingSafeArea: 0.01,
|
||||
onToggleOrientationLock: { [weak appEnvironment] in
|
||||
|
||||
@@ -74,7 +74,10 @@ struct ExpandedPlayerSheet: View {
|
||||
/// 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)
|
||||
@State var playerControlsLayout: PlayerControlsLayout = .default
|
||||
// Seeded from the warm cache so the first frames resolve the correct
|
||||
// layout (corner mode) instead of flashing the default stacked layout;
|
||||
// loadPlayerControlsLayout replaces it with the authoritative value.
|
||||
@State var playerControlsLayout: PlayerControlsLayout = PlayerControlsLayout.cached
|
||||
|
||||
// MARK: - Autoplay Countdown State
|
||||
|
||||
@@ -470,8 +473,19 @@ struct ExpandedPlayerSheet: View {
|
||||
)
|
||||
#if os(iOS)
|
||||
.toolbar(.hidden, for: .navigationBar)
|
||||
.playerStatusBarHidden(isInWideScreenLayout || !isPortraitPanelVisible)
|
||||
.persistentSystemOverlays((isInWideScreenLayout || !isPortraitPanelVisible) ? .hidden : .automatic)
|
||||
// Corner layout with panels visible keeps the system status bar and
|
||||
// home indicator; hiding the panels (fullscreen state) hides them so
|
||||
// the video and controls reach the screen edges.
|
||||
.playerStatusBarHidden(
|
||||
navigationCoordinator?.isCornerLayoutActive == true
|
||||
? !(appEnvironment?.settingsManager.landscapeDetailsPanelVisible ?? true)
|
||||
: (isInWideScreenLayout || !isPortraitPanelVisible)
|
||||
)
|
||||
.persistentSystemOverlays(
|
||||
(navigationCoordinator?.isCornerLayoutActive == true
|
||||
? !(appEnvironment?.settingsManager.landscapeDetailsPanelVisible ?? true)
|
||||
: (isInWideScreenLayout || !isPortraitPanelVisible)) ? .hidden : .automatic
|
||||
)
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -944,6 +958,7 @@ private struct PlayerEventHandlersModifier: ViewModifier {
|
||||
if let service = appEnvironment?.playerControlsLayoutService {
|
||||
let layout = await service.activeLayout()
|
||||
playerControlsLayout = layout
|
||||
PlayerControlsLayout.cached = layout
|
||||
GlobalLayoutSettings.cached = layout.globalSettings
|
||||
MiniPlayerSettings.cached = layout.effectiveMiniPlayerSettings
|
||||
PanelSettings.cached = layout.effectivePanelSettings
|
||||
|
||||
@@ -144,6 +144,8 @@ private final class DragToDismissGestureHandler: NSObject, UIGestureRecognizerDe
|
||||
var isPanelDragging: (() -> Bool)?
|
||||
/// Returns the portrait panel frame in screen coordinates (for gesture conflict resolution)
|
||||
var getPortraitPanelFrame: (() -> CGRect)?
|
||||
/// Returns the corner layout below-video panel frame in screen coordinates (blocks dismiss pan)
|
||||
var getCornerBelowPanelFrame: (() -> CGRect)?
|
||||
/// Returns whether the portrait panel is currently visible (not hidden off-screen)
|
||||
var isPortraitPanelVisible: (() -> Bool)?
|
||||
/// Returns the progress bar frame in screen coordinates (for gesture conflict resolution)
|
||||
@@ -366,6 +368,16 @@ private final class DragToDismissGestureHandler: NSObject, UIGestureRecognizerDe
|
||||
return false
|
||||
}
|
||||
|
||||
// Corner layout: don't begin dismiss gesture from inside the
|
||||
// below-video panel — its drag handle hides the panel instead
|
||||
let belowPanelFrame = getCornerBelowPanelFrame?() ?? .zero
|
||||
if !belowPanelFrame.isEmpty {
|
||||
let touchLocation = panGesture.location(in: nil)
|
||||
if belowPanelFrame.contains(touchLocation) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// When portrait panel is visible, only allow dismiss from player area (above panel)
|
||||
// This prevents race conditions where isPanelDragging isn't set yet when gesture begins
|
||||
let panelFrame = getPortraitPanelFrame?() ?? .zero
|
||||
@@ -845,6 +857,9 @@ final class ExpandedPlayerWindowManager {
|
||||
handler.getPortraitPanelFrame = { [weak self] in
|
||||
self?.appEnvironment?.navigationCoordinator.portraitPanelFrame ?? .zero
|
||||
}
|
||||
handler.getCornerBelowPanelFrame = { [weak self] in
|
||||
self?.appEnvironment?.navigationCoordinator.cornerBelowPanelFrame ?? .zero
|
||||
}
|
||||
handler.isPortraitPanelVisible = { [weak self] in
|
||||
self?.appEnvironment?.navigationCoordinator.isPortraitPanelVisible ?? true
|
||||
}
|
||||
|
||||
@@ -19,6 +19,15 @@ struct InfoPanelPage: View {
|
||||
/// and related videos as inline sections below it.
|
||||
var inlineSections: Bool = false
|
||||
|
||||
/// Collapse only while something actually renders below the description —
|
||||
/// with no chapters and no related videos the description stays expanded.
|
||||
/// While details are still loading, keep it collapsed to avoid an
|
||||
/// expand-then-clamp flash when sections arrive.
|
||||
private var usesCollapsedDescription: Bool {
|
||||
inlineSections
|
||||
&& (!chapters.isEmpty || !relatedVideos.isEmpty || videoDetailsState == .loading)
|
||||
}
|
||||
|
||||
@State private var showFormattedDate = false
|
||||
@State private var showOriginalTitle = false
|
||||
@State private var isDescriptionExpanded = false
|
||||
@@ -124,7 +133,7 @@ struct InfoPanelPage: View {
|
||||
|
||||
return VStack(alignment: .leading, spacing: 8) {
|
||||
if !description.isEmpty {
|
||||
if inlineSections {
|
||||
if usesCollapsedDescription {
|
||||
collapsibleDescription(description)
|
||||
} else {
|
||||
Text(DescriptionText.attributed(description, linkColor: context.accentColor))
|
||||
|
||||
@@ -18,6 +18,9 @@ struct PanelResizeGrabber: View {
|
||||
let isPinned: Bool
|
||||
@Binding var isDragActive: Bool
|
||||
|
||||
/// Width of the grabber hit area (hosts can narrow it, e.g. corner layout)
|
||||
var hitWidth: CGFloat = PanelResizeGrabber.grabberHitWidth
|
||||
|
||||
@State private var isDragging = false
|
||||
@State private var dragStartWidth: CGFloat? = nil
|
||||
@State private var dragStartLocation: CGFloat? = nil
|
||||
@@ -26,29 +29,29 @@ struct PanelResizeGrabber: View {
|
||||
#endif
|
||||
|
||||
/// Width of the grabber hit area
|
||||
private static let grabberHitWidth: CGFloat = 20
|
||||
static let grabberHitWidth: CGFloat = 20
|
||||
|
||||
/// Grabber fill color - needs to be visible against both light (pinned) and dark (floating) backgrounds
|
||||
private var grabberColor: Color {
|
||||
// When pinned (light background), use a darker gray for visibility
|
||||
// When floating (dark background), use lighter color
|
||||
let baseColor = isPinned ? Color.gray : Color.secondary
|
||||
// Pinned matches the panel drag handle (secondary 0.4);
|
||||
// floating (dark background) stays a bit lighter
|
||||
let isActive = isDragging
|
||||
return baseColor.opacity(isActive ? 1.0 : (isPinned ? 0.5 : 0.6))
|
||||
return Color.secondary.opacity(isActive ? 1.0 : (isPinned ? 0.4 : 0.6))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
// Full-height container for expanded hit area
|
||||
Rectangle()
|
||||
.fill(Color.clear)
|
||||
.frame(width: Self.grabberHitWidth)
|
||||
.frame(width: hitWidth)
|
||||
.frame(maxHeight: .infinity)
|
||||
.contentShape(Rectangle())
|
||||
.overlay {
|
||||
// Visual pill indicator (centered)
|
||||
RoundedRectangle(cornerRadius: 3)
|
||||
// Visual pill indicator (centered) — sized like the panel
|
||||
// drag handle capsule (36x4), rotated vertical
|
||||
Capsule()
|
||||
.fill(grabberColor)
|
||||
.frame(width: 6, height: 40)
|
||||
.frame(width: 4, height: 36)
|
||||
}
|
||||
.gesture(dragGesture)
|
||||
#if os(macOS)
|
||||
|
||||
@@ -32,6 +32,10 @@ struct PlayerPanelContentView: View {
|
||||
/// those pages are then dropped from the page list.
|
||||
var inlineInfoSections: Bool = false
|
||||
|
||||
/// Pages the host renders elsewhere (e.g. corner layout keeps info below
|
||||
/// the video), removed from this view's page list.
|
||||
var excludedPages: Set<PanelPageType> = []
|
||||
|
||||
private var appEnvironment: AppEnvironment? { context.appEnvironment }
|
||||
|
||||
// MARK: - Page Visibility
|
||||
@@ -73,12 +77,15 @@ struct PlayerPanelContentView: View {
|
||||
}
|
||||
|
||||
private var visiblePages: [PanelPageConfiguration] {
|
||||
let pages = Self.visiblePages(
|
||||
var pages = Self.visiblePages(
|
||||
panelSettings: panelSettings,
|
||||
region: region,
|
||||
video: context.video,
|
||||
appEnvironment: appEnvironment
|
||||
)
|
||||
if !excludedPages.isEmpty {
|
||||
pages = pages.filter { !excludedPages.contains($0.pageType) }
|
||||
}
|
||||
guard inlineInfoSections else { return pages }
|
||||
return pages.filter { $0.pageType != .chapters && $0.pageType != .related }
|
||||
}
|
||||
|
||||
@@ -39,6 +39,10 @@ struct PlayerControlsActions {
|
||||
#if os(iOS)
|
||||
/// Whether the side panel is pinned (iPad landscape)
|
||||
let isPanelPinned: Bool
|
||||
|
||||
/// Whether the corner layout is active: the fullscreen button then
|
||||
/// toggles the panels (hide = fullscreen video, unhide on next press)
|
||||
var isCornerLayout: Bool = false
|
||||
#endif
|
||||
|
||||
/// Which side the panel is on
|
||||
@@ -119,6 +123,10 @@ struct PlayerControlsActions {
|
||||
/// Toggle details visibility (portrait fullscreen)
|
||||
var onToggleDetailsVisibility: (() -> Void)?
|
||||
|
||||
/// Hide the controls overlay (corner layout hides them before the
|
||||
/// panel toggle so the controls-resize never plays visibly)
|
||||
var onHideControls: (() -> Void)?
|
||||
|
||||
#if os(iOS)
|
||||
/// Toggle orientation lock
|
||||
var onToggleOrientationLock: (() -> Void)?
|
||||
@@ -263,6 +271,10 @@ struct PlayerControlsActions {
|
||||
/// Uses rotation icons when tapping will cause device rotation,
|
||||
/// otherwise uses standard fullscreen arrows.
|
||||
var fullscreenIcon: String {
|
||||
if isCornerLayout {
|
||||
// Corner layout: reflects the panels state, not orientation
|
||||
return isPanelVisible ? "arrow.up.left.and.arrow.down.right" : "arrow.down.right.and.arrow.up.left"
|
||||
}
|
||||
if willRotateOnFullscreenToggle {
|
||||
if isFullscreen {
|
||||
// Currently landscape fullscreen, will rotate to portrait
|
||||
@@ -280,6 +292,14 @@ struct PlayerControlsActions {
|
||||
/// Shared fullscreen-tap decision used by the fullscreen button and tap gestures.
|
||||
/// Keep in sync with `willRotateOnFullscreenToggle` (icon mirror of this logic).
|
||||
func performFullscreenTap() {
|
||||
if isCornerLayout {
|
||||
// Corner layout: hide the controls and toggle the panels —
|
||||
// the controls fade out while the panel transition plays
|
||||
onHideControls?()
|
||||
onTogglePanel?()
|
||||
return
|
||||
}
|
||||
|
||||
let isActualWidescreenLayout = isWideScreenLayout && onTogglePanel != nil
|
||||
|
||||
if isIPad {
|
||||
@@ -334,6 +354,11 @@ struct PlayerControlsActions {
|
||||
|
||||
/// Whether the fullscreen button should be shown
|
||||
var shouldShowFullscreenButton: Bool {
|
||||
// Corner layout: always shown, toggles the panels both ways
|
||||
if isCornerLayout {
|
||||
return onTogglePanel != nil
|
||||
}
|
||||
|
||||
// iPad widescreen (true landscape): hidden (can't force rotation)
|
||||
let isActualWidescreen = isWideScreenLayout && onTogglePanel != nil
|
||||
let showFullscreenButton = !(isIPad && isActualWidescreen)
|
||||
|
||||
@@ -43,6 +43,8 @@ struct PlayerControlsView: View {
|
||||
var isPanelVisible: Bool = true
|
||||
var panelSide: FloatingPanelSide = .right
|
||||
var isPanelPinned: Bool = false
|
||||
/// Corner layout: fullscreen button toggles the panels
|
||||
var isCornerLayout: Bool = false
|
||||
/// Safe area padding from layout (when panel is pinned)
|
||||
var layoutLeadingSafeArea: CGFloat = 0
|
||||
var layoutTrailingSafeArea: CGFloat = 0
|
||||
@@ -230,6 +232,14 @@ struct PlayerControlsView: View {
|
||||
cancelHideTimer()
|
||||
}
|
||||
}
|
||||
.onChange(of: isPanelVisible) { oldValue, newValue in
|
||||
// Corner layout: any panel toggle resizes the controls area —
|
||||
// keep the controls hidden while that transition plays
|
||||
if isCornerLayout, oldValue != newValue {
|
||||
showControls = false
|
||||
cancelHideTimer()
|
||||
}
|
||||
}
|
||||
.onChange(of: playerState.retryState.exhausted) { _, exhausted in
|
||||
// Hide controls when retries are exhausted (retry exhausted overlay takes over)
|
||||
if exhausted {
|
||||
@@ -389,6 +399,7 @@ struct PlayerControlsView: View {
|
||||
isOrientationLocked: isOrientationLocked,
|
||||
isPanelVisible: isPanelVisible,
|
||||
isPanelPinned: isPanelPinned,
|
||||
isCornerLayout: isCornerLayout,
|
||||
panelSide: panelSide,
|
||||
isIPad: isIPad,
|
||||
showVolumeControls: showVolumeControls,
|
||||
@@ -415,6 +426,10 @@ struct PlayerControlsView: View {
|
||||
onTogglePiP: onTogglePiP,
|
||||
onToggleFullscreen: onToggleFullscreen,
|
||||
onToggleDetailsVisibility: onToggleDetailsVisibility,
|
||||
onHideControls: { [self] in
|
||||
showControls = false
|
||||
cancelHideTimer()
|
||||
},
|
||||
onToggleOrientationLock: onToggleOrientationLock,
|
||||
onTogglePanel: onTogglePanel,
|
||||
onTogglePanscan: onTogglePanscan,
|
||||
@@ -484,7 +499,11 @@ struct PlayerControlsView: View {
|
||||
// bottomExtraPadding handles positioning, so no safe area needed
|
||||
// In landscape (widescreen), cap the safe area since home indicator needs less clearance
|
||||
let floatingPanelActive = isPanelVisible && !isPanelPinned
|
||||
let needsBottomSafeArea = (isWideScreenLayout || isFullscreen) && !floatingPanelActive
|
||||
// Corner layout with panels visible: the controls frame ends at the
|
||||
// video area mid-screen, so the window's bottom safe area (incl. the
|
||||
// PiP inset) must not push the bottom bar up from the video edge
|
||||
let cornerPanelsActive = isCornerLayout && isPanelVisible
|
||||
let needsBottomSafeArea = (isWideScreenLayout || isFullscreen) && !floatingPanelActive && !cornerPanelsActive
|
||||
let bottomSafeArea = isWideScreenLayout ? min(safeArea.bottom, 12) : safeArea.bottom
|
||||
let bottomPadding: CGFloat = 2 + (needsBottomSafeArea ? bottomSafeArea : 0)
|
||||
|
||||
|
||||
@@ -18,6 +18,19 @@ struct PortraitDetailsPanel: View {
|
||||
let onFullscreen: (() -> Void)?
|
||||
let pillsOverlayOpacity: CGFloat
|
||||
|
||||
/// Corner layout host: comments live in the side column, so the pill is
|
||||
/// suppressed entirely.
|
||||
var hidesCommentsPill: Bool = false
|
||||
|
||||
/// Portrait inlines chapters/related into the info page; the corner
|
||||
/// layout's below-video host turns this off because those live in the
|
||||
/// side column tabs (the description stays collapsible either way).
|
||||
var inlineInfoSections: Bool = true
|
||||
|
||||
/// Panel clip rounding — the corner layout's below-video host uses 0
|
||||
/// for a plain rectangle.
|
||||
var panelCornerRadius: CGFloat = 16
|
||||
|
||||
// Drag gesture callbacks
|
||||
var onDragChanged: ((CGFloat) -> Void)?
|
||||
var onDragEnded: ((CGFloat, CGFloat) -> Void)?
|
||||
@@ -64,7 +77,7 @@ struct PortraitDetailsPanel: View {
|
||||
playerPillSettings.effectiveCommentsPillMode
|
||||
}
|
||||
private var shouldShowCommentsPill: Bool {
|
||||
playerPillSettings.shouldShowCommentsPill
|
||||
!hidesCommentsPill && playerPillSettings.shouldShowCommentsPill
|
||||
}
|
||||
private var isCommentsPillAlwaysCollapsed: Bool {
|
||||
playerPillSettings.isCommentsPillAlwaysCollapsed
|
||||
@@ -165,7 +178,7 @@ struct PortraitDetailsPanel: View {
|
||||
),
|
||||
pageBarTopPadding: 14,
|
||||
showPageBar: false,
|
||||
inlineInfoSections: true
|
||||
inlineInfoSections: inlineInfoSections
|
||||
)
|
||||
.padding(.top, 10)
|
||||
.background(Color(.systemBackground))
|
||||
@@ -220,7 +233,7 @@ struct PortraitDetailsPanel: View {
|
||||
}
|
||||
}
|
||||
.background(Color(.systemBackground))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||
.clipShape(RoundedRectangle(cornerRadius: panelCornerRadius))
|
||||
// Pills overlay at bottom
|
||||
.overlay {
|
||||
if !isCommentsExpanded {
|
||||
|
||||
Reference in New Issue
Block a user