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) + } }