Yattee v2 rewrite

This commit is contained in:
Arkadiusz Fal
2026-02-08 18:31:16 +01:00
parent 20d0cfc0c7
commit 05f921d605
1043 changed files with 163875 additions and 68430 deletions

View File

@@ -0,0 +1,394 @@
//
// 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)
}