yattee/Shared/Player/Controls/TimelineView.swift
2022-08-15 16:34:25 +02:00

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()
}
}