NuvioStreaming/MPVKit/Sources/DesktopMPVBridge/NuvioControlsView.swift

1226 lines
49 KiB
Swift

import SwiftUI
struct ControlMetrics {
let horizontalPadding: CGFloat
let verticalPadding: CGFloat
let titleSize: CGFloat
let episodeInfoSize: CGFloat
let metadataSize: CGFloat
let centerGap: CGFloat
let centerLift: CGFloat
let sliderBottomOffset: CGFloat
let timeSize: CGFloat
let headerIconSize: CGFloat
let sideIconSize: CGFloat
let playIconSize: CGFloat
static func from(width: CGFloat) -> ControlMetrics {
if width >= 1440 {
return ControlMetrics(
horizontalPadding: 28, verticalPadding: 24,
titleSize: 28, episodeInfoSize: 16, metadataSize: 14,
centerGap: 112, centerLift: 24, sliderBottomOffset: 28,
timeSize: 14, headerIconSize: 24, sideIconSize: 34, playIconSize: 44
)
} else if width >= 1024 {
return ControlMetrics(
horizontalPadding: 24, verticalPadding: 20,
titleSize: 24, episodeInfoSize: 15, metadataSize: 13,
centerGap: 88, centerLift: 18, sliderBottomOffset: 24,
timeSize: 13, headerIconSize: 22, sideIconSize: 32, playIconSize: 42
)
} else if width >= 768 {
return ControlMetrics(
horizontalPadding: 20, verticalPadding: 16,
titleSize: 22, episodeInfoSize: 14, metadataSize: 12,
centerGap: 72, centerLift: 14, sliderBottomOffset: 20,
timeSize: 12, headerIconSize: 20, sideIconSize: 30, playIconSize: 38
)
} else {
return ControlMetrics(
horizontalPadding: 20, verticalPadding: 16,
titleSize: 18, episodeInfoSize: 14, metadataSize: 12,
centerGap: 56, centerLift: 10, sliderBottomOffset: 16,
timeSize: 12, headerIconSize: 20, sideIconSize: 26, playIconSize: 34
)
}
}
}
struct NuvioControlsView: View {
@ObservedObject var state: NuvioPlayerState
var onPlay: () -> Void
var onPause: () -> Void
var onSeekBack: () -> Void
var onSeekForward: () -> Void
var onSeekTo: (Int64) -> Void
var onCycleResize: () -> Void
var onCycleSpeed: () -> Void
var onSelectAudioTrack: (Int) -> Void
var onSelectSubtitleTrack: (Int) -> Void
var onClose: () -> Void
var onSkip: () -> Void
var onNextEpisode: () -> Void
var onApplySubtitleStyle: ((String, Float, Float, Int) -> Void)?
var onAddSubtitleUrl: ((String) -> Void)?
var onRemoveExternalAndSelect: ((Int) -> Void)?
var onFetchAddonSubtitles: (() -> Void)?
var onSubmitIntro: ((String, Double, Double) -> Void)?
@State private var isDragging = false
@State private var dragPosition: Double = 0
@State private var submitIntroSegmentType = "intro"
@State private var submitIntroStartTime = "00:00"
@State private var submitIntroEndTime = "00:00"
var body: some View {
GeometryReader { geometry in
let metrics = ControlMetrics.from(width: geometry.size.width)
ZStack {
Color.clear
.contentShape(Rectangle())
.onTapGesture {
if state.controlsLocked {
state.lockedOverlayVisible = true
} else {
state.controlsVisible.toggle()
}
}
Group {
topGradient
bottomGradient
VStack {
headerView(metrics: metrics)
Spacer()
}
centerControls(metrics: metrics)
.offset(y: -metrics.centerLift)
VStack {
Spacer()
progressControls(metrics: metrics)
.padding(.horizontal, metrics.horizontalPadding)
.padding(.bottom, metrics.sliderBottomOffset)
}
}
.opacity(state.controlsVisible && !state.controlsLocked ? 1 : 0)
.animation(.easeInOut(duration: 0.25), value: state.controlsVisible)
if !state.controlsLocked && state.skipButtonType != nil {
VStack {
Spacer()
HStack {
skipButton
.padding(.leading, metrics.horizontalPadding)
.padding(.bottom, metrics.sliderBottomOffset + 120)
Spacer()
}
}
}
if !state.controlsLocked && state.showNextEpisode {
VStack {
Spacer()
HStack {
Spacer()
nextEpisodeCard
.padding(.trailing, metrics.horizontalPadding)
.padding(.bottom, 40)
}
}
}
if state.showSubtitlePanel {
subtitleModal
}
if state.showAudioPanel {
trackSelectionPanel(
title: "Audio",
tracks: state.audioTracks,
showNone: false,
onSelect: { trackId in
onSelectAudioTrack(trackId)
state.showAudioPanel = false
},
onDismiss: { state.showAudioPanel = false }
)
}
if state.showSubmitIntroPanel {
submitIntroModal
}
if state.controlsLocked && state.lockedOverlayVisible {
lockedOverlay
}
if state.showSourcesPanel {
NuvioSourcesPanel(state: state) {
state.showSourcesPanel = false
state.controlsVisible = true
}
}
if state.showEpisodesPanel {
NuvioEpisodesPanel(state: state) {
state.showEpisodesPanel = false
state.showEpisodeStreams = false
state.controlsVisible = true
}
}
if let feedback = state.gestureFeedback {
VStack {
GestureFeedbackPill(feedback: feedback)
.padding(.top, 40)
.transition(.opacity)
Spacer()
}
.animation(.easeInOut(duration: 0.15), value: state.gestureFeedback == nil)
}
if let error = state.errorMessage, !error.isEmpty {
errorOverlay(message: error)
}
if !state.initialLoadCompleted && state.errorMessage == nil {
openingOverlay
.frame(maxWidth: .infinity, maxHeight: .infinity)
.transition(.opacity)
.animation(.easeInOut(duration: 0.5), value: state.initialLoadCompleted)
}
}
}
}
var topGradient: some View {
VStack {
LinearGradient(
colors: [Color.black.opacity(0.7), Color.clear],
startPoint: .top,
endPoint: .bottom
)
.frame(height: 160)
.allowsHitTesting(false)
Spacer()
}
}
var bottomGradient: some View {
VStack {
Spacer()
LinearGradient(
colors: [Color.clear, Color.black.opacity(0.7)],
startPoint: .top,
endPoint: .bottom
)
.frame(height: 220)
.allowsHitTesting(false)
}
}
func headerView(metrics: ControlMetrics) -> some View {
HStack(alignment: .top) {
VStack(alignment: .leading, spacing: 6) {
Text(state.title)
.font(.system(size: metrics.titleSize, weight: .bold))
.foregroundColor(.white)
.lineLimit(2)
if let sn = state.seasonNumber, let en = state.episodeNumber,
let et = state.episodeTitle, !et.isEmpty {
Text("S\(sn)E\(en)\(et)")
.font(.system(size: metrics.episodeInfoSize))
.foregroundColor(.white.opacity(0.9))
.lineLimit(1)
}
HStack(spacing: 8) {
Text(state.streamTitle)
.font(.system(size: metrics.metadataSize))
.foregroundColor(.white.opacity(0.7))
.lineLimit(1)
Text(state.providerName)
.font(.system(size: metrics.metadataSize))
.italic()
.foregroundColor(.white.opacity(0.7))
.lineLimit(1)
}
}
Spacer()
HStack(spacing: 10) {
if state.canSubmitIntro {
headerIconButton(icon: "flag.fill", size: metrics.headerIconSize) {
state.showSubmitIntroPanel = true
}
}
headerIconButton(icon: state.controlsLocked ? "lock.open.fill" : "lock.fill", size: metrics.headerIconSize) {
if state.controlsLocked {
state.controlsLocked = false
state.lockedOverlayVisible = false
state.controlsVisible = true
} else {
state.controlsLocked = true
state.controlsVisible = false
state.lockedOverlayVisible = false
state.showSubtitlePanel = false
state.showAudioPanel = false
state.showSourcesPanel = false
state.showEpisodesPanel = false
state.showSubmitIntroPanel = false
}
}
headerIconButton(icon: "xmark", size: metrics.headerIconSize, action: onClose)
}
}
.padding(.horizontal, 28)
.padding(.top, 16)
}
func headerIconButton(icon: String, size: CGFloat, action: @escaping () -> Void) -> some View {
Button(action: action) {
Image(systemName: icon)
.font(.system(size: size, weight: .semibold))
.foregroundColor(.white)
.frame(width: size + 16, height: size + 16)
.background(Color.black.opacity(0.35))
.clipShape(Circle())
}
.buttonStyle(.plain)
}
func centerControls(metrics: ControlMetrics) -> some View {
HStack(spacing: metrics.centerGap) {
Button(action: onSeekBack) {
Image(systemName: "gobackward.10")
.font(.system(size: metrics.sideIconSize))
.foregroundColor(.white)
.frame(width: metrics.sideIconSize + 28, height: metrics.sideIconSize + 28)
.contentShape(Circle())
}
.buttonStyle(.plain)
Button(action: { state.isPlaying ? onPause() : onPlay() }) {
Group {
if state.isLoading {
ProgressView()
.progressViewStyle(.circular)
.scaleEffect(1.2)
.frame(width: metrics.playIconSize, height: metrics.playIconSize)
} else {
Image(systemName: state.isPlaying ? "pause.fill" : "play.fill")
.font(.system(size: metrics.playIconSize))
.foregroundColor(.white)
}
}
.frame(width: metrics.playIconSize + 36, height: metrics.playIconSize + 36)
.contentShape(Circle())
}
.buttonStyle(.plain)
Button(action: onSeekForward) {
Image(systemName: "goforward.10")
.font(.system(size: metrics.sideIconSize))
.foregroundColor(.white)
.frame(width: metrics.sideIconSize + 28, height: metrics.sideIconSize + 28)
.contentShape(Circle())
}
.buttonStyle(.plain)
}
}
func progressControls(metrics: ControlMetrics) -> some View {
let duration = max(state.durationMs, 1)
let position = isDragging ? Int64(dragPosition) : state.positionMs
return VStack(spacing: 0) {
playerSlider(
value: Binding(
get: { Double(position) },
set: { newVal in
isDragging = true
dragPosition = newVal
}
),
range: 0...Double(duration),
onFinished: {
let seekPos = Int64(dragPosition.clamped(to: 0...Double(duration)))
isDragging = false
onSeekTo(seekPos)
}
)
.frame(height: 28)
HStack {
timePill(text: state.formattedTime(position), fontSize: metrics.timeSize)
Spacer()
timePill(text: state.formattedTime(duration), fontSize: metrics.timeSize)
}
.padding(.horizontal, 14)
.padding(.top, 4)
.padding(.bottom, 8)
HStack(spacing: 0) {
actionPillContainer {
actionPillButton(icon: "aspectratio", label: state.resizeModeLabel, action: onCycleResize)
actionPillButton(icon: "speedometer", label: state.speedLabel, action: onCycleSpeed)
actionPillButton(icon: "captions.bubble", label: "Subs", action: {
state.showSubtitlePanel = true
})
actionPillButton(icon: "speaker.wave.2", label: "Audio", action: {
state.showAudioPanel = true
})
if state.hasVideoId {
actionPillButton(icon: "arrow.left.arrow.right", label: "Sources", action: {
state.sourcesOpenRequested = true
state.showSourcesPanel = true
state.showEpisodesPanel = false
state.controlsVisible = false
})
}
if state.isSeries {
actionPillButton(icon: "rectangle.stack.fill", label: "Episodes", action: {
state.episodesOpenRequested = true
state.showEpisodesPanel = true
state.showSourcesPanel = false
state.controlsVisible = false
})
}
}
}
.frame(maxWidth: .infinity)
}
}
func playerSlider(value: Binding<Double>, range: ClosedRange<Double>, onFinished: @escaping () -> Void) -> some View {
GeometryReader { geometry in
let width = geometry.size.width
let span = range.upperBound - range.lowerBound
let fraction = span > 0 ? CGFloat((value.wrappedValue - range.lowerBound) / span) : 0
let clampedFraction = min(max(fraction, 0), 1)
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 2)
.fill(Color.white.opacity(0.3))
.frame(height: 4)
RoundedRectangle(cornerRadius: 2)
.fill(Color.white)
.frame(width: width * clampedFraction, height: 4)
Circle()
.fill(Color.white)
.frame(width: 14, height: 14)
.offset(x: width * clampedFraction - 7)
}
.frame(height: 28)
.contentShape(Rectangle())
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { gesture in
let newFraction = min(max(gesture.location.x / width, 0), 1)
value.wrappedValue = range.lowerBound + Double(newFraction) * span
}
.onEnded { _ in
onFinished()
}
)
}
}
func timePill(text: String, fontSize: CGFloat) -> some View {
Text(text)
.font(.system(size: fontSize, weight: .medium))
.foregroundColor(.white)
.padding(.horizontal, 10)
.padding(.vertical, 4)
.background(Color.black.opacity(0.5))
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(Color.white.opacity(0.2), lineWidth: 1)
)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
func actionPillContainer<Content: View>(@ViewBuilder content: () -> Content) -> some View {
HStack(spacing: 0) {
content()
}
.padding(.horizontal, 4)
.padding(.vertical, 2)
.background(Color.black.opacity(0.5))
.overlay(
RoundedRectangle(cornerRadius: 24)
.stroke(Color.white.opacity(0.2), lineWidth: 1)
)
.clipShape(RoundedRectangle(cornerRadius: 24))
}
func actionPillButton(icon: String, label: String, action: @escaping () -> Void) -> some View {
Button(action: action) {
HStack(spacing: 8) {
Image(systemName: icon)
.font(.system(size: 14))
.foregroundColor(.white)
.frame(width: 18, height: 18)
Text(label)
.font(.system(size: 12))
.foregroundColor(.white)
}
.padding(.horizontal, 12)
.padding(.vertical, 12)
.contentShape(RoundedRectangle(cornerRadius: 22))
}
.buttonStyle(.plain)
}
var skipButton: some View {
let labelText: String = {
switch state.skipButtonType?.lowercased() {
case "intro", "op", "mixed-op": return "Skip Intro"
case "outro", "ed", "mixed-ed", "credits": return "Skip Outro"
case "recap": return "Skip Recap"
default: return "Skip"
}
}()
return Button(action: onSkip) {
HStack(spacing: 8) {
Image(systemName: "forward.end.fill")
.font(.system(size: 14))
.foregroundColor(.white)
Text(labelText)
.font(.system(size: 14))
.foregroundColor(.white)
}
.padding(.horizontal, 18)
.padding(.vertical, 12)
.background(Color(red: 0.118, green: 0.118, blue: 0.118).opacity(0.85))
.clipShape(RoundedRectangle(cornerRadius: 16))
}
.buttonStyle(.plain)
}
var nextEpisodeCard: some View {
let hasAired = state.nextEpisodeHasAired
return Button(action: { if hasAired { onNextEpisode() } }) {
HStack(spacing: 8) {
if let thumb = state.nextEpisodeThumbnail, !thumb.isEmpty {
if #available(macOS 12.0, *) {
AsyncImage(url: URL(string: thumb)) { image in
image.resizable().aspectRatio(contentMode: .fill)
} placeholder: {
Color.gray.opacity(0.3)
}
.frame(width: 78, height: 44)
.clipShape(RoundedRectangle(cornerRadius: 9))
} else {
Color.gray.opacity(0.3)
.frame(width: 78, height: 44)
.clipShape(RoundedRectangle(cornerRadius: 9))
}
}
VStack(alignment: .leading, spacing: 2) {
Text("Next Episode")
.font(.system(size: 10, weight: .medium))
.foregroundColor(.white.opacity(0.8))
if let sn = state.nextEpisodeSeason, let en = state.nextEpisodeEpisode {
Text("S\(sn)E\(en)\(state.nextEpisodeTitle ?? "")")
.font(.system(size: 12, weight: .semibold))
.foregroundColor(.white)
.lineLimit(1)
}
}
HStack(spacing: 3) {
Image(systemName: "play.fill")
.font(.system(size: 11))
.foregroundColor(hasAired ? .white : .white.opacity(0.65))
Text(hasAired ? "Play" : "Unaired")
.font(.system(size: 11))
.foregroundColor(hasAired ? .white : .white.opacity(0.72))
}
.padding(.horizontal, 8)
.padding(.vertical, 5)
.overlay(
Capsule().stroke(Color.white.opacity(0.2), lineWidth: 1)
)
}
.padding(.horizontal, 9)
.padding(.vertical, 8)
.background(Color(red: 0.098, green: 0.098, blue: 0.098).opacity(0.89))
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(Color.white.opacity(0.12), lineWidth: 1)
)
.clipShape(RoundedRectangle(cornerRadius: 16))
}
.buttonStyle(.plain)
.frame(maxWidth: 292)
}
func trackSelectionPanel(
title: String,
tracks: [NuvioTrackInfo],
showNone: Bool,
onSelect: @escaping (Int) -> Void,
onDismiss: @escaping () -> Void
) -> some View {
ZStack {
Color.black.opacity(0.6)
.onTapGesture { onDismiss() }
VStack(spacing: 0) {
HStack {
Text(title)
.font(.system(size: 18, weight: .bold))
.foregroundColor(.white)
Spacer()
Button(action: onDismiss) {
Image(systemName: "xmark")
.font(.system(size: 16, weight: .semibold))
.foregroundColor(.white)
.frame(width: 32, height: 32)
.background(Color.white.opacity(0.15))
.clipShape(Circle())
}
.buttonStyle(.plain)
}
.padding(.horizontal, 20)
.padding(.top, 20)
.padding(.bottom, 12)
ScrollView {
VStack(spacing: 2) {
if showNone {
trackRow(label: "None", lang: "", isSelected: !tracks.contains(where: { $0.selected })) {
onSelect(-1)
}
}
ForEach(Array(tracks.enumerated()), id: \.element.id) { _, track in
let label = track.title.isEmpty ? (track.lang.isEmpty ? "Track \(track.id)" : track.lang) : track.title
trackRow(label: label, lang: track.lang, isSelected: track.selected) {
onSelect(track.id)
}
}
}
.padding(.horizontal, 12)
.padding(.bottom, 20)
}
}
.frame(width: 360)
.frame(maxHeight: 500)
.background(Color(red: 0.12, green: 0.12, blue: 0.12))
.clipShape(RoundedRectangle(cornerRadius: 16))
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(Color.white.opacity(0.1), lineWidth: 1)
)
}
}
func trackRow(label: String, lang: String, isSelected: Bool, action: @escaping () -> Void) -> some View {
Button(action: action) {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(label)
.font(.system(size: 14))
.foregroundColor(.white)
if !lang.isEmpty && lang != label {
Text(lang)
.font(.system(size: 11))
.foregroundColor(.white.opacity(0.6))
}
}
Spacer()
if isSelected {
Image(systemName: "checkmark")
.font(.system(size: 14, weight: .bold))
.foregroundColor(.white)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(isSelected ? Color.white.opacity(0.1) : Color.clear)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
.buttonStyle(.plain)
}
var openingOverlay: some View {
GeometryReader { geo in
ZStack {
Color.black.opacity(0.85)
if let artworkUrl = state.artwork, !artworkUrl.isEmpty {
if #available(macOS 12.0, *) {
AsyncImage(url: URL(string: artworkUrl)) { image in
image.resizable().aspectRatio(contentMode: .fill)
} placeholder: {
Color.clear
}
.frame(width: geo.size.width, height: geo.size.height)
.clipped()
}
LinearGradient(
colors: [
Color.black.opacity(0.3),
Color.black.opacity(0.6),
Color.black.opacity(0.8),
Color.black.opacity(0.9),
],
startPoint: .top,
endPoint: .bottom
)
}
VStack {
HStack {
Spacer()
Button(action: onClose) {
Image(systemName: "xmark")
.font(.system(size: 24, weight: .semibold))
.foregroundColor(.white)
.frame(width: 44, height: 44)
.background(Color.black.opacity(0.3))
.clipShape(Circle())
}
.buttonStyle(.plain)
.padding(.top, 20)
.padding(.trailing, 48)
}
Spacer()
}
OpeningOverlayContent(logo: state.logo, title: state.title)
}
.frame(width: geo.size.width, height: geo.size.height)
}
}
var lockedOverlay: some View {
ZStack {
Color.black.opacity(0.45)
.onTapGesture {
state.lockedOverlayVisible = false
}
Button(action: {
state.controlsLocked = false
state.lockedOverlayVisible = false
state.controlsVisible = true
}) {
HStack(spacing: 10) {
Image(systemName: "lock.open.fill")
.font(.system(size: 18, weight: .semibold))
Text("Unlock")
.font(.system(size: 15, weight: .bold))
}
.foregroundColor(.white)
.padding(.horizontal, 22)
.padding(.vertical, 14)
.background(Color.white.opacity(0.16))
.clipShape(Capsule())
.overlay(Capsule().stroke(Color.white.opacity(0.18), lineWidth: 1))
}
.buttonStyle(.plain)
}
}
var submitIntroModal: some View {
ZStack {
Color.black.opacity(0.6)
.onTapGesture { state.showSubmitIntroPanel = false }
VStack(spacing: 16) {
HStack {
Text("Submit Timestamps")
.font(.system(size: 18, weight: .bold))
.foregroundColor(.white)
Spacer()
Button(action: { state.showSubmitIntroPanel = false }) {
Image(systemName: "xmark")
.font(.system(size: 16, weight: .semibold))
.foregroundColor(.white)
.frame(width: 32, height: 32)
.background(Color.white.opacity(0.15))
.clipShape(Circle())
}
.buttonStyle(.plain)
}
HStack(spacing: 8) {
submitSegmentButton(title: "Intro", value: "intro")
submitSegmentButton(title: "Recap", value: "recap")
submitSegmentButton(title: "Outro", value: "outro")
}
submitTimeRow(title: "Start Time", text: $submitIntroStartTime)
submitTimeRow(title: "End Time", text: $submitIntroEndTime)
HStack(spacing: 12) {
Button(action: { state.showSubmitIntroPanel = false }) {
Text("Cancel")
.font(.system(size: 14, weight: .semibold))
.foregroundColor(.white.opacity(0.75))
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
.background(Color.white.opacity(0.08))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
.buttonStyle(.plain)
Button(action: {
guard let start = parseSubmitTime(submitIntroStartTime),
let end = parseSubmitTime(submitIntroEndTime),
end > start else { return }
onSubmitIntro?(submitIntroSegmentType, start, end)
state.showSubmitIntroPanel = false
}) {
HStack(spacing: 8) {
Image(systemName: "paperplane.fill")
.font(.system(size: 13, weight: .bold))
Text("Submit")
.font(.system(size: 14, weight: .bold))
}
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
.background(Color.blue)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
.buttonStyle(.plain)
}
}
.padding(20)
.frame(width: 420)
.background(Color(red: 0.12, green: 0.12, blue: 0.12))
.clipShape(RoundedRectangle(cornerRadius: 24))
.overlay(
RoundedRectangle(cornerRadius: 24)
.stroke(Color.white.opacity(0.1), lineWidth: 1)
)
.onAppear {
if submitIntroStartTime == "00:00" && submitIntroEndTime == "00:00" {
submitIntroStartTime = formatSubmitTime(Double(state.positionMs) / 1000.0)
submitIntroEndTime = formatSubmitTime(Double(state.positionMs) / 1000.0)
}
}
}
}
func submitSegmentButton(title: String, value: String) -> some View {
let selected = submitIntroSegmentType == value
return Button(action: { submitIntroSegmentType = value }) {
Text(title)
.font(.system(size: 13, weight: .bold))
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.background(selected ? Color.white.opacity(0.2) : Color.white.opacity(0.08))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
.buttonStyle(.plain)
}
func submitTimeRow(title: String, text: Binding<String>) -> some View {
HStack(spacing: 10) {
Text(title)
.font(.system(size: 13, weight: .semibold))
.foregroundColor(.white.opacity(0.75))
.frame(width: 92, alignment: .leading)
TextField("00:00", text: text)
.textFieldStyle(.plain)
.font(.system(size: 15, weight: .semibold, design: .monospaced))
.foregroundColor(.white)
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(Color.white.opacity(0.08))
.clipShape(RoundedRectangle(cornerRadius: 10))
Button(action: {
text.wrappedValue = formatSubmitTime(Double(state.positionMs) / 1000.0)
}) {
Text("Now")
.font(.system(size: 12, weight: .bold))
.foregroundColor(.white)
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(Color.white.opacity(0.12))
.clipShape(RoundedRectangle(cornerRadius: 10))
}
.buttonStyle(.plain)
}
}
func parseSubmitTime(_ value: String) -> Double? {
let parts = value.split(separator: ":").map(String.init)
guard parts.count == 2 || parts.count == 3 else { return nil }
let numbers = parts.compactMap(Double.init)
guard numbers.count == parts.count else { return nil }
if numbers.contains(where: { $0 < 0 }) { return nil }
if numbers.count == 2 {
return numbers[0] * 60 + numbers[1]
}
return numbers[0] * 3600 + numbers[1] * 60 + numbers[2]
}
func formatSubmitTime(_ seconds: Double) -> String {
let total = max(Int(seconds.rounded(.down)), 0)
let h = total / 3600
let m = (total / 60) % 60
let s = total % 60
if h > 0 {
return "\(h):\(String(format: "%02d", m)):\(String(format: "%02d", s))"
}
return "\(String(format: "%02d", m)):\(String(format: "%02d", s))"
}
var subtitleModal: some View {
ZStack {
Color.black.opacity(0.6)
.onTapGesture { state.showSubtitlePanel = false }
VStack(spacing: 0) {
HStack {
Text("Subtitles")
.font(.system(size: 18, weight: .bold))
.foregroundColor(.white)
Spacer()
Button(action: { state.showSubtitlePanel = false }) {
Image(systemName: "xmark")
.font(.system(size: 16, weight: .semibold))
.foregroundColor(.white)
.frame(width: 32, height: 32)
.background(Color.white.opacity(0.15))
.clipShape(Circle())
}
.buttonStyle(.plain)
}
.padding(.horizontal, 20)
.padding(.top, 20)
.padding(.bottom, 12)
HStack(spacing: 4) {
subtitleTabButton(title: "Built-In", index: 0)
subtitleTabButton(title: "Addons", index: 1)
subtitleTabButton(title: "Style", index: 2)
}
.padding(.horizontal, 16)
.padding(.bottom, 12)
ScrollView {
switch state.subtitleTab {
case 0:
builtInSubtitleList
case 1:
addonSubtitleList
case 2:
subtitleStylePanel
default:
EmptyView()
}
}
}
.frame(width: 420)
.frame(maxHeight: 520)
.background(Color(red: 0.12, green: 0.12, blue: 0.12))
.clipShape(RoundedRectangle(cornerRadius: 24))
.overlay(
RoundedRectangle(cornerRadius: 24)
.stroke(Color.white.opacity(0.1), lineWidth: 1)
)
}
}
func subtitleTabButton(title: String, index: Int) -> some View {
let selected = state.subtitleTab == index
return Button(action: { state.subtitleTab = index }) {
Text(title)
.font(.system(size: 13, weight: .semibold))
.foregroundColor(.white)
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(selected ? Color.white.opacity(0.15) : Color.clear)
.clipShape(RoundedRectangle(cornerRadius: selected ? 10 : 40))
}
.buttonStyle(.plain)
}
var builtInSubtitleList: some View {
VStack(spacing: 2) {
trackRow(
label: "None",
lang: "",
isSelected: !state.subtitleTracks.contains(where: { $0.selected })
) {
state.selectedAddonSubtitleId = nil
onRemoveExternalAndSelect?(-1)
state.showSubtitlePanel = false
}
ForEach(Array(state.subtitleTracks.enumerated()), id: \.element.id) { _, track in
let label = track.title.isEmpty ? (track.lang.isEmpty ? "Track \(track.id)" : track.lang) : track.title
trackRow(label: label, lang: track.lang, isSelected: track.selected) {
state.selectedAddonSubtitleId = nil
onRemoveExternalAndSelect?(track.id)
state.showSubtitlePanel = false
}
}
}
.padding(.horizontal, 12)
.padding(.bottom, 20)
}
var addonSubtitleList: some View {
VStack(spacing: 8) {
if state.addonSubtitlesLoading {
ProgressView()
.progressViewStyle(.circular)
.scaleEffect(0.8)
.frame(height: 80)
} else if state.addonSubtitles.isEmpty {
Button(action: { onFetchAddonSubtitles?() }) {
VStack(spacing: 12) {
Image(systemName: "icloud.and.arrow.down")
.font(.system(size: 28))
.foregroundColor(.white.opacity(0.6))
Text("Tap to fetch subtitles")
.font(.system(size: 14))
.foregroundColor(.white.opacity(0.6))
}
.frame(maxWidth: .infinity)
.frame(height: 120)
}
.buttonStyle(.plain)
} else {
ForEach(state.addonSubtitles) { addon in
let selected = state.selectedAddonSubtitleId == addon.id
Button(action: {
state.selectedAddonSubtitleId = addon.id
onAddSubtitleUrl?(addon.url)
state.showSubtitlePanel = false
}) {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(addon.display)
.font(.system(size: 14, weight: .semibold))
.foregroundColor(.white)
.lineLimit(1)
Text(addon.language)
.font(.system(size: 11))
.foregroundColor(.white.opacity(0.6))
}
Spacer()
if selected {
Image(systemName: "checkmark")
.font(.system(size: 14, weight: .bold))
.foregroundColor(.white)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(selected ? Color.white.opacity(0.1) : Color.clear)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
.buttonStyle(.plain)
}
}
}
.padding(.horizontal, 12)
.padding(.bottom, 20)
}
var subtitleStylePanel: some View {
VStack(spacing: 16) {
VStack(spacing: 12) {
styleRow(label: "Font Size") {
stepperControl(
value: state.subtitleStyleFontSize,
label: "\(state.subtitleStyleFontSize)sp",
onMinus: {
state.subtitleStyleFontSize = max(12, state.subtitleStyleFontSize - 2)
applyCurrentSubtitleStyle()
},
onPlus: {
state.subtitleStyleFontSize = min(40, state.subtitleStyleFontSize + 2)
applyCurrentSubtitleStyle()
}
)
}
styleRow(label: "Outline") {
Button(action: {
state.subtitleStyleOutlineEnabled.toggle()
applyCurrentSubtitleStyle()
}) {
Text(state.subtitleStyleOutlineEnabled ? "On" : "Off")
.font(.system(size: 13, weight: .bold))
.foregroundColor(.white)
.padding(.horizontal, 16)
.padding(.vertical, 6)
.background(state.subtitleStyleOutlineEnabled ? Color.white.opacity(0.2) : Color.white.opacity(0.08))
.clipShape(RoundedRectangle(cornerRadius: 10))
}
.buttonStyle(.plain)
}
styleRow(label: "Bottom Offset") {
stepperControl(
value: state.subtitleStyleBottomOffset,
label: "\(state.subtitleStyleBottomOffset)",
onMinus: {
state.subtitleStyleBottomOffset = max(0, state.subtitleStyleBottomOffset - 5)
applyCurrentSubtitleStyle()
},
onPlus: {
state.subtitleStyleBottomOffset = min(200, state.subtitleStyleBottomOffset + 5)
applyCurrentSubtitleStyle()
}
)
}
}
.padding(16)
.background(Color.white.opacity(0.06))
.clipShape(RoundedRectangle(cornerRadius: 16))
VStack(alignment: .leading, spacing: 10) {
Text("Color")
.font(.system(size: 13, weight: .semibold))
.foregroundColor(.white.opacity(0.8))
LazyVGrid(columns: Array(repeating: GridItem(.fixed(28), spacing: 10), count: 5), spacing: 10) {
ForEach(0..<subtitleColorSwatches.count, id: \.self) { i in
let (_, nsColor) = subtitleColorSwatches[i]
let swiftUIColor = Color(nsColor)
let selected = state.subtitleStyleTextColor == i
Button(action: {
state.subtitleStyleTextColor = i
applyCurrentSubtitleStyle()
}) {
Circle()
.fill(swiftUIColor)
.frame(width: 22, height: 22)
.overlay(
Circle().stroke(selected ? Color.white : Color.white.opacity(0.2), lineWidth: selected ? 2 : 1)
)
}
.buttonStyle(.plain)
}
}
}
.padding(16)
.background(Color.white.opacity(0.06))
.clipShape(RoundedRectangle(cornerRadius: 16))
Button(action: {
state.subtitleStyleTextColor = 0
state.subtitleStyleFontSize = 18
state.subtitleStyleOutlineEnabled = false
state.subtitleStyleBottomOffset = 20
applyCurrentSubtitleStyle()
}) {
Text("Reset Defaults")
.font(.system(size: 13, weight: .semibold))
.foregroundColor(.white.opacity(0.7))
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.background(Color.white.opacity(0.06))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
.buttonStyle(.plain)
}
.padding(.horizontal, 16)
.padding(.bottom, 20)
}
func styleRow<Content: View>(label: String, @ViewBuilder content: () -> Content) -> some View {
HStack {
Text(label)
.font(.system(size: 13, weight: .medium))
.foregroundColor(.white.opacity(0.8))
Spacer()
content()
}
}
func stepperControl(value: Int, label: String, onMinus: @escaping () -> Void, onPlus: @escaping () -> Void) -> some View {
HStack(spacing: 6) {
Button(action: onMinus) {
Image(systemName: "minus")
.font(.system(size: 12, weight: .bold))
.foregroundColor(.white)
.frame(width: 28, height: 28)
.background(Color.white.opacity(0.15))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
.buttonStyle(.plain)
Text(label)
.font(.system(size: 13, weight: .bold))
.foregroundColor(.white)
.frame(minWidth: 48)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.white.opacity(0.08))
.clipShape(RoundedRectangle(cornerRadius: 10))
Button(action: onPlus) {
Image(systemName: "plus")
.font(.system(size: 12, weight: .bold))
.foregroundColor(.white)
.frame(width: 28, height: 28)
.background(Color.white.opacity(0.15))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
.buttonStyle(.plain)
}
}
private func applyCurrentSubtitleStyle() {
let (hex, _) = subtitleColorSwatches[state.subtitleStyleTextColor]
let outline: Float = state.subtitleStyleOutlineEnabled ? 2.0 : 0.0
let fontSize = Float(state.subtitleStyleFontSize)
let subPos = 100 - state.subtitleStyleBottomOffset
onApplySubtitleStyle?(hex, outline, fontSize, subPos)
state.subtitleStyleDirty = true
}
func errorOverlay(message: String) -> some View {
ZStack {
Color.black.opacity(0.9)
VStack(spacing: 16) {
Text("Playback error")
.font(.system(size: 24, weight: .bold))
.foregroundColor(.white)
Text(message)
.font(.system(size: 16))
.foregroundColor(.white.opacity(0.72))
.multilineTextAlignment(.center)
.lineLimit(4)
Button(action: onClose) {
Text("Go back")
.font(.system(size: 16, weight: .bold))
.foregroundColor(.white)
.frame(minWidth: 180, maxWidth: 260)
.padding(.vertical, 12)
.background(Color.red)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
.buttonStyle(.plain)
}
.padding(.horizontal, 32)
}
}
}
private extension Double {
func clamped(to range: ClosedRange<Double>) -> Double {
return min(max(self, range.lowerBound), range.upperBound)
}
}