mirror of
https://github.com/yattee/yattee.git
synced 2026-02-20 17:59:45 +00:00
1070 lines
38 KiB
Swift
1070 lines
38 KiB
Swift
//
|
|
// ControlsSectionRenderer.swift
|
|
// Yattee
|
|
//
|
|
// Renders a section of player control buttons dynamically based on layout configuration.
|
|
//
|
|
|
|
import SwiftUI
|
|
|
|
#if os(iOS)
|
|
|
|
/// Renders a horizontal row of control buttons based on layout configuration.
|
|
struct ControlsSectionRenderer: View {
|
|
let section: LayoutSection
|
|
let actions: PlayerControlsActions
|
|
let globalSettings: GlobalLayoutSettings
|
|
let controlsVisible: Bool
|
|
|
|
/// Convenience accessor for button size.
|
|
private var buttonSize: ButtonSize { globalSettings.buttonSize }
|
|
|
|
/// Convenience accessor for font style.
|
|
private var fontStyle: ControlsFontStyle { globalSettings.fontStyle }
|
|
|
|
/// State for volume slider adjustment
|
|
@State private var isAdjustingVolume = false
|
|
|
|
/// State for brightness slider adjustment
|
|
@State private var isAdjustingBrightness = false
|
|
|
|
/// State for play next button animation
|
|
@State private var playNextTapCount = 0
|
|
|
|
/// State for play previous button animation
|
|
@State private var playPreviousTapCount = 0
|
|
|
|
/// State for seek button animation
|
|
@State private var seekTapCount = 0
|
|
|
|
/// State for controls lock button unlock progress
|
|
@State private var unlockProgress: Double = 0
|
|
@State private var unlockTimer: Timer?
|
|
|
|
/// State for expandable volume slider
|
|
@State private var isVolumeExpanded = false
|
|
|
|
/// State for expandable brightness slider
|
|
@State private var isBrightnessExpanded = false
|
|
|
|
var body: some View {
|
|
HStack {
|
|
ForEach(section.visibleButtons(isWideLayout: actions.isWideScreenLayout)) { config in
|
|
renderButton(config)
|
|
}
|
|
}
|
|
.animation(.easeInOut(duration: 0.2), value: isVolumeExpanded)
|
|
.animation(.easeInOut(duration: 0.2), value: isBrightnessExpanded)
|
|
.onChange(of: controlsVisible) { _, visible in
|
|
if !visible {
|
|
isVolumeExpanded = false
|
|
isBrightnessExpanded = false
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Whether buttons should be disabled due to controls lock (all except settings)
|
|
private var isLocked: Bool {
|
|
actions.isControlsLocked
|
|
}
|
|
|
|
// MARK: - Button Rendering
|
|
|
|
@ViewBuilder
|
|
private func renderButton(_ config: ControlButtonConfiguration) -> some View {
|
|
switch config.buttonType {
|
|
case .spacer:
|
|
renderSpacer(config)
|
|
|
|
case .close:
|
|
if actions.showCloseButton, actions.onClose != nil {
|
|
controlButton(systemImage: "xmark") {
|
|
actions.onClose?()
|
|
}
|
|
.disabled(isLocked)
|
|
.opacity(isLocked ? 0.5 : 1.0)
|
|
.accessibilityIdentifier("player.closeButton")
|
|
.accessibilityLabel("player.closeButton")
|
|
}
|
|
|
|
case .airplay:
|
|
airplayButtonWithBackground
|
|
.disabled(isLocked)
|
|
.opacity(isLocked ? 0.5 : 1.0)
|
|
|
|
case .mpvDebug:
|
|
if actions.showDebugButton, actions.onToggleDebug != nil {
|
|
controlButton(systemImage: "info.circle") {
|
|
actions.onToggleDebug?()
|
|
}
|
|
.disabled(isLocked)
|
|
.opacity(isLocked ? 0.5 : 1.0)
|
|
}
|
|
|
|
case .brightness:
|
|
brightnessControls(config: config)
|
|
.disabled(isLocked)
|
|
.opacity(isLocked ? 0.5 : 1.0)
|
|
|
|
case .volume:
|
|
if actions.showVolumeControls {
|
|
volumeControls(config: config)
|
|
.disabled(isLocked)
|
|
.opacity(isLocked ? 0.5 : 1.0)
|
|
}
|
|
|
|
case .pictureInPicture:
|
|
if actions.isPiPAvailable, actions.onTogglePiP != nil {
|
|
controlButton(systemImage: actions.pipIcon) {
|
|
actions.onTogglePiP?()
|
|
}
|
|
.disabled(isLocked)
|
|
.opacity(isLocked ? 0.5 : 1.0)
|
|
}
|
|
|
|
case .fullscreen:
|
|
if actions.shouldShowFullscreenButton {
|
|
controlButton(systemImage: actions.fullscreenIcon) {
|
|
handleFullscreenTap()
|
|
}
|
|
.disabled(isLocked)
|
|
.opacity(isLocked ? 0.5 : 1.0)
|
|
}
|
|
|
|
case .orientationLock:
|
|
// Only on iPhone (not iPad)
|
|
if !actions.isIPad, actions.onToggleOrientationLock != nil {
|
|
controlButton(
|
|
systemImage: actions.orientationLockIcon,
|
|
tint: actions.isOrientationLocked ? .red : .white
|
|
) {
|
|
actions.onToggleOrientationLock?()
|
|
}
|
|
.disabled(isLocked)
|
|
.opacity(isLocked ? 0.5 : 1.0)
|
|
}
|
|
|
|
case .panelToggle:
|
|
if actions.onTogglePanel != nil {
|
|
Button {
|
|
actions.onTogglePanel?()
|
|
} label: {
|
|
panelToggleButtonContent
|
|
}
|
|
.disabled(isLocked)
|
|
.opacity(isLocked ? 0.5 : 1.0)
|
|
}
|
|
|
|
case .settings:
|
|
if actions.onShowSettings != nil {
|
|
controlButton(systemImage: "gearshape") {
|
|
actions.onShowSettings?()
|
|
}
|
|
}
|
|
|
|
case .controlsLock:
|
|
if actions.onControlsLockToggled != nil {
|
|
controlsLockButton
|
|
}
|
|
|
|
case .playPrevious:
|
|
if actions.hasPreviousInQueue, actions.onPlayPrevious != nil {
|
|
Button {
|
|
playPreviousTapCount += 1
|
|
Task { await actions.onPlayPrevious?() }
|
|
} label: {
|
|
playPreviousButtonContent()
|
|
}
|
|
.disabled(isLocked)
|
|
.opacity(isLocked ? 0.5 : 1.0)
|
|
}
|
|
|
|
case .playNext:
|
|
if actions.hasNextInQueue, actions.onPlayNext != nil {
|
|
Button {
|
|
playNextTapCount += 1
|
|
Task { await actions.onPlayNext?() }
|
|
} label: {
|
|
playNextButtonContent()
|
|
}
|
|
.disabled(isLocked)
|
|
.opacity(isLocked ? 0.5 : 1.0)
|
|
}
|
|
|
|
case .queue:
|
|
if actions.onShowQueue != nil {
|
|
controlButton(systemImage: "list.bullet") {
|
|
actions.onShowQueue?()
|
|
}
|
|
.disabled(isLocked)
|
|
.opacity(isLocked ? 0.5 : 1.0)
|
|
}
|
|
|
|
case .timeDisplay:
|
|
timeDisplayView(config)
|
|
|
|
case .playbackSpeed:
|
|
playbackSpeedMenu
|
|
.disabled(isLocked)
|
|
.opacity(isLocked ? 0.5 : 1.0)
|
|
|
|
case .videoTrack:
|
|
videoTrackButton
|
|
.disabled(isLocked)
|
|
.opacity(isLocked ? 0.5 : 1.0)
|
|
|
|
case .audioTrack:
|
|
audioTrackButton
|
|
.disabled(isLocked)
|
|
.opacity(isLocked ? 0.5 : 1.0)
|
|
|
|
case .captions:
|
|
captionsButton
|
|
.disabled(isLocked)
|
|
.opacity(isLocked ? 0.5 : 1.0)
|
|
|
|
case .chapters:
|
|
chaptersButton
|
|
.disabled(isLocked)
|
|
.opacity(isLocked ? 0.5 : 1.0)
|
|
|
|
case .share:
|
|
shareButton
|
|
.disabled(isLocked)
|
|
.opacity(isLocked ? 0.5 : 1.0)
|
|
|
|
case .addToPlaylist:
|
|
addToPlaylistButton
|
|
.disabled(isLocked)
|
|
.opacity(isLocked ? 0.5 : 1.0)
|
|
|
|
case .contextMenu:
|
|
contextMenuButton
|
|
.disabled(isLocked)
|
|
.opacity(isLocked ? 0.5 : 1.0)
|
|
|
|
case .playPause:
|
|
if actions.onPlayPause != nil {
|
|
Button {
|
|
actions.onPlayPause?()
|
|
} label: {
|
|
playPauseButtonContent()
|
|
}
|
|
.disabled(isTransportDisabled || isLocked)
|
|
.opacity(isLocked ? 0.5 : 1.0)
|
|
}
|
|
|
|
case .titleAuthor:
|
|
if actions.onTogglePanel != nil {
|
|
Button {
|
|
actions.onTogglePanel?()
|
|
} label: {
|
|
titleAuthorButtonContent(config: config)
|
|
}
|
|
.disabled(isLocked)
|
|
.opacity(isLocked ? 0.5 : 1.0)
|
|
}
|
|
|
|
case .panscan:
|
|
panscanButton
|
|
.disabled(isLocked || !actions.isPanscanAllowed)
|
|
.opacity(isLocked || !actions.isPanscanAllowed ? 0.5 : 1.0)
|
|
|
|
case .autoPlayNext:
|
|
autoPlayNextButton
|
|
.disabled(isLocked)
|
|
.opacity(isLocked ? 0.5 : 1.0)
|
|
|
|
case .seekBackward, .seekForward:
|
|
// These are center section only buttons, not rendered here
|
|
EmptyView()
|
|
|
|
case .seek:
|
|
if let settings = config.seekSettings {
|
|
seekButton(settings: settings, config: config)
|
|
.disabled(isLocked)
|
|
.opacity(isLocked ? 0.5 : 1.0)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Helper Views
|
|
|
|
/// Convenience accessor for button background style.
|
|
private var buttonBackground: ButtonBackgroundStyle { globalSettings.buttonBackground }
|
|
|
|
/// Convenience accessor for theme.
|
|
private var theme: ControlsTheme { globalSettings.theme }
|
|
|
|
/// The size of the button background circle (slightly larger than button tap target).
|
|
private var buttonBackgroundSize: CGFloat {
|
|
buttonSize.pointSize * 1.15
|
|
}
|
|
|
|
// MARK: - Controls Lock Button
|
|
|
|
@ViewBuilder
|
|
private var controlsLockButton: some View {
|
|
let frameSize = buttonBackground.glassStyle != nil ? buttonBackgroundSize : buttonSize.pointSize
|
|
|
|
if isLocked {
|
|
// Locked state: show progress ring, hold to unlock
|
|
Button { } label: {
|
|
Image(systemName: "lock")
|
|
.font(.system(size: buttonSize.iconSize))
|
|
.foregroundStyle(.red)
|
|
.frame(width: frameSize, height: frameSize)
|
|
.modifier(OptionalGlassBackgroundModifier(style: buttonBackground, theme: theme))
|
|
.overlay {
|
|
// Progress ring overlay - doesn't affect layout
|
|
Circle()
|
|
.trim(from: 0, to: unlockProgress)
|
|
.stroke(Color.white, lineWidth: 2)
|
|
.rotationEffect(.degrees(-90))
|
|
.frame(width: frameSize + 4, height: frameSize + 4)
|
|
.opacity(unlockProgress > 0 ? 1 : 0)
|
|
}
|
|
.contentShape(buttonBackground.glassStyle != nil ? AnyShape(Circle()) : AnyShape(Rectangle()))
|
|
}
|
|
.simultaneousGesture(
|
|
DragGesture(minimumDistance: 0)
|
|
.onChanged { _ in startUnlock() }
|
|
.onEnded { _ in cancelUnlock() }
|
|
)
|
|
} else {
|
|
// Unlocked state: tap to lock
|
|
controlButton(systemImage: "lock.open") {
|
|
actions.onControlsLockToggled?(true)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func startUnlock() {
|
|
guard unlockTimer == nil else { return }
|
|
unlockProgress = 0
|
|
|
|
// Cancel hide timer while unlocking
|
|
actions.onCancelHideTimer?()
|
|
|
|
unlockTimer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { _ in
|
|
Task { @MainActor in
|
|
self.unlockProgress += 0.05 / 3.0 // 3 seconds total
|
|
if self.unlockProgress >= 1.0 {
|
|
self.unlockTimer?.invalidate()
|
|
self.unlockTimer = nil
|
|
self.actions.onControlsLockToggled?(false)
|
|
self.unlockProgress = 0
|
|
// Reset hide timer after unlock completes
|
|
self.actions.onResetHideTimer?()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func cancelUnlock() {
|
|
unlockTimer?.invalidate()
|
|
unlockTimer = nil
|
|
withAnimation(.easeOut(duration: 0.2)) {
|
|
unlockProgress = 0
|
|
}
|
|
// Reset hide timer when user releases early
|
|
actions.onResetHideTimer?()
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func controlButton(
|
|
systemImage: String,
|
|
tint: Color = .white,
|
|
action: @escaping () -> Void
|
|
) -> some View {
|
|
Button(action: action) {
|
|
buttonContent(systemImage: systemImage, tint: tint)
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func buttonContent(systemImage: String, tint: Color) -> some View {
|
|
if let glassStyle = buttonBackground.glassStyle {
|
|
Image(systemName: systemImage)
|
|
.font(.system(size: buttonSize.iconSize))
|
|
.foregroundStyle(tint)
|
|
.frame(width: buttonBackgroundSize, height: buttonBackgroundSize)
|
|
.glassBackground(glassStyle, in: .circle, fallback: .ultraThinMaterial, colorScheme: theme.colorScheme)
|
|
.contentShape(Circle())
|
|
} else {
|
|
Image(systemName: systemImage)
|
|
.font(.system(size: buttonSize.iconSize))
|
|
.foregroundStyle(tint)
|
|
.frame(width: buttonSize.pointSize, height: buttonSize.pointSize)
|
|
.contentShape(Rectangle())
|
|
}
|
|
}
|
|
|
|
/// Volume button content using variable value SF Symbol.
|
|
@ViewBuilder
|
|
private func volumeButtonContent(tint: Color) -> some View {
|
|
if let glassStyle = buttonBackground.glassStyle {
|
|
volumeImage
|
|
.font(.system(size: buttonSize.iconSize))
|
|
.foregroundStyle(tint)
|
|
.frame(width: buttonBackgroundSize, height: buttonBackgroundSize)
|
|
.glassBackground(glassStyle, in: .circle, fallback: .ultraThinMaterial, colorScheme: theme.colorScheme)
|
|
.contentShape(Circle())
|
|
} else {
|
|
volumeImage
|
|
.font(.system(size: buttonSize.iconSize))
|
|
.foregroundStyle(tint)
|
|
.frame(width: buttonSize.pointSize, height: buttonSize.pointSize)
|
|
.contentShape(Rectangle())
|
|
}
|
|
}
|
|
|
|
/// Volume icon image using variable value SF Symbol.
|
|
private var volumeImage: Image {
|
|
if actions.playerState.isMuted {
|
|
Image(systemName: "speaker.slash.fill")
|
|
} else {
|
|
Image(systemName: "speaker.wave.3.fill", variableValue: Double(actions.playerState.volume))
|
|
}
|
|
}
|
|
|
|
/// Brightness button content using variable value SF Symbol.
|
|
@ViewBuilder
|
|
private func brightnessButtonContent() -> some View {
|
|
if let glassStyle = buttonBackground.glassStyle {
|
|
brightnessImage
|
|
.font(.system(size: buttonSize.iconSize))
|
|
.foregroundStyle(.white)
|
|
.frame(width: buttonBackgroundSize, height: buttonBackgroundSize)
|
|
.glassBackground(glassStyle, in: .circle, fallback: .ultraThinMaterial, colorScheme: theme.colorScheme)
|
|
.contentShape(Circle())
|
|
} else {
|
|
brightnessImage
|
|
.font(.system(size: buttonSize.iconSize))
|
|
.foregroundStyle(.white)
|
|
.frame(width: buttonSize.pointSize, height: buttonSize.pointSize)
|
|
.contentShape(Rectangle())
|
|
}
|
|
}
|
|
|
|
/// Brightness icon image using variable value SF Symbol.
|
|
private var brightnessImage: Image {
|
|
Image(systemName: "sun.max.fill", variableValue: UIScreen.main.brightness)
|
|
}
|
|
|
|
/// Panel toggle button content with glass background support.
|
|
@ViewBuilder
|
|
private var panelToggleButtonContent: some View {
|
|
let frameSize = buttonBackground.glassStyle != nil ? buttonBackgroundSize : buttonSize.pointSize
|
|
Group {
|
|
Image(systemName: actions.panelToggleIcon)
|
|
.font(.system(size: buttonSize.iconSize))
|
|
.foregroundStyle(.white)
|
|
.scaleEffect(x: actions.panelSide == .right ? 1 : -1)
|
|
}
|
|
.frame(width: frameSize, height: frameSize)
|
|
.modifier(OptionalGlassBackgroundModifier(style: buttonBackground, theme: theme))
|
|
.contentShape(buttonBackground.glassStyle != nil ? AnyShape(Circle()) : AnyShape(Rectangle()))
|
|
}
|
|
|
|
/// Title/Author button content showing video title and source info.
|
|
@ViewBuilder
|
|
private func titleAuthorButtonContent(config: ControlButtonConfiguration) -> some View {
|
|
let settings = config.titleAuthorSettings ?? TitleAuthorSettings()
|
|
let video = actions.currentVideo
|
|
let imageSize = buttonSize.pointSize * 0.9
|
|
|
|
HStack(spacing: 8) {
|
|
// Source/author image
|
|
if settings.showSourceImage, let video {
|
|
ChannelAvatarView(
|
|
author: video.author,
|
|
size: imageSize,
|
|
yatteeServerURL: actions.yatteeServerURL,
|
|
source: video.id.source
|
|
)
|
|
}
|
|
|
|
// Title and source name stack
|
|
if settings.showTitle || settings.showSourceName {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
if settings.showTitle, let video {
|
|
Text(actions.deArrowBrandingProvider?.title(for: video) ?? video.title)
|
|
.font(fontStyle.font(.caption).weight(.medium))
|
|
.foregroundStyle(.white)
|
|
.lineLimit(1)
|
|
}
|
|
|
|
if settings.showSourceName, let video {
|
|
Text(video.author.name)
|
|
.font(fontStyle.font(.caption2))
|
|
.foregroundStyle(.white.opacity(0.7))
|
|
.lineLimit(1)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding(.horizontal, buttonBackground.glassStyle != nil ? 12 : 0)
|
|
.padding(.vertical, buttonBackground.glassStyle != nil ? 6 : 0)
|
|
.modifier(OptionalCapsuleGlassBackgroundModifier(style: buttonBackground, theme: theme))
|
|
.contentShape(buttonBackground.glassStyle != nil ? AnyShape(Capsule()) : AnyShape(Rectangle()))
|
|
}
|
|
|
|
/// Play previous button content with glass background and symbol effect support.
|
|
@ViewBuilder
|
|
private func playPreviousButtonContent() -> some View {
|
|
let frameSize = buttonBackground.glassStyle != nil ? buttonBackgroundSize : buttonSize.pointSize
|
|
Image(systemName: "backward.fill")
|
|
.font(.system(size: buttonSize.iconSize))
|
|
.foregroundStyle(.white)
|
|
.frame(width: frameSize, height: frameSize)
|
|
.modifier(OptionalGlassBackgroundModifier(style: buttonBackground, theme: theme))
|
|
.contentShape(buttonBackground.glassStyle != nil ? AnyShape(Circle()) : AnyShape(Rectangle()))
|
|
.symbolEffect(.bounce.down.byLayer, options: .nonRepeating, value: playPreviousTapCount)
|
|
}
|
|
|
|
/// Play next button content with glass background and symbol effect support.
|
|
@ViewBuilder
|
|
private func playNextButtonContent() -> some View {
|
|
let frameSize = buttonBackground.glassStyle != nil ? buttonBackgroundSize : buttonSize.pointSize
|
|
Image(systemName: "forward.fill")
|
|
.font(.system(size: buttonSize.iconSize))
|
|
.foregroundStyle(.white)
|
|
.frame(width: frameSize, height: frameSize)
|
|
.modifier(OptionalGlassBackgroundModifier(style: buttonBackground, theme: theme))
|
|
.contentShape(buttonBackground.glassStyle != nil ? AnyShape(Circle()) : AnyShape(Rectangle()))
|
|
.symbolEffect(.bounce.down.byLayer, options: .nonRepeating, value: playNextTapCount)
|
|
}
|
|
|
|
/// Play/pause button content with glass background and symbol transition effect.
|
|
@ViewBuilder
|
|
private func playPauseButtonContent() -> some View {
|
|
let frameSize = buttonBackground.glassStyle != nil ? buttonBackgroundSize : buttonSize.pointSize
|
|
Image(systemName: playPauseIcon)
|
|
.contentTransition(.symbolEffect(.replace, options: .speed(2)))
|
|
.font(.system(size: buttonSize.iconSize))
|
|
.foregroundStyle(.white)
|
|
.frame(width: frameSize, height: frameSize)
|
|
.modifier(OptionalGlassBackgroundModifier(style: buttonBackground, theme: theme))
|
|
.contentShape(buttonBackground.glassStyle != nil ? AnyShape(Circle()) : AnyShape(Rectangle()))
|
|
}
|
|
|
|
/// Icon for play/pause button based on current playback state.
|
|
private var playPauseIcon: String {
|
|
switch actions.playerState.playbackState {
|
|
case .playing:
|
|
return "pause.fill"
|
|
default:
|
|
return "play.fill"
|
|
}
|
|
}
|
|
|
|
/// AirPlay button with optional glass background.
|
|
@ViewBuilder
|
|
private var airplayButtonWithBackground: some View {
|
|
let frameSize = buttonBackground.glassStyle != nil ? buttonBackgroundSize : buttonSize.pointSize
|
|
if buttonBackground.glassStyle != nil {
|
|
AirPlayButton()
|
|
.frame(width: buttonSize.pointSize, height: buttonSize.pointSize)
|
|
.frame(width: frameSize, height: frameSize)
|
|
.modifier(OptionalGlassBackgroundModifier(style: buttonBackground, theme: theme))
|
|
} else {
|
|
AirPlayButton()
|
|
.frame(width: frameSize, height: frameSize)
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func renderSpacer(_ config: ControlButtonConfiguration) -> some View {
|
|
if let settings = config.spacerSettings {
|
|
if settings.isFlexible {
|
|
Spacer()
|
|
} else {
|
|
Spacer()
|
|
.frame(width: CGFloat(settings.fixedWidth))
|
|
}
|
|
} else {
|
|
Spacer()
|
|
}
|
|
}
|
|
|
|
// MARK: - Time Display
|
|
|
|
@ViewBuilder
|
|
private func timeDisplayView(_ config: ControlButtonConfiguration) -> some View {
|
|
let playerState = actions.playerState
|
|
let timeFont = fontStyle.font(.caption)
|
|
|
|
Group {
|
|
if playerState.isLive {
|
|
// LIVE indicator
|
|
HStack(spacing: 4) {
|
|
Circle()
|
|
.fill(.red)
|
|
.frame(width: 6, height: 6)
|
|
Text("LIVE")
|
|
.font(.caption.weight(.semibold))
|
|
.foregroundStyle(.red)
|
|
}
|
|
} else {
|
|
// Time display based on format
|
|
let format = config.timeDisplaySettings?.format ?? .currentAndTotal
|
|
|
|
switch format {
|
|
case .currentOnly:
|
|
Text(playerState.formattedCurrentTime)
|
|
.font(timeFont)
|
|
.foregroundStyle(.white)
|
|
|
|
case .currentAndTotal:
|
|
HStack(spacing: 0) {
|
|
Text(playerState.formattedCurrentTime)
|
|
.font(timeFont)
|
|
.foregroundStyle(.white)
|
|
|
|
Text(" / ")
|
|
.font(timeFont)
|
|
.foregroundStyle(.white.opacity(0.7))
|
|
|
|
Text(playerState.formattedDuration)
|
|
.font(timeFont)
|
|
.foregroundStyle(.white.opacity(0.7))
|
|
}
|
|
|
|
case .currentAndTotalExcludingSponsor:
|
|
// TODO: Calculate SponsorBlock-adjusted duration
|
|
HStack(spacing: 0) {
|
|
Text(playerState.formattedCurrentTime)
|
|
.font(timeFont)
|
|
.foregroundStyle(.white)
|
|
|
|
Text(" / ")
|
|
.font(timeFont)
|
|
.foregroundStyle(.white.opacity(0.7))
|
|
|
|
Text(playerState.formattedDuration)
|
|
.font(timeFont)
|
|
.foregroundStyle(.white.opacity(0.7))
|
|
}
|
|
|
|
case .currentAndRemaining:
|
|
HStack(spacing: 0) {
|
|
Text(playerState.formattedCurrentTime)
|
|
.font(timeFont)
|
|
.foregroundStyle(.white)
|
|
|
|
Text(" / ")
|
|
.font(timeFont)
|
|
.foregroundStyle(.white.opacity(0.7))
|
|
|
|
Text("-\(formattedRemainingTime)")
|
|
.font(timeFont)
|
|
.foregroundStyle(.white.opacity(0.7))
|
|
}
|
|
|
|
case .currentAndRemainingExcludingSponsor:
|
|
// TODO: Calculate SponsorBlock-adjusted remaining
|
|
HStack(spacing: 0) {
|
|
Text(playerState.formattedCurrentTime)
|
|
.font(timeFont)
|
|
.foregroundStyle(.white)
|
|
|
|
Text(" / ")
|
|
.font(timeFont)
|
|
.foregroundStyle(.white.opacity(0.7))
|
|
|
|
Text("-\(formattedRemainingTime)")
|
|
.font(timeFont)
|
|
.foregroundStyle(.white.opacity(0.7))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.lineLimit(1)
|
|
.minimumScaleFactor(0.8)
|
|
.truncationMode(.middle)
|
|
}
|
|
|
|
private var formattedRemainingTime: String {
|
|
let remaining = max(0, actions.playerState.duration - actions.playerState.currentTime)
|
|
return formatTime(remaining)
|
|
}
|
|
|
|
private func formatTime(_ time: TimeInterval) -> String {
|
|
let totalSeconds = Int(time)
|
|
let hours = totalSeconds / 3600
|
|
let minutes = (totalSeconds % 3600) / 60
|
|
let seconds = totalSeconds % 60
|
|
|
|
if hours > 0 {
|
|
return String(format: "%d:%02d:%02d", hours, minutes, seconds)
|
|
} else {
|
|
return String(format: "%d:%02d", minutes, seconds)
|
|
}
|
|
}
|
|
|
|
// MARK: - Volume Controls
|
|
|
|
@ViewBuilder
|
|
private func volumeControls(config: ControlButtonConfiguration) -> some View {
|
|
let behavior = config.sliderSettings?.sliderBehavior ?? .expandOnTap
|
|
|
|
// Determine effective behavior based on orientation for autoExpandInLandscape
|
|
let effectiveBehavior: SliderBehavior = {
|
|
if behavior == .autoExpandInLandscape {
|
|
return actions.isWideScreenLayout ? .alwaysVisible : .expandOnTap
|
|
}
|
|
return behavior
|
|
}()
|
|
|
|
HStack(spacing: buttonBackground.glassStyle != nil ? 8 : 4) {
|
|
// Mute/expand button
|
|
Button {
|
|
if actions.playerState.isMuted {
|
|
// Always unmute when muted, regardless of behavior
|
|
actions.onMuteToggled?()
|
|
} else if effectiveBehavior == .expandOnTap {
|
|
// Toggle slider expansion when not muted
|
|
withAnimation(.easeInOut(duration: 0.2)) {
|
|
isVolumeExpanded.toggle()
|
|
}
|
|
} else {
|
|
// Toggle mute when always visible
|
|
actions.onMuteToggled?()
|
|
}
|
|
} label: {
|
|
volumeButtonContent(tint: actions.playerState.isMuted ? .red : .white)
|
|
}
|
|
|
|
// Volume slider - shown based on behavior
|
|
if effectiveBehavior == .alwaysVisible || isVolumeExpanded {
|
|
Slider(
|
|
value: Binding(
|
|
get: { Double(actions.playerState.volume) },
|
|
set: { newValue in
|
|
actions.playerState.volume = Float(newValue)
|
|
actions.onVolumeChanged?(Float(newValue))
|
|
}
|
|
),
|
|
in: 0...1,
|
|
onEditingChanged: { editing in
|
|
isAdjustingVolume = editing
|
|
actions.onSliderAdjustmentChanged?(editing)
|
|
if editing {
|
|
actions.onCancelHideTimer?()
|
|
} else {
|
|
actions.onResetHideTimer?()
|
|
}
|
|
}
|
|
)
|
|
.frame(width: 80)
|
|
.tint(.white)
|
|
.disabled(actions.playerState.isMuted)
|
|
.transition(.opacity)
|
|
}
|
|
}
|
|
.animation(.easeInOut(duration: 0.2), value: isVolumeExpanded)
|
|
}
|
|
|
|
// MARK: - Brightness Controls
|
|
|
|
@ViewBuilder
|
|
private func brightnessControls(config: ControlButtonConfiguration) -> some View {
|
|
let behavior = config.sliderSettings?.sliderBehavior ?? .expandOnTap
|
|
|
|
// Determine effective behavior based on orientation for autoExpandInLandscape
|
|
let effectiveBehavior: SliderBehavior = {
|
|
if behavior == .autoExpandInLandscape {
|
|
return actions.isWideScreenLayout ? .alwaysVisible : .expandOnTap
|
|
}
|
|
return behavior
|
|
}()
|
|
|
|
HStack(spacing: buttonBackground.glassStyle != nil ? 8 : 4) {
|
|
// Brightness icon/expand button
|
|
Button {
|
|
if effectiveBehavior == .expandOnTap {
|
|
// Toggle slider expansion
|
|
withAnimation(.easeInOut(duration: 0.2)) {
|
|
isBrightnessExpanded.toggle()
|
|
}
|
|
}
|
|
// No action when always visible (brightness has no toggle like mute)
|
|
} label: {
|
|
brightnessButtonContent()
|
|
}
|
|
|
|
// Brightness slider - shown based on behavior
|
|
if effectiveBehavior == .alwaysVisible || isBrightnessExpanded {
|
|
Slider(
|
|
value: Binding(
|
|
get: { UIScreen.main.brightness },
|
|
set: { newValue in
|
|
UIScreen.main.brightness = newValue
|
|
}
|
|
),
|
|
in: 0...1,
|
|
onEditingChanged: { editing in
|
|
isAdjustingBrightness = editing
|
|
actions.onSliderAdjustmentChanged?(editing)
|
|
if editing {
|
|
actions.onCancelHideTimer?()
|
|
} else {
|
|
actions.onResetHideTimer?()
|
|
}
|
|
}
|
|
)
|
|
.frame(width: 80)
|
|
.tint(.white)
|
|
.transition(.opacity)
|
|
}
|
|
}
|
|
.animation(.easeInOut(duration: 0.2), value: isBrightnessExpanded)
|
|
}
|
|
|
|
// MARK: - Fullscreen Handling
|
|
|
|
private func handleFullscreenTap() {
|
|
let isActualWidescreenLayout = actions.isWideScreenLayout && actions.onTogglePanel != nil
|
|
|
|
if actions.isIPad {
|
|
// iPad: always toggle details visibility
|
|
actions.onToggleDetailsVisibility?()
|
|
} else if isActualWidescreenLayout && actions.isFullscreen && !actions.isWidescreenVideo {
|
|
// iPhone in landscape with portrait video fullscreen: rotate back to portrait
|
|
actions.onToggleFullscreen?()
|
|
} else if !actions.isWidescreenVideo {
|
|
// iPhone portrait video in portrait layout: toggle details visibility
|
|
actions.onToggleDetailsVisibility?()
|
|
} else {
|
|
// iPhone with widescreen video: rotate orientation
|
|
actions.onToggleFullscreen?()
|
|
}
|
|
}
|
|
|
|
// MARK: - Transport State
|
|
|
|
private var isTransportDisabled: Bool {
|
|
let state = actions.playerState
|
|
return state.playbackState == .loading ||
|
|
state.playbackState == .buffering ||
|
|
!state.isFirstFrameReady ||
|
|
!state.isBufferReady
|
|
}
|
|
|
|
// MARK: - Playback Speed Menu
|
|
|
|
@ViewBuilder
|
|
private var playbackSpeedMenu: some View {
|
|
let hasText = actions.playbackRateDisplay != nil
|
|
let frameSize = buttonBackground.glassStyle != nil ? buttonBackgroundSize : buttonSize.pointSize
|
|
|
|
Menu {
|
|
ForEach(PlaybackRate.allCases) { rate in
|
|
Button {
|
|
actions.onRateChanged?(rate)
|
|
} label: {
|
|
HStack {
|
|
Text(rate.displayText)
|
|
if actions.playerState.rate == rate {
|
|
Image(systemName: "checkmark")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} label: {
|
|
playbackSpeedLabel(hasText: hasText, frameSize: frameSize)
|
|
}
|
|
.menuIndicator(.hidden)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func playbackSpeedLabel(hasText: Bool, frameSize: CGFloat) -> some View {
|
|
let content = HStack(spacing: 2) {
|
|
Image(systemName: "gauge.with.needle")
|
|
.font(.system(size: buttonSize.iconSize))
|
|
if let rateDisplay = actions.playbackRateDisplay {
|
|
Text(rateDisplay)
|
|
.font(fontStyle.font(.caption).weight(.semibold))
|
|
}
|
|
}
|
|
.foregroundStyle(.white)
|
|
.frame(minWidth: frameSize, minHeight: frameSize)
|
|
.padding(.horizontal, hasText && buttonBackground.glassStyle != nil ? 8 : 0)
|
|
|
|
if hasText {
|
|
content
|
|
.modifier(OptionalCapsuleGlassBackgroundModifier(style: buttonBackground, theme: theme))
|
|
.contentShape(buttonBackground.glassStyle != nil ? AnyShape(Capsule()) : AnyShape(Rectangle()))
|
|
} else {
|
|
content
|
|
.modifier(OptionalGlassBackgroundModifier(style: buttonBackground, theme: theme))
|
|
.contentShape(buttonBackground.glassStyle != nil ? AnyShape(Circle()) : AnyShape(Rectangle()))
|
|
}
|
|
}
|
|
|
|
// MARK: - Video Track Button
|
|
|
|
@ViewBuilder
|
|
private var videoTrackButton: some View {
|
|
if actions.onShowVideoTrackSelector != nil {
|
|
controlButton(systemImage: "film") {
|
|
actions.onShowVideoTrackSelector?()
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Audio Track Button
|
|
|
|
@ViewBuilder
|
|
private var audioTrackButton: some View {
|
|
if actions.onShowAudioTrackSelector != nil {
|
|
controlButton(systemImage: "waveform") {
|
|
actions.onShowAudioTrackSelector?()
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Captions Button
|
|
|
|
@ViewBuilder
|
|
private var captionsButton: some View {
|
|
if actions.hasCaptions, actions.onCaptionSelected != nil {
|
|
controlButton(systemImage: actions.currentCaption != nil ? "captions.bubble.fill" : "captions.bubble") {
|
|
actions.onShowCaptionsSelector?()
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Chapters Button
|
|
|
|
@ViewBuilder
|
|
private var chaptersButton: some View {
|
|
if actions.hasChapters {
|
|
controlButton(systemImage: "list.bullet.rectangle") {
|
|
actions.onShowChaptersSelector?()
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Share Button
|
|
|
|
@ViewBuilder
|
|
private var shareButton: some View {
|
|
if let video = actions.currentVideo {
|
|
ShareLink(item: video.shareURL) {
|
|
buttonContent(systemImage: "square.and.arrow.up", tint: .white)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Add to Playlist Button
|
|
|
|
@ViewBuilder
|
|
private var addToPlaylistButton: some View {
|
|
if actions.canAddToPlaylist, actions.onShowPlaylistSelector != nil {
|
|
controlButton(systemImage: "text.badge.plus") {
|
|
actions.onShowPlaylistSelector?()
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Context Menu Button
|
|
|
|
@ViewBuilder
|
|
private var contextMenuButton: some View {
|
|
if let video = actions.currentVideo {
|
|
VideoContextMenuView(
|
|
video: video,
|
|
accentColor: .white,
|
|
buttonSize: buttonSize.pointSize,
|
|
buttonBackgroundStyle: buttonBackground,
|
|
theme: theme
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - Panscan Button
|
|
|
|
@ViewBuilder
|
|
private var panscanButton: some View {
|
|
if actions.onTogglePanscan != nil {
|
|
controlButton(systemImage: actions.panscanIcon) {
|
|
actions.onTogglePanscan?()
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Seek Button (Horizontal Sections)
|
|
|
|
@ViewBuilder
|
|
private func seekButton(settings: SeekSettings, config: ControlButtonConfiguration) -> some View {
|
|
let frameSize = buttonBackground.glassStyle != nil ? buttonBackgroundSize : buttonSize.pointSize
|
|
|
|
Button {
|
|
seekTapCount += 1
|
|
Task {
|
|
if settings.direction == .forward {
|
|
await actions.onSeekForward?(TimeInterval(settings.seconds))
|
|
} else {
|
|
await actions.onSeekBackward?(TimeInterval(settings.seconds))
|
|
}
|
|
}
|
|
} label: {
|
|
Image(systemName: settings.systemImage)
|
|
.font(.system(size: buttonSize.iconSize))
|
|
.foregroundStyle(.white)
|
|
.frame(width: frameSize, height: frameSize)
|
|
.modifier(OptionalGlassBackgroundModifier(style: buttonBackground, theme: theme))
|
|
.contentShape(buttonBackground.glassStyle != nil ? AnyShape(Circle()) : AnyShape(Rectangle()))
|
|
.symbolEffect(.rotate.byLayer, options: .speed(2).nonRepeating, value: seekTapCount)
|
|
}
|
|
}
|
|
|
|
// MARK: - Auto-Play Next Button
|
|
|
|
@ViewBuilder
|
|
private var autoPlayNextButton: some View {
|
|
if actions.onToggleAutoPlayNext != nil {
|
|
controlButton(
|
|
systemImage: "play.square.stack.fill",
|
|
tint: actions.isAutoPlayNextEnabled ? .red : .white
|
|
) {
|
|
actions.onToggleAutoPlayNext?()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Optional Glass Background Modifier
|
|
|
|
/// A view modifier that conditionally applies a glass background based on the button background style.
|
|
private struct OptionalGlassBackgroundModifier: ViewModifier {
|
|
let style: ButtonBackgroundStyle
|
|
var theme: ControlsTheme = .dark
|
|
|
|
func body(content: Content) -> some View {
|
|
if let glassStyle = style.glassStyle {
|
|
content.glassBackground(glassStyle, in: .circle, fallback: .ultraThinMaterial, colorScheme: theme.colorScheme)
|
|
} else {
|
|
content
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A view modifier that conditionally applies a capsule glass background based on the button background style.
|
|
private struct OptionalCapsuleGlassBackgroundModifier: ViewModifier {
|
|
let style: ButtonBackgroundStyle
|
|
var theme: ControlsTheme = .dark
|
|
|
|
func body(content: Content) -> some View {
|
|
if let glassStyle = style.glassStyle {
|
|
content.glassBackground(glassStyle, in: .capsule, fallback: .ultraThinMaterial, colorScheme: theme.colorScheme)
|
|
} else {
|
|
content
|
|
}
|
|
}
|
|
}
|
|
|
|
#endif
|