diff --git a/Yattee/Localizable.xcstrings b/Yattee/Localizable.xcstrings index ed7d6ee0..7113c92e 100644 --- a/Yattee/Localizable.xcstrings +++ b/Yattee/Localizable.xcstrings @@ -7153,6 +7153,39 @@ } } }, + "panel.belowVideo.info" : { + "comment" : "Corner layout below-video content option", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Info" + } + } + } + }, + "panel.belowVideo.infoAndComments" : { + "comment" : "Corner layout below-video content option", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Info and Comments" + } + } + } + }, + "panel.belowVideo.nothing" : { + "comment" : "Corner layout below-video content option", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nothing" + } + } + } + }, "panel.defaultVisibility.hidden" : { "comment" : "Details panel default visibility option", "localizations" : { @@ -7263,6 +7296,28 @@ } } }, + "panel.settings.belowVideo" : { + "comment" : "Corner layout below-video content picker label", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Below Video" + } + } + } + }, + "panel.settings.belowVideo.footer" : { + "comment" : "Corner layout below-video content footer", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Corner shows the video in the top corner with pages in a side column. Only the info panel can sit below the video; comments there show as its pill. Related, chapters and queue always use the side column. The standard layout is used automatically when the window is too narrow." + } + } + } + }, "panel.settings.comments.showAvatars" : { "comment" : "Comments page element toggle", "localizations" : { @@ -7461,28 +7516,6 @@ } } }, - "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.pageBarLabels" : { "comment" : "Details panel page bar labels toggle", "localizations" : { diff --git a/Yattee/Models/PlayerControls/PanelSettings.swift b/Yattee/Models/PlayerControls/PanelSettings.swift index a53cc232..9bc14ea0 100644 --- a/Yattee/Models/PlayerControls/PanelSettings.swift +++ b/Yattee/Models/PlayerControls/PanelSettings.swift @@ -72,11 +72,16 @@ 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. +/// +/// The below-video host is the portrait info panel, not a page host: only +/// `.info` can live there, plus `.comments` as the panel's pill (iOS). Every +/// other page is clamped to `.primary` by `PanelSettings.normalizedPages`, and +/// settings expose the choice as one `PanelBelowVideoContent` picker. enum PanelRegion: String, Codable, CaseIterable, Sendable { case primary case belowVideo - /// Localized name for the settings picker. + /// Localized name for the pages list. var displayName: String { switch self { case .primary: @@ -87,6 +92,36 @@ enum PanelRegion: String, Codable, CaseIterable, Sendable { } } +/// What the corner layout shows below the video: the three layouts the +/// below-video host can actually render. Stored as the info/comments page +/// regions (see `PanelSettings.belowVideoContent`). +enum PanelBelowVideoContent: String, CaseIterable, Sendable { + /// Video fills the left column; every page sits in the side column. + case nothing + /// The info panel below the video; comments join the side column. + case info + /// The info panel with the comments pill below the video (iOS only). + case infoAndComments + + /// Localized name for the settings picker. + var displayName: String { + switch self { + case .nothing: + return String(localized: "panel.belowVideo.nothing") + case .info: + return String(localized: "panel.belowVideo.info") + case .infoAndComments: + return String(localized: "panel.belowVideo.infoAndComments") + } + } + + /// Options this platform can render: the comments pill lives on the + /// iOS portrait panel only. + static var availableCases: [PanelBelowVideoContent] { + PanelSettings.supportsBelowVideoComments ? allCases : [.nothing, .info] + } +} + // MARK: - Layout Mode /// How the details panel is laid out on iPad/macOS. @@ -484,10 +519,27 @@ struct PanelSettings: Codable, Hashable, Sendable { /// other pages or switched off. Its element toggles remain configurable. static let pinnedPageType: PanelPageType = .info + /// Page types the below-video host can render: the info panel itself and + /// comments as its pill. Other types assigned `.belowVideo` (older + /// presets) are clamped back to the side column. + static let belowVideoHostablePageTypes: Set = [.info, .comments] + + /// Whether this platform renders comments below the video (the comments + /// pill on the portrait info panel exists on iOS only). + static var supportsBelowVideoComments: Bool { + #if os(iOS) + return true + #else + return false + #endif + } + /// 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, and the pinned page forced - /// first and enabled (older presets may have stored it elsewhere). + /// pages surface in settings after an update, the pinned page forced + /// first and enabled (older presets may have stored it elsewhere), and + /// regions clamped to what the corner layout can host: only info and + /// comments may sit below the video, comments only together with info. var normalizedPages: [PanelPageConfiguration] { var result = pages let known = Set(pages.map(\.pageType)) @@ -500,9 +552,59 @@ struct PanelSettings: Codable, Hashable, Sendable { pinned.isEnabled = true result.insert(pinned, at: 0) } + + let infoBelowVideo = result.contains { $0.pageType == .info && $0.region == .belowVideo } + for index in result.indices where result[index].region == .belowVideo { + let type = result[index].pageType + let hostable = Self.belowVideoHostablePageTypes.contains(type) + && (type != .comments || infoBelowVideo) + if !hostable { + result[index].region = .primary + } + } return result } + /// The stored below-video choice, derived from the info/comments regions. + /// Platform-agnostic so presets keep their value across devices; use + /// `effectiveBelowVideoContent` for what this device renders. + var belowVideoContent: PanelBelowVideoContent { + get { + guard configuration(for: .info)?.region == .belowVideo else { return .nothing } + return configuration(for: .comments)?.region == .belowVideo ? .infoAndComments : .info + } + set { + normalize() + let infoRegion: PanelRegion = newValue == .nothing ? .primary : .belowVideo + let commentsRegion: PanelRegion = newValue == .infoAndComments ? .belowVideo : .primary + for index in pages.indices { + switch pages[index].pageType { + case .info: pages[index].region = infoRegion + case .comments: pages[index].region = commentsRegion + default: pages[index].region = .primary + } + } + } + } + + /// The below-video content this platform renders: comments fall back to + /// the side column where the pill does not exist. + var effectiveBelowVideoContent: PanelBelowVideoContent { + let stored = belowVideoContent + if stored == .infoAndComments, !Self.supportsBelowVideoComments { return .info } + return stored + } + + /// Where the corner layout actually places a page on this platform. + func effectiveRegion(for pageType: PanelPageType) -> PanelRegion { + switch (pageType, effectiveBelowVideoContent) { + case (.info, .info), (.info, .infoAndComments), (.comments, .infoAndComments): + return .belowVideo + default: + return .primary + } + } + /// 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 = [.chapters, .related] @@ -615,12 +717,6 @@ 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/ViewModels/PlayerControls/PlayerControlsSettingsViewModel.swift b/Yattee/ViewModels/PlayerControls/PlayerControlsSettingsViewModel.swift index b91d3661..9528db3b 100644 --- a/Yattee/ViewModels/PlayerControls/PlayerControlsSettingsViewModel.swift +++ b/Yattee/ViewModels/PlayerControls/PlayerControlsSettingsViewModel.swift @@ -830,10 +830,15 @@ final class PlayerControlsSettingsViewModel { } } - /// Synchronously updates a page's corner-layout region assignment. - func setPanelPageRegion(_ region: PanelRegion, for pageType: PanelPageType) { + /// What the corner layout shows below the video (stored value). + var panelBelowVideoContent: PanelBelowVideoContent { + panelSettings.belowVideoContent + } + + /// Synchronously updates what the corner layout shows below the video. + func syncPanelBelowVideoContent(_ content: PanelBelowVideoContent) { updatePanelSettingsSync { settings in - settings.setRegion(region, for: pageType) + settings.belowVideoContent = content } } diff --git a/Yattee/Views/Player/CornerPlayerLayout.swift b/Yattee/Views/Player/CornerPlayerLayout.swift index 47dfb473..ccbfa12a 100644 --- a/Yattee/Views/Player/CornerPlayerLayout.swift +++ b/Yattee/Views/Player/CornerPlayerLayout.swift @@ -134,30 +134,23 @@ struct CornerPlayerLayout: View { playerControlsLayout.effectivePanelSettings } - /// Whether the info page is assigned to (and enabled in) the below-video - /// region — the below panel only exists to host it. + /// Whether the info page is assigned below the video — the below panel + /// only exists to host it. (`effectiveRegion` already clamps to what + /// this platform can render; info is always enabled.) private var hasBelowVideoInfo: Bool { - guard let info = panelSettings.configuration(for: .info) else { return false } - return info.isEnabled && info.region == .belowVideo + panelSettings.effectiveRegion(for: .info) == .belowVideo } /// Comments assigned below the video render as the portrait panel's - /// comments pill (iOS only, and only while the info panel is below — - /// the pill lives inside it). Everything else falls back to the column. + /// comments pill (iOS only, and only together with the info panel — the + /// pill lives inside it; `effectiveRegion` enforces both). private var hasBelowVideoComments: Bool { - #if os(iOS) - guard hasBelowVideoInfo, - let comments = panelSettings.configuration(for: .comments) else { return false } - return comments.isEnabled && comments.region == .belowVideo - #else - return false - #endif + panelSettings.isPageEnabled(.comments) + && panelSettings.effectiveRegion(for: .comments) == .belowVideo } - /// Pages the below-video host actually renders. Enabled pages assigned - /// `.belowVideo` that it can't host (chapters/related/queue/tracks, or - /// comments without the info panel) fall back to the side column instead - /// of disappearing. + /// Pages the below-video host renders; everything else goes to the side + /// column. private var belowVideoPageTypes: Set { var types: Set = [] if hasBelowVideoInfo { types.insert(.info) } diff --git a/Yattee/Views/Settings/PlayerControls/PanelPageEditorView.swift b/Yattee/Views/Settings/PlayerControls/PanelPageEditorView.swift index 1c99c892..81075d57 100644 --- a/Yattee/Views/Settings/PlayerControls/PanelPageEditorView.swift +++ b/Yattee/Views/Settings/PlayerControls/PanelPageEditorView.swift @@ -15,7 +15,6 @@ 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 descriptionExpandedByDefault: Bool = false @State private var descriptionCollapsedLines: Int = InfoPageSettings.defaultDescriptionCollapsedLines @@ -47,10 +46,6 @@ struct PanelPageEditorView: View { enabledSection } - if PanelSettingsView.supportsCornerLayout, viewModel.panelLayoutMode == .corner { - regionSection - } - switch pageType { case .info: infoElementsSection @@ -101,28 +96,6 @@ 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 @ViewBuilder @@ -351,7 +324,6 @@ struct PanelPageEditorView: View { private func syncLocalState() { isEnabled = configuration?.isEnabled ?? true - region = configuration?.region ?? .primary let info = infoSettings showDescription = info.effectiveShowDescription descriptionExpandedByDefault = info.effectiveDescriptionExpandedByDefault diff --git a/Yattee/Views/Settings/PlayerControls/PanelSettingsView.swift b/Yattee/Views/Settings/PlayerControls/PanelSettingsView.swift index eac62681..e98ea307 100644 --- a/Yattee/Views/Settings/PlayerControls/PanelSettingsView.swift +++ b/Yattee/Views/Settings/PlayerControls/PanelSettingsView.swift @@ -17,6 +17,7 @@ struct PanelSettingsView: View { @State private var landscapeDefaultVisibility: PanelDefaultVisibility = .hidden @State private var layoutMode: PanelLayoutMode = .stacked @State private var wideLayoutSide: FloatingPanelSide = .right + @State private var belowVideoContent: PanelBelowVideoContent = .info @State private var showPageBarLabels: Bool = true @State private var commentsShowAvatars: Bool = true @State private var commentsPillMode: CommentsPillMode = .pill @@ -71,7 +72,11 @@ struct PanelSettingsView: View { private var layoutSection: some View { Section { - PanelLayoutSchematic(mode: layoutMode, side: wideLayoutSide) + PanelLayoutSchematic( + mode: layoutMode, + side: wideLayoutSide, + showsBelowVideo: belowVideoContent != .nothing + ) .frame(maxWidth: .infinity) .padding(.vertical, 4) .listRowBackground(Color.clear) @@ -104,9 +109,25 @@ struct PanelSettingsView: View { guard newValue != viewModel.panelWideLayoutSide else { return } viewModel.syncPanelWideLayoutSide(newValue) } + } else { + Picker( + String(localized: "panel.settings.belowVideo"), + selection: $belowVideoContent + ) { + ForEach(PanelBelowVideoContent.availableCases, id: \.self) { content in + Text(content.displayName).tag(content) + } + } + .disabled(!viewModel.canEditActivePreset) + .onChange(of: belowVideoContent) { _, newValue in + guard newValue != viewModel.panelBelowVideoContent else { return } + viewModel.syncPanelBelowVideoContent(newValue) + } } } footer: { - Text(String(localized: "panel.settings.layoutMode.footer")) + Text(layoutMode == .corner + ? String(localized: "panel.settings.belowVideo.footer") + : String(localized: "panel.settings.layoutMode.footer")) } } @@ -295,7 +316,7 @@ struct PanelSettingsView: View { } Spacer() if page.isEnabled, layoutMode == .corner, Self.supportsCornerLayout { - Text(page.region.displayName) + Text(viewModel.panelSettings.effectiveRegion(for: page.pageType).displayName) .foregroundStyle(.secondary) } else { Text(page.isEnabled @@ -314,6 +335,8 @@ struct PanelSettingsView: View { landscapeDefaultVisibility = viewModel.panelDefaultVisibility(for: .landscape) layoutMode = viewModel.panelLayoutMode wideLayoutSide = viewModel.panelWideLayoutSide + // Clamp to what this platform offers so the picker never shows blank. + belowVideoContent = viewModel.panelSettings.effectiveBelowVideoContent showPageBarLabels = viewModel.panelShowPageBarLabels commentsShowAvatars = commentsSettings.effectiveShowAvatars commentsPillMode = commentsSettings.effectivePillMode @@ -328,6 +351,8 @@ private struct PanelLayoutSchematic: View { let mode: PanelLayoutMode /// Panel side in the standard layout; the corner column is always right. let side: FloatingPanelSide + /// Whether the corner layout has an info panel below the video. + let showsBelowVideo: Bool private let cornerRadius: CGFloat = 4 @@ -341,8 +366,10 @@ private struct PanelLayoutSchematic: View { if mode == .corner { VStack(spacing: 3) { videoBlock - panelBlock - .frame(height: 26) + if showsBelowVideo { + panelBlock + .frame(height: 26) + } } } else { videoBlock @@ -361,6 +388,7 @@ private struct PanelLayoutSchematic: View { } .animation(.easeInOut(duration: 0.25), value: mode) .animation(.easeInOut(duration: 0.25), value: side) + .animation(.easeInOut(duration: 0.25), value: showsBelowVideo) } private var videoBlock: some View { diff --git a/YatteeTests/PanelSettingsTests.swift b/YatteeTests/PanelSettingsTests.swift index 019d15db..abb93d7e 100644 --- a/YatteeTests/PanelSettingsTests.swift +++ b/YatteeTests/PanelSettingsTests.swift @@ -279,17 +279,48 @@ struct PanelSettingsTests { #expect(settings.pages.map(\.pageType) == PanelSettings.default.pages.map(\.pageType)) } - @Test("Region assignment and layout mode round-trip through Codable") - func regionAndLayoutModeRoundTrip() throws { + @Test("Below-video content and layout mode round-trip through Codable") + func belowVideoContentAndLayoutModeRoundTrip() 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]) + #expect(settings.belowVideoContent == .infoAndComments) + + settings.belowVideoContent = .info + #expect(settings.configuration(for: .info)?.region == .belowVideo) + #expect(settings.configuration(for: .comments)?.region == .primary) + #expect(settings.enabledPages(in: .primary).map(\.pageType) == [.comments, .related, .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) + #expect(decoded.belowVideoContent == .info) + + settings.belowVideoContent = .nothing + #expect(settings.enabledPages(in: .belowVideo).isEmpty) + #expect(settings.enabledPages(in: .primary).map(\.pageType) == [.info, .comments, .related, .chapters, .queue]) + } + + @Test("Pages the below-video host cannot render are clamped to the side column") + func unhostableBelowVideoRegionsClamp() { + // Older presets could assign any page below the video. + var settings = PanelSettings(pages: [ + PanelPageConfiguration(pageType: .info, region: .primary), + PanelPageConfiguration(pageType: .comments, region: .belowVideo), + PanelPageConfiguration(pageType: .related, region: .belowVideo), + PanelPageConfiguration(pageType: .chapters, region: .belowVideo), + PanelPageConfiguration(pageType: .queue, region: .belowVideo), + ]) + #expect(settings.belowVideoContent == .nothing) + #expect(settings.enabledPages(in: .belowVideo).isEmpty) + #expect(settings.effectiveRegion(for: .related) == .primary) + + // Comments may sit below only together with info. + settings.belowVideoContent = .infoAndComments + #expect(settings.enabledPages(in: .belowVideo).map(\.pageType) == [.info, .comments]) + #expect(settings.enabledPages(in: .primary).map(\.pageType) == [.related, .chapters, .queue]) + + settings.pages[0].region = .primary + #expect(settings.belowVideoContent == .nothing) + #expect(settings.effectiveRegion(for: .comments) == .primary) } }