mirror of
https://github.com/yattee/yattee.git
synced 2026-02-20 09:49:46 +00:00
395 lines
13 KiB
Swift
395 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,
|
|
duration: duration,
|
|
currentTime: currentTime,
|
|
bufferedTime: bufferedTime,
|
|
segmentWidth: segmentWidth,
|
|
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 duration: TimeInterval
|
|
let currentTime: TimeInterval
|
|
let bufferedTime: TimeInterval
|
|
let segmentWidth: CGFloat
|
|
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)
|
|
}
|