mirror of
https://github.com/yattee/yattee.git
synced 2024-12-26 23:43:41 +00:00
392 lines
13 KiB
Swift
392 lines
13 KiB
Swift
import SwiftUI
|
|
|
|
struct TimelineView: View {
|
|
enum Context {
|
|
case controls
|
|
case player
|
|
}
|
|
|
|
private var duration: Double {
|
|
playerTime.duration.seconds
|
|
}
|
|
|
|
private var current: Double {
|
|
get {
|
|
playerTime.currentTime.seconds
|
|
}
|
|
|
|
set(value) {
|
|
playerTime.currentTime = .secondsInDefaultTimescale(value)
|
|
}
|
|
}
|
|
|
|
@State private var size = CGSize.zero
|
|
@State private var tooltipSize = CGSize.zero
|
|
@State private var dragging = false { didSet {
|
|
if dragging {
|
|
player.backend.stopControlsUpdates()
|
|
} else {
|
|
player.backend.startControlsUpdates()
|
|
}
|
|
}}
|
|
@State private var dragOffset: Double = 0
|
|
@State private var draggedFrom: Double = 0
|
|
|
|
private var start: Double = 0.0
|
|
private var height = 8.0
|
|
|
|
var cornerRadius: Double
|
|
var thumbAreaWidth: Double = 40
|
|
var context: Context
|
|
|
|
@EnvironmentObject<PlayerModel> private var player
|
|
@EnvironmentObject<PlayerControlsModel> private var controls
|
|
@EnvironmentObject<PlayerTimeModel> private var playerTime
|
|
|
|
var chapters: [Chapter] {
|
|
player.currentVideo?.chapters ?? []
|
|
}
|
|
|
|
init(
|
|
cornerRadius: Double = 10.0,
|
|
context: Context = .controls
|
|
) {
|
|
self.cornerRadius = cornerRadius
|
|
self.context = context
|
|
}
|
|
|
|
var body: some View {
|
|
VStack {
|
|
Group {
|
|
VStack(spacing: 3) {
|
|
if dragging {
|
|
if let segment = projectedSegment,
|
|
let description = SponsorBlockAPI.categoryDescription(segment.category)
|
|
{
|
|
Text(description)
|
|
.font(.system(size: 8))
|
|
.fixedSize()
|
|
.lineLimit(1)
|
|
.foregroundColor(Color("AppRedColor"))
|
|
}
|
|
if let chapter = projectedChapter {
|
|
Text(chapter.title)
|
|
.lineLimit(3)
|
|
.font(.system(size: 11).bold())
|
|
.frame(maxWidth: 250)
|
|
.fixedSize()
|
|
}
|
|
}
|
|
Text((dragging ? projectedValue : current).formattedAsPlaybackTime(allowZero: true) ?? PlayerTimeModel.timePlaceholder)
|
|
.font(.system(size: 11).monospacedDigit())
|
|
}
|
|
|
|
.padding(.vertical, 3)
|
|
.padding(.horizontal, 8)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 3)
|
|
.foregroundColor(.black)
|
|
)
|
|
|
|
.foregroundColor(.white)
|
|
}
|
|
.animation(.easeInOut(duration: 0.2))
|
|
.frame(maxHeight: 300, alignment: .bottom)
|
|
.offset(x: thumbTooltipOffset)
|
|
.overlay(GeometryReader { proxy in
|
|
Color.clear
|
|
.onAppear {
|
|
tooltipSize = proxy.size
|
|
}
|
|
.onChange(of: proxy.size) { _ in
|
|
tooltipSize = proxy.size
|
|
}
|
|
})
|
|
|
|
.frame(height: 80)
|
|
.opacity(dragging ? 1 : 0)
|
|
.animation(.easeOut, value: thumbTooltipOffset)
|
|
HStack(spacing: 4) {
|
|
Text((dragging ? projectedValue : nil)?.formattedAsPlaybackTime(allowZero: true) ?? playerTime.currentPlaybackTime)
|
|
.opacity(player.liveStreamInAVPlayer ? 0 : 1)
|
|
.frame(minWidth: 35)
|
|
#if os(tvOS)
|
|
.font(.system(size: 20))
|
|
#endif
|
|
|
|
ZStack(alignment: .center) {
|
|
ZStack(alignment: .leading) {
|
|
ZStack(alignment: .leading) {
|
|
Rectangle()
|
|
.fill(Color.white.opacity(0.2))
|
|
.frame(maxHeight: height)
|
|
.offset(x: current * oneUnitWidth)
|
|
.zIndex(1)
|
|
|
|
Rectangle()
|
|
.fill(Color.white.opacity(0.6))
|
|
.frame(maxHeight: height)
|
|
.frame(width: current * oneUnitWidth)
|
|
.zIndex(1)
|
|
|
|
segmentsLayers
|
|
.zIndex(2)
|
|
}
|
|
.clipShape(RoundedRectangle(cornerRadius: cornerRadius))
|
|
|
|
chaptersLayers
|
|
.zIndex(3)
|
|
}
|
|
|
|
Circle()
|
|
.contentShape(Rectangle())
|
|
.foregroundColor(.clear)
|
|
.background(
|
|
ZStack {
|
|
Circle()
|
|
.fill(dragging ? .white : .gray)
|
|
.frame(maxWidth: 8)
|
|
|
|
Circle()
|
|
.fill(dragging ? .gray : .white)
|
|
.frame(maxWidth: 6)
|
|
}
|
|
)
|
|
.offset(x: thumbOffset)
|
|
.frame(maxWidth: thumbAreaWidth, minHeight: thumbAreaWidth)
|
|
|
|
#if !os(tvOS)
|
|
.gesture(
|
|
DragGesture(minimumDistance: 0)
|
|
.onChanged { value in
|
|
if !dragging {
|
|
controls.removeTimer()
|
|
draggedFrom = current
|
|
}
|
|
|
|
dragging = true
|
|
|
|
let drag = value.translation.width
|
|
let change = (drag / size.width) * units
|
|
let changedCurrent = current + change
|
|
|
|
guard changedCurrent >= start, changedCurrent <= duration else {
|
|
return
|
|
}
|
|
withAnimation(Animation.linear(duration: 0.2)) {
|
|
dragOffset = drag
|
|
}
|
|
}
|
|
.onEnded { _ in
|
|
if abs(dragOffset) > 0 {
|
|
playerTime.currentTime = .secondsInDefaultTimescale(projectedValue)
|
|
player.backend.seek(to: projectedValue)
|
|
}
|
|
|
|
dragging = false
|
|
dragOffset = 0.0
|
|
draggedFrom = 0.0
|
|
controls.resetTimer()
|
|
}
|
|
)
|
|
#endif
|
|
}
|
|
.opacity(player.liveStreamInAVPlayer ? 0 : 1)
|
|
.overlay(GeometryReader { proxy in
|
|
Color.clear
|
|
.onAppear {
|
|
self.size = proxy.size
|
|
}
|
|
.onChange(of: proxy.size) { size in
|
|
self.size = size
|
|
}
|
|
})
|
|
.frame(maxHeight: 20)
|
|
#if !os(tvOS)
|
|
.gesture(DragGesture(minimumDistance: 0).onEnded { value in
|
|
let target = (value.location.x / size.width) * units
|
|
self.playerTime.currentTime = .secondsInDefaultTimescale(target)
|
|
player.backend.seek(to: target)
|
|
})
|
|
#endif
|
|
|
|
durationView
|
|
.frame(minWidth: 30, alignment: .trailing)
|
|
}
|
|
.clipShape(RoundedRectangle(cornerRadius: 3))
|
|
.font(.system(size: 9).monospacedDigit())
|
|
.zIndex(2)
|
|
}
|
|
}
|
|
|
|
@ViewBuilder var durationView: some View {
|
|
if player.live {
|
|
if player.playingLive || player.activeBackend == .appleAVPlayer {
|
|
Text("LIVE")
|
|
.fontWeight(.bold)
|
|
.padding(2)
|
|
.foregroundColor(.white)
|
|
.background(RoundedRectangle(cornerRadius: 2).foregroundColor(.red))
|
|
} else {
|
|
Button {
|
|
if let duration = player.videoDuration {
|
|
player.backend.seek(to: duration - 5)
|
|
}
|
|
} label: {
|
|
Text("LIVE")
|
|
.fontWeight(.bold)
|
|
.padding(2)
|
|
.foregroundColor(.primary)
|
|
.background(RoundedRectangle(cornerRadius: 2).strokeBorder(.red, lineWidth: 1).foregroundColor(.white))
|
|
}
|
|
}
|
|
} else {
|
|
Text(dragging ? playerTime.durationPlaybackTime : playerTime.withoutSegmentsPlaybackTime)
|
|
.clipShape(RoundedRectangle(cornerRadius: 3))
|
|
.frame(minWidth: 35)
|
|
#if os(tvOS)
|
|
.font(.system(size: 20))
|
|
#endif
|
|
}
|
|
}
|
|
|
|
var tooltipVeritcalOffset: Double {
|
|
var offset = -20.0
|
|
|
|
if !projectedChapter.isNil {
|
|
offset -= 8.0
|
|
}
|
|
|
|
if !projectedSegment.isNil {
|
|
offset -= 6.5
|
|
}
|
|
|
|
return offset
|
|
}
|
|
|
|
var projectedValue: Double {
|
|
let change = (dragOffset / size.width) * units
|
|
let projected = draggedFrom + change
|
|
|
|
guard projected.isFinite && projected >= 0 && projected <= duration else {
|
|
return 0.0
|
|
}
|
|
|
|
return projected.clamped(to: 0 ... duration)
|
|
}
|
|
|
|
var thumbOffset: Double {
|
|
let offset = dragging ? draggedThumbHorizontalOffset : thumbHorizontalOffset
|
|
return offset.isFinite ? offset : thumbLeadingOffset
|
|
}
|
|
|
|
var thumbTooltipOffset: Double {
|
|
let leadingOffset = abs(size.width / 2 - (tooltipSize.width / 2))
|
|
let offsetForThumb = thumbOffset - thumbLeadingOffset
|
|
|
|
guard offsetForThumb > tooltipSize.width / 2 else {
|
|
return -leadingOffset
|
|
}
|
|
|
|
return thumbOffset.clamped(to: -leadingOffset ... leadingOffset)
|
|
}
|
|
|
|
var minThumbTooltipOffset: Double {
|
|
60
|
|
}
|
|
|
|
var maxThumbTooltipOffset: Double {
|
|
max(minThumbTooltipOffset, units * oneUnitWidth)
|
|
}
|
|
|
|
var segments: [Segment] {
|
|
player.sponsorBlock.segments
|
|
}
|
|
|
|
var segmentsLayers: some View {
|
|
ForEach(segments, id: \.uuid) { segment in
|
|
Rectangle()
|
|
.offset(x: segmentLayerHorizontalOffset(segment))
|
|
.foregroundColor(Color("AppRedColor"))
|
|
.frame(maxHeight: height)
|
|
.frame(width: segmentLayerWidth(segment))
|
|
}
|
|
}
|
|
|
|
var projectedSegment: Segment? {
|
|
segments.first { $0.timeInSegment(.secondsInDefaultTimescale(projectedValue)) }
|
|
}
|
|
|
|
var projectedChapter: Chapter? {
|
|
chapters.last { $0.start <= projectedValue }
|
|
}
|
|
|
|
var chaptersLayers: some View {
|
|
ForEach(chapters) { chapter in
|
|
RoundedRectangle(cornerRadius: 4)
|
|
.fill(Color.orange)
|
|
.frame(maxWidth: 2, maxHeight: 12)
|
|
.offset(x: (chapter.start * oneUnitWidth) - 1)
|
|
}
|
|
}
|
|
|
|
func segmentLayerHorizontalOffset(_ segment: Segment) -> Double {
|
|
segment.start * oneUnitWidth
|
|
}
|
|
|
|
func segmentLayerWidth(_ segment: Segment) -> Double {
|
|
let width = segment.duration * oneUnitWidth
|
|
return width.isFinite ? width : 1
|
|
}
|
|
|
|
var draggedThumbHorizontalOffset: Double {
|
|
thumbLeadingOffset + (draggedFrom * oneUnitWidth) + dragOffset
|
|
}
|
|
|
|
var thumbHorizontalOffset: Double {
|
|
thumbLeadingOffset + (current * oneUnitWidth)
|
|
}
|
|
|
|
var thumbLeadingOffset: Double {
|
|
-size.width / 2
|
|
}
|
|
|
|
var oneUnitWidth: Double {
|
|
let one = size.width / units
|
|
return one.isFinite ? one : 0
|
|
}
|
|
|
|
var units: Double {
|
|
duration - start
|
|
}
|
|
}
|
|
|
|
struct TimelineView_Previews: PreviewProvider {
|
|
static var duration = 100.0
|
|
static var current = 0.0
|
|
static var durationBinding: Binding<Double> = .init(
|
|
get: { duration },
|
|
set: { value in duration = value }
|
|
)
|
|
static var currentBinding = Binding<Double>(
|
|
get: { current },
|
|
set: { value in current = value }
|
|
)
|
|
|
|
static var previews: some View {
|
|
let playerModel = PlayerModel()
|
|
playerModel.currentItem = .init(Video.fixture)
|
|
let playerTimeModel = PlayerTimeModel()
|
|
playerTimeModel.player = playerModel
|
|
playerTimeModel.currentTime = .secondsInDefaultTimescale(33)
|
|
playerTimeModel.duration = .secondsInDefaultTimescale(100)
|
|
return VStack(spacing: 40) {
|
|
TimelineView()
|
|
}
|
|
.environmentObject(playerModel)
|
|
.environmentObject(playerTimeModel)
|
|
.environmentObject(PlayerControlsModel())
|
|
.padding()
|
|
}
|
|
}
|