Files
yattee/Yattee/Views/Settings/PlayerControls/PlayerControlsPreviewView.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

535 lines
21 KiB
Swift

//
// PlayerControlsPreviewView.swift
// Yattee
//
// Preview component showing player controls layout in settings.
//
import SwiftUI
// MARK: - Preview Theme Modifier
/// A view modifier that applies the controls theme color scheme to preview.
struct PreviewThemeModifier: ViewModifier {
let theme: ControlsTheme
func body(content: Content) -> some View {
if let forcedScheme = theme.colorScheme {
content.environment(\.colorScheme, forcedScheme)
} else {
// Default to dark for preview since it's on black background
content.environment(\.colorScheme, .dark)
}
}
}
/// A static preview of the player controls layout for the settings view.
struct PlayerControlsPreviewView: View {
let layout: PlayerControlsLayout
let isLandscape: Bool
var body: some View {
GeometryReader { geometry in
let containerWidth = geometry.size.width
let containerHeight = geometry.size.height
// Use 16:9 aspect ratio for both modes, capped at container height
let aspectRatioHeight = containerWidth / (16.0 / 9.0)
let previewWidth = containerWidth
let previewHeight = min(containerHeight, aspectRatioHeight)
previewContent(width: previewWidth, height: previewHeight)
.position(x: containerWidth / 2, y: containerHeight / 2)
}
}
// MARK: - Preview Content
@ViewBuilder
private func previewContent(width: CGFloat, height: CGFloat) -> some View {
// Calculate minimum width needed for all buttons
let minContentWidth = calculateMinimumContentWidth()
let contentWidth = max(width, minContentWidth)
let needsScrolling = contentWidth > width
ScrollView(.horizontal, showsIndicators: needsScrolling) {
previewContentView(width: contentWidth, height: height)
.frame(width: contentWidth, height: height)
}
.scrollDisabled(!needsScrolling)
.frame(width: width, height: height)
.clipShape(RoundedRectangle(cornerRadius: 8))
.modifier(PreviewThemeModifier(theme: layout.globalSettings.theme))
}
/// Calculate minimum width needed to display all buttons without overlap
private func calculateMinimumContentWidth() -> CGFloat {
let topButtons = visibleButtons(in: layout.topSection)
let bottomButtons = visibleButtons(in: layout.bottomSection)
let topWidth = calculateSectionWidth(buttons: topButtons)
let bottomWidth = calculateSectionWidth(buttons: bottomButtons)
// Return the larger of top/bottom, plus padding
return max(topWidth, bottomWidth) + (previewPadding * 2)
}
/// Whether buttons have glass backgrounds (affects frame sizes).
private var hasButtonBackground: Bool {
layout.globalSettings.buttonBackground.glassStyle != nil
}
/// Frame size for regular buttons - matches ControlsSectionRenderer
private var regularButtonFrameSize: CGFloat {
hasButtonBackground ? previewButtonBackgroundSize : previewButtonSize
}
/// Frame size for slider icons - matches ControlsSectionRenderer
private var sliderIconFrameSize: CGFloat {
hasButtonBackground ? previewButtonBackgroundSize : previewButtonSize
}
/// Calculate width needed for a section's buttons
private func calculateSectionWidth(buttons: [ControlButtonConfiguration]) -> CGFloat {
var totalWidth: CGFloat = 0
var hasFlexibleSpacer = false
let sliderWidth: CGFloat = 80 * previewScale // Matches actual player slider width
for button in buttons {
if button.buttonType == .spacer {
if let settings = button.settings,
case .spacer(let spacerSettings) = settings {
if spacerSettings.isFlexible {
hasFlexibleSpacer = true
totalWidth += 20 // Minimum width for flexible spacer
} else {
totalWidth += CGFloat(spacerSettings.fixedWidth) * previewScale
}
}
} else if button.buttonType == .timeDisplay {
totalWidth += 60 // Approximate width for time display text
} else if button.buttonType == .brightness || button.buttonType == .volume {
let behavior = button.sliderSettings?.sliderBehavior ?? .expandOnTap
let effectiveBehavior: SliderBehavior = {
if behavior == .autoExpandInLandscape {
return isLandscape ? .alwaysVisible : .expandOnTap
}
return behavior
}()
if effectiveBehavior == .alwaysVisible {
// Slider icon + spacing + slider track
totalWidth += sliderIconFrameSize + 3 + sliderWidth
} else {
totalWidth += sliderIconFrameSize
}
} else if button.buttonType == .titleAuthor {
// Title/Author button is wider - estimate based on settings
let settings = button.titleAuthorSettings ?? TitleAuthorSettings()
var width: CGFloat = 0
if settings.showSourceImage { width += previewButtonSize * 1.2 + 4 }
if settings.showTitle || settings.showSourceName { width += 50 * previewScale }
if hasButtonBackground { width += 12 } // padding
totalWidth += max(width, regularButtonFrameSize)
} else {
totalWidth += regularButtonFrameSize
}
totalWidth += previewButtonSpacing
}
// If there's a flexible spacer, add extra minimum space
if hasFlexibleSpacer {
totalWidth += 40
}
return totalWidth
}
@ViewBuilder
private func previewContentView(width: CGFloat, height: CGFloat) -> some View {
// Video background
Image("PlayerControlsPreviewBackground")
.resizable()
.scaledToFill()
.frame(width: width, height: height)
.clipped()
.overlay {
// Solid color shade matching the controls opacity setting
Color.black.opacity(layout.globalSettings.controlsFadeOpacity)
}
.overlay {
// Controls overlay using ZStack for precise positioning
ZStack {
// Top section - aligned to top trailing
VStack {
HStack(spacing: previewButtonSpacing) {
ForEach(visibleButtons(in: layout.topSection)) { button in
PreviewButton(
configuration: button,
size: previewButtonSize,
backgroundSize: previewButtonBackgroundSize,
isLandscape: isLandscape,
fontStyle: layout.globalSettings.fontStyle,
buttonBackground: layout.globalSettings.buttonBackground,
previewScale: previewScale,
buttonSize: layout.globalSettings.buttonSize
)
}
}
.frame(maxWidth: .infinity, alignment: .trailing)
.padding(.horizontal, previewPadding)
.padding(.top, previewPadding)
Spacer()
}
// Center section
HStack(spacing: previewCenterSpacing) {
if layout.centerSettings.showSeekBackward {
PreviewCenterButton(
systemImage: layout.centerSettings.seekBackwardSystemImage,
fontSize: previewSeekFontSize,
frameSize: previewSeekButtonSize,
buttonBackground: layout.globalSettings.buttonBackground
)
}
if layout.centerSettings.showPlayPause {
PreviewCenterButton(
systemImage: "play.fill",
fontSize: previewPlayFontSize,
frameSize: previewPlayButtonSize,
buttonBackground: layout.globalSettings.buttonBackground
)
}
if layout.centerSettings.showSeekForward {
PreviewCenterButton(
systemImage: layout.centerSettings.seekForwardSystemImage,
fontSize: previewSeekFontSize,
frameSize: previewSeekButtonSize,
buttonBackground: layout.globalSettings.buttonBackground
)
}
}
// Bottom section - aligned to bottom leading
VStack {
Spacer()
HStack(spacing: previewButtonSpacing) {
ForEach(visibleButtons(in: layout.bottomSection)) { button in
PreviewButton(
configuration: button,
size: previewButtonSize,
backgroundSize: previewButtonBackgroundSize,
isLandscape: isLandscape,
fontStyle: layout.globalSettings.fontStyle,
buttonBackground: layout.globalSettings.buttonBackground,
previewScale: previewScale,
buttonSize: layout.globalSettings.buttonSize
)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, previewPadding)
.padding(.bottom, previewPadding)
}
}
}
}
// MARK: - Layout Constants
/// Scale factor for preview (to fit in settings view)
private var previewScale: CGFloat { 0.55 }
/// Button size for top/bottom bars - matches ControlsSectionRenderer which uses buttonSize.pointSize
private var previewButtonSize: CGFloat {
layout.globalSettings.buttonSize.pointSize * previewScale
}
/// Background size for buttons (slightly larger than button, matching ControlsSectionRenderer)
private var previewButtonBackgroundSize: CGFloat {
layout.globalSettings.buttonSize.pointSize * 1.15 * previewScale
}
/// Center button sizes - matches PlayerControlsView hardcoded values
private var previewSeekButtonSize: CGFloat {
let hasBackground = layout.globalSettings.buttonBackground.glassStyle != nil
return (hasBackground ? 64 : 56) * previewScale
}
private var previewPlayButtonSize: CGFloat {
let hasBackground = layout.globalSettings.buttonBackground.glassStyle != nil
return (hasBackground ? 82 : 72) * previewScale
}
/// Font sizes for center buttons - matches PlayerControlsView hardcoded values
private var previewSeekFontSize: CGFloat { 36 * previewScale }
private var previewPlayFontSize: CGFloat { 56 * previewScale }
private var previewButtonSpacing: CGFloat { 8 }
private var previewCenterSpacing: CGFloat {
let hasBackground = layout.globalSettings.buttonBackground.glassStyle != nil
return (hasBackground ? 40 : 32) * previewScale
}
private var previewPadding: CGFloat { 12 }
// MARK: - Helpers
private func visibleButtons(in section: LayoutSection) -> [ControlButtonConfiguration] {
section.visibleButtons(isWideLayout: isLandscape)
}
}
// MARK: - Preview Button
private struct PreviewButton: View {
let configuration: ControlButtonConfiguration
/// Button frame size (for tap target)
let size: CGFloat
/// Background frame size (for glass background, slightly larger)
let backgroundSize: CGFloat
let isLandscape: Bool
let fontStyle: ControlsFontStyle
let buttonBackground: ButtonBackgroundStyle
/// Scale factor for preview (used for slider width calculations)
let previewScale: CGFloat
/// Button size setting (small/medium/large) for icon scaling
let buttonSize: ButtonSize
/// Whether this button should show a glass background.
private var hasBackground: Bool { buttonBackground.glassStyle != nil }
/// Frame size - use backgroundSize when glass background enabled
private var frameSize: CGFloat { hasBackground ? backgroundSize : size }
/// Icon font size - uses buttonSize.iconSize to match actual player renderer
private var iconFontSize: CGFloat { buttonSize.iconSize * previewScale }
var body: some View {
Group {
if configuration.buttonType == .spacer {
// Spacer
if let settings = configuration.settings,
case .spacer(let spacerSettings) = settings,
spacerSettings.isFlexible {
Spacer()
} else {
Rectangle()
.fill(Color.clear)
.frame(width: spacerWidth)
}
} else if configuration.buttonType == .timeDisplay {
// Time display
Text(verbatim: "0:00 / 3:45")
.font(fontStyle.font(.caption))
.foregroundStyle(.white.opacity(0.9))
} else if configuration.buttonType == .brightness || configuration.buttonType == .volume {
// Slider buttons (brightness/volume) - show based on behavior
sliderPreview
} else if configuration.buttonType == .titleAuthor {
// Title/Author button - show preview matching actual layout
titleAuthorPreview
} else {
// Regular button
regularButtonContent
}
}
.opacity(buttonOpacity)
}
// MARK: - Regular Button Content
@ViewBuilder
private var regularButtonContent: some View {
if let glassStyle = buttonBackground.glassStyle {
Image(systemName: configuration.buttonType.systemImage)
.font(.system(size: iconFontSize))
.foregroundStyle(.white.opacity(0.9))
.frame(width: frameSize, height: frameSize)
.glassBackground(glassStyle, in: .circle, fallback: .ultraThinMaterial)
} else {
Image(systemName: configuration.buttonType.systemImage)
.font(.system(size: iconFontSize))
.foregroundStyle(.white.opacity(0.9))
.frame(width: frameSize, height: frameSize)
}
}
// MARK: - Title/Author Preview
@ViewBuilder
private var titleAuthorPreview: some View {
let settings = configuration.titleAuthorSettings ?? TitleAuthorSettings()
let avatarSize = size * 1.2
HStack(spacing: 4) {
// Avatar placeholder
if settings.showSourceImage {
Circle()
.fill(.white.opacity(0.3))
.frame(width: avatarSize, height: avatarSize)
.overlay {
Text(verbatim: "Y")
.font(.system(size: size * 0.6, weight: .semibold))
.foregroundStyle(.white.opacity(0.8))
}
}
// Title and author stack
if settings.showTitle || settings.showSourceName {
VStack(alignment: .leading, spacing: 0) {
if settings.showTitle {
Text(String(localized: "common.videoTitle"))
.font(fontStyle.font(size: size * 0.5, weight: .medium))
.foregroundStyle(.white.opacity(0.9))
.lineLimit(1)
}
if settings.showSourceName {
Text(String(localized: "common.channel"))
.font(fontStyle.font(size: size * 0.4))
.foregroundStyle(.white.opacity(0.6))
.lineLimit(1)
}
}
}
}
.padding(.horizontal, buttonBackground.glassStyle != nil ? 6 : 0)
.padding(.vertical, buttonBackground.glassStyle != nil ? 3 : 0)
.modifier(OptionalCapsuleGlassBackgroundModifier(style: buttonBackground))
}
// MARK: - Slider Preview
@ViewBuilder
private var sliderPreview: some View {
let behavior = configuration.sliderSettings?.sliderBehavior ?? .expandOnTap
// Compute effective behavior based on orientation for autoExpandInLandscape
let effectiveBehavior: SliderBehavior = {
if behavior == .autoExpandInLandscape {
return isLandscape ? .alwaysVisible : .expandOnTap
}
return behavior
}()
HStack(spacing: 3) {
// Icon with optional glass background
sliderIconContent
// Fake slider - show when effectively always visible
if effectiveBehavior == .alwaysVisible {
ZStack(alignment: .leading) {
// Slider track - 80pt width in actual player, scaled for preview
let sliderWidth: CGFloat = 80 * previewScale
RoundedRectangle(cornerRadius: 1.5)
.fill(.white.opacity(0.3))
.frame(width: sliderWidth, height: 3)
// Slider fill (showing ~60% filled)
RoundedRectangle(cornerRadius: 1.5)
.fill(.white.opacity(0.8))
.frame(width: sliderWidth * 0.6, height: 3)
}
}
}
}
@ViewBuilder
private var sliderIconContent: some View {
if let glassStyle = buttonBackground.glassStyle {
Image(systemName: configuration.buttonType.systemImage)
.font(.system(size: iconFontSize))
.foregroundStyle(.white.opacity(0.9))
.frame(width: frameSize, height: frameSize)
.glassBackground(glassStyle, in: .circle, fallback: .ultraThinMaterial)
} else {
Image(systemName: configuration.buttonType.systemImage)
.font(.system(size: iconFontSize))
.foregroundStyle(.white.opacity(0.9))
.frame(width: frameSize, height: frameSize)
}
}
private var spacerWidth: CGFloat {
guard let settings = configuration.settings,
case .spacer(let spacerSettings) = settings else {
return 8
}
return CGFloat(spacerSettings.fixedWidth) * previewScale
}
private var buttonOpacity: Double {
// Could dim buttons that don't match current orientation
1.0
}
}
// MARK: - Preview Center Button
private struct PreviewCenterButton: View {
let systemImage: String
let fontSize: CGFloat
let frameSize: CGFloat
let buttonBackground: ButtonBackgroundStyle
/// Background frame size (slightly larger when background is enabled).
private var backgroundFrameSize: CGFloat {
buttonBackground.glassStyle != nil ? frameSize * 1.15 : frameSize
}
var body: some View {
if let glassStyle = buttonBackground.glassStyle {
Image(systemName: systemImage)
.font(.system(size: fontSize))
.foregroundStyle(.white)
.frame(width: backgroundFrameSize, height: backgroundFrameSize)
.glassBackground(glassStyle, in: .circle, fallback: .ultraThinMaterial)
} else {
Image(systemName: systemImage)
.font(.system(size: fontSize))
.foregroundStyle(.white)
.frame(width: frameSize, height: frameSize)
}
}
}
// MARK: - Optional Capsule Glass Background Modifier
/// A view modifier that conditionally applies a capsule glass background.
private struct OptionalCapsuleGlassBackgroundModifier: ViewModifier {
let style: ButtonBackgroundStyle
func body(content: Content) -> some View {
if let glassStyle = style.glassStyle {
content.glassBackground(glassStyle, in: .capsule, fallback: .ultraThinMaterial)
} else {
content
}
}
}
// MARK: - Preview
#Preview("Landscape") {
PlayerControlsPreviewView(
layout: .default,
isLandscape: true
)
.frame(height: 200)
.padding()
.background(Color.gray.opacity(0.2))
}
#Preview("Portrait") {
PlayerControlsPreviewView(
layout: .default,
isLandscape: false
)
.frame(height: 300)
.padding()
.background(Color.gray.opacity(0.2))
}