Files
yattee/Yattee/Models/PlayerControls/PanelSettings.swift
Arkadiusz Fal 4210b0ab05 Simplify the wide-layout details panel: floating-only on iPhone, side as a preset setting
iPhone landscape now uses the floating panel only, always on the right;
the pin and side-toggle buttons on the grabber are gone and the pinned
setting resolves to false on phone.

iPad and macOS default to the pinned side-by-side layout, keep the pin
button to switch to floating, and pick the panel side in the preset's
panel settings instead of an on-screen arrow. The pinned state persists
as a layout preference: closing the player or the hidden default
visibility no longer resets it.

Removes PanelAlignmentButton, the global floatingDetailsPanelSide setting
and its key, FloatingPanelSide.opposite, and the legacy
wideLayoutPanelAlignment preset field.
2026-09-26 22:48:11 +02:00

545 lines
20 KiB
Swift

//
// PanelSettings.swift
// Yattee
//
// Preset-backed configuration for the player details panel pages.
//
import Foundation
import SwiftUI
// MARK: - Panel Page Type
/// A page that can be shown inside the player details panel.
enum PanelPageType: String, Codable, CaseIterable, Sendable {
case info
case comments
case related
case chapters
case queue
/// Localized display name shown in the page bar and settings.
var displayName: String {
switch self {
case .info:
return String(localized: "panel.page.info")
case .comments:
return String(localized: "player.comments")
case .related:
return String(localized: "panel.page.related")
case .chapters:
return String(localized: "player.chapters")
case .queue:
return String(localized: "panel.page.queue")
}
}
/// The panel settings version when this page type was added.
/// Used for showing "NEW" badges on recently added pages.
var versionAdded: Int {
// All initial pages shipped together; bump for future page types.
return 1
}
/// Pages added after this version show a "NEW" badge in settings.
/// Raise it together with the release that introduces a new page type.
static let newBadgeThresholdVersion = 1
/// Whether this page should carry a "NEW" badge in settings.
var isRecentlyAdded: Bool {
versionAdded > Self.newBadgeThresholdVersion
}
/// SF Symbol used in the page bar and settings.
var systemImage: String {
switch self {
case .info:
return "info.circle"
case .comments:
return "bubble.left.and.bubble.right"
case .related:
return "rectangle.stack.badge.play"
case .chapters:
return "list.bullet.rectangle"
case .queue:
return "list.bullet"
}
}
}
// MARK: - Panel Region
/// 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, 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, 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
/// Default panel visibility when the player opens on iPad/macOS.
enum PanelDefaultVisibility: String, Codable, CaseIterable, Sendable {
case hidden
case visible
case remember
/// Localized name for the settings picker.
var displayName: String {
switch self {
case .hidden:
return String(localized: "panel.defaultVisibility.hidden")
case .visible:
return String(localized: "panel.defaultVisibility.visible")
case .remember:
return String(localized: "panel.defaultVisibility.remember")
}
}
}
// MARK: - Per-Page Settings
/// Element toggles for the info page. Optionals fall back to defaults so
/// future fields stay compatible with old saved presets.
struct InfoPageSettings: Codable, Hashable, Sendable {
var showStatsRow: Bool?
var showChannelRow: Bool?
var showDescription: Bool?
var descriptionCollapsedLines: Int?
var effectiveShowStatsRow: Bool { showStatsRow ?? true }
var effectiveShowChannelRow: Bool { showChannelRow ?? true }
var effectiveShowDescription: Bool { showDescription ?? true }
}
// MARK: - Comments Pill Mode
/// Controls how the comments pill is displayed in the player.
/// Stored on the comments page settings (moved from the player pill settings;
/// `PlayerControlsLayout.effectivePanelSettings` migrates the legacy value).
enum CommentsPillMode: String, Codable, Hashable, Sendable, CaseIterable {
case pill // Default - shows expanded pill, collapses on scroll
case button // Always show collapsed (button-only)
case disabled // No button/pill visible
/// Localized display name for settings UI.
var displayName: String {
switch self {
case .pill:
return String(localized: "commentsPill.mode.pill")
case .button:
return String(localized: "commentsPill.mode.button")
case .disabled:
return String(localized: "commentsPill.mode.disabled")
}
}
/// Whether the comments pill should always be collapsed (button mode).
var alwaysCollapsed: Bool {
self == .button
}
}
/// Element toggles for the comments page.
struct CommentsPageSettings: Codable, Hashable, Sendable {
var showAvatars: Bool?
/// How the comments pill (portrait/floating panel) is displayed.
var pillMode: CommentsPillMode?
var effectiveShowAvatars: Bool { showAvatars ?? true }
var effectivePillMode: CommentsPillMode { pillMode ?? .pill }
}
/// Element toggles for the related videos page.
struct RelatedPageSettings: Codable, Hashable, Sendable {
var showThumbnails: Bool?
var effectiveShowThumbnails: Bool { showThumbnails ?? true }
}
/// Element toggles for the queue page.
struct QueuePageSettings: Codable, Hashable, Sendable {
var showHistory: Bool?
var effectiveShowHistory: Bool { showHistory ?? true }
}
/// Element toggles for the chapters page.
struct ChaptersPageSettings: Codable, Hashable, Sendable {
var showThumbnails: Bool?
var effectiveShowThumbnails: Bool { showThumbnails ?? true }
}
/// Typed per-page settings payload. Uses the same discriminator pattern as
/// `ButtonSettings`; an unknown discriminator from a future app version
/// decodes to `nil` at the configuration level so the pages array never
/// fails to decode as a whole.
enum PanelPageSettings: Codable, Hashable, Sendable {
case info(InfoPageSettings)
case comments(CommentsPageSettings)
case related(RelatedPageSettings)
case chapters(ChaptersPageSettings)
case queue(QueuePageSettings)
private enum CodingKeys: String, CodingKey {
case type
case info
case comments
case related
case chapters
case queue
}
private enum SettingsType: String, Codable {
case info
case comments
case related
case chapters
case queue
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let type = try container.decode(SettingsType.self, forKey: .type)
switch type {
case .info:
self = .info(try container.decode(InfoPageSettings.self, forKey: .info))
case .comments:
self = .comments(try container.decode(CommentsPageSettings.self, forKey: .comments))
case .related:
self = .related(try container.decode(RelatedPageSettings.self, forKey: .related))
case .chapters:
self = .chapters(try container.decode(ChaptersPageSettings.self, forKey: .chapters))
case .queue:
self = .queue(try container.decode(QueuePageSettings.self, forKey: .queue))
}
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
switch self {
case .info(let settings):
try container.encode(SettingsType.info, forKey: .type)
try container.encode(settings, forKey: .info)
case .comments(let settings):
try container.encode(SettingsType.comments, forKey: .type)
try container.encode(settings, forKey: .comments)
case .related(let settings):
try container.encode(SettingsType.related, forKey: .type)
try container.encode(settings, forKey: .related)
case .chapters(let settings):
try container.encode(SettingsType.chapters, forKey: .type)
try container.encode(settings, forKey: .chapters)
case .queue(let settings):
try container.encode(SettingsType.queue, forKey: .type)
try container.encode(settings, forKey: .queue)
}
}
}
// MARK: - Page Configuration
/// One page entry in the ordered panel pages list.
struct PanelPageConfiguration: Codable, Hashable, Sendable, Identifiable {
var id: PanelPageType { pageType }
var pageType: PanelPageType
var isEnabled: Bool
var region: PanelRegion
var settings: PanelPageSettings?
init(
pageType: PanelPageType,
isEnabled: Bool = true,
region: PanelRegion = .primary,
settings: PanelPageSettings? = nil
) {
self.pageType = pageType
self.isEnabled = isEnabled
self.region = region
self.settings = settings
}
/// Info page settings when this is an info page, defaults otherwise.
var infoSettings: InfoPageSettings {
if case .info(let settings) = settings { return settings }
return InfoPageSettings()
}
/// Comments page settings when set, defaults otherwise.
var commentsSettings: CommentsPageSettings {
if case .comments(let settings) = settings { return settings }
return CommentsPageSettings()
}
/// Related page settings when set, defaults otherwise.
var relatedSettings: RelatedPageSettings {
if case .related(let settings) = settings { return settings }
return RelatedPageSettings()
}
/// Chapters page settings when set, defaults otherwise.
var chaptersSettings: ChaptersPageSettings {
if case .chapters(let settings) = settings { return settings }
return ChaptersPageSettings()
}
/// Queue page settings when set, defaults otherwise.
var queueSettings: QueuePageSettings {
if case .queue(let settings) = settings { return settings }
return QueuePageSettings()
}
private enum CodingKeys: String, CodingKey {
case pageType
case isEnabled
case region
case settings
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
pageType = try container.decode(PanelPageType.self, forKey: .pageType)
isEnabled = try container.decodeIfPresent(Bool.self, forKey: .isEnabled) ?? true
region = try container.decodeIfPresent(PanelRegion.self, forKey: .region) ?? .primary
// Unknown discriminators from future versions decode to nil (defaults)
// instead of corrupting the pages array.
settings = try? container.decodeIfPresent(PanelPageSettings.self, forKey: .settings)
}
}
// MARK: - Panel Settings
/// Complete details panel configuration stored on a `PlayerControlsLayout`.
struct PanelSettings: Codable, Hashable, Sendable {
/// One ordered list of pages: the single source of truth for order,
/// enablement, and (corner mode) region assignment.
var pages: [PanelPageConfiguration]
/// Layout mode on iPad/macOS. Effective default: `.stacked`.
var layoutMode: PanelLayoutMode?
/// Default panel visibility when the player opens. Effective default:
/// `.hidden` (today's behavior).
var defaultVisibility: PanelDefaultVisibility?
/// Whether the page bar shows labels next to icons.
var showPageBarLabels: Bool?
/// Which side the details panel sits on in the standard wide layout on
/// iPad/macOS. iPhone ignores it (always right). Effective default: `.right`.
var wideLayoutSide: FloatingPanelSide?
init(
pages: [PanelPageConfiguration],
layoutMode: PanelLayoutMode? = nil,
defaultVisibility: PanelDefaultVisibility? = nil,
showPageBarLabels: Bool? = nil,
wideLayoutSide: FloatingPanelSide? = nil
) {
self.pages = pages
self.layoutMode = layoutMode
self.defaultVisibility = defaultVisibility
self.showPageBarLabels = showPageBarLabels
self.wideLayoutSide = wideLayoutSide
}
/// In-memory cache of the active preset's panel settings, refreshed by the
/// player whenever the layout loads. Lets synchronous appear paths (e.g.
/// default-visibility resets) read the last known value before the async
/// layout fetch completes.
nonisolated(unsafe) static var cached: PanelSettings = .default
static let `default` = PanelSettings(pages: [
PanelPageConfiguration(pageType: .info, region: .belowVideo),
PanelPageConfiguration(pageType: .comments, region: .belowVideo),
PanelPageConfiguration(pageType: .related, region: .primary),
PanelPageConfiguration(pageType: .chapters, region: .primary),
PanelPageConfiguration(pageType: .queue, region: .primary),
])
var effectiveLayoutMode: PanelLayoutMode { layoutMode ?? .stacked }
var effectiveDefaultVisibility: PanelDefaultVisibility { defaultVisibility ?? .hidden }
var effectiveShowPageBarLabels: Bool { showPageBarLabels ?? true }
var effectiveWideLayoutSide: FloatingPanelSide { wideLayoutSide ?? .right }
/// Pages the panel should offer, in order. Pass a region to filter for
/// corner mode; `nil` returns enabled pages from all regions.
func enabledPages(in region: PanelRegion? = nil) -> [PanelPageConfiguration] {
normalizedPages.filter { page in
guard page.isEnabled else { return false }
if let region { return page.region == region }
return true
}
}
/// The pages list with any page types this app version knows about but
/// the stored list is missing appended as disabled, so newly introduced
/// pages surface in settings after an update.
var normalizedPages: [PanelPageConfiguration] {
var result = pages
let known = Set(pages.map(\.pageType))
for pageType in PanelPageType.allCases where !known.contains(pageType) {
result.append(PanelPageConfiguration(pageType: pageType, isEnabled: false))
}
return result
}
/// Page types the info page can render as collapsible inline sections
/// below the description (iPhone, and the stacked iPad portrait panel).
static let inlineSectionTypes: Set<PanelPageType> = [.chapters, .related]
/// iPhone has no page switcher: every host renders one info page with
/// inline sections, so settings talk about "sections" rather than "pages".
static var isSectionsOnlyDevice: Bool {
#if os(iOS)
return UIDevice.current.userInterfaceIdiom == .phone
#else
return false
#endif
}
/// Enabled pages an inline-sections host renders below the video info,
/// in configured order, with their per-page element settings.
func inlineSections(in region: PanelRegion? = nil) -> [PanelPageConfiguration] {
enabledPages(in: region).filter { Self.inlineSectionTypes.contains($0.pageType) }
}
/// Whether a page type is enabled in this configuration.
func isPageEnabled(_ pageType: PanelPageType) -> Bool {
enabledPages().contains { $0.pageType == pageType }
}
/// The comments pill mode from the comments page settings.
var commentsPillMode: CommentsPillMode {
configuration(for: .comments)?.commentsSettings.effectivePillMode ?? .pill
}
/// Whether comments should be loaded from the API: needed by the comments
/// page (when enabled) and by the pill (unless disabled).
var shouldLoadComments: Bool {
isPageEnabled(.comments) || commentsPillMode != .disabled
}
/// The stored configuration for a page type, if present.
func configuration(for pageType: PanelPageType) -> PanelPageConfiguration? {
normalizedPages.first { $0.pageType == pageType }
}
// MARK: - Mutations (settings UI)
/// Replaces the stored pages with the normalized list so mutations by
/// index operate on the same list the UI displays.
mutating func normalize() {
pages = normalizedPages
}
mutating func setEnabled(_ isEnabled: Bool, for pageType: PanelPageType) {
normalize()
guard let index = pages.firstIndex(where: { $0.pageType == pageType }) else { return }
pages[index].isEnabled = isEnabled
}
mutating func movePages(fromOffsets source: IndexSet, toOffset destination: Int) {
normalize()
pages.move(fromOffsets: source, toOffset: destination)
}
mutating func updateSettings(_ newSettings: PanelPageSettings?, for pageType: PanelPageType) {
normalize()
guard let index = pages.firstIndex(where: { $0.pageType == pageType }) else { return }
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
case defaultVisibility
case showPageBarLabels
case wideLayoutSide
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
// Lenient element decoding: a page entry with an unknown pageType
// (from a future app version) is dropped instead of failing the
// whole settings decode.
var pagesContainer = try container.nestedUnkeyedContainer(forKey: .pages)
var decodedPages: [PanelPageConfiguration] = []
while !pagesContainer.isAtEnd {
if let page = try? pagesContainer.decode(PanelPageConfiguration.self) {
decodedPages.append(page)
} else {
// Skip the undecodable element to keep the container advancing.
_ = try? pagesContainer.decode(AnyDecodableValue.self)
}
}
pages = decodedPages
layoutMode = try? container.decodeIfPresent(PanelLayoutMode.self, forKey: .layoutMode)
defaultVisibility = try? container.decodeIfPresent(PanelDefaultVisibility.self, forKey: .defaultVisibility)
showPageBarLabels = try container.decodeIfPresent(Bool.self, forKey: .showPageBarLabels)
wideLayoutSide = try? container.decodeIfPresent(FloatingPanelSide.self, forKey: .wideLayoutSide)
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(pages, forKey: .pages)
try container.encodeIfPresent(layoutMode, forKey: .layoutMode)
try container.encodeIfPresent(defaultVisibility, forKey: .defaultVisibility)
try container.encodeIfPresent(showPageBarLabels, forKey: .showPageBarLabels)
try container.encodeIfPresent(wideLayoutSide, forKey: .wideLayoutSide)
}
}
/// Consumes any JSON value during lenient array decoding.
private struct AnyDecodableValue: Decodable {
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if container.decodeNil() { return }
if (try? container.decode(Bool.self)) != nil { return }
if (try? container.decode(Double.self)) != nil { return }
if (try? container.decode(String.self)) != nil { return }
if (try? container.decode([String: AnyDecodableValue].self)) != nil { return }
if (try? container.decode([AnyDecodableValue].self)) != nil { return }
}
}