Files
yattee/Yattee/Views/Settings/PlayerControls/MiniPlayerEditorView.swift
2026-02-08 18:33:56 +01:00

373 lines
12 KiB
Swift

//
// MiniPlayerEditorView.swift
// Yattee
//
// Settings editor for mini player behavior.
//
import SwiftUI
struct MiniPlayerEditorView: View {
@Bindable var viewModel: PlayerControlsSettingsViewModel
// Local state for immediate UI updates
@State private var showVideo: Bool = true
@State private var videoTapAction: MiniPlayerVideoTapAction = .startPiP
var body: some View {
List {
MiniPlayerPreviewView(
buttons: viewModel.miniPlayerButtons,
showVideo: showVideo
)
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
videoSection
buttonsSection
addButtonSection
}
.navigationTitle(String(localized: "settings.playerControls.miniPlayer"))
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.onAppear {
syncLocalState()
}
.onChange(of: viewModel.activePreset?.id) { _, _ in
syncLocalState()
}
}
// MARK: - Video Section
private var videoSection: some View {
Section {
Toggle(
String(localized: "settings.behavior.miniPlayer.showVideo"),
isOn: $showVideo
)
.disabled(!viewModel.canEditActivePreset)
.onChange(of: showVideo) { _, newValue in
guard newValue != viewModel.miniPlayerShowVideo else { return }
viewModel.syncMiniPlayerShowVideo(newValue)
}
if showVideo {
Picker(
String(localized: "settings.behavior.miniPlayer.videoTapAction"),
selection: $videoTapAction
) {
ForEach(MiniPlayerVideoTapAction.allCases, id: \.self) { action in
Text(action.displayName).tag(action)
}
}
.disabled(!viewModel.canEditActivePreset)
.onChange(of: videoTapAction) { _, newValue in
guard newValue != viewModel.miniPlayerVideoTapAction else { return }
viewModel.syncMiniPlayerVideoTapAction(newValue)
}
}
} header: {
Text(String(localized: "settings.behavior.miniPlayer.video.header"))
} footer: {
Text(String(localized: "settings.behavior.miniPlayer.showVideo.footer"))
}
}
// MARK: - Buttons Section
private var buttonsSection: some View {
Section {
if viewModel.miniPlayerButtons.isEmpty {
Text(String(localized: "miniPlayer.buttons.empty"))
.foregroundStyle(.secondary)
} else {
ForEach(Array(viewModel.miniPlayerButtons.enumerated()), id: \.element.id) { _, config in
buttonRow(for: config)
}
.onMove { source, destination in
viewModel.moveMiniPlayerButtons(fromOffsets: source, toOffset: destination)
}
.onDelete { indexSet in
indexSet.forEach { viewModel.removeMiniPlayerButton(at: $0) }
}
}
} header: {
Text(String(localized: "miniPlayer.buttons.header"))
} footer: {
Text(String(localized: "miniPlayer.buttons.footer"))
}
.disabled(!viewModel.canEditActivePreset)
}
@ViewBuilder
private func buttonRow(for config: ControlButtonConfiguration) -> some View {
if config.buttonType.hasSettings {
NavigationLink {
MiniPlayerButtonConfigurationView(
buttonID: config.id,
viewModel: viewModel
)
} label: {
buttonRowContent(for: config)
}
} else {
buttonRowContent(for: config)
}
}
@ViewBuilder
private func buttonRowContent(for config: ControlButtonConfiguration) -> some View {
HStack {
Image(systemName: buttonIcon(for: config))
.frame(width: 24)
.foregroundStyle(.secondary)
Text(config.buttonType.displayName)
// Show configuration summary for configurable buttons
if let summary = configurationSummary(for: config) {
Spacer()
Text(summary)
.foregroundStyle(.secondary)
.font(.caption)
}
}
}
/// Returns the appropriate icon for a button, using configuration-specific icons when available.
private func buttonIcon(for config: ControlButtonConfiguration) -> String {
if config.buttonType == .seek, let seekSettings = config.seekSettings {
return seekSettings.systemImage
}
return config.buttonType.systemImage
}
/// Returns a summary string for buttons with configurable settings.
private func configurationSummary(for config: ControlButtonConfiguration) -> String? {
if config.buttonType == .seek, let seekSettings = config.seekSettings {
return "\(seekSettings.seconds)s \(seekSettings.direction.displayName)"
}
return nil
}
// MARK: - Add Button Section
private var addButtonSection: some View {
Section {
ForEach(availableButtons, id: \.self) { buttonType in
Button {
viewModel.addMiniPlayerButton(buttonType)
} label: {
HStack {
Image(systemName: buttonType.systemImage)
.frame(width: 24)
.foregroundStyle(.secondary)
Text(buttonType.displayName)
.foregroundStyle(.primary)
}
}
}
} header: {
Text(String(localized: "miniPlayer.addButton.header"))
}
.disabled(!viewModel.canEditActivePreset)
}
// MARK: - Helpers
/// Button types available to add (not already in the mini player).
/// Seek buttons can be added multiple times (like spacers), others are unique.
private var availableButtons: [ControlButtonType] {
let usedTypes = Set(viewModel.miniPlayerButtons.map(\.buttonType))
return ControlButtonType.availableForMiniPlayer.filter { buttonType in
// Seek can be added multiple times (e.g., backward + forward)
buttonType == .seek || !usedTypes.contains(buttonType)
}
}
private func syncLocalState() {
showVideo = viewModel.miniPlayerShowVideo
videoTapAction = viewModel.miniPlayerVideoTapAction
}
}
// MARK: - Mini Player Preview
private struct MiniPlayerPreviewView: View {
let buttons: [ControlButtonConfiguration]
let showVideo: Bool
private let buttonSize: CGFloat = 28
private let buttonSpacing: CGFloat = 4
var body: some View {
HStack(spacing: 12) {
// Video thumbnail placeholder (always shown - matches actual mini player behavior)
RoundedRectangle(cornerRadius: 4)
.fill(.quaternary)
.frame(width: 60, height: 34)
.overlay {
Image(systemName: "play.rectangle")
.foregroundStyle(.secondary)
.font(.system(size: 14))
}
// Title/channel placeholders
VStack(alignment: .leading, spacing: 2) {
RoundedRectangle(cornerRadius: 2)
.fill(.secondary.opacity(0.5))
.frame(width: 100, height: 12)
RoundedRectangle(cornerRadius: 2)
.fill(.tertiary.opacity(0.5))
.frame(width: 70, height: 10)
}
Spacer()
// Buttons
HStack(spacing: buttonSpacing) {
ForEach(buttons) { config in
CompactPreviewButtonView(configuration: config, size: buttonSize)
}
}
}
.padding(.horizontal)
.padding(.vertical, 10)
.background(
Image("PlayerControlsPreviewBackground")
.resizable()
.scaledToFill()
.overlay(Color.black.opacity(0.5))
)
.clipShape(RoundedRectangle(cornerRadius: 8))
.padding(.horizontal)
.padding(.vertical, 12)
}
}
// MARK: - Mini Player Button Configuration View
/// View for configuring a single mini player button's settings.
struct MiniPlayerButtonConfigurationView: View {
let buttonID: UUID
@Bindable var viewModel: PlayerControlsSettingsViewModel
// Local state for immediate UI updates
@State private var seekSeconds: Double = 10
@State private var seekDirection: SeekDirection = .forward
/// Look up the current configuration from the view model's mini player settings.
private var configuration: ControlButtonConfiguration? {
viewModel.miniPlayerButtons.first { $0.id == buttonID }
}
var body: some View {
if let config = configuration {
Form {
// Type-specific settings
if config.buttonType.hasSettings {
typeSpecificSettings(for: config)
}
}
.navigationTitle(config.buttonType.displayName)
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.onAppear {
syncFromConfiguration(config)
}
} else {
ContentUnavailableView(
String(localized: "settings.playerControls.buttonNotFound"),
systemImage: "exclamationmark.triangle"
)
}
}
// MARK: - Sync from Configuration
private func syncFromConfiguration(_ config: ControlButtonConfiguration) {
switch config.settings {
case .seek(let settings):
seekSeconds = Double(settings.seconds)
seekDirection = settings.direction
default:
break
}
}
// MARK: - Type-Specific Settings
@ViewBuilder
private func typeSpecificSettings(for config: ControlButtonConfiguration) -> some View {
switch config.buttonType {
case .seek:
seekSettingsSection
default:
EmptyView()
}
}
// MARK: - Seek Settings
@ViewBuilder
private var seekSettingsSection: some View {
Section {
// Direction picker
Picker(
String(localized: "settings.playerControls.seek.direction"),
selection: $seekDirection
) {
ForEach(SeekDirection.allCases, id: \.self) { direction in
Text(direction.displayName).tag(direction)
}
}
.onChange(of: seekDirection) { _, newValue in
updateSettings(.seek(SeekSettings(seconds: Int(seekSeconds), direction: newValue)))
}
#if !os(tvOS)
HStack {
Text(String(localized: "settings.playerControls.seek.seconds"))
Spacer()
Text("\(Int(seekSeconds))s")
.foregroundStyle(.secondary)
}
Slider(
value: $seekSeconds,
in: 1...60,
step: 1
)
.onChange(of: seekSeconds) { _, newValue in
updateSettings(.seek(SeekSettings(seconds: Int(newValue), direction: seekDirection)))
}
#endif
// Quick presets
HStack {
ForEach([5, 10, 15, 30], id: \.self) { preset in
Button("\(preset)s") {
seekSeconds = Double(preset)
updateSettings(.seek(SeekSettings(seconds: preset, direction: seekDirection)))
}
.buttonStyle(.bordered)
.tint(Int(seekSeconds) == preset ? .accentColor : .secondary)
}
}
} header: {
Text(String(localized: "settings.playerControls.seek.header"))
}
}
// MARK: - Update Helpers
private func updateSettings(_ settings: ButtonSettings) {
guard var updated = configuration else { return }
updated.settings = settings
viewModel.updateMiniPlayerButtonConfiguration(updated)
}
}