mirror of
https://github.com/yattee/yattee.git
synced 2025-11-25 02:38:29 +00:00
In fullscreen playback, swipe-down and timeline seek gestures now respect a 60pt safe zone at the top of the screen, allowing the system notification center gesture to work without triggering app gestures.
408 lines
14 KiB
Swift
408 lines
14 KiB
Swift
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 = 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
|
|
@Default(.sponsorBlockColors) private var sponsorBlockColors
|
|
@Default(.sponsorBlockShowTimeWithSkipsRemoved) private var showTimeWithSkipsRemoved
|
|
@Default(.sponsorBlockShowCategoriesInTimeline) private var showCategoriesInTimeline
|
|
|
|
var playerControlsLayout: PlayerControlsLayout {
|
|
player.playingFullScreen ? fullScreenPlayerControlsLayout : regularPlayerControlsLayout
|
|
}
|
|
|
|
private func getColor(for category: String) -> Color {
|
|
if let hexString = sponsorBlockColors[category], let rgbValue = Int(hexString.dropFirst(), radix: 16) {
|
|
let r = Double((rgbValue >> 16) & 0xFF) / 255.0
|
|
let g = Double((rgbValue >> 8) & 0xFF) / 255.0
|
|
let b = Double(rgbValue & 0xFF) / 255.0
|
|
return Color(red: r, green: g, blue: b)
|
|
}
|
|
return Color("AppRedColor") // Fallback color if no match found
|
|
}
|
|
|
|
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 showCategoriesInTimeline {
|
|
if let segment = projectedSegment,
|
|
let description = SponsorBlockAPI.categoryDescription(segment.category)
|
|
{
|
|
Text(description)
|
|
.font(.system(size: playerControlsLayout.segmentFontSize))
|
|
.fixedSize()
|
|
.foregroundColor(getColor(for: segment.category))
|
|
}
|
|
}
|
|
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)
|
|
}
|
|
#if os(tvOS)
|
|
.frame(maxHeight: 300, alignment: .bottom)
|
|
#endif
|
|
.offset(x: thumbTooltipOffset)
|
|
.overlay(GeometryReader { proxy in
|
|
Color.clear
|
|
.onAppear {
|
|
tooltipSize = proxy.size
|
|
}
|
|
.onChange(of: proxy.size) { _ in
|
|
tooltipSize = proxy.size
|
|
}
|
|
})
|
|
#if os(tvOS)
|
|
.frame(height: 80)
|
|
#endif
|
|
.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)
|
|
|
|
if showCategoriesInTimeline {
|
|
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
|
|
}
|
|
})
|
|
|
|
durationView
|
|
.shadow(radius: 3)
|
|
.padding(.leading, playerControlsLayout.timeTrailingEdgePadding)
|
|
.padding(.trailing, playerControlsLayout.timeLeadingEdgePadding)
|
|
.frame(minWidth: 30, alignment: .trailing)
|
|
.clipShape(RoundedRectangle(cornerRadius: 4))
|
|
}
|
|
|
|
.font(.system(size: playerControlsLayout.timeFontSize).monospacedDigit())
|
|
.zIndex(2)
|
|
.foregroundColor(.white)
|
|
}
|
|
.contentShape(Rectangle())
|
|
#if !os(tvOS)
|
|
.gesture(
|
|
DragGesture(minimumDistance: 5, coordinateSpace: .global)
|
|
.onChanged { value in
|
|
#if os(iOS)
|
|
// In fullscreen, ignore gestures that start in the top notification center area
|
|
// to allow system notification center gesture to work
|
|
if player.playingFullScreen {
|
|
if value.startLocation.y < Constants.notificationCenterZoneHeight {
|
|
return
|
|
}
|
|
}
|
|
#endif
|
|
|
|
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
|
|
}
|
|
|
|
@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 || !showTimeWithSkipsRemoved ? 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(getColor(for: segment.category))
|
|
.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.filter { $0.start != 0 }) { chapter in
|
|
RoundedRectangle(cornerRadius: 4)
|
|
.fill(Color("AppRedColor"))
|
|
.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<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.shared
|
|
playerTimeModel.currentTime = .secondsInDefaultTimescale(33)
|
|
playerTimeModel.duration = .secondsInDefaultTimescale(100)
|
|
return VStack(spacing: 40) {
|
|
TimelineView()
|
|
}
|
|
.background(Color.black)
|
|
.padding()
|
|
}
|
|
}
|