mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 13:12:53 +00:00
Add corner player layout on iPad and macOS
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
This commit is contained in:
@@ -133,6 +133,7 @@ enum SettingsKey: String, CaseIterable {
|
||||
case landscapeDetailsPanelVisible
|
||||
case landscapeDetailsPanelPinned
|
||||
case detailsPanelLastVisible // Last user-set visibility, for the "remember" default
|
||||
case cornerPanelColumnWidth // Corner layout - width of the side column
|
||||
|
||||
// Player Controls
|
||||
case activeControlsPresetID
|
||||
@@ -168,7 +169,7 @@ enum SettingsKey: String, CaseIterable {
|
||||
// Player details panel — iOS/iPadOS only, different on other platforms
|
||||
.floatingDetailsPanelSide, .floatingDetailsPanelWidth,
|
||||
.landscapeDetailsPanelVisible, .landscapeDetailsPanelPinned,
|
||||
.detailsPanelLastVisible,
|
||||
.detailsPanelLastVisible, .cornerPanelColumnWidth,
|
||||
// Video swipe actions — touch-gesture feature
|
||||
.videoSwipeActionOrder, .videoSwipeActionVisibility:
|
||||
return true
|
||||
|
||||
@@ -290,6 +290,23 @@ extension SettingsManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Default side column width in the corner player layout.
|
||||
static let defaultCornerPanelColumnWidth: CGFloat = 380
|
||||
|
||||
/// Width of the side column in the corner player layout (iPad/macOS).
|
||||
/// User can resize via drag gesture. Persisted across sessions.
|
||||
var cornerPanelColumnWidth: CGFloat {
|
||||
get {
|
||||
if let cached = _cornerPanelColumnWidth { return cached }
|
||||
let value = double(for: .cornerPanelColumnWidth)
|
||||
return value > 0 ? CGFloat(value) : Self.defaultCornerPanelColumnWidth
|
||||
}
|
||||
set {
|
||||
_cornerPanelColumnWidth = newValue
|
||||
set(Double(newValue), for: .cornerPanelColumnWidth)
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the details panel is pinned in landscape layout.
|
||||
/// Default is false (floating mode).
|
||||
var landscapeDetailsPanelPinned: Bool {
|
||||
|
||||
@@ -183,6 +183,7 @@ final class SettingsManager {
|
||||
var _landscapeDetailsPanelVisible: Bool?
|
||||
var _landscapeDetailsPanelPinned: Bool?
|
||||
var _detailsPanelLastVisible: Bool?
|
||||
var _cornerPanelColumnWidth: CGFloat?
|
||||
|
||||
// Notification settings
|
||||
var _backgroundNotificationsEnabled: Bool?
|
||||
@@ -534,6 +535,7 @@ final class SettingsManager {
|
||||
_landscapeDetailsPanelVisible = nil
|
||||
_landscapeDetailsPanelPinned = nil
|
||||
_detailsPanelLastVisible = nil
|
||||
_cornerPanelColumnWidth = nil
|
||||
_backgroundNotificationsEnabled = nil
|
||||
_defaultNotificationsForNewChannels = nil
|
||||
_lastBackgroundCheck = nil
|
||||
|
||||
@@ -7467,6 +7467,28 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"panel.layoutMode.corner" : {
|
||||
"comment" : "Details panel layout mode option",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Corner"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"panel.layoutMode.stacked" : {
|
||||
"comment" : "Details panel layout mode option",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Standard"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"panel.page.info" : {
|
||||
"comment" : "Details panel page name",
|
||||
"localizations" : {
|
||||
@@ -7511,6 +7533,28 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"panel.region.belowVideo" : {
|
||||
"comment" : "Corner layout region option",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Below Video"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"panel.region.primary" : {
|
||||
"comment" : "Corner layout region option",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Side Column"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"panel.settings.defaultVisibility" : {
|
||||
"comment" : "Details panel settings picker label",
|
||||
"localizations" : {
|
||||
@@ -7577,6 +7621,28 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"panel.settings.layoutMode" : {
|
||||
"comment" : "Details panel layout mode picker label",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Layout"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"panel.settings.layoutMode.footer" : {
|
||||
"comment" : "Details panel layout mode footer",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Corner shows the video in the top corner with pages in a side column and below the video. The standard layout is used automatically when the window is too narrow."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"panel.settings.page.enabled" : {
|
||||
"comment" : "Details panel page editor toggle",
|
||||
"localizations" : {
|
||||
@@ -7599,6 +7665,28 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"panel.settings.page.region" : {
|
||||
"comment" : "Corner layout page region picker label",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Location"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"panel.settings.page.region.footer" : {
|
||||
"comment" : "Corner layout page region footer",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Where this page appears in the corner layout."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"panel.settings.pages.header" : {
|
||||
"comment" : "Details panel settings section header",
|
||||
"localizations" : {
|
||||
|
||||
@@ -61,18 +61,38 @@ enum PanelPageType: String, Codable, CaseIterable, Sendable {
|
||||
/// 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 {
|
||||
enum PanelRegion: String, Codable, CaseIterable, Sendable {
|
||||
case primary
|
||||
case belowVideo
|
||||
|
||||
/// Localized name for the settings picker.
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .primary:
|
||||
return String(localized: "panel.region.primary")
|
||||
case .belowVideo:
|
||||
return String(localized: "panel.region.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 {
|
||||
enum PanelLayoutMode: String, Codable, CaseIterable, Sendable {
|
||||
case stacked
|
||||
case corner
|
||||
|
||||
/// Localized name for the settings picker.
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .stacked:
|
||||
return String(localized: "panel.layoutMode.stacked")
|
||||
case .corner:
|
||||
return String(localized: "panel.layoutMode.corner")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Default Visibility
|
||||
@@ -377,6 +397,12 @@ struct PanelSettings: Codable, Hashable, Sendable {
|
||||
pages[index].settings = newSettings
|
||||
}
|
||||
|
||||
mutating func setRegion(_ region: PanelRegion, for pageType: PanelPageType) {
|
||||
normalize()
|
||||
guard let index = pages.firstIndex(where: { $0.pageType == pageType }) else { return }
|
||||
pages[index].region = region
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case pages
|
||||
case layoutMode
|
||||
|
||||
@@ -67,6 +67,7 @@ final class NavigationCoordinator {
|
||||
// visibility once; closing the player arms the next application.
|
||||
if !isPlayerExpanded {
|
||||
hasAppliedPanelDefaultVisibility = false
|
||||
isCornerLayoutActive = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -76,6 +77,11 @@ final class NavigationCoordinator {
|
||||
/// so window-mode switches and layout re-mounts don't re-apply it.
|
||||
var hasAppliedPanelDefaultVisibility = false
|
||||
|
||||
/// Whether the expanded player currently resolves to the corner layout
|
||||
/// (iPad/macOS). Drives macOS window aspect-lock gating, which lives in a
|
||||
/// different view than the layout resolver.
|
||||
var isCornerLayoutActive = false
|
||||
|
||||
/// Whether the mini player queue sheet is showing.
|
||||
var isMiniPlayerQueueSheetPresented = false
|
||||
|
||||
|
||||
@@ -819,6 +819,25 @@ final class PlayerControlsSettingsViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
/// Current panel layout mode (iPad/macOS).
|
||||
var panelLayoutMode: PanelLayoutMode {
|
||||
panelSettings.effectiveLayoutMode
|
||||
}
|
||||
|
||||
/// Synchronously updates the panel layout mode.
|
||||
func syncPanelLayoutMode(_ mode: PanelLayoutMode) {
|
||||
updatePanelSettingsSync { settings in
|
||||
settings.layoutMode = mode
|
||||
}
|
||||
}
|
||||
|
||||
/// Synchronously updates a page's corner-layout region assignment.
|
||||
func setPanelPageRegion(_ region: PanelRegion, for pageType: PanelPageType) {
|
||||
updatePanelSettingsSync { settings in
|
||||
settings.setRegion(region, for: pageType)
|
||||
}
|
||||
}
|
||||
|
||||
/// Enables or disables a panel page. The last enabled page cannot be
|
||||
/// disabled — the panel always needs at least one page.
|
||||
func setPanelPageEnabled(_ pageType: PanelPageType, isEnabled: Bool) {
|
||||
|
||||
218
Yattee/Views/Player/CornerPlayerLayout.swift
Normal file
218
Yattee/Views/Player/CornerPlayerLayout.swift
Normal file
@@ -0,0 +1,218 @@
|
||||
//
|
||||
// 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
|
||||
@@ -16,6 +16,11 @@ extension ExpandedPlayerSheet {
|
||||
/// Minimum width required to show widescreen layout with floating panel.
|
||||
static let minWidthForWidescreen: CGFloat = 700
|
||||
|
||||
/// Minimum width required for the corner layout; below this the corner
|
||||
/// preset setting falls back to the stacked widescreen layout (Split View,
|
||||
/// narrow windows).
|
||||
static let minWidthForCornerLayout: CGFloat = 900
|
||||
|
||||
// MARK: - Layout Detection
|
||||
|
||||
/// Check if widescreen layout should be used based on available size.
|
||||
@@ -31,6 +36,21 @@ extension ExpandedPlayerSheet {
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(iOS) || os(macOS)
|
||||
/// Whether the corner layout should be used for the given size: iPad/macOS
|
||||
/// only, opted into via the preset's panel settings, wide enough, and in a
|
||||
/// widescreen orientation. Evaluated per layout pass so window resizes and
|
||||
/// Split View changes fall back automatically.
|
||||
func isCornerLayout(size: CGSize) -> Bool {
|
||||
#if os(iOS)
|
||||
guard UIDevice.current.userInterfaceIdiom == .pad else { return false }
|
||||
#endif
|
||||
return playerControlsLayout.effectivePanelSettings.effectiveLayoutMode == .corner
|
||||
&& size.width >= Self.minWidthForCornerLayout
|
||||
&& isWideScreenLayout(size: size)
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: - Download Action Handler
|
||||
|
||||
#if !os(tvOS)
|
||||
@@ -1276,6 +1296,36 @@ extension ExpandedPlayerSheet {
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Corner Content
|
||||
|
||||
/// Corner layout: video top-left, side column, optional below-video
|
||||
/// region. Reuses `playerAreaForWidescreen` so the MPV hosting path is
|
||||
/// identical to the stacked layout — only the container size differs.
|
||||
@ViewBuilder
|
||||
func cornerContent(video: Video) -> some View {
|
||||
CornerPlayerLayout(
|
||||
playerControlsLayout: playerControlsLayout,
|
||||
playerContent: { onTogglePanel, isPanelVisible, width, height in
|
||||
// isPanelPinned=true keeps the controls visible alongside the
|
||||
// panel regions (corner regions behave like a pinned panel).
|
||||
playerAreaForWidescreen(
|
||||
onTogglePanel: onTogglePanel,
|
||||
isPanelVisible: isPanelVisible,
|
||||
isPanelPinned: true,
|
||||
panelSide: .right,
|
||||
onHidePanel: {},
|
||||
leadingSafeArea: 0,
|
||||
trailingSafeArea: 0,
|
||||
fullWidth: width,
|
||||
fullHeight: height
|
||||
)
|
||||
},
|
||||
onChannelTap: video.author.hasRealChannelInfo ? {
|
||||
navigationCoordinator?.navigateToChannel(for: video, collapsePlayer: true)
|
||||
} : nil
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Widescreen Player Area
|
||||
|
||||
/// Player area for widescreen layout with panel support.
|
||||
|
||||
@@ -294,6 +294,9 @@ struct ExpandedPlayerSheet: View {
|
||||
private var playerContent: some View {
|
||||
GeometryReader { geometry in
|
||||
let wideScreen = isWideScreenLayout(size: geometry.size)
|
||||
#if os(iOS) || os(macOS)
|
||||
let cornerActive = isCornerLayout(size: geometry.size)
|
||||
#endif
|
||||
|
||||
ZStack(alignment: .top) {
|
||||
// Full-screen background - prevents content leaking during aspect ratio animation
|
||||
@@ -310,10 +313,17 @@ struct ExpandedPlayerSheet: View {
|
||||
Color.black.ignoresSafeArea(.all)
|
||||
} else if wideScreen {
|
||||
if let video = playerState?.currentVideo {
|
||||
// Widescreen layout with floating panel
|
||||
// Must ignore safe area to get full screen geometry
|
||||
wideScreenContent(video: video)
|
||||
.ignoresSafeArea(.all)
|
||||
if cornerActive {
|
||||
// Corner layout: video top-left with panel regions.
|
||||
// Laid out within the safe area (black background
|
||||
// extends beneath it).
|
||||
cornerContent(video: video)
|
||||
} else {
|
||||
// Widescreen layout with floating panel
|
||||
// Must ignore safe area to get full screen geometry
|
||||
wideScreenContent(video: video)
|
||||
.ignoresSafeArea(.all)
|
||||
}
|
||||
} else {
|
||||
// Widescreen loading state - just show black with spinner
|
||||
Color.black
|
||||
@@ -337,6 +347,11 @@ struct ExpandedPlayerSheet: View {
|
||||
// Disable animations during layout switch to prevent position interpolation
|
||||
transaction.animation = nil
|
||||
}
|
||||
#if os(iOS) || os(macOS)
|
||||
.onChange(of: cornerActive) { _, newValue in
|
||||
navigationCoordinator?.isCornerLayoutActive = newValue
|
||||
}
|
||||
#endif
|
||||
.onChange(of: wideScreen) { _, newValue in
|
||||
isInWideScreenLayout = newValue
|
||||
#if os(iOS)
|
||||
@@ -371,6 +386,9 @@ struct ExpandedPlayerSheet: View {
|
||||
}
|
||||
.onAppear {
|
||||
isInWideScreenLayout = wideScreen
|
||||
#if os(iOS) || os(macOS)
|
||||
navigationCoordinator?.isCornerLayoutActive = cornerActive
|
||||
#endif
|
||||
#if os(iOS)
|
||||
previousIsLandscape = wideScreen
|
||||
if wideScreen {
|
||||
@@ -988,6 +1006,11 @@ private struct PlayerMacOSEventHandlersModifier: ViewModifier {
|
||||
|
||||
var playerState: PlayerState? { appEnvironment?.playerService.state }
|
||||
|
||||
/// Corner layout resizes the window freely: no aspect lock, no auto-fit.
|
||||
private var isCornerLayoutActive: Bool {
|
||||
appEnvironment?.navigationCoordinator.isCornerLayoutActive == true
|
||||
}
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.onChange(of: playerState?.showDebugOverlay) { _, isVisible in
|
||||
@@ -1003,12 +1026,30 @@ private struct PlayerMacOSEventHandlersModifier: ViewModifier {
|
||||
.onChange(of: playerState?.currentVideo?.id) { oldValue, newValue in
|
||||
handleVideoChangeForResize(oldValue: oldValue, newValue: newValue)
|
||||
}
|
||||
.onChange(of: appEnvironment?.navigationCoordinator.isCornerLayoutActive) { _, isCorner in
|
||||
handleCornerLayoutChange(isCorner: isCorner == true)
|
||||
}
|
||||
.onAppear {
|
||||
handleMacOSAppear()
|
||||
}
|
||||
}
|
||||
|
||||
/// Entering corner mode drops the window's aspect-ratio constraint so it
|
||||
/// resizes freely; leaving it restores the lock (and auto-fit, when on)
|
||||
/// exactly as the aspect-change handler would have applied it.
|
||||
private func handleCornerLayoutChange(isCorner: Bool) {
|
||||
if isCorner {
|
||||
ExpandedPlayerWindowManager.shared.unlockAspectRatio()
|
||||
} else if let aspectRatio = playerState?.videoAspectRatio, aspectRatio > 0 {
|
||||
ExpandedPlayerWindowManager.shared.lockAspectRatio(aspectRatio)
|
||||
if appEnvironment?.settingsManager.playerSheetAutoResize == true {
|
||||
ExpandedPlayerWindowManager.shared.resizeToFitAspectRatio(aspectRatio, animated: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleAspectRatioChange(oldValue: Double?, newValue: Double?) {
|
||||
guard !isCornerLayoutActive else { return }
|
||||
// nil while a video is loaded means audio-only playback (no video
|
||||
// track reports a size) — fall back to the standard 16:9 instead of
|
||||
// keeping the previous video's ratio.
|
||||
@@ -1039,6 +1080,7 @@ private struct PlayerMacOSEventHandlersModifier: ViewModifier {
|
||||
}
|
||||
|
||||
private func handleVideoChangeForResize(oldValue: Video.ID?, newValue: Video.ID?) {
|
||||
guard !isCornerLayoutActive else { return }
|
||||
guard oldValue != nil, newValue != nil else { return }
|
||||
guard appEnvironment?.settingsManager.playerSheetAutoResize == true else { return }
|
||||
|
||||
@@ -1054,6 +1096,7 @@ private struct PlayerMacOSEventHandlersModifier: ViewModifier {
|
||||
}
|
||||
|
||||
private func handleMacOSAppear() {
|
||||
guard !isCornerLayoutActive else { return }
|
||||
guard let aspectRatio = playerState?.videoAspectRatio, aspectRatio > 0 else { return }
|
||||
|
||||
// Lock aspect ratio for manual resize unconditionally.
|
||||
|
||||
@@ -176,7 +176,11 @@ final class ExpandedPlayerWindowManager: NSObject {
|
||||
|
||||
// Lock manual resize to the video aspect ratio. Seeded with the real ratio
|
||||
// when known (else 16:9); updated as soon as the real ratio is known.
|
||||
Self.applyAspectRatioConstraint(seedAspect, to: window)
|
||||
// Corner layout (already active on a window re-mount, e.g. switching
|
||||
// from the inline overlay) keeps the window unconstrained.
|
||||
if appEnvironment.navigationCoordinator.isCornerLayoutActive != true {
|
||||
Self.applyAspectRatioConstraint(seedAspect, to: window)
|
||||
}
|
||||
|
||||
// Force the intended size. Assigning `contentViewController` above resizes
|
||||
// the window to the hosting controller's fitting size — and the lightweight
|
||||
@@ -588,6 +592,17 @@ final class ExpandedPlayerWindowManager: NSObject {
|
||||
Self.applyAspectRatioConstraint(aspectRatio, to: window)
|
||||
}
|
||||
|
||||
/// Removes the window's aspect-ratio resize constraint so it resizes
|
||||
/// freely. Used by the corner layout, where the window frames the whole
|
||||
/// video-plus-panels arrangement rather than the video alone. The size
|
||||
/// floor stays enforced by `windowWillResize(_:to:)`.
|
||||
func unlockAspectRatio() {
|
||||
guard let window = playerWindow else { return }
|
||||
pendingFullScreenAspectRatio = nil
|
||||
window.contentAspectRatio = .zero
|
||||
LoggingService.shared.debug("unlockAspectRatio (corner layout)", category: .player)
|
||||
}
|
||||
|
||||
// MARK: - Private Helpers
|
||||
|
||||
/// Sets `contentAspectRatio` and a ratio-consistent minimum content size on
|
||||
@@ -813,6 +828,8 @@ extension ExpandedPlayerWindowManager: NSWindowDelegate {
|
||||
// exactly what the aspect-change handler would have done windowed.
|
||||
if let ratio = pendingFullScreenAspectRatio {
|
||||
pendingFullScreenAspectRatio = nil
|
||||
// Corner layout keeps the window unconstrained.
|
||||
guard appEnvironment?.navigationCoordinator.isCornerLayoutActive != true else { return }
|
||||
Self.applyAspectRatioConstraint(ratio, to: window)
|
||||
if appEnvironment?.settingsManager.playerSheetAutoResize == true {
|
||||
resizeToFitAspectRatio(ratio, animated: false)
|
||||
|
||||
@@ -15,6 +15,7 @@ struct PanelPageEditorView: View {
|
||||
|
||||
// Local state for immediate UI updates
|
||||
@State private var isEnabled: Bool = true
|
||||
@State private var region: PanelRegion = .primary
|
||||
@State private var showDescription: Bool = true
|
||||
@State private var showStatsRow: Bool = true
|
||||
@State private var showChannelRow: Bool = true
|
||||
@@ -32,6 +33,10 @@ struct PanelPageEditorView: View {
|
||||
Form {
|
||||
enabledSection
|
||||
|
||||
if PanelSettingsView.supportsCornerLayout, viewModel.panelLayoutMode == .corner {
|
||||
regionSection
|
||||
}
|
||||
|
||||
if pageType == .info {
|
||||
infoElementsSection
|
||||
}
|
||||
@@ -73,6 +78,28 @@ struct PanelPageEditorView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Region Section (corner layout)
|
||||
|
||||
private var regionSection: some View {
|
||||
Section {
|
||||
Picker(
|
||||
String(localized: "panel.settings.page.region"),
|
||||
selection: $region
|
||||
) {
|
||||
ForEach(PanelRegion.allCases, id: \.self) { region in
|
||||
Text(region.displayName).tag(region)
|
||||
}
|
||||
}
|
||||
.disabled(!viewModel.canEditActivePreset)
|
||||
.onChange(of: region) { _, newValue in
|
||||
guard newValue != configuration?.region else { return }
|
||||
viewModel.setPanelPageRegion(newValue, for: pageType)
|
||||
}
|
||||
} footer: {
|
||||
Text(String(localized: "panel.settings.page.region.footer"))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Info Elements Section
|
||||
|
||||
private var infoElementsSection: some View {
|
||||
@@ -117,6 +144,7 @@ struct PanelPageEditorView: View {
|
||||
|
||||
private func syncLocalState() {
|
||||
isEnabled = configuration?.isEnabled ?? true
|
||||
region = configuration?.region ?? .primary
|
||||
let info = infoSettings
|
||||
showDescription = info.effectiveShowDescription
|
||||
showStatsRow = info.effectiveShowStatsRow
|
||||
|
||||
@@ -14,9 +14,22 @@ struct PanelSettingsView: View {
|
||||
|
||||
// Local state for immediate UI updates
|
||||
@State private var defaultVisibility: PanelDefaultVisibility = .hidden
|
||||
@State private var layoutMode: PanelLayoutMode = .stacked
|
||||
|
||||
/// Corner layout exists on iPad and macOS only.
|
||||
static var supportsCornerLayout: Bool {
|
||||
#if os(macOS)
|
||||
return true
|
||||
#else
|
||||
return UIDevice.current.userInterfaceIdiom == .pad
|
||||
#endif
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
if Self.supportsCornerLayout {
|
||||
layoutSection
|
||||
}
|
||||
visibilitySection
|
||||
pagesSection
|
||||
}
|
||||
@@ -40,6 +53,28 @@ struct PanelSettingsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Layout Section
|
||||
|
||||
private var layoutSection: some View {
|
||||
Section {
|
||||
Picker(
|
||||
String(localized: "panel.settings.layoutMode"),
|
||||
selection: $layoutMode
|
||||
) {
|
||||
ForEach(PanelLayoutMode.allCases, id: \.self) { mode in
|
||||
Text(mode.displayName).tag(mode)
|
||||
}
|
||||
}
|
||||
.disabled(!viewModel.canEditActivePreset)
|
||||
.onChange(of: layoutMode) { _, newValue in
|
||||
guard newValue != viewModel.panelLayoutMode else { return }
|
||||
viewModel.syncPanelLayoutMode(newValue)
|
||||
}
|
||||
} footer: {
|
||||
Text(String(localized: "panel.settings.layoutMode.footer"))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Visibility Section
|
||||
|
||||
private var visibilitySection: some View {
|
||||
@@ -92,10 +127,15 @@ struct PanelSettingsView: View {
|
||||
HStack {
|
||||
Label(page.pageType.displayName, systemImage: page.pageType.systemImage)
|
||||
Spacer()
|
||||
Text(page.isEnabled
|
||||
? String(localized: "common.on")
|
||||
: String(localized: "common.off"))
|
||||
.foregroundStyle(.secondary)
|
||||
if page.isEnabled, layoutMode == .corner, Self.supportsCornerLayout {
|
||||
Text(page.region.displayName)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
Text(page.isEnabled
|
||||
? String(localized: "common.on")
|
||||
: String(localized: "common.off"))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -104,6 +144,7 @@ struct PanelSettingsView: View {
|
||||
|
||||
private func syncLocalState() {
|
||||
defaultVisibility = viewModel.panelDefaultVisibility
|
||||
layoutMode = viewModel.panelLayoutMode
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -160,4 +160,18 @@ struct PanelSettingsTests {
|
||||
settings.setEnabled(true, for: .comments)
|
||||
#expect(settings.pages.map(\.pageType) == PanelSettings.default.pages.map(\.pageType))
|
||||
}
|
||||
|
||||
@Test("Region assignment and layout mode round-trip through Codable")
|
||||
func regionAndLayoutModeRoundTrip() throws {
|
||||
var settings = PanelSettings.default
|
||||
settings.layoutMode = .corner
|
||||
settings.setRegion(.belowVideo, for: .related)
|
||||
#expect(settings.configuration(for: .related)?.region == .belowVideo)
|
||||
#expect(settings.enabledPages(in: .primary).map(\.pageType) == [.chapters, .queue])
|
||||
|
||||
let data = try JSONEncoder().encode(settings)
|
||||
let decoded = try JSONDecoder().decode(PanelSettings.self, from: data)
|
||||
#expect(decoded.effectiveLayoutMode == .corner)
|
||||
#expect(decoded.configuration(for: .related)?.region == .belowVideo)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user