From a6d989c7af051901e194020d58ea4daa1c13f31a Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Mon, 21 Sep 2026 23:52:09 +0200 Subject: [PATCH] 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 --- Yattee/Core/Settings/SettingsKey.swift | 3 +- .../Settings/SettingsManager+Advanced.swift | 17 ++ Yattee/Core/SettingsManager.swift | 2 + Yattee/Localizable.xcstrings | 88 +++++++ .../Models/PlayerControls/PanelSettings.swift | 30 ++- .../Navigation/NavigationCoordinator.swift | 6 + .../PlayerControlsSettingsViewModel.swift | 19 ++ Yattee/Views/Player/CornerPlayerLayout.swift | 218 ++++++++++++++++++ .../Player/ExpandedPlayerSheet+Layouts.swift | 50 ++++ Yattee/Views/Player/ExpandedPlayerSheet.swift | 51 +++- .../Player/ExpandedPlayerWindowManager.swift | 19 +- .../PlayerControls/PanelPageEditorView.swift | 28 +++ .../PlayerControls/PanelSettingsView.swift | 49 +++- YatteeTests/PanelSettingsTests.swift | 14 ++ 14 files changed, 582 insertions(+), 12 deletions(-) create mode 100644 Yattee/Views/Player/CornerPlayerLayout.swift diff --git a/Yattee/Core/Settings/SettingsKey.swift b/Yattee/Core/Settings/SettingsKey.swift index 86e9a345..5cf9cda5 100644 --- a/Yattee/Core/Settings/SettingsKey.swift +++ b/Yattee/Core/Settings/SettingsKey.swift @@ -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 diff --git a/Yattee/Core/Settings/SettingsManager+Advanced.swift b/Yattee/Core/Settings/SettingsManager+Advanced.swift index f61773d2..8a306872 100644 --- a/Yattee/Core/Settings/SettingsManager+Advanced.swift +++ b/Yattee/Core/Settings/SettingsManager+Advanced.swift @@ -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 { diff --git a/Yattee/Core/SettingsManager.swift b/Yattee/Core/SettingsManager.swift index fd4a9ece..d4380e25 100644 --- a/Yattee/Core/SettingsManager.swift +++ b/Yattee/Core/SettingsManager.swift @@ -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 diff --git a/Yattee/Localizable.xcstrings b/Yattee/Localizable.xcstrings index 200a9f22..44791fd5 100644 --- a/Yattee/Localizable.xcstrings +++ b/Yattee/Localizable.xcstrings @@ -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" : { diff --git a/Yattee/Models/PlayerControls/PanelSettings.swift b/Yattee/Models/PlayerControls/PanelSettings.swift index 1207b1b3..d960e5ff 100644 --- a/Yattee/Models/PlayerControls/PanelSettings.swift +++ b/Yattee/Models/PlayerControls/PanelSettings.swift @@ -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 diff --git a/Yattee/Services/Navigation/NavigationCoordinator.swift b/Yattee/Services/Navigation/NavigationCoordinator.swift index 84f89d33..9245b429 100644 --- a/Yattee/Services/Navigation/NavigationCoordinator.swift +++ b/Yattee/Services/Navigation/NavigationCoordinator.swift @@ -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 diff --git a/Yattee/ViewModels/PlayerControls/PlayerControlsSettingsViewModel.swift b/Yattee/ViewModels/PlayerControls/PlayerControlsSettingsViewModel.swift index ddf4c5e2..200e54c0 100644 --- a/Yattee/ViewModels/PlayerControls/PlayerControlsSettingsViewModel.swift +++ b/Yattee/ViewModels/PlayerControls/PlayerControlsSettingsViewModel.swift @@ -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) { diff --git a/Yattee/Views/Player/CornerPlayerLayout.swift b/Yattee/Views/Player/CornerPlayerLayout.swift new file mode 100644 index 00000000..1479f7a9 --- /dev/null +++ b/Yattee/Views/Player/CornerPlayerLayout.swift @@ -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: 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, + 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 diff --git a/Yattee/Views/Player/ExpandedPlayerSheet+Layouts.swift b/Yattee/Views/Player/ExpandedPlayerSheet+Layouts.swift index d1ef6882..895a0da6 100644 --- a/Yattee/Views/Player/ExpandedPlayerSheet+Layouts.swift +++ b/Yattee/Views/Player/ExpandedPlayerSheet+Layouts.swift @@ -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. diff --git a/Yattee/Views/Player/ExpandedPlayerSheet.swift b/Yattee/Views/Player/ExpandedPlayerSheet.swift index aa8fe9a4..8623ae3f 100644 --- a/Yattee/Views/Player/ExpandedPlayerSheet.swift +++ b/Yattee/Views/Player/ExpandedPlayerSheet.swift @@ -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. diff --git a/Yattee/Views/Player/ExpandedPlayerWindowManager.swift b/Yattee/Views/Player/ExpandedPlayerWindowManager.swift index 2501bd07..f0b2b758 100644 --- a/Yattee/Views/Player/ExpandedPlayerWindowManager.swift +++ b/Yattee/Views/Player/ExpandedPlayerWindowManager.swift @@ -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) diff --git a/Yattee/Views/Settings/PlayerControls/PanelPageEditorView.swift b/Yattee/Views/Settings/PlayerControls/PanelPageEditorView.swift index 0e5433fc..e8ea4db9 100644 --- a/Yattee/Views/Settings/PlayerControls/PanelPageEditorView.swift +++ b/Yattee/Views/Settings/PlayerControls/PanelPageEditorView.swift @@ -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 diff --git a/Yattee/Views/Settings/PlayerControls/PanelSettingsView.swift b/Yattee/Views/Settings/PlayerControls/PanelSettingsView.swift index 04699b08..79776455 100644 --- a/Yattee/Views/Settings/PlayerControls/PanelSettingsView.swift +++ b/Yattee/Views/Settings/PlayerControls/PanelSettingsView.swift @@ -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 } } diff --git a/YatteeTests/PanelSettingsTests.swift b/YatteeTests/PanelSettingsTests.swift index c939b020..cb835acc 100644 --- a/YatteeTests/PanelSettingsTests.swift +++ b/YatteeTests/PanelSettingsTests.swift @@ -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) + } }