From e71bb76f1344c9ba37ef6c67f5c7b1e6833b75aa Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Mon, 12 Oct 2026 18:24:46 +0200 Subject: [PATCH] Default the built-in presets to the corner layout The built-in presets now use the corner layout, which still falls back to stacked where it doesn't fit (iPhone, iPad portrait, narrow windows). In the corner layout the landscape panels open visible unless the preset says otherwise, on devices that can show it: iPad, macOS, and foldable iPhones, whose hinge is now remembered once reported. Custom presets and presets saved without panel settings keep the stacked layout. --- .../PlayerControls/BuiltInPresets.swift | 10 ++--- .../Models/PlayerControls/PanelSettings.swift | 44 ++++++++++++++++++- Yattee/Views/Player/HingeLayoutObserver.swift | 2 + YatteeTests/PanelSettingsTests.swift | 23 ++++++++++ 4 files changed, 72 insertions(+), 7 deletions(-) diff --git a/Yattee/Models/PlayerControls/BuiltInPresets.swift b/Yattee/Models/PlayerControls/BuiltInPresets.swift index 72f367c5..48e93b5b 100644 --- a/Yattee/Models/PlayerControls/BuiltInPresets.swift +++ b/Yattee/Models/PlayerControls/BuiltInPresets.swift @@ -11,7 +11,7 @@ extension LayoutPreset { /// Bump this version whenever any built-in preset definition changes. /// On launch, the app compares this against the last-applied version /// and replaces stale built-in presets with fresh copies from code. - static let builtInPresetsVersion = 9 + static let builtInPresetsVersion = 10 // MARK: - Built-in Preset IDs @@ -132,7 +132,7 @@ extension LayoutPreset { gesturesSettings: gesturesSettings, playerPillSettings: playerPillSettings, miniPlayerSettings: miniPlayerSettings, - panelSettings: .default + panelSettings: .builtInDefault ) } @@ -194,7 +194,7 @@ extension LayoutPreset { gesturesSettings: nil, playerPillSettings: nil, miniPlayerSettings: MiniPlayerSettings(), - panelSettings: .default + panelSettings: .builtInDefault ) } @@ -288,7 +288,7 @@ extension LayoutPreset { gesturesSettings: gesturesSettings, playerPillSettings: playerPillSettings, miniPlayerSettings: miniPlayerSettings, - panelSettings: .default + panelSettings: .builtInDefault ) } @@ -340,7 +340,7 @@ extension LayoutPreset { gesturesSettings: nil, playerPillSettings: nil, miniPlayerSettings: MiniPlayerSettings(), - panelSettings: .default + panelSettings: .builtInDefault ) } diff --git a/Yattee/Models/PlayerControls/PanelSettings.swift b/Yattee/Models/PlayerControls/PanelSettings.swift index 9bc14ea0..5e387742 100644 --- a/Yattee/Models/PlayerControls/PanelSettings.swift +++ b/Yattee/Models/PlayerControls/PanelSettings.swift @@ -478,16 +478,56 @@ struct PanelSettings: Codable, Hashable, Sendable { PanelPageConfiguration(pageType: .queue, region: .primary), ]) + /// Panel settings of the built-in presets: the default pages in the + /// corner layout. The player still falls back to the stacked layout where + /// the corner layout doesn't fit (iPhone, iPad portrait, narrow windows). + /// `default` stays stacked so presets saved without panel settings keep + /// the layout they had. + static let builtInDefault: PanelSettings = { + var settings = PanelSettings.default + settings.layoutMode = .corner + return settings + }() + var effectiveLayoutMode: PanelLayoutMode { layoutMode ?? .stacked } /// The default visibility for an orientation: its own value, else the - /// legacy single value, else portrait visible / landscape hidden. + /// legacy single value, else portrait visible / landscape hidden — except + /// landscape in the corner layout on a device that can show it, where the + /// panels are the point of the layout and open visible. func effectiveDefaultVisibility(for orientation: PanelOrientation) -> PanelDefaultVisibility { switch orientation { case .portrait: return portraitDefaultVisibility ?? defaultVisibility ?? .visible case .landscape: - return landscapeDefaultVisibility ?? defaultVisibility ?? .hidden + let fallback: PanelDefaultVisibility = + effectiveLayoutMode == .corner && Self.canHostCornerLayout ? .visible : .hidden + return landscapeDefaultVisibility ?? defaultVisibility ?? fallback + } + } + + /// Whether this device has a screen the corner layout fits on: iPad, + /// macOS, and foldable iPhones (interior display). + static var canHostCornerLayout: Bool { + #if os(macOS) + return true + #elseif os(iOS) + return UIDevice.current.userInterfaceIdiom == .pad || deviceHasHinge + #else + return false + #endif + } + + private static let deviceHasHingeKey = "deviceHasHinge" + + /// Whether a hinge has ever been reported on this device (foldable + /// iPhone). Hinge state only arrives through views, so the hinge + /// observers record it here for code that runs without one. Local only. + static var deviceHasHinge: Bool { + get { UserDefaults.standard.bool(forKey: deviceHasHingeKey) } + set { + guard newValue != deviceHasHinge else { return } + UserDefaults.standard.set(newValue, forKey: deviceHasHingeKey) } } diff --git a/Yattee/Views/Player/HingeLayoutObserver.swift b/Yattee/Views/Player/HingeLayoutObserver.swift index 879ae37c..38d72e1a 100644 --- a/Yattee/Views/Player/HingeLayoutObserver.swift +++ b/Yattee/Views/Player/HingeLayoutObserver.swift @@ -64,6 +64,7 @@ struct HingeLayoutObserver: ViewModifier { } // nil when leaving the hierarchy: keep the last state then guard let status = context.hinge?.status else { return } + PanelSettings.deviceHasHinge = true apply(status == .partiallyOpen) } } else { @@ -83,6 +84,7 @@ private struct HingePresenceObserver: ViewModifier { // nil also when leaving a hinge-providing hierarchy; keep the last value then guard context.hinge != nil else { return } hasHinge = true + PanelSettings.deviceHasHinge = true } } else { content diff --git a/YatteeTests/PanelSettingsTests.swift b/YatteeTests/PanelSettingsTests.swift index abb93d7e..81afd7e7 100644 --- a/YatteeTests/PanelSettingsTests.swift +++ b/YatteeTests/PanelSettingsTests.swift @@ -323,4 +323,27 @@ struct PanelSettingsTests { #expect(settings.belowVideoContent == .nothing) #expect(settings.effectiveRegion(for: .comments) == .primary) } + + @Test("Built-in presets default to the corner layout; legacy presets keep stacked") + func builtInPresetsUseCornerLayout() { + #expect(PanelSettings.builtInDefault.effectiveLayoutMode == .corner) + #expect(PanelSettings.builtInDefault.pages == PanelSettings.default.pages) + #expect(PanelSettings.default.effectiveLayoutMode == .stacked) + for preset in LayoutPreset.allBuiltIn() { + #expect(preset.layout.effectivePanelSettings.effectiveLayoutMode == .corner) + } + } + + @Test("Landscape opens visible only in the corner layout on corner-capable devices") + func cornerLandscapeDefaultVisibility() { + let corner = PanelSettings.builtInDefault + let expected: PanelDefaultVisibility = PanelSettings.canHostCornerLayout ? .visible : .hidden + #expect(corner.effectiveDefaultVisibility(for: .landscape) == expected) + #expect(corner.effectiveDefaultVisibility(for: .portrait) == .visible) + #expect(PanelSettings.default.effectiveDefaultVisibility(for: .landscape) == .hidden) + + var explicit = corner + explicit.landscapeDefaultVisibility = .hidden + #expect(explicit.effectiveDefaultVisibility(for: .landscape) == .hidden) + } }