mirror of
https://github.com/yattee/yattee.git
synced 2026-10-24 21:52:51 +00:00
Pages list - Info is pinned first and always enabled in every layout: normalized pages force it to the front and enabled (older presets self-correct), disabling it is a no-op, and any move that would displace it snaps back. The iOS/macOS list renders it outside the reorderable ForEach (no drag handle, nothing drops above it) and the editors hide its enabled toggle; tvOS also hides its move buttons. The "at least one page must stay enabled" guard and its strings are gone since Info always counts. - iPhone never renders queue or comments as pages, so its Sections list hides them (PanelSettings.hiddenSettingsPageTypes). Reordering goes through moveVisiblePages, which keeps hidden entries in their stored slots. Comments get their own group on the Details Panel screen (pill mode + avatars) and the overlay hosts pass the avatars flag. Default visibility - "When Player Opens" is now configured per orientation (portraitDefaultVisibility / landscapeDefaultVisibility). The legacy single value is still decoded and seeds both until set individually; presets that never set it default to portrait Visible, landscape Hidden. macOS shows one picker (no portrait layout). - "Remember Last" no longer leaks across orientations: separate remembered flags, written by the portrait toggles and by the wide and corner layouts respectively, with the old shared flag read once as a fallback. The corner layout seeded from the portrait resolution before; it now uses landscape. Section options (iOS inline-sections hosts) - Chapters and Related editors gain "Expanded by Default" (on by default). The Info editor gains a Description group with "Expanded by Default" (off) and a "Collapsed Lines" stepper (1–10, default 3). Applied when the panel appears and on each video change. - Expanding a collapsed section scrolls its header to the top of the panel: PanelPageScrollView can hand its scroll proxy to page content and each inline section is wrapped in one anchored container. - The portrait panel's bottom fade sized its touch blocker from a stale "expanded comments pill" answer that ignored collapse on scroll, so a collapsed section header (the last item) sat under it and could not be tapped. The predicate now tracks the pill's actual collapse state.
393 lines
14 KiB
Swift
393 lines
14 KiB
Swift
//
|
|
// PanelSettingsView.swift
|
|
// Yattee
|
|
//
|
|
// Settings editor for the player details panel pages.
|
|
//
|
|
|
|
import SwiftUI
|
|
|
|
#if os(iOS) || os(macOS)
|
|
|
|
struct PanelSettingsView: View {
|
|
@Bindable var viewModel: PlayerControlsSettingsViewModel
|
|
|
|
// Local state for immediate UI updates
|
|
@State private var portraitDefaultVisibility: PanelDefaultVisibility = .visible
|
|
@State private var landscapeDefaultVisibility: PanelDefaultVisibility = .hidden
|
|
@State private var layoutMode: PanelLayoutMode = .stacked
|
|
@State private var wideLayoutSide: FloatingPanelSide = .right
|
|
@State private var showPageBarLabels: Bool = true
|
|
@State private var commentsShowAvatars: Bool = true
|
|
@State private var commentsPillMode: CommentsPillMode = .pill
|
|
|
|
/// Corner layout exists on iPad and macOS only.
|
|
static var supportsCornerLayout: Bool {
|
|
#if os(macOS)
|
|
return true
|
|
#else
|
|
return UIDevice.current.userInterfaceIdiom == .pad
|
|
#endif
|
|
}
|
|
|
|
/// iPhone has no page bar: the list configures inline sections instead.
|
|
private var usesSections: Bool { PanelSettings.isSectionsOnlyDevice }
|
|
|
|
var body: some View {
|
|
List {
|
|
if Self.supportsCornerLayout {
|
|
layoutSection
|
|
}
|
|
visibilitySection
|
|
if !usesSections {
|
|
pageBarSection
|
|
}
|
|
pagesSection
|
|
if usesSections {
|
|
commentsSection
|
|
}
|
|
}
|
|
.navigationTitle(String(localized: "settings.playerControls.detailsPanel"))
|
|
#if os(iOS)
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
#elseif os(macOS)
|
|
.formStyle(.grouped)
|
|
#endif
|
|
.opaqueSettingsFormBackground()
|
|
.toolbar {
|
|
#if os(iOS)
|
|
EditButton()
|
|
#endif
|
|
}
|
|
.onAppear {
|
|
syncLocalState()
|
|
}
|
|
.onChange(of: viewModel.activePreset?.id) { _, _ in
|
|
syncLocalState()
|
|
}
|
|
}
|
|
|
|
// MARK: - Layout Section
|
|
|
|
private var layoutSection: some View {
|
|
Section {
|
|
PanelLayoutSchematic(mode: layoutMode, side: wideLayoutSide)
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 4)
|
|
.listRowBackground(Color.clear)
|
|
|
|
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)
|
|
}
|
|
|
|
if layoutMode == .stacked {
|
|
Picker(
|
|
String(localized: "panel.settings.side"),
|
|
selection: $wideLayoutSide
|
|
) {
|
|
ForEach(FloatingPanelSide.allCases, id: \.self) { side in
|
|
Text(side.displayName).tag(side)
|
|
}
|
|
}
|
|
.disabled(!viewModel.canEditActivePreset)
|
|
.onChange(of: wideLayoutSide) { _, newValue in
|
|
guard newValue != viewModel.panelWideLayoutSide else { return }
|
|
viewModel.syncPanelWideLayoutSide(newValue)
|
|
}
|
|
}
|
|
} footer: {
|
|
Text(String(localized: "panel.settings.layoutMode.footer"))
|
|
}
|
|
}
|
|
|
|
// MARK: - Visibility Section
|
|
|
|
/// macOS has no portrait player layout: only the landscape default.
|
|
private static var hasPortraitLayout: Bool {
|
|
#if os(macOS)
|
|
return false
|
|
#else
|
|
return true
|
|
#endif
|
|
}
|
|
|
|
private var visibilitySection: some View {
|
|
Section {
|
|
if Self.hasPortraitLayout {
|
|
visibilityPicker(
|
|
String(localized: "panel.settings.defaultVisibility.portrait"),
|
|
selection: $portraitDefaultVisibility,
|
|
orientation: .portrait
|
|
)
|
|
visibilityPicker(
|
|
String(localized: "panel.settings.defaultVisibility.landscape"),
|
|
selection: $landscapeDefaultVisibility,
|
|
orientation: .landscape
|
|
)
|
|
} else {
|
|
visibilityPicker(
|
|
String(localized: "panel.settings.defaultVisibility"),
|
|
selection: $landscapeDefaultVisibility,
|
|
orientation: .landscape
|
|
)
|
|
}
|
|
} header: {
|
|
if Self.hasPortraitLayout {
|
|
Text(String(localized: "panel.settings.defaultVisibility"))
|
|
}
|
|
} footer: {
|
|
Text(String(localized: "panel.settings.defaultVisibility.footer"))
|
|
}
|
|
}
|
|
|
|
private func visibilityPicker(
|
|
_ title: String,
|
|
selection: Binding<PanelDefaultVisibility>,
|
|
orientation: PanelOrientation
|
|
) -> some View {
|
|
Picker(title, selection: selection) {
|
|
ForEach(PanelDefaultVisibility.allCases, id: \.self) { visibility in
|
|
Text(visibility.displayName).tag(visibility)
|
|
}
|
|
}
|
|
.disabled(!viewModel.canEditActivePreset)
|
|
.onChange(of: selection.wrappedValue) { _, newValue in
|
|
guard newValue != viewModel.panelDefaultVisibility(for: orientation) else { return }
|
|
viewModel.syncPanelDefaultVisibility(newValue, for: orientation)
|
|
}
|
|
}
|
|
|
|
// MARK: - Page Bar Section
|
|
|
|
private var pageBarSection: some View {
|
|
Section {
|
|
Toggle(
|
|
String(localized: "panel.settings.pageBarLabels"),
|
|
isOn: $showPageBarLabels
|
|
)
|
|
.disabled(!viewModel.canEditActivePreset)
|
|
.onChange(of: showPageBarLabels) { _, newValue in
|
|
guard newValue != viewModel.panelShowPageBarLabels else { return }
|
|
viewModel.syncPanelShowPageBarLabels(newValue)
|
|
}
|
|
} footer: {
|
|
Text(String(localized: "panel.settings.pageBarLabels.footer"))
|
|
}
|
|
}
|
|
|
|
// MARK: - Comments Section (iPhone)
|
|
|
|
/// iPhone never renders a comments page: comments live on the pill and
|
|
/// overlay, so their settings sit here instead of in the sections list.
|
|
private var commentsSettings: CommentsPageSettings {
|
|
viewModel.panelSettings.configuration(for: .comments)?.commentsSettings ?? CommentsPageSettings()
|
|
}
|
|
|
|
private var commentsSection: some View {
|
|
Section {
|
|
Picker(
|
|
String(localized: "commentsPill.mode.title"),
|
|
selection: $commentsPillMode
|
|
) {
|
|
ForEach(CommentsPillMode.allCases, id: \.self) { mode in
|
|
Text(mode.displayName).tag(mode)
|
|
}
|
|
}
|
|
.onChange(of: commentsPillMode) { _, newValue in
|
|
guard newValue != commentsSettings.effectivePillMode else { return }
|
|
viewModel.updatePanelCommentsSettingsSync { $0.pillMode = newValue }
|
|
}
|
|
|
|
Toggle(
|
|
String(localized: "panel.settings.comments.showAvatars"),
|
|
isOn: $commentsShowAvatars
|
|
)
|
|
.onChange(of: commentsShowAvatars) { _, newValue in
|
|
guard newValue != commentsSettings.effectiveShowAvatars else { return }
|
|
viewModel.updatePanelCommentsSettingsSync { $0.showAvatars = newValue }
|
|
}
|
|
} header: {
|
|
Text(String(localized: "player.comments"))
|
|
} footer: {
|
|
if !viewModel.canEditActivePreset {
|
|
Text(String(localized: "settings.playerControls.duplicateToEdit"))
|
|
}
|
|
}
|
|
.disabled(!viewModel.canEditActivePreset)
|
|
}
|
|
|
|
// MARK: - Pages Section
|
|
|
|
/// The pinned page (info) is rendered outside the reorderable list so it
|
|
/// gets no drag handle and nothing can be dropped above it.
|
|
private var pinnedPage: PanelPageConfiguration? {
|
|
viewModel.panelPages.first { $0.pageType == PanelSettings.pinnedPageType }
|
|
}
|
|
|
|
/// Pages the user can reorder: everything but the pinned page and the
|
|
/// types this device never renders (iPhone: queue).
|
|
private var reorderablePages: [PanelPageConfiguration] {
|
|
let hidden = PanelSettings.hiddenSettingsPageTypes
|
|
return viewModel.panelPages.filter {
|
|
$0.pageType != PanelSettings.pinnedPageType && !hidden.contains($0.pageType)
|
|
}
|
|
}
|
|
|
|
private var pagesSection: some View {
|
|
Section {
|
|
if let pinnedPage {
|
|
pageRow(for: pinnedPage)
|
|
.moveDisabled(true)
|
|
}
|
|
|
|
ForEach(reorderablePages) { page in
|
|
pageRow(for: page)
|
|
}
|
|
.onMove { source, destination in
|
|
// Offsets are relative to the displayed list; the pinned and
|
|
// hidden pages keep their slots in the stored list.
|
|
viewModel.movePanelPages(
|
|
visible: reorderablePages.map(\.pageType),
|
|
fromOffsets: source,
|
|
toOffset: destination
|
|
)
|
|
}
|
|
} header: {
|
|
Text(usesSections
|
|
? String(localized: "panel.settings.sections.header")
|
|
: String(localized: "panel.settings.pages.header"))
|
|
} footer: {
|
|
if viewModel.canEditActivePreset {
|
|
Text(usesSections
|
|
? String(localized: "panel.settings.sections.footer")
|
|
: String(localized: "panel.settings.pages.footer"))
|
|
} else {
|
|
Text(String(localized: "settings.playerControls.duplicateToEdit"))
|
|
}
|
|
}
|
|
.disabled(!viewModel.canEditActivePreset)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func pageRow(for page: PanelPageConfiguration) -> some View {
|
|
NavigationLink {
|
|
PanelPageEditorView(pageType: page.pageType, viewModel: viewModel)
|
|
} label: {
|
|
HStack {
|
|
Label(page.pageType.displayName, systemImage: page.pageType.systemImage)
|
|
if page.pageType.isRecentlyAdded {
|
|
Text(String(localized: "common.badge.new"))
|
|
.font(.caption2.weight(.bold))
|
|
.foregroundStyle(.white)
|
|
.padding(.horizontal, 5)
|
|
.padding(.vertical, 2)
|
|
.background(Capsule().fill(.tint))
|
|
}
|
|
Spacer()
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
private func syncLocalState() {
|
|
portraitDefaultVisibility = viewModel.panelDefaultVisibility(for: .portrait)
|
|
landscapeDefaultVisibility = viewModel.panelDefaultVisibility(for: .landscape)
|
|
layoutMode = viewModel.panelLayoutMode
|
|
wideLayoutSide = viewModel.panelWideLayoutSide
|
|
showPageBarLabels = viewModel.panelShowPageBarLabels
|
|
commentsShowAvatars = commentsSettings.effectiveShowAvatars
|
|
commentsPillMode = commentsSettings.effectivePillMode
|
|
}
|
|
}
|
|
|
|
// MARK: - Layout Schematic
|
|
|
|
/// Small diagram of the player window for the layout mode picker: the video
|
|
/// area plus where the details panel regions sit in each mode.
|
|
private struct PanelLayoutSchematic: View {
|
|
let mode: PanelLayoutMode
|
|
/// Panel side in the standard layout; the corner column is always right.
|
|
let side: FloatingPanelSide
|
|
|
|
private let cornerRadius: CGFloat = 4
|
|
|
|
var body: some View {
|
|
HStack(spacing: 3) {
|
|
if mode == .stacked && side == .left {
|
|
panelBlock
|
|
.frame(width: 44)
|
|
}
|
|
|
|
if mode == .corner {
|
|
VStack(spacing: 3) {
|
|
videoBlock
|
|
panelBlock
|
|
.frame(height: 26)
|
|
}
|
|
} else {
|
|
videoBlock
|
|
}
|
|
|
|
if mode == .corner || side == .right {
|
|
panelBlock
|
|
.frame(width: 44)
|
|
}
|
|
}
|
|
.padding(6)
|
|
.frame(width: 180, height: 110)
|
|
.background {
|
|
RoundedRectangle(cornerRadius: 8)
|
|
.fill(.quaternary.opacity(0.4))
|
|
}
|
|
.animation(.easeInOut(duration: 0.25), value: mode)
|
|
.animation(.easeInOut(duration: 0.25), value: side)
|
|
}
|
|
|
|
private var videoBlock: some View {
|
|
RoundedRectangle(cornerRadius: cornerRadius)
|
|
.fill(.secondary)
|
|
.overlay {
|
|
Image(systemName: "play.fill")
|
|
.font(.caption)
|
|
.foregroundStyle(.background)
|
|
}
|
|
}
|
|
|
|
private var panelBlock: some View {
|
|
RoundedRectangle(cornerRadius: cornerRadius)
|
|
.fill(.quaternary)
|
|
.overlay {
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
ForEach(0..<3, id: \.self) { _ in
|
|
Capsule()
|
|
.fill(.secondary.opacity(0.5))
|
|
.frame(height: 3)
|
|
}
|
|
}
|
|
.padding(6)
|
|
}
|
|
}
|
|
}
|
|
|
|
#endif
|