mirror of
https://github.com/yattee/yattee.git
synced 2026-02-20 09:49:46 +00:00
499 lines
18 KiB
Swift
499 lines
18 KiB
Swift
//
|
|
// SidebarSettingsView.swift
|
|
// Yattee
|
|
//
|
|
// Settings for customizing sidebar content on iOS 18+, macOS, and tvOS 18+.
|
|
//
|
|
|
|
import SwiftUI
|
|
|
|
struct SidebarSettingsView: View {
|
|
@Environment(\.appEnvironment) private var appEnvironment
|
|
|
|
// Local state for main navigation editing (copied from settings on appear, saved on dismiss)
|
|
@State private var mainItemOrder: [SidebarMainItem] = []
|
|
@State private var mainItemVisibility: [SidebarMainItem: Bool] = [:]
|
|
|
|
private var settingsManager: SettingsManager? { appEnvironment?.settingsManager }
|
|
|
|
// MARK: - Sources Bindings
|
|
|
|
private var sourcesEnabledBinding: Binding<Bool> {
|
|
Binding(
|
|
get: { settingsManager?.sidebarSourcesEnabled ?? true },
|
|
set: { settingsManager?.sidebarSourcesEnabled = $0 }
|
|
)
|
|
}
|
|
|
|
private var sourceSortBinding: Binding<SidebarSourceSort> {
|
|
Binding(
|
|
get: { settingsManager?.sidebarSourceSort ?? .name },
|
|
set: { settingsManager?.sidebarSourceSort = $0 }
|
|
)
|
|
}
|
|
|
|
private var sourcesLimitEnabledBinding: Binding<Bool> {
|
|
Binding(
|
|
get: { settingsManager?.sidebarSourcesLimitEnabled ?? false },
|
|
set: { settingsManager?.sidebarSourcesLimitEnabled = $0 }
|
|
)
|
|
}
|
|
|
|
private var maxSourcesBinding: Binding<Int> {
|
|
Binding(
|
|
get: { settingsManager?.sidebarMaxSources ?? SettingsManager.defaultSidebarMaxSources },
|
|
set: { settingsManager?.sidebarMaxSources = $0 }
|
|
)
|
|
}
|
|
|
|
// MARK: - Channels Bindings
|
|
|
|
private var maxChannelsBinding: Binding<Int> {
|
|
Binding(
|
|
get: { settingsManager?.sidebarMaxChannels ?? SettingsManager.defaultSidebarMaxChannels },
|
|
set: { settingsManager?.sidebarMaxChannels = $0 }
|
|
)
|
|
}
|
|
|
|
private var channelSortBinding: Binding<SidebarChannelSort> {
|
|
Binding(
|
|
get: { settingsManager?.sidebarChannelSort ?? .alphabetical },
|
|
set: { settingsManager?.sidebarChannelSort = $0 }
|
|
)
|
|
}
|
|
|
|
private var channelsLimitEnabledBinding: Binding<Bool> {
|
|
Binding(
|
|
get: { settingsManager?.sidebarChannelsLimitEnabled ?? true },
|
|
set: { settingsManager?.sidebarChannelsLimitEnabled = $0 }
|
|
)
|
|
}
|
|
|
|
private var channelsEnabledBinding: Binding<Bool> {
|
|
Binding(
|
|
get: { settingsManager?.sidebarChannelsEnabled ?? true },
|
|
set: { settingsManager?.sidebarChannelsEnabled = $0 }
|
|
)
|
|
}
|
|
|
|
// MARK: - Playlists Bindings
|
|
|
|
private var maxPlaylistsBinding: Binding<Int> {
|
|
Binding(
|
|
get: { settingsManager?.sidebarMaxPlaylists ?? SettingsManager.defaultSidebarMaxPlaylists },
|
|
set: { settingsManager?.sidebarMaxPlaylists = $0 }
|
|
)
|
|
}
|
|
|
|
private var playlistSortBinding: Binding<SidebarPlaylistSort> {
|
|
Binding(
|
|
get: { settingsManager?.sidebarPlaylistSort ?? .alphabetical },
|
|
set: { settingsManager?.sidebarPlaylistSort = $0 }
|
|
)
|
|
}
|
|
|
|
private var playlistsLimitEnabledBinding: Binding<Bool> {
|
|
Binding(
|
|
get: { settingsManager?.sidebarPlaylistsLimitEnabled ?? false },
|
|
set: { settingsManager?.sidebarPlaylistsLimitEnabled = $0 }
|
|
)
|
|
}
|
|
|
|
private var playlistsEnabledBinding: Binding<Bool> {
|
|
Binding(
|
|
get: { settingsManager?.sidebarPlaylistsEnabled ?? true },
|
|
set: { settingsManager?.sidebarPlaylistsEnabled = $0 }
|
|
)
|
|
}
|
|
|
|
// MARK: - Startup Binding
|
|
|
|
private var startupTabBinding: Binding<SidebarMainItem> {
|
|
Binding(
|
|
get: { settingsManager?.sidebarStartupTab ?? .home },
|
|
set: { settingsManager?.sidebarStartupTab = $0 }
|
|
)
|
|
}
|
|
|
|
/// Valid startup tabs based on current visibility settings.
|
|
private var validStartupTabs: [SidebarMainItem] {
|
|
// Filter main items by visibility (respecting required items and platform availability)
|
|
let visibility = mainItemVisibility
|
|
return mainItemOrder
|
|
.filter { $0.isAvailableOnCurrentPlatform }
|
|
.filter { $0.isRequired || (visibility[$0] ?? true) }
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
List {
|
|
startupSection
|
|
mainNavigationSection
|
|
sourcesSection
|
|
channelsSection
|
|
playlistsSection
|
|
}
|
|
#if os(iOS) || os(tvOS)
|
|
.environment(\.editMode, .constant(.active))
|
|
#endif
|
|
.navigationTitle(String(localized: "settings.sidebar.title"))
|
|
#if os(iOS)
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
#endif
|
|
.onAppear {
|
|
loadMainNavigationSettings()
|
|
}
|
|
}
|
|
#if os(macOS)
|
|
.frame(minWidth: 400, minHeight: 300)
|
|
#endif
|
|
}
|
|
|
|
// MARK: - Startup Section
|
|
|
|
private var startupSection: some View {
|
|
Section {
|
|
Picker(String(localized: "settings.sidebar.startup.tab"), selection: startupTabBinding) {
|
|
ForEach(validStartupTabs) { item in
|
|
Text(item.localizedTitle).tag(item)
|
|
}
|
|
}
|
|
} header: {
|
|
Text(String(localized: "settings.sidebar.startup.header"))
|
|
} footer: {
|
|
Text(String(localized: "settings.sidebar.startup.footer"))
|
|
}
|
|
}
|
|
|
|
// MARK: - Main Navigation Section
|
|
|
|
private var mainNavigationSection: some View {
|
|
Section {
|
|
ForEach(mainItemOrder.filter { $0.isAvailableOnCurrentPlatform }) { item in
|
|
SidebarMainItemRow(
|
|
icon: item.icon,
|
|
title: item.localizedTitle,
|
|
isRequired: item.isRequired,
|
|
isVisible: mainItemBinding(for: item)
|
|
)
|
|
}
|
|
.onMove { from, to in
|
|
// Filter to get only platform-available items for correct index mapping
|
|
let availableItems = mainItemOrder.filter { $0.isAvailableOnCurrentPlatform }
|
|
|
|
// Get the items being moved
|
|
guard let fromIndex = from.first,
|
|
fromIndex < availableItems.count,
|
|
to <= availableItems.count else { return }
|
|
|
|
let movedItem = availableItems[fromIndex]
|
|
let targetItem = to < availableItems.count ? availableItems[to] : nil
|
|
|
|
// Find actual indices in mainItemOrder
|
|
guard let actualFromIndex = mainItemOrder.firstIndex(of: movedItem) else { return }
|
|
|
|
// Remove from current position
|
|
mainItemOrder.remove(at: actualFromIndex)
|
|
|
|
// Find target position
|
|
if let targetItem = targetItem,
|
|
let actualToIndex = mainItemOrder.firstIndex(of: targetItem) {
|
|
mainItemOrder.insert(movedItem, at: actualToIndex)
|
|
} else {
|
|
mainItemOrder.append(movedItem)
|
|
}
|
|
|
|
// Save immediately
|
|
saveMainNavigationSettings()
|
|
}
|
|
} header: {
|
|
Text(String(localized: "settings.sidebar.mainNavigation.header"))
|
|
} footer: {
|
|
Text(String(localized: "settings.sidebar.mainNavigation.footer"))
|
|
}
|
|
}
|
|
|
|
private func mainItemBinding(for item: SidebarMainItem) -> Binding<Bool> {
|
|
Binding(
|
|
get: { mainItemVisibility[item] ?? true },
|
|
set: { newValue in
|
|
mainItemVisibility[item] = newValue
|
|
saveMainNavigationSettings()
|
|
|
|
// Reset startup tab to Home if the hidden item was the startup tab
|
|
if !newValue, settingsManager?.sidebarStartupTab == item {
|
|
settingsManager?.sidebarStartupTab = .home
|
|
}
|
|
}
|
|
)
|
|
}
|
|
|
|
// MARK: - Main Navigation Data Management
|
|
|
|
private func loadMainNavigationSettings() {
|
|
guard let settings = settingsManager else { return }
|
|
mainItemOrder = settings.sidebarMainItemOrder
|
|
mainItemVisibility = settings.sidebarMainItemVisibility
|
|
}
|
|
|
|
private func saveMainNavigationSettings() {
|
|
guard let settings = settingsManager else { return }
|
|
settings.sidebarMainItemOrder = mainItemOrder
|
|
settings.sidebarMainItemVisibility = mainItemVisibility
|
|
notifySidebarSettingsChanged()
|
|
}
|
|
|
|
// MARK: - Sections
|
|
|
|
private var sourcesSection: some View {
|
|
Section {
|
|
// Show in Sidebar toggle
|
|
Toggle(String(localized: "settings.sidebar.showInSidebar"), isOn: sourcesEnabledBinding)
|
|
.onChange(of: sourcesEnabledBinding.wrappedValue) {
|
|
notifySidebarSettingsChanged()
|
|
}
|
|
|
|
// Source sort order
|
|
Picker(String(localized: "settings.sidebar.sourceSort"), selection: sourceSortBinding) {
|
|
ForEach(SidebarSourceSort.allCases) { sort in
|
|
Text(sort.localizedTitle).tag(sort)
|
|
}
|
|
}
|
|
.disabled(!sourcesEnabledBinding.wrappedValue)
|
|
.onChange(of: sourceSortBinding.wrappedValue) {
|
|
notifySidebarSettingsChanged()
|
|
}
|
|
|
|
// Limit sources toggle
|
|
Toggle(String(localized: "settings.sidebar.sourcesLimitEnabled"), isOn: sourcesLimitEnabledBinding)
|
|
.disabled(!sourcesEnabledBinding.wrappedValue)
|
|
.onChange(of: sourcesLimitEnabledBinding.wrappedValue) {
|
|
notifySidebarSettingsChanged()
|
|
}
|
|
|
|
// Max sources (only shown when limit is enabled)
|
|
if sourcesLimitEnabledBinding.wrappedValue {
|
|
#if os(tvOS)
|
|
// tvOS uses Picker instead of Slider (Slider/Stepper unavailable)
|
|
Picker(String(localized: "settings.sidebar.maxSources"), selection: maxSourcesBinding) {
|
|
ForEach([5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 60, 70, 80, 90, 100], id: \.self) { value in
|
|
Text("\(value)").tag(value)
|
|
}
|
|
}
|
|
.disabled(!sourcesEnabledBinding.wrappedValue)
|
|
.onChange(of: maxSourcesBinding.wrappedValue) {
|
|
notifySidebarSettingsChanged()
|
|
}
|
|
#else
|
|
// Max sources slider
|
|
HStack {
|
|
Text(String(localized: "settings.sidebar.maxSources"))
|
|
Spacer()
|
|
Text("\(maxSourcesBinding.wrappedValue)")
|
|
.foregroundStyle(.secondary)
|
|
.monospacedDigit()
|
|
}
|
|
.foregroundStyle(sourcesEnabledBinding.wrappedValue ? .primary : .secondary)
|
|
|
|
Slider(
|
|
value: Binding(
|
|
get: { Double(maxSourcesBinding.wrappedValue) },
|
|
set: { maxSourcesBinding.wrappedValue = Int($0) }
|
|
),
|
|
in: 5...100,
|
|
step: 5
|
|
) { editing in
|
|
if !editing {
|
|
notifySidebarSettingsChanged()
|
|
}
|
|
}
|
|
.disabled(!sourcesEnabledBinding.wrappedValue)
|
|
#endif
|
|
}
|
|
} header: {
|
|
Text(String(localized: "settings.sidebar.sources.header"))
|
|
} footer: {
|
|
Text(String(localized: "settings.sidebar.sources.footer"))
|
|
}
|
|
}
|
|
|
|
private var channelsSection: some View {
|
|
Section {
|
|
// Show in Sidebar toggle (first)
|
|
Toggle(String(localized: "settings.sidebar.showInSidebar"), isOn: channelsEnabledBinding)
|
|
.onChange(of: channelsEnabledBinding.wrappedValue) {
|
|
notifySidebarSettingsChanged()
|
|
}
|
|
|
|
// Channel sort order
|
|
Picker(String(localized: "settings.sidebar.channelSort"), selection: channelSortBinding) {
|
|
ForEach(SidebarChannelSort.allCases.filter { $0 != .custom }) { sort in
|
|
Text(sort.localizedTitle).tag(sort)
|
|
}
|
|
}
|
|
.disabled(!channelsEnabledBinding.wrappedValue)
|
|
.onChange(of: channelSortBinding.wrappedValue) {
|
|
notifySidebarSettingsChanged()
|
|
}
|
|
|
|
// Limit channels toggle
|
|
Toggle(String(localized: "settings.sidebar.channelsLimitEnabled"), isOn: channelsLimitEnabledBinding)
|
|
.disabled(!channelsEnabledBinding.wrappedValue)
|
|
.onChange(of: channelsLimitEnabledBinding.wrappedValue) {
|
|
notifySidebarSettingsChanged()
|
|
}
|
|
|
|
// Max channels (only shown when limit is enabled)
|
|
if channelsLimitEnabledBinding.wrappedValue {
|
|
#if os(tvOS)
|
|
// tvOS uses Picker instead of Slider (Slider/Stepper unavailable)
|
|
Picker(String(localized: "settings.sidebar.maxChannels"), selection: maxChannelsBinding) {
|
|
ForEach([5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 60, 70, 80, 90, 100], id: \.self) { value in
|
|
Text("\(value)").tag(value)
|
|
}
|
|
}
|
|
.disabled(!channelsEnabledBinding.wrappedValue)
|
|
.onChange(of: maxChannelsBinding.wrappedValue) {
|
|
notifySidebarSettingsChanged()
|
|
}
|
|
#else
|
|
// Max channels slider
|
|
HStack {
|
|
Text(String(localized: "settings.sidebar.maxChannels"))
|
|
Spacer()
|
|
Text("\(maxChannelsBinding.wrappedValue)")
|
|
.foregroundStyle(.secondary)
|
|
.monospacedDigit()
|
|
}
|
|
.foregroundStyle(channelsEnabledBinding.wrappedValue ? .primary : .secondary)
|
|
|
|
Slider(
|
|
value: Binding(
|
|
get: { Double(maxChannelsBinding.wrappedValue) },
|
|
set: { maxChannelsBinding.wrappedValue = Int($0) }
|
|
),
|
|
in: 5...100,
|
|
step: 5
|
|
) { editing in
|
|
if !editing {
|
|
notifySidebarSettingsChanged()
|
|
}
|
|
}
|
|
.disabled(!channelsEnabledBinding.wrappedValue)
|
|
#endif
|
|
}
|
|
} header: {
|
|
Text(String(localized: "settings.sidebar.channels.header"))
|
|
} footer: {
|
|
Text(String(localized: "settings.sidebar.channels.footer"))
|
|
}
|
|
}
|
|
|
|
private var playlistsSection: some View {
|
|
Section {
|
|
// Show in Sidebar toggle (first)
|
|
Toggle(String(localized: "settings.sidebar.showInSidebar"), isOn: playlistsEnabledBinding)
|
|
.onChange(of: playlistsEnabledBinding.wrappedValue) {
|
|
notifySidebarSettingsChanged()
|
|
}
|
|
|
|
// Playlist sort order
|
|
Picker(String(localized: "settings.sidebar.playlistSort"), selection: playlistSortBinding) {
|
|
ForEach(SidebarPlaylistSort.allCases) { sort in
|
|
Text(sort.localizedTitle).tag(sort)
|
|
}
|
|
}
|
|
.disabled(!playlistsEnabledBinding.wrappedValue)
|
|
.onChange(of: playlistSortBinding.wrappedValue) {
|
|
notifySidebarSettingsChanged()
|
|
}
|
|
|
|
// Limit playlists toggle
|
|
Toggle(String(localized: "settings.sidebar.playlistsLimitEnabled"), isOn: playlistsLimitEnabledBinding)
|
|
.disabled(!playlistsEnabledBinding.wrappedValue)
|
|
.onChange(of: playlistsLimitEnabledBinding.wrappedValue) {
|
|
notifySidebarSettingsChanged()
|
|
}
|
|
|
|
// Max playlists (only shown when limit is enabled)
|
|
if playlistsLimitEnabledBinding.wrappedValue {
|
|
#if os(tvOS)
|
|
// tvOS uses Picker instead of Slider (Slider/Stepper unavailable)
|
|
Picker(String(localized: "settings.sidebar.maxPlaylists"), selection: maxPlaylistsBinding) {
|
|
ForEach([5, 10, 15, 20, 25, 30], id: \.self) { value in
|
|
Text("\(value)").tag(value)
|
|
}
|
|
}
|
|
.disabled(!playlistsEnabledBinding.wrappedValue)
|
|
.onChange(of: maxPlaylistsBinding.wrappedValue) {
|
|
notifySidebarSettingsChanged()
|
|
}
|
|
#else
|
|
// Max playlists slider
|
|
HStack {
|
|
Text(String(localized: "settings.sidebar.maxPlaylists"))
|
|
Spacer()
|
|
Text("\(maxPlaylistsBinding.wrappedValue)")
|
|
.foregroundStyle(.secondary)
|
|
.monospacedDigit()
|
|
}
|
|
.foregroundStyle(playlistsEnabledBinding.wrappedValue ? .primary : .secondary)
|
|
|
|
Slider(
|
|
value: Binding(
|
|
get: { Double(maxPlaylistsBinding.wrappedValue) },
|
|
set: { maxPlaylistsBinding.wrappedValue = Int($0) }
|
|
),
|
|
in: 5...30,
|
|
step: 5
|
|
) { editing in
|
|
if !editing {
|
|
notifySidebarSettingsChanged()
|
|
}
|
|
}
|
|
.disabled(!playlistsEnabledBinding.wrappedValue)
|
|
#endif
|
|
}
|
|
} header: {
|
|
Text(String(localized: "settings.sidebar.playlists.header"))
|
|
} footer: {
|
|
Text(String(localized: "settings.sidebar.playlists.footer"))
|
|
}
|
|
}
|
|
|
|
// MARK: - Notifications
|
|
|
|
private func notifySidebarSettingsChanged() {
|
|
NotificationCenter.default.post(name: .sidebarSettingsDidChange, object: nil)
|
|
}
|
|
}
|
|
|
|
// MARK: - Sidebar Main Item Row
|
|
|
|
private struct SidebarMainItemRow: View {
|
|
let icon: String
|
|
let title: String
|
|
let isRequired: Bool
|
|
@Binding var isVisible: Bool
|
|
|
|
var body: some View {
|
|
HStack {
|
|
Image(systemName: icon)
|
|
.frame(width: 24)
|
|
.foregroundStyle(.secondary)
|
|
Text(title)
|
|
Spacer()
|
|
Toggle("", isOn: $isVisible)
|
|
.labelsHidden()
|
|
.disabled(isRequired)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Preview
|
|
|
|
#Preview {
|
|
SidebarSettingsView()
|
|
.appEnvironment(.preview)
|
|
}
|