Files
yattee/Yattee/Views/Settings/PlayerControls/MiniPlayerEditorView.swift
Arkadiusz Fal 4770bbe3b9 Remove dead code found by Periphery on all platforms
Delete declarations that Periphery reports unused on every platform that
compiles them (iOS, macOS, tvOS), including nine views used only by their
own previews. Platform-gated code still used on another platform, device-
only MPV render paths, migration models, CloudKit record names, App
Intents metadata and unused parameters are kept.
2026-10-08 23:57:56 +02:00

392 lines
13 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
)
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
videoSection
buttonsSection
addButtonSection
}
.navigationTitle(String(localized: "settings.playerControls.miniPlayer"))
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#elseif os(macOS)
.formStyle(.grouped)
#endif
.opaqueSettingsFormBackground()
.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 {
Group {
if config.buttonType.hasSettings {
NavigationLink {
MiniPlayerButtonConfigurationView(
buttonID: config.id,
viewModel: viewModel
)
} label: {
buttonRowContent(for: config)
}
} else {
buttonRowContent(for: config)
}
}
.contextMenu {
Button(role: .destructive) {
if let index = viewModel.miniPlayerButtons.firstIndex(where: { $0.id == config.id }) {
viewModel.removeMiniPlayerButton(at: index)
}
} label: {
Label(
String(localized: "settings.playerControls.delete"),
systemImage: "trash"
)
}
}
}
@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]
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)
#elseif os(macOS)
.formStyle(.grouped)
#endif
.opaqueSettingsFormBackground()
.onAppear {
syncFromConfiguration(config)
}
} else {
ContentUnavailableView(
String(localized: "settings.playerControls.buttonNotFound"),
systemImage: "exclamationmark.triangle"
)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
// 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)
}
}