Files
yattee/Yattee/Views/Player/SegmentedProgressBar.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

391 lines
13 KiB
Swift

//
// SegmentedProgressBar.swift
// Yattee
//
// Progress bar with visual chapter segments separated by gaps,
// and optional SponsorBlock segment overlays.
//
import SwiftUI
/// A progress bar that displays chapter boundaries as visual segments with gaps.
/// When no chapters are available, renders as a single continuous bar.
/// Optionally displays SponsorBlock segments as colored overlays.
struct SegmentedProgressBar: View {
let chapters: [VideoChapter]
let duration: TimeInterval
let currentTime: TimeInterval
let bufferedTime: TimeInterval
let height: CGFloat
let gapWidth: CGFloat
let playedColor: Color
let bufferedColor: Color
let backgroundColor: Color
/// SponsorBlock segments to display on the progress bar.
var sponsorSegments: [SponsorBlockSegment] = []
/// Settings for SponsorBlock segment display.
var sponsorBlockSettings: SponsorBlockSegmentSettings = .default
/// Progress as a fraction (0-1).
private var progress: Double {
guard duration > 0 else { return 0 }
return min(max(currentTime / duration, 0), 1)
}
/// Buffered progress as a fraction (0-1).
private var bufferedProgress: Double {
guard duration > 0 else { return 0 }
return min(bufferedTime / duration, 1)
}
/// Filtered sponsor segments that should be displayed.
private var visibleSponsorSegments: [SponsorBlockSegment] {
guard sponsorBlockSettings.showSegments else { return [] }
return sponsorSegments.filter { segment in
sponsorBlockSettings.settings(for: segment.category).isVisible
}
}
var body: some View {
GeometryReader { geometry in
if chapters.count >= 2 {
segmentedBar(geometry: geometry)
} else {
singleBar(geometry: geometry)
}
}
.frame(height: height)
}
// MARK: - Single Bar (No Chapters)
@ViewBuilder
private func singleBar(geometry: GeometryProxy) -> some View {
ZStack(alignment: .leading) {
// Background
Rectangle()
.fill(backgroundColor)
// Buffered
Rectangle()
.fill(bufferedColor)
.frame(width: geometry.size.width * bufferedProgress)
// Played
Rectangle()
.fill(playedColor)
.frame(width: geometry.size.width * progress)
// SponsorBlock segments (on top)
sponsorSegmentsOverlay(
totalWidth: geometry.size.width,
rangeStart: 0,
rangeEnd: duration
)
}
}
// MARK: - Segmented Bar (With Chapters)
@ViewBuilder
private func segmentedBar(geometry: GeometryProxy) -> some View {
let totalGapWidth = CGFloat(chapters.count - 1) * gapWidth
let availableWidth = geometry.size.width - totalGapWidth
HStack(spacing: gapWidth) {
ForEach(Array(chapters.enumerated()), id: \.element.id) { index, chapter in
let segmentWidth = segmentWidth(for: chapter, index: index, availableWidth: availableWidth)
let chapterEnd = nextChapterStart(after: index)
ChapterSegmentView(
chapter: chapter,
nextChapterStart: chapterEnd,
currentTime: currentTime,
bufferedTime: bufferedTime,
playedColor: playedColor,
bufferedColor: bufferedColor,
backgroundColor: backgroundColor,
sponsorSegments: segmentsInRange(start: chapter.startTime, end: chapterEnd),
sponsorBlockSettings: sponsorBlockSettings
)
.frame(width: segmentWidth, height: height)
}
}
}
// MARK: - Sponsor Segments Overlay
@ViewBuilder
private func sponsorSegmentsOverlay(
totalWidth: CGFloat,
rangeStart: TimeInterval,
rangeEnd: TimeInterval
) -> some View {
let rangeDuration = rangeEnd - rangeStart
ForEach(visibleSponsorSegments, id: \.uuid) { segment in
if rangeDuration > 0 {
let segmentStart = max(segment.startTime, rangeStart)
let segmentEnd = min(segment.endTime, rangeEnd)
// Only render if segment overlaps with range
if segmentStart < segmentEnd {
let startFraction = (segmentStart - rangeStart) / rangeDuration
let endFraction = (segmentEnd - rangeStart) / rangeDuration
let segmentWidthFraction = endFraction - startFraction
let categorySettings = sponsorBlockSettings.settings(for: segment.category)
Rectangle()
.fill(categorySettings.color.color)
.frame(width: totalWidth * segmentWidthFraction)
.offset(x: totalWidth * startFraction)
}
}
}
}
// MARK: - Helpers
private func segmentWidth(for chapter: VideoChapter, index: Int, availableWidth: CGFloat) -> CGFloat {
guard duration > 0 else { return 0 }
let chapterEnd = nextChapterStart(after: index)
let chapterDuration = chapterEnd - chapter.startTime
let proportion = chapterDuration / duration
return max(1, availableWidth * proportion) // Minimum 1pt width
}
private func nextChapterStart(after index: Int) -> TimeInterval {
if index < chapters.count - 1 {
return chapters[index + 1].startTime
}
return duration
}
/// Returns sponsor segments that overlap with the given time range.
private func segmentsInRange(start: TimeInterval, end: TimeInterval) -> [SponsorBlockSegment] {
visibleSponsorSegments.filter { segment in
segment.startTime < end && segment.endTime > start
}
}
}
// MARK: - Chapter Segment View
/// A single chapter segment within the progress bar.
private struct ChapterSegmentView: View {
let chapter: VideoChapter
let nextChapterStart: TimeInterval
let currentTime: TimeInterval
let bufferedTime: TimeInterval
let playedColor: Color
let bufferedColor: Color
let backgroundColor: Color
let sponsorSegments: [SponsorBlockSegment]
let sponsorBlockSettings: SponsorBlockSegmentSettings
/// How much of this chapter has been played (0-1).
private var playedProgress: Double {
let chapterDuration = nextChapterStart - chapter.startTime
guard chapterDuration > 0 else { return 0 }
if currentTime <= chapter.startTime {
return 0
} else if currentTime >= nextChapterStart {
return 1
} else {
return (currentTime - chapter.startTime) / chapterDuration
}
}
/// How much of this chapter has been buffered (0-1).
private var bufferedProgress: Double {
let chapterDuration = nextChapterStart - chapter.startTime
guard chapterDuration > 0 else { return 0 }
if bufferedTime <= chapter.startTime {
return 0
} else if bufferedTime >= nextChapterStart {
return 1
} else {
return (bufferedTime - chapter.startTime) / chapterDuration
}
}
var body: some View {
GeometryReader { geometry in
ZStack(alignment: .leading) {
// Background
Rectangle()
.fill(backgroundColor)
// Buffered
Rectangle()
.fill(bufferedColor)
.frame(width: geometry.size.width * bufferedProgress)
// Played
Rectangle()
.fill(playedColor)
.frame(width: geometry.size.width * playedProgress)
// SponsorBlock segments (on top)
sponsorSegmentsOverlay(geometry: geometry)
}
}
}
@ViewBuilder
private func sponsorSegmentsOverlay(geometry: GeometryProxy) -> some View {
let chapterDuration = nextChapterStart - chapter.startTime
ForEach(sponsorSegments, id: \.uuid) { segment in
if chapterDuration > 0 {
let segmentStart = max(segment.startTime, chapter.startTime)
let segmentEnd = min(segment.endTime, nextChapterStart)
if segmentStart < segmentEnd {
let startFraction = (segmentStart - chapter.startTime) / chapterDuration
let endFraction = (segmentEnd - chapter.startTime) / chapterDuration
let segmentWidthFraction = endFraction - startFraction
let categorySettings = sponsorBlockSettings.settings(for: segment.category)
Rectangle()
.fill(categorySettings.color.color)
.frame(width: geometry.size.width * segmentWidthFraction)
.offset(x: geometry.size.width * startFraction)
}
}
}
}
}
// MARK: - Preview
#Preview("With Chapters") {
VStack(spacing: 20) {
SegmentedProgressBar(
chapters: [
VideoChapter(title: "Intro", startTime: 0, endTime: 60),
VideoChapter(title: "Topic A", startTime: 60, endTime: 180),
VideoChapter(title: "Topic B", startTime: 180, endTime: 400),
VideoChapter(title: "Outro", startTime: 400, endTime: 600),
],
duration: 600,
currentTime: 150,
bufferedTime: 300,
height: 4,
gapWidth: 2,
playedColor: .red,
bufferedColor: .white.opacity(0.5),
backgroundColor: .white.opacity(0.3)
)
.frame(width: 300)
SegmentedProgressBar(
chapters: [],
duration: 600,
currentTime: 150,
bufferedTime: 300,
height: 4,
gapWidth: 2,
playedColor: .red,
bufferedColor: .white.opacity(0.5),
backgroundColor: .white.opacity(0.3)
)
.frame(width: 300)
}
.padding()
.background(Color.black)
}
#Preview("With SponsorBlock Segments") {
VStack(spacing: 20) {
// Single bar with sponsor segments
SegmentedProgressBar(
chapters: [],
duration: 600,
currentTime: 150,
bufferedTime: 300,
height: 4,
gapWidth: 2,
playedColor: .red,
bufferedColor: .white.opacity(0.5),
backgroundColor: .white.opacity(0.3),
sponsorSegments: [
SponsorBlockSegment(
uuid: "1",
category: .sponsor,
actionType: .skip,
segment: [30.0, 90.0],
videoDuration: 600,
locked: nil,
votes: nil,
segmentDescription: nil
),
SponsorBlockSegment(
uuid: "2",
category: .intro,
actionType: .skip,
segment: [0.0, 15.0],
videoDuration: 600,
locked: nil,
votes: nil,
segmentDescription: nil
),
],
sponsorBlockSettings: .default
)
.frame(width: 300)
// Segmented bar with sponsor segments
SegmentedProgressBar(
chapters: [
VideoChapter(title: "Intro", startTime: 0, endTime: 60),
VideoChapter(title: "Topic A", startTime: 60, endTime: 180),
VideoChapter(title: "Topic B", startTime: 180, endTime: 400),
VideoChapter(title: "Outro", startTime: 400, endTime: 600),
],
duration: 600,
currentTime: 150,
bufferedTime: 300,
height: 4,
gapWidth: 2,
playedColor: .red,
bufferedColor: .white.opacity(0.5),
backgroundColor: .white.opacity(0.3),
sponsorSegments: [
SponsorBlockSegment(
uuid: "1",
category: .sponsor,
actionType: .skip,
segment: [30.0, 90.0],
videoDuration: 600,
locked: nil,
votes: nil,
segmentDescription: nil
),
SponsorBlockSegment(
uuid: "2",
category: .outro,
actionType: .skip,
segment: [500.0, 600.0],
videoDuration: 600,
locked: nil,
votes: nil,
segmentDescription: nil
),
],
sponsorBlockSettings: .default
)
.frame(width: 300)
}
.padding()
.background(Color.black)
}