// // 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 @Environment(\.colorScheme) private var systemColorScheme 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("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("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("Video Title") .font(fontStyle.font(size: size * 0.5, weight: .medium)) .foregroundStyle(.white.opacity(0.9)) .lineLimit(1) } if settings.showSourceName { Text("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)) }