mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 21:22:53 +00:00
Preset-selected two-region layout: video in the top-left, a resizable side column, and an optional region below the video. Pages are assigned to regions per preset (Location picker in the page editor); the layout falls back to the stacked widescreen layout below 900pt width, so Split View and narrow windows degrade automatically. - CornerPlayerLayout hosts the shared playerAreaForWidescreen builder, so the MPV hosting path is identical to the stacked layout - Layout resolver in ExpandedPlayerSheet picks corner per layout pass; NavigationCoordinator.isCornerLayoutActive feeds the macOS window manager, which drops the aspect-ratio lock and auto-fit while corner is active (and restores them on exit, incl. fullscreen replay and window re-mounts) - Side column width persisted via new cornerPanelColumnWidth setting (280-560pt, video column kept >= 480pt) - Settings: layout mode picker (iPad/macOS) with automatic-fallback footer, per-page region picker when corner is on, region shown in the pages list
219 lines
8.1 KiB
Swift
219 lines
8.1 KiB
Swift
//
|
|
// 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.
|
|
//
|
|
|
|
import SwiftUI
|
|
|
|
#if os(iOS) || os(macOS)
|
|
|
|
struct CornerPlayerLayout<PlayerContent: View>: View {
|
|
@Environment(\.appEnvironment) private var appEnvironment
|
|
|
|
let playerControlsLayout: PlayerControlsLayout
|
|
|
|
/// Video + controls area builder, sized by the layout.
|
|
@ViewBuilder let playerContent: (
|
|
_ onTogglePanel: @escaping () -> Void,
|
|
_ isPanelVisible: Bool,
|
|
_ width: CGFloat,
|
|
_ height: CGFloat
|
|
) -> PlayerContent
|
|
|
|
let onChannelTap: (() -> Void)?
|
|
|
|
// MARK: - Layout Constants
|
|
|
|
/// Side column width bounds.
|
|
static var minColumnWidth: CGFloat { 280 }
|
|
static var maxColumnWidthCap: CGFloat { 560 }
|
|
/// Minimum width kept for the video column.
|
|
static var minVideoColumnWidth: CGFloat { 480 }
|
|
/// Maximum fraction of the window height the video area may take when a
|
|
/// below-video region needs space.
|
|
static var maxVideoHeightFraction: CGFloat { 0.72 }
|
|
|
|
// MARK: - State
|
|
|
|
@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
|
|
|
|
private var settingsManager: SettingsManager? { appEnvironment?.settingsManager }
|
|
private var playerState: PlayerState? { appEnvironment?.playerService.state }
|
|
private var video: Video? { playerState?.currentVideo }
|
|
private var accentColor: Color { settingsManager?.resolvedAccentColor ?? .accentColor }
|
|
|
|
private var panelSettings: PanelSettings {
|
|
playerControlsLayout.effectivePanelSettings
|
|
}
|
|
|
|
/// Region 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
|
|
}
|
|
|
|
private var hasBelowVideoRegion: Bool {
|
|
!panelSettings.enabledPages(in: .belowVideo).isEmpty
|
|
}
|
|
|
|
private var panelBackgroundColor: Color {
|
|
#if os(iOS)
|
|
Color(uiColor: .systemBackground)
|
|
#else
|
|
Color(nsColor: .windowBackgroundColor)
|
|
#endif
|
|
}
|
|
|
|
private func clampColumnWidth(_ width: CGFloat, availableWidth: CGFloat) -> CGFloat {
|
|
let maxWidth = max(
|
|
Self.minColumnWidth,
|
|
min(Self.maxColumnWidthCap, availableWidth * 0.45, availableWidth - Self.minVideoColumnWidth)
|
|
)
|
|
return min(max(width, Self.minColumnWidth), maxWidth)
|
|
}
|
|
|
|
// MARK: - Body
|
|
|
|
var body: some View {
|
|
GeometryReader { geometry in
|
|
let width = max(1, geometry.size.width)
|
|
let height = max(1, geometry.size.height)
|
|
|
|
let showsPrimary = isPanelVisible && hasPrimaryRegion
|
|
let showsBelowVideo = isPanelVisible && hasBelowVideoRegion
|
|
|
|
let effectiveColumnWidth = clampColumnWidth(columnWidth, availableWidth: width)
|
|
// Grabber sits on the column's leading edge, inside the column background.
|
|
let totalColumnWidth = effectiveColumnWidth + 20
|
|
|
|
let videoWidth = showsPrimary ? max(1, width - totalColumnWidth) : width
|
|
let videoHeight: CGFloat = {
|
|
guard showsBelowVideo else { return height }
|
|
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))
|
|
}()
|
|
|
|
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
|
|
)
|
|
.frame(width: videoWidth)
|
|
.frame(maxHeight: .infinity)
|
|
}
|
|
}
|
|
|
|
if showsPrimary, let video {
|
|
HStack(spacing: 0) {
|
|
PanelResizeGrabber(
|
|
panelWidth: $columnWidth,
|
|
minWidth: Self.minColumnWidth,
|
|
maxWidth: clampColumnWidth(.infinity, availableWidth: width),
|
|
panelSide: .right,
|
|
isPinned: true,
|
|
isDragActive: $isGrabberActive
|
|
)
|
|
|
|
regionPanel(
|
|
region: .primary,
|
|
selectedPage: $primaryPage,
|
|
video: video
|
|
)
|
|
.frame(width: effectiveColumnWidth)
|
|
}
|
|
.background(panelBackgroundColor.ignoresSafeArea(edges: .vertical))
|
|
}
|
|
}
|
|
}
|
|
.onChange(of: width) { _, newWidth in
|
|
// Re-clamp when the window shrinks (still above the corner
|
|
// threshold — below it the resolver switches layouts).
|
|
columnWidth = clampColumnWidth(columnWidth, availableWidth: newWidth)
|
|
}
|
|
}
|
|
.onAppear {
|
|
isPanelVisible = settingsManager?.landscapeDetailsPanelVisible ?? true
|
|
if let savedWidth = settingsManager?.cornerPanelColumnWidth, savedWidth > 0 {
|
|
columnWidth = savedWidth
|
|
}
|
|
}
|
|
.onChange(of: columnWidth) { _, newWidth in
|
|
settingsManager?.cornerPanelColumnWidth = newWidth
|
|
}
|
|
.onChange(of: settingsManager?.landscapeDetailsPanelVisible) { _, newValue in
|
|
// External writes (preset default visibility, other layout hosts)
|
|
// must reach the local state too.
|
|
guard let newValue, newValue != isPanelVisible else { return }
|
|
withAnimation(.easeInOut(duration: 0.25)) {
|
|
isPanelVisible = newValue
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Region Panel
|
|
|
|
@ViewBuilder
|
|
private func regionPanel(
|
|
region: PanelRegion,
|
|
selectedPage: Binding<PanelPageType>,
|
|
video: Video
|
|
) -> some View {
|
|
PlayerPanelContentView(
|
|
panelSettings: panelSettings,
|
|
region: region,
|
|
selectedPage: selectedPage,
|
|
context: PanelPageContext(
|
|
appEnvironment: appEnvironment,
|
|
video: video,
|
|
accentColor: accentColor,
|
|
onChannelTap: onChannelTap
|
|
),
|
|
scrollConfig: PanelScrollConfig(
|
|
topInset: 4,
|
|
bottomInset: 16
|
|
),
|
|
pageBarTopPadding: 8
|
|
)
|
|
.background(panelBackgroundColor.ignoresSafeArea(edges: .vertical))
|
|
}
|
|
|
|
// MARK: - Actions
|
|
|
|
private func togglePanel() {
|
|
withAnimation(.easeInOut(duration: 0.25)) {
|
|
isPanelVisible.toggle()
|
|
settingsManager?.landscapeDetailsPanelVisible = isPanelVisible
|
|
settingsManager?.detailsPanelLastVisible = isPanelVisible
|
|
}
|
|
}
|
|
}
|
|
|
|
#endif
|