mirror of
https://github.com/yattee/yattee.git
synced 2026-02-20 09:49:46 +00:00
Yattee v2 rewrite
This commit is contained in:
394
Yattee/Views/Player/SegmentedProgressBar.swift
Normal file
394
Yattee/Views/Player/SegmentedProgressBar.swift
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user