import Defaults import SwiftUI struct TimelineView: View { enum Context { case controls case player } private var duration: Double { playerTime.duration.seconds } private var current: Double { get { max(0, 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.controlsUpdates { player.backend.stopControlsUpdates() } else if !dragging, !player.backend.controlsUpdates { 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 #if os(iOS) @Environment(\.verticalSizeClass) private var verticalSizeClass #endif @ObservedObject private var playerTime = PlayerTimeModel.shared @ObservedObject private var player = PlayerModel.shared private var controls = PlayerControlsModel.shared @Default(.playerControlsLayout) private var regularPlayerControlsLayout @Default(.fullScreenPlayerControlsLayout) private var fullScreenPlayerControlsLayout var playerControlsLayout: PlayerControlsLayout { player.playingFullScreen ? fullScreenPlayerControlsLayout : regularPlayerControlsLayout } 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: playerControlsLayout.segmentFontSize)) .fixedSize() .foregroundColor(Color("AppRedColor")) } if let chapter = projectedChapter { Text(chapter.title) .lineLimit(3) .font(.system(size: playerControlsLayout.chapterFontSize).bold()) .frame(maxWidth: player.playerSize.width - 100) .fixedSize() } } Text((dragging ? projectedValue : current).formattedAsPlaybackTime(allowZero: true, forceHours: playerTime.forceHours) ?? PlayerTimeModel.timePlaceholder) .font(.system(size: playerControlsLayout.projectedTimeFontSize).monospacedDigit()) } .animation(.easeIn(duration: 0.2), value: projectedChapter) .animation(.easeIn(duration: 0.2), value: projectedSegment) .padding(.vertical, 3) .padding(.horizontal, 8) .background( RoundedRectangle(cornerRadius: 3) .foregroundColor(.black) ) .foregroundColor(.white) } .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, forceHours: playerTime.forceHours) ?? playerTime.currentPlaybackTime) .opacity(player.liveStreamInAVPlayer ? 0 : 1) .frame(minWidth: 35) .padding(.leading, playerControlsLayout.timeLeadingEdgePadding) .padding(.trailing, playerControlsLayout.timeTrailingEdgePadding) .shadow(radius: 3) .clipShape(RoundedRectangle(cornerRadius: 4)) ZStack { ZStack(alignment: .leading) { ZStack(alignment: .leading) { Rectangle() .fill(Color.white.opacity(0.2)) .frame(maxHeight: height) .offset(x: (dragging ? projectedValue : current) * oneUnitWidth) .zIndex(1) Rectangle() .fill(Color.white.opacity(0.6)) .frame(maxHeight: height) .frame(width: (dragging ? projectedValue : current) * oneUnitWidth) .zIndex(1) segmentsLayers .zIndex(2) } .clipShape(RoundedRectangle(cornerRadius: cornerRadius)) chaptersLayers .zIndex(3) } } .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: playerControlsLayout.timelineHeight) #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, seekType: .userInteracted) }) #endif durationView .shadow(radius: 3) .padding(.leading, playerControlsLayout.timeTrailingEdgePadding) .padding(.trailing, playerControlsLayout.timeLeadingEdgePadding) .frame(minWidth: 30, alignment: .trailing) .clipShape(RoundedRectangle(cornerRadius: 4)) } #if !os(tvOS) .highPriorityGesture( DragGesture(minimumDistance: 5, coordinateSpace: .global) .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 } dragOffset = drag } .onEnded { _ in if abs(dragOffset) > 0 { playerTime.currentTime = .secondsInDefaultTimescale(projectedValue) player.backend.seek(to: projectedValue, seekType: .userInteracted) } dragging = false dragOffset = 0.0 draggedFrom = 0.0 controls.resetTimer() } ) #endif .font(.system(size: playerControlsLayout.timeFontSize).monospacedDigit()) .zIndex(2) .foregroundColor(.white) } } @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, seekType: .userInteracted) } } 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) } } 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: height) .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 = .init( get: { duration }, set: { value in duration = value } ) static var currentBinding = Binding( get: { current }, set: { value in current = value } ) static var previews: some View { let playerModel = PlayerModel() playerModel.currentItem = .init(Video.fixture) let playerTimeModel = PlayerTimeModel.shared playerTimeModel.currentTime = .secondsInDefaultTimescale(33) playerTimeModel.duration = .secondsInDefaultTimescale(100) return VStack(spacing: 40) { TimelineView() } .background(Color.black) .padding() } }