Controls layouts, gestures and settings

This commit is contained in:
Arkadiusz Fal
2022-08-28 19:18:49 +02:00
parent 5b785cc9c2
commit 0f7d826a3e
28 changed files with 1318 additions and 537 deletions

View File

@@ -9,7 +9,7 @@ struct ChapterView: View {
var body: some View {
Button {
player.backend.seek(to: chapter.start)
player.backend.seek(to: chapter.start, seekType: .userInteracted)
} label: {
HStack(spacing: 12) {
if !chapter.image.isNil {

View File

@@ -1,3 +1,4 @@
import Defaults
import Foundation
import SwiftUI
@@ -5,6 +6,27 @@ struct Buffering: View {
var reason = "Buffering stream..."
var state: String?
#if os(iOS)
@Environment(\.verticalSizeClass) private var verticalSizeClass
#endif
@EnvironmentObject<PlayerModel> private var player
@Default(.playerControlsLayout) private var regularPlayerControlsLayout
@Default(.fullScreenPlayerControlsLayout) private var fullScreenPlayerControlsLayout
var playerControlsLayout: PlayerControlsLayout {
fullScreenLayout ? fullScreenPlayerControlsLayout : regularPlayerControlsLayout
}
var fullScreenLayout: Bool {
#if os(iOS)
player.playingFullScreen || verticalSizeClass == .compact
#else
player.playingFullScreen
#endif
}
var body: some View {
VStack(spacing: 2) {
ProgressView()
@@ -17,10 +39,10 @@ struct Buffering: View {
.progressViewStyle(.circular)
Text(reason)
.font(.caption)
.font(.system(size: playerControlsLayout.timeFontSize))
if let state = state {
Text(state)
.font(.caption2.monospacedDigit())
.font(.system(size: playerControlsLayout.bufferingStateFontSize).monospacedDigit())
}
}
.padding(8)

View File

@@ -0,0 +1,186 @@
import Defaults
import SwiftUI
struct Seek: View {
#if os(iOS)
@Environment(\.verticalSizeClass) private var verticalSizeClass
#endif
@EnvironmentObject<PlayerControlsModel> private var controls
@EnvironmentObject<PlayerTimeModel> private var model
@State private var dismissTimer: Timer?
@State private var isSeeking = false
private var updateThrottle = Throttle(interval: 2)
@Default(.playerControlsLayout) private var regularPlayerControlsLayout
@Default(.fullScreenPlayerControlsLayout) private var fullScreenPlayerControlsLayout
var body: some View {
Button(action: model.restoreTime) {
VStack(spacing: 2) {
ProgressBar(value: progress)
.frame(maxHeight: 5)
timeline
if isSeeking {
Divider()
gestureSeekTime
.foregroundColor(.secondary)
.font(.system(size: playerControlsLayout.chapterFontSize).monospacedDigit())
.frame(height: playerControlsLayout.chapterFontSize + 5)
if let chapter = projectedChapter {
Divider()
Text(chapter.title)
.multilineTextAlignment(.center)
.font(.system(size: playerControlsLayout.chapterFontSize))
}
if let segment = projectedSegment {
Text(SponsorBlockAPI.categoryDescription(segment.category) ?? "Sponsor")
.font(.system(size: playerControlsLayout.segmentFontSize))
.foregroundColor(Color("AppRedColor"))
}
} else {
if !model.restoreSeekTime.isNil {
Divider()
Label(model.restoreSeekPlaybackTime, systemImage: "arrow.counterclockwise")
.foregroundColor(.secondary)
.font(.system(size: playerControlsLayout.chapterFontSize).monospacedDigit())
.frame(height: playerControlsLayout.chapterFontSize + 5)
}
Group {
switch model.lastSeekType {
case let .segmentSkip(category):
Divider()
Text(SponsorBlockAPI.categoryDescription(category) ?? "Sponsor")
.font(.system(size: playerControlsLayout.segmentFontSize))
.foregroundColor(Color("AppRedColor"))
default:
EmptyView()
}
}
}
}
#if os(tvOS)
.frame(minWidth: 250, minHeight: 100)
.padding(10)
#endif
.frame(maxWidth: playerControlsLayout.seekOSDWidth)
.padding(2)
.modifier(ControlBackgroundModifier())
.clipShape(RoundedRectangle(cornerRadius: 3))
.foregroundColor(.primary)
}
#if os(tvOS)
.fixedSize()
.buttonStyle(.card)
#else
.buttonStyle(.plain)
#endif
.opacity(visible || YatteeApp.isForPreviews ? 1 : 0)
.onChange(of: model.lastSeekTime) { _ in
isSeeking = false
dismissTimer?.invalidate()
dismissTimer = Delay.by(3) {
withAnimation(.easeIn(duration: 0.1)) { model.seekOSDDismissed = true }
}
if model.seekOSDDismissed {
withAnimation(.easeIn(duration: 0.1)) { self.model.seekOSDDismissed = false }
}
}
.onChange(of: model.gestureSeek) { newValue in
let newIsSeekingValue = isSeeking || model.gestureSeek != 0
if !isSeeking, newIsSeekingValue {
model.onSeekGestureStart()
}
isSeeking = newIsSeekingValue
guard newValue != 0 else { return }
updateThrottle.execute {
model.player.backend.getTimeUpdates()
model.player.backend.updateControls()
}
dismissTimer?.invalidate()
if model.seekOSDDismissed {
withAnimation(.easeIn(duration: 0.1)) { self.model.seekOSDDismissed = false }
}
}
}
var timeline: some View {
let text = model.gestureSeek != 0 && model.lastSeekTime.isNil ?
"\(model.gestureSeekDestinationPlaybackTime)/\(model.durationPlaybackTime)" :
"\(model.lastSeekPlaybackTime)/\(model.durationPlaybackTime)"
return Text(text)
.fontWeight(.bold)
.font(.system(size: playerControlsLayout.projectedTimeFontSize).monospacedDigit())
}
var gestureSeekTime: some View {
var seek = model.gestureSeekDestinationTime - model.currentTime.seconds
if seek > 0 {
seek = min(seek, model.duration.seconds - model.currentTime.seconds)
} else {
seek = min(seek, model.currentTime.seconds)
}
let timeText = abs(seek)
.formattedAsPlaybackTime(allowZero: true, forceHours: model.forceHours) ?? ""
return Label(
timeText,
systemImage: seek >= 0 ? "goforward.plus" : "gobackward.minus"
)
}
var visible: Bool {
guard !(model.lastSeekTime.isNil && !isSeeking) else { return false }
if let type = model.lastSeekType, !type.presentable { return false }
return !controls.presentingControls && !controls.presentingOverlays && !model.seekOSDDismissed
}
var progress: Double {
if isSeeking {
return model.gestureSeekDestinationTime / model.duration.seconds
}
guard model.duration.seconds.isFinite, model.duration.seconds > 0 else { return 0 }
guard let seekTime = model.lastSeekTime else { return model.currentTime.seconds / model.duration.seconds }
return seekTime.seconds / model.duration.seconds
}
var projectedChapter: Chapter? {
(model.player?.currentVideo?.chapters ?? []).last { $0.start <= model.gestureSeekDestinationTime }
}
var projectedSegment: Segment? {
(model.player?.sponsorBlock.segments ?? []).first { $0.timeInSegment(.secondsInDefaultTimescale(model.gestureSeekDestinationTime)) }
}
var playerControlsLayout: PlayerControlsLayout {
fullScreenLayout ? fullScreenPlayerControlsLayout : regularPlayerControlsLayout
}
var fullScreenLayout: Bool {
guard let player = model.player else { return false }
#if os(iOS)
return player.playingFullScreen || verticalSizeClass == .compact
#else
return player.playingFullScreen
#endif
}
}
struct Seek_Previews: PreviewProvider {
static var previews: some View {
Seek()
.environmentObject(PlayerTimeModel())
}
}

View File

@@ -15,6 +15,7 @@ struct PlayerControls: View {
@Environment(\.verticalSizeClass) private var verticalSizeClass
#elseif os(tvOS)
enum Field: Hashable {
case seekOSD
case play
case backward
case forward
@@ -29,67 +30,125 @@ struct PlayerControls: View {
@Default(.closePlayerOnItemClose) private var closePlayerOnItemClose
#endif
@Default(.playerControlsLayout) private var regularPlayerControlsLayout
@Default(.fullScreenPlayerControlsLayout) private var fullScreenPlayerControlsLayout
var playerControlsLayout: PlayerControlsLayout {
fullScreenLayout ? fullScreenPlayerControlsLayout : regularPlayerControlsLayout
}
init(player: PlayerModel, thumbnails: ThumbnailsModel) {
self.player = player
self.thumbnails = thumbnails
}
var body: some View {
ZStack(alignment: .center) {
VStack {
ZStack(alignment: .center) {
OpeningStream()
NetworkState()
if model.presentingControls && !model.presentingOverlays {
VStack(spacing: 4) {
#if !os(tvOS)
buttonsBar
HStack {
if !player.currentVideo.isNil, fullScreenLayout {
Button {
withAnimation(Self.animation) {
model.presentingDetailsOverlay = true
}
} label: {
ControlsBar(fullScreen: $model.presentingDetailsOverlay, presentingControls: false, detailsTogglePlayer: false, detailsToggleFullScreen: false)
.clipShape(RoundedRectangle(cornerRadius: 4))
.frame(maxWidth: 300, alignment: .leading)
}
.buttonStyle(.plain)
}
Spacer()
}
#endif
Spacer()
Group {
ZStack(alignment: .bottom) {
floatingControls
.padding(.top, 20)
.padding(4)
.modifier(ControlBackgroundModifier())
.clipShape(RoundedRectangle(cornerRadius: 4))
timeline
.padding(4)
.offset(y: -25)
.zIndex(1)
}
.frame(maxWidth: 500)
.padding(.bottom, 2)
}
}
.padding(.top, 2)
.padding(.horizontal, 2)
.transition(.opacity)
ZStack(alignment: .topLeading) {
Seek()
.zIndex(4)
.transition(.opacity)
.frame(maxWidth: .infinity, alignment: .topLeading)
#if os(tvOS)
.offset(x: 10, y: 5)
.focused($focusedField, equals: .seekOSD)
.onChange(of: player.playerTime.lastSeekTime) { _ in
if !model.presentingControls {
focusedField = .seekOSD
}
}
#else
.offset(y: 2)
#endif
VStack {
ZStack(alignment: .center) {
VStack(spacing: 0) {
ZStack {
OpeningStream()
NetworkState()
}
Spacer()
}
.offset(y: playerControlsLayout.osdVerticalOffset + 5)
if model.presentingControls, !model.presentingOverlays {
#if !os(tvOS)
HStack {
seekBackwardButton
Spacer()
togglePlayButton
Spacer()
seekForwardButton
}
.font(.system(size: playerControlsLayout.bigButtonFontSize))
#endif
ZStack(alignment: .bottom) {
VStack(spacing: 4) {
#if !os(tvOS)
buttonsBar
HStack {
if !player.currentVideo.isNil, fullScreenLayout {
Button {
withAnimation(Self.animation) {
model.presentingDetailsOverlay = true
}
} label: {
ControlsBar(fullScreen: $model.presentingDetailsOverlay, presentingControls: false, detailsTogglePlayer: false, detailsToggleFullScreen: false)
.clipShape(RoundedRectangle(cornerRadius: 4))
.frame(maxWidth: 300, alignment: .leading)
}
.buttonStyle(.plain)
}
Spacer()
}
#endif
Spacer()
timeline
.frame(maxWidth: 1000)
.padding(.bottom, 2)
}
.zIndex(1)
.padding(.top, 2)
.transition(.opacity)
HStack(spacing: playerControlsLayout.buttonsSpacing) {
#if os(tvOS)
togglePlayButton
seekBackwardButton
seekForwardButton
#endif
restartVideoButton
advanceToNextItemButton
Spacer()
#if os(tvOS)
settingsButton
#endif
playbackModeButton
#if os(tvOS)
closeVideoButton
#else
musicModeButton
#endif
}
#if os(tvOS)
.frame(width: 1200)
#endif
.zIndex(0)
#if os(tvOS)
.offset(y: -playerControlsLayout.timelineHeight - 30)
#else
.offset(y: -playerControlsLayout.timelineHeight - 5)
#endif
}
}
}
.frame(maxHeight: .infinity)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.frame(maxWidth: .infinity)
#if os(tvOS)
.onChange(of: model.presentingControls) { newValue in
if newValue { focusedField = .play }
@@ -108,31 +167,6 @@ struct PlayerControls: View {
}
.frame(maxHeight: .infinity, alignment: .top)
}
if !model.presentingControls,
!model.presentingOverlays,
let segment = player.lastSkipped
{
Button {
player.restoreLastSkippedSegment()
} label: {
HStack(spacing: 10) {
Image(systemName: "arrow.counterclockwise")
Text("Skipped \(segment.durationText) seconds of \(SponsorBlockAPI.categoryDescription(segment.category)?.lowercased() ?? "segment")")
.frame(alignment: .bottomLeading)
}
.padding(.vertical, 4)
.padding(.horizontal, 5)
.font(.system(size: 10))
.foregroundColor(.secondary)
.modifier(ControlBackgroundModifier())
.clipShape(RoundedRectangle(cornerRadius: 2))
}
.frame(maxHeight: .infinity, alignment: .top)
.buttonStyle(.plain)
.transition(.opacity)
}
}
.onChange(of: model.presentingOverlays) { newValue in
if newValue {
@@ -141,6 +175,7 @@ struct PlayerControls: View {
}
#if os(tvOS)
.onReceive(model.reporter) { value in
guard player.presentingPlayer else { return }
if value == "swipe down", !model.presentingControls, !model.presentingOverlays {
withAnimation(Self.animation) {
model.presentingControlsOverlay = true
@@ -225,7 +260,7 @@ struct PlayerControls: View {
}
var buttonsBar: some View {
HStack(spacing: 20) {
HStack(spacing: playerControlsLayout.buttonsSpacing) {
fullscreenButton
pipButton
@@ -273,7 +308,7 @@ struct PlayerControls: View {
}
private var musicModeButton: some View {
button("Music Mode", systemImage: "music.note", background: false, active: player.musicMode, action: player.toggleMusicMode)
button("Music Mode", systemImage: "music.note", active: player.musicMode, action: player.toggleMusicMode)
}
private var pipButton: some View {
@@ -299,43 +334,25 @@ struct PlayerControls: View {
}
#endif
var floatingControls: some View {
HStack {
HStack(spacing: 20) {
togglePlayButton
seekBackwardButton
seekForwardButton
}
.frame(maxWidth: .infinity, alignment: .leading)
Spacer()
HStack(spacing: 20) {
playbackModeButton
restartVideoButton
advanceToNextItemButton
#if !os(tvOS)
musicModeButton
#else
settingsButton
closeVideoButton
#endif
}
.frame(maxWidth: .infinity, alignment: .trailing)
}
.font(.system(size: 20))
}
var playbackModeButton: some View {
button("Playback Mode", systemImage: player.playbackMode.systemImage, background: false) {
button("Playback Mode", systemImage: player.playbackMode.systemImage) {
player.playbackMode = player.playbackMode.next()
model.objectWillChange.send()
}
}
var seekBackwardButton: some View {
button("Seek Backward", systemImage: "gobackward.10", size: 25, cornerRadius: 5, background: false) {
player.backend.seek(relative: .secondsInDefaultTimescale(-10))
var foregroundColor: Color?
var fontSize: Double?
var size: Double?
#if !os(tvOS)
foregroundColor = .white
fontSize = playerControlsLayout.bigButtonFontSize
size = playerControlsLayout.bigButtonSize
#endif
return button("Seek Backward", systemImage: "gobackward.10", fontSize: fontSize, size: size, cornerRadius: 5, background: false, foregroundColor: foregroundColor) {
player.backend.seek(relative: .secondsInDefaultTimescale(-10), seekType: .userInteracted)
}
.disabled(player.liveStreamInAVPlayer)
#if os(tvOS)
@@ -347,8 +364,17 @@ struct PlayerControls: View {
}
var seekForwardButton: some View {
button("Seek Forward", systemImage: "goforward.10", size: 25, cornerRadius: 5, background: false) {
player.backend.seek(relative: .secondsInDefaultTimescale(10))
var foregroundColor: Color?
var fontSize: Double?
var size: Double?
#if !os(tvOS)
foregroundColor = .white
fontSize = playerControlsLayout.bigButtonFontSize
size = playerControlsLayout.bigButtonSize
#endif
return button("Seek Forward", systemImage: "goforward.10", fontSize: fontSize, size: size, cornerRadius: 5, background: false, foregroundColor: foregroundColor) {
player.backend.seek(relative: .secondsInDefaultTimescale(10), seekType: .userInteracted)
}
.disabled(player.liveStreamInAVPlayer)
#if os(tvOS)
@@ -360,16 +386,27 @@ struct PlayerControls: View {
}
private var restartVideoButton: some View {
button("Restart video", systemImage: "backward.end.fill", size: 25, cornerRadius: 5, background: false) {
player.backend.seek(to: 0.0)
button("Restart video", systemImage: "backward.end.fill", cornerRadius: 5) {
player.backend.seek(to: 0.0, seekType: .userInteracted)
}
}
private var togglePlayButton: some View {
button(
var foregroundColor: Color?
var fontSize: Double?
var size: Double?
#if !os(tvOS)
foregroundColor = .white
fontSize = playerControlsLayout.bigButtonFontSize
size = playerControlsLayout.bigButtonSize
#endif
return button(
model.isPlaying ? "Pause" : "Play",
systemImage: model.isPlaying ? "pause.fill" : "play.fill",
size: 25, cornerRadius: 5, background: false
fontSize: fontSize,
size: size,
background: false, foregroundColor: foregroundColor
) {
player.backend.togglePlay()
}
@@ -383,7 +420,7 @@ struct PlayerControls: View {
}
private var advanceToNextItemButton: some View {
button("Next", systemImage: "forward.fill", size: 25, cornerRadius: 5, background: false) {
button("Next", systemImage: "forward.fill", cornerRadius: 5) {
player.advanceToNextItem()
}
.disabled(!player.isAdvanceToNextItemAvailable)
@@ -392,11 +429,13 @@ struct PlayerControls: View {
func button(
_ label: String,
systemImage: String? = nil,
size: Double = 25,
width: Double? = nil,
height: Double? = nil,
fontSize: Double? = nil,
size: Double? = nil,
width _: Double? = nil,
height _: Double? = nil,
cornerRadius: Double = 3,
background: Bool = true,
foregroundColor: Color? = nil,
active: Bool = false,
action: @escaping () -> Void = {}
) -> some View {
@@ -420,11 +459,12 @@ struct PlayerControls: View {
}
.padding()
.contentShape(Rectangle())
.shadow(radius: (foregroundColor == .white || !useBackground) ? 3 : 0)
}
.font(.system(size: 13))
.font(.system(size: fontSize ?? playerControlsLayout.buttonFontSize))
.buttonStyle(.plain)
.foregroundColor(active ? Color("AppRedColor") : .primary)
.frame(width: width ?? size, height: height ?? size)
.foregroundColor(foregroundColor.isNil ? (active ? Color("AppRedColor") : .primary) : foregroundColor)
.frame(width: size ?? playerControlsLayout.buttonSize, height: size ?? playerControlsLayout.buttonSize)
.modifier(ControlBackgroundModifier(enabled: useBackground))
.clipShape(RoundedRectangle(cornerRadius: cornerRadius))
}

View File

@@ -0,0 +1,248 @@
import Defaults
import Foundation
enum PlayerControlsLayout: String, CaseIterable, Defaults.Serializable {
case veryLarge
case large
case medium
case small
case smaller
var description: String {
switch self {
case .veryLarge:
return "Very Large"
default:
return rawValue.capitalized
}
}
var buttonsSpacing: Double {
switch self {
case .veryLarge:
return 40
case .large:
return 30
case .medium:
return 25
case .small:
return 20
case .smaller:
return 20
}
}
var buttonFontSize: Double {
switch self {
case .veryLarge:
return 35
case .large:
return 28
case .medium:
return 22
case .small:
return 18
case .smaller:
return 15
}
}
var bigButtonFontSize: Double {
switch self {
case .veryLarge:
return 55
case .large:
return 45
case .medium:
return 35
case .small:
return 30
case .smaller:
return 25
}
}
var buttonSize: Double {
switch self {
case .veryLarge:
return 60
case .large:
return 45
case .medium:
return 35
case .small:
return 30
case .smaller:
return 25
}
}
var bigButtonSize: Double {
switch self {
case .veryLarge:
return 85
case .large:
return 70
case .medium:
return 60
case .small:
return 60
case .smaller:
return 60
}
}
var segmentFontSize: Double {
switch self {
case .veryLarge:
return 16
case .large:
return 12
case .medium:
return 10
case .small:
return 9
case .smaller:
return 9
}
}
var chapterFontSize: Double {
switch self {
case .veryLarge:
return 20
case .large:
return 16
case .medium:
return 12
case .small:
return 10
case .smaller:
return 10
}
}
var projectedTimeFontSize: Double {
switch self {
case .veryLarge:
return 25
case .large:
return 20
case .medium:
return 15
case .small:
return 13
case .smaller:
return 11
}
}
var thumbSize: Double {
switch self {
case .veryLarge:
return 35
case .large:
return 30
case .medium:
return 20
case .small:
return 15
case .smaller:
return 13
}
}
var timeFontSize: Double {
switch self {
case .veryLarge:
return 35
case .large:
return 28
case .medium:
return 17
case .small:
return 13
case .smaller:
return 9
}
}
var bufferingStateFontSize: Double {
switch self {
case .veryLarge:
return 30
case .large:
return 24
case .medium:
return 14
case .small:
return 10
case .smaller:
return 7
}
}
var timeLeadingEdgePadding: Double {
switch self {
case .veryLarge:
return 5
case .large:
return 5
case .medium:
return 5
case .small:
return 3
case .smaller:
return 3
}
}
var timeTrailingEdgePadding: Double {
switch self {
case .veryLarge:
return 16
case .large:
return 14
case .medium:
return 9
case .small:
return 6
case .smaller:
return 2
}
}
var timelineHeight: Double {
switch self {
case .veryLarge:
return 40
case .large:
return 35
case .medium:
return 30
case .small:
return 25
case .smaller:
return 20
}
}
var seekOSDWidth: Double {
switch self {
case .veryLarge:
return 240
case .large:
return 200
case .medium:
return 180
case .small:
return 140
case .smaller:
return 120
}
}
var osdVerticalOffset: Double {
buttonSize
}
}

View File

@@ -0,0 +1,27 @@
import SwiftUI
struct ProgressBar: View {
var value: Double
var body: some View {
GeometryReader { geometry in
ZStack(alignment: .leading) {
Rectangle().frame(width: geometry.size.width, height: geometry.size.height)
.opacity(0.3)
.foregroundColor(Color.secondary)
Rectangle().frame(width: min(CGFloat(self.value) * geometry.size.width, geometry.size.width), height: geometry.size.height)
.foregroundColor(Color.accentColor)
.animation(.linear)
}.cornerRadius(45.0)
}
}
}
struct ProgressBar_Previews: PreviewProvider {
static var previews: some View {
ProgressBar(value: 0.5)
.frame(maxHeight: 6)
}
}

View File

@@ -22,10 +22,13 @@ struct TVControls: UIViewRepresentable {
let downSwipe = UISwipeGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.handleSwipeDown(sender:)))
downSwipe.direction = .down
let tap = UITapGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.handleTap(sender:)))
controlsArea.addGestureRecognizer(leftSwipe)
controlsArea.addGestureRecognizer(rightSwipe)
controlsArea.addGestureRecognizer(upSwipe)
controlsArea.addGestureRecognizer(downSwipe)
controlsArea.addGestureRecognizer(tap)
let controls = UIHostingController(rootView: PlayerControls(player: player, thumbnails: thumbnails))
controls.view.frame = .init(
@@ -67,5 +70,11 @@ struct TVControls: UIViewRepresentable {
@objc func handleSwipeDown(sender _: UISwipeGestureRecognizer) {
model.reporter.send("swipe down")
}
@objc func handleTap(sender _: UITapGestureRecognizer) {
if !model.presentingControls, model.player.playerTime.seekOSDDismissed {
model.show()
}
}
}
}

View File

@@ -1,3 +1,4 @@
import Defaults
import SwiftUI
struct TimelineView: View {
@@ -39,10 +40,29 @@ struct TimelineView: View {
var thumbAreaWidth: Double = 40
var context: Context
#if os(iOS)
@Environment(\.verticalSizeClass) private var verticalSizeClass
#endif
@EnvironmentObject<PlayerModel> private var player
@EnvironmentObject<PlayerControlsModel> private var controls
@EnvironmentObject<PlayerTimeModel> private var playerTime
@Default(.playerControlsLayout) private var regularPlayerControlsLayout
@Default(.fullScreenPlayerControlsLayout) private var fullScreenPlayerControlsLayout
var playerControlsLayout: PlayerControlsLayout {
fullScreenLayout ? fullScreenPlayerControlsLayout : regularPlayerControlsLayout
}
var fullScreenLayout: Bool {
#if os(iOS)
player.playingFullScreen || verticalSizeClass == .compact
#else
player.playingFullScreen
#endif
}
var chapters: [Chapter] {
player.currentVideo?.chapters ?? []
}
@@ -64,23 +84,23 @@ struct TimelineView: View {
let description = SponsorBlockAPI.categoryDescription(segment.category)
{
Text(description)
.font(.system(size: 8))
.font(.system(size: playerControlsLayout.segmentFontSize))
.fixedSize()
.lineLimit(1)
.foregroundColor(Color("AppRedColor"))
}
if let chapter = projectedChapter {
Text(chapter.title)
.lineLimit(3)
.font(.system(size: 11).bold())
.frame(maxWidth: 250)
.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: 11).monospacedDigit())
.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(
@@ -90,7 +110,6 @@ struct TimelineView: View {
.foregroundColor(.white)
}
.animation(.easeInOut(duration: 0.2))
.frame(maxHeight: 300, alignment: .bottom)
.offset(x: thumbTooltipOffset)
.overlay(GeometryReader { proxy in
@@ -110,9 +129,8 @@ struct TimelineView: View {
Text((dragging ? projectedValue : nil)?.formattedAsPlaybackTime(allowZero: true, forceHours: playerTime.forceHours) ?? playerTime.currentPlaybackTime)
.opacity(player.liveStreamInAVPlayer ? 0 : 1)
.frame(minWidth: 35)
#if os(tvOS)
.font(.system(size: 20))
#endif
.padding(.leading, playerControlsLayout.timeLeadingEdgePadding)
.padding(.trailing, playerControlsLayout.timeTrailingEdgePadding)
ZStack(alignment: .center) {
ZStack(alignment: .leading) {
@@ -145,51 +163,15 @@ struct TimelineView: View {
ZStack {
Circle()
.fill(dragging ? .white : .gray)
.frame(width: 13)
.frame(width: playerControlsLayout.thumbSize)
Circle()
.fill(dragging ? .gray : .white)
.frame(width: 11)
.frame(width: playerControlsLayout.thumbSize * 0.95)
}
)
.offset(x: thumbOffset)
.frame(width: thumbAreaWidth, height: 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
@@ -201,20 +183,57 @@ struct TimelineView: View {
self.size = size
}
})
.frame(maxHeight: 20)
.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)
player.backend.seek(to: target, seekType: .userInteracted)
})
#endif
durationView
.padding(.leading, playerControlsLayout.timeTrailingEdgePadding)
.padding(.trailing, playerControlsLayout.timeLeadingEdgePadding)
.frame(minWidth: 30, alignment: .trailing)
}
.clipShape(RoundedRectangle(cornerRadius: 3))
.font(.system(size: 9).monospacedDigit())
#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
.modifier(ControlBackgroundModifier())
.clipShape(RoundedRectangle(cornerRadius: 4))
.font(.system(size: playerControlsLayout.timeFontSize).monospacedDigit())
.zIndex(2)
}
}
@@ -230,7 +249,7 @@ struct TimelineView: View {
} else {
Button {
if let duration = player.videoDuration {
player.backend.seek(to: duration - 5)
player.backend.seek(to: duration - 5, seekType: .userInteracted)
}
} label: {
Text("LIVE")
@@ -244,9 +263,6 @@ struct TimelineView: View {
Text(dragging ? playerTime.durationPlaybackTime : playerTime.withoutSegmentsPlaybackTime)
.clipShape(RoundedRectangle(cornerRadius: 3))
.frame(minWidth: 35)
#if os(tvOS)
.font(.system(size: 20))
#endif
}
}

View File

@@ -34,8 +34,6 @@ struct PlayerBackendView: View {
.padding(.top, controlsTopPadding)
.padding(.bottom, controlsBottomPadding)
#endif
#else
hiddenControlsButton
#endif
}
#if os(iOS)
@@ -72,22 +70,6 @@ struct PlayerBackendView: View {
}
}
#endif
#if os(tvOS)
private var hiddenControlsButton: some View {
VStack {
Button {
player.controls.show()
} label: {
EmptyView()
}
.offset(y: -100)
.buttonStyle(.plain)
.background(Color.clear)
.foregroundColor(.clear)
}
}
#endif
}
struct PlayerBackendView_Previews: PreviewProvider {

View File

@@ -0,0 +1,99 @@
import Defaults
import SwiftUI
extension VideoPlayerView {
var playerDragGesture: some Gesture {
DragGesture(minimumDistance: 0, coordinateSpace: .global)
#if os(iOS)
.updating($dragGestureOffset) { value, state, _ in
guard isVerticalDrag else { return }
var translation = value.translation
translation.height = max(0, translation.height)
state = translation
}
#endif
.updating($dragGestureState) { _, state, _ in
state = true
}
.onChanged { value in
guard player.presentingPlayer,
!playerControls.presentingControlsOverlay else { return }
if playerControls.presentingControls, !player.musicMode {
playerControls.presentingControls = false
}
if player.musicMode {
player.backend.stopControlsUpdates()
}
let verticalDrag = value.translation.height
let horizontalDrag = value.translation.width
#if os(iOS)
if viewDragOffset > 0, !isVerticalDrag {
isVerticalDrag = true
}
#endif
if !isVerticalDrag, abs(horizontalDrag) > 15, !isHorizontalDrag {
isHorizontalDrag = true
player.playerTime.resetSeek()
viewDragOffset = 0
}
if horizontalPlayerGestureEnabled, isHorizontalDrag {
player.playerTime.onSeekGestureStart {
let timeSeek = (player.playerTime.duration.seconds / player.playerSize.width) * horizontalDrag * seekGestureSpeed
player.playerTime.gestureSeek = timeSeek
}
return
}
guard verticalDrag > 0 else { return }
viewDragOffset = verticalDrag
if verticalDrag > 60,
player.playingFullScreen
{
player.exitFullScreen(showControls: false)
#if os(iOS)
if Defaults[.rotateToPortraitOnExitFullScreen] {
Orientation.lockOrientation(.allButUpsideDown, andRotateTo: .portrait)
}
#endif
}
}
.onEnded { _ in
onPlayerDragGestureEnded()
}
}
private func onPlayerDragGestureEnded() {
if horizontalPlayerGestureEnabled, isHorizontalDrag {
isHorizontalDrag = false
player.playerTime.onSeekGestureEnd()
}
isVerticalDrag = false
guard player.presentingPlayer,
!playerControls.presentingControlsOverlay else { return }
if viewDragOffset > 100 {
withAnimation(Constants.overlayAnimation) {
viewDragOffset = Self.hiddenOffset
}
} else {
withAnimation(Constants.overlayAnimation) {
viewDragOffset = 0
}
player.backend.setNeedsDrawing(true)
player.show()
if player.musicMode {
player.backend.startControlsUpdates()
}
}
}
}

View File

@@ -11,7 +11,7 @@ struct PlayerGestures: View {
tapSensitivity: 0.2,
singleTapAction: { singleTapAction() },
doubleTapAction: {
player.backend.seek(relative: .secondsInDefaultTimescale(-10))
player.backend.seek(relative: .secondsInDefaultTimescale(-10), seekType: .userInteracted)
},
anyTapAction: {
model.update()
@@ -35,7 +35,7 @@ struct PlayerGestures: View {
tapSensitivity: 0.2,
singleTapAction: { singleTapAction() },
doubleTapAction: {
player.backend.seek(relative: .secondsInDefaultTimescale(10))
player.backend.seek(relative: .secondsInDefaultTimescale(10), seekType: .userInteracted)
},
anyTapAction: {
model.update()

View File

@@ -0,0 +1,69 @@
import Defaults
import Foundation
import SwiftUI
extension VideoPlayerView {
func configureOrientationUpdatesBasedOnAccelerometer() {
let currentOrientation = OrientationTracker.shared.currentInterfaceOrientation
if currentOrientation.isLandscape,
Defaults[.enterFullscreenInLandscape],
!player.playingFullScreen,
!player.playingInPictureInPicture
{
guard player.presentingPlayer else { return }
DispatchQueue.main.async {
playerControls.presentingControls = false
player.enterFullScreen(showControls: false)
}
player.onPresentPlayer.append {
Orientation.lockOrientation(.allButUpsideDown, andRotateTo: currentOrientation)
}
}
orientationObserver = NotificationCenter.default.addObserver(
forName: OrientationTracker.deviceOrientationChangedNotification,
object: nil,
queue: .main
) { _ in
guard !Defaults[.honorSystemOrientationLock],
player.presentingPlayer,
!player.playingInPictureInPicture,
player.lockedOrientation.isNil
else {
return
}
let orientation = OrientationTracker.shared.currentInterfaceOrientation
guard lastOrientation != orientation else {
return
}
lastOrientation = orientation
DispatchQueue.main.async {
guard Defaults[.enterFullscreenInLandscape],
player.presentingPlayer
else {
return
}
if orientation.isLandscape {
playerControls.presentingControls = false
player.enterFullScreen(showControls: false)
Orientation.lockOrientation(OrientationTracker.shared.currentInterfaceOrientationMask, andRotateTo: orientation)
} else {
player.exitFullScreen(showControls: false)
Orientation.lockOrientation(.allButUpsideDown, andRotateTo: .portrait)
}
}
}
}
func stopOrientationUpdates() {
guard let observer = orientationObserver else { return }
NotificationCenter.default.removeObserver(observer)
}
}

View File

@@ -117,37 +117,41 @@ struct VideoDescription: View {
label.preferredMaxLayoutWidth = (detailsSize?.width ?? 330) - 30
label.URLColor = UIColor(Color.accentColor)
label.timestampColor = UIColor(Color.accentColor)
label.handleURLTap { url in
var urlToOpen = url
if var components = URLComponents(url: url, resolvingAgainstBaseURL: false) {
components.scheme = "yattee"
if let yatteeURL = components.url {
let parser = URLParser(url: urlToOpen)
let destination = parser.destination
if destination == .video,
parser.videoID == player.currentVideo?.videoID,
let time = parser.time
{
player.backend.seek(to: Double(time))
return
} else if destination != nil {
urlToOpen = yatteeURL
}
}
}
openURL(urlToOpen)
}
label.handleTimestampTap { timestamp in
player.backend.seek(to: timestamp.timeInterval)
}
label.handleURLTap(urlTapHandler(_:))
label.handleTimestampTap(timestampTapHandler(_:))
}
}
func updatePreferredMaxLayoutWidth() {
label.preferredMaxLayoutWidth = (detailsSize?.width ?? 330) - 30
}
func urlTapHandler(_ url: URL) {
var urlToOpen = url
if var components = URLComponents(url: url, resolvingAgainstBaseURL: false) {
components.scheme = "yattee"
if let yatteeURL = components.url {
let parser = URLParser(url: urlToOpen)
let destination = parser.destination
if destination == .video,
parser.videoID == player.currentVideo?.videoID,
let time = parser.time
{
player.backend.seek(to: Double(time), seekType: .userInteracted)
return
} else if destination != nil {
urlToOpen = yatteeURL
}
}
}
openURL(urlToOpen)
}
func timestampTapHandler(_ timestamp: Timestamp) {
player.backend.seek(to: timestamp.timeInterval, seekType: .userInteracted)
}
}
#endif

View File

@@ -14,6 +14,10 @@ struct VideoPlayerView: View {
static let defaultSidebarQueueValue = Defaults[.playerSidebar] != .never
#endif
#if os(macOS)
static let hiddenOffset = 0.0
#endif
static let defaultAspectRatio = 16 / 9.0
static var defaultMinimumHeightLeft: Double {
#if os(macOS)
@@ -35,27 +39,32 @@ struct VideoPlayerView: View {
#if os(iOS)
@Environment(\.verticalSizeClass) private var verticalSizeClass
@State private var orientation = UIInterfaceOrientation.portrait
@State private var lastOrientation: UIInterfaceOrientation?
@State internal var orientation = UIInterfaceOrientation.portrait
@State internal var lastOrientation: UIInterfaceOrientation?
#elseif os(macOS)
var hoverThrottle = Throttle(interval: 0.5)
var mouseLocation: CGPoint { NSEvent.mouseLocation }
#endif
#if os(iOS)
@GestureState private var dragGestureState = false
@GestureState private var dragGestureOffset = CGSize.zero
@State private var viewDragOffset = Self.hiddenOffset
@State private var orientationObserver: Any?
#if !os(tvOS)
@GestureState internal var dragGestureState = false
@GestureState internal var dragGestureOffset = CGSize.zero
@State internal var isHorizontalDrag = false
@State internal var isVerticalDrag = false
@State internal var viewDragOffset = Self.hiddenOffset
@State internal var orientationObserver: Any?
#endif
@EnvironmentObject<AccountsModel> private var accounts
@EnvironmentObject<NavigationModel> private var navigation
@EnvironmentObject<PlayerModel> private var player
@EnvironmentObject<PlayerControlsModel> private var playerControls
@EnvironmentObject<RecentsModel> private var recents
@EnvironmentObject<SearchModel> private var search
@EnvironmentObject<ThumbnailsModel> private var thumbnails
@EnvironmentObject<AccountsModel> internal var accounts
@EnvironmentObject<NavigationModel> internal var navigation
@EnvironmentObject<PlayerModel> internal var player
@EnvironmentObject<PlayerControlsModel> internal var playerControls
@EnvironmentObject<RecentsModel> internal var recents
@EnvironmentObject<SearchModel> internal var search
@EnvironmentObject<ThumbnailsModel> internal var thumbnails
@Default(.horizontalPlayerGestureEnabled) var horizontalPlayerGestureEnabled
@Default(.seekGestureSpeed) var seekGestureSpeed
var body: some View {
ZStack(alignment: overlayAlignment) {
@@ -65,42 +74,7 @@ struct VideoPlayerView: View {
.gesture(playerControls.presentingControlsOverlay ? videoPlayerCloseControlsOverlayGesture : nil)
#endif
VStack {
if playerControls.presentingControlsOverlay {
HStack {
HStack {
ControlsOverlay()
#if os(tvOS)
.onExitCommand {
withAnimation(PlayerControls.animation) {
playerControls.hideOverlays()
}
}
.onPlayPauseCommand {
player.togglePlay()
}
#endif
.padding()
.modifier(ControlBackgroundModifier())
.clipShape(RoundedRectangle(cornerRadius: 4))
}
#if !os(tvOS)
.frame(maxWidth: fullScreenLayout ? .infinity : player.playerSize.width)
#endif
#if !os(tvOS)
if !fullScreenLayout && sidebarQueue {
Spacer()
}
#endif
}
#if os(tvOS)
.clipShape(RoundedRectangle(cornerRadius: 10))
#endif
.zIndex(1)
.transition(.opacity)
}
}
overlay
}
.animation(nil, value: player.playerSize)
.onAppear {
@@ -189,19 +163,56 @@ struct VideoPlayerView: View {
player.hide(animate: false)
}
}
#endif
}
.compositingGroup()
#if os(iOS)
.offset(y: playerOffset)
.animation(dragGestureState ? .interactiveSpring(response: 0.05) : .easeOut(duration: 0.2), value: playerOffset)
.backport
.persistentSystemOverlays(!fullScreenLayout)
.offset(y: playerOffset)
.animation(dragGestureState ? .interactiveSpring(response: 0.05) : .easeOut(duration: 0.2), value: playerOffset)
.backport
.persistentSystemOverlays(!fullScreenLayout)
#endif
#endif
}
var overlay: some View {
VStack {
if playerControls.presentingControlsOverlay {
HStack {
HStack {
ControlsOverlay()
#if os(tvOS)
.onExitCommand {
withAnimation(PlayerControls.animation) {
playerControls.hideOverlays()
}
}
.onPlayPauseCommand {
player.togglePlay()
}
#endif
.padding()
.modifier(ControlBackgroundModifier())
.clipShape(RoundedRectangle(cornerRadius: 4))
}
#if !os(tvOS)
.frame(maxWidth: fullScreenLayout ? .infinity : player.playerSize.width)
#endif
#if !os(tvOS)
if !fullScreenLayout && sidebarQueue {
Spacer()
}
#endif
}
#if os(tvOS)
.clipShape(RoundedRectangle(cornerRadius: 10))
#endif
.zIndex(1)
.transition(.opacity)
}
}
}
var overlayWidth: Double {
guard playerSize.width.isFinite else { return 200 }
return [playerSize.width - 50, 250].min()!
@@ -225,7 +236,7 @@ struct VideoPlayerView: View {
}
var playerOffset: Double {
dragGestureState ? dragGestureOffset.height : viewDragOffset
dragGestureState && !isHorizontalDrag ? dragGestureOffset.height : viewDragOffset
}
var playerWidth: Double? {
@@ -280,23 +291,24 @@ struct VideoPlayerView: View {
hoveringPlayer = hovering
hovering ? playerControls.show() : playerControls.hide()
}
#if os(iOS)
#if !os(tvOS)
.gesture(playerControls.presentingOverlays ? nil : playerDragGesture)
#elseif os(macOS)
.onAppear(perform: {
NSEvent.addLocalMonitorForEvents(matching: [.mouseMoved]) {
hoverThrottle.execute {
if !player.currentItem.isNil, hoveringPlayer {
playerControls.resetTimer()
}
#endif
#if os(macOS)
.onAppear(perform: {
NSEvent.addLocalMonitorForEvents(matching: [.mouseMoved]) {
hoverThrottle.execute {
if !player.currentItem.isNil, hoveringPlayer {
playerControls.resetTimer()
}
return $0
}
})
return $0
}
})
#endif
.background(Color.black)
.background(Color.black)
#if !os(tvOS)
if !fullScreenLayout {
@@ -338,10 +350,10 @@ struct VideoPlayerView: View {
guard !playerControls.presentingControls else { return }
if direction == .left {
player.backend.seek(relative: .secondsInDefaultTimescale(-10))
player.backend.seek(relative: .secondsInDefaultTimescale(-10), seekType: .userInteracted)
}
if direction == .right {
player.backend.seek(relative: .secondsInDefaultTimescale(10))
player.backend.seek(relative: .secondsInDefaultTimescale(10), seekType: .userInteracted)
}
}
.onPlayPauseCommand {
@@ -430,133 +442,6 @@ struct VideoPlayerView: View {
}
}
#if os(iOS)
var playerDragGesture: some Gesture {
DragGesture(minimumDistance: 0, coordinateSpace: .global)
.updating($dragGestureOffset) { value, state, _ in
state = value.translation.height > 0 ? value.translation : .zero
}
.updating($dragGestureState) { _, state, _ in
state = true
}
.onChanged { value in
guard player.presentingPlayer,
!playerControls.presentingControlsOverlay else { return }
if playerControls.presentingControls, !player.musicMode {
playerControls.presentingControls = false
}
if player.musicMode {
player.backend.stopControlsUpdates()
}
let drag = value.translation.height
guard drag > 0 else { return }
viewDragOffset = drag
if drag > 60,
player.playingFullScreen
{
player.exitFullScreen(showControls: false)
if Defaults[.rotateToPortraitOnExitFullScreen] {
Orientation.lockOrientation(.allButUpsideDown, andRotateTo: .portrait)
}
}
}
.onEnded { _ in
onPlayerDragGestureEnded()
}
}
private func onPlayerDragGestureEnded() {
guard player.presentingPlayer,
!playerControls.presentingControlsOverlay else { return }
if viewDragOffset > 100 {
withAnimation(Constants.overlayAnimation) {
viewDragOffset = Self.hiddenOffset
}
} else {
withAnimation(Constants.overlayAnimation) {
viewDragOffset = 0
}
player.backend.setNeedsDrawing(true)
player.show()
if player.musicMode {
player.backend.startControlsUpdates()
}
}
}
private func configureOrientationUpdatesBasedOnAccelerometer() {
let currentOrientation = OrientationTracker.shared.currentInterfaceOrientation
if currentOrientation.isLandscape,
Defaults[.enterFullscreenInLandscape],
!player.playingFullScreen,
!player.playingInPictureInPicture
{
guard player.presentingPlayer else { return }
DispatchQueue.main.async {
playerControls.presentingControls = false
player.enterFullScreen(showControls: false)
}
player.onPresentPlayer.append {
Orientation.lockOrientation(.allButUpsideDown, andRotateTo: currentOrientation)
}
}
orientationObserver = NotificationCenter.default.addObserver(
forName: OrientationTracker.deviceOrientationChangedNotification,
object: nil,
queue: .main
) { _ in
guard !Defaults[.honorSystemOrientationLock],
player.presentingPlayer,
!player.playingInPictureInPicture,
player.lockedOrientation.isNil
else {
return
}
let orientation = OrientationTracker.shared.currentInterfaceOrientation
guard lastOrientation != orientation else {
return
}
lastOrientation = orientation
DispatchQueue.main.async {
guard Defaults[.enterFullscreenInLandscape],
player.presentingPlayer
else {
return
}
if orientation.isLandscape {
playerControls.presentingControls = false
player.enterFullScreen(showControls: false)
Orientation.lockOrientation(OrientationTracker.shared.currentInterfaceOrientationMask, andRotateTo: orientation)
} else {
player.exitFullScreen(showControls: false)
Orientation.lockOrientation(.allButUpsideDown, andRotateTo: .portrait)
}
}
}
}
private func stopOrientationUpdates() {
guard let observer = orientationObserver else { return }
NotificationCenter.default.removeObserver(observer)
}
#endif
#if os(tvOS)
var tvControls: some View {
TVControls(model: playerControls, player: player, thumbnails: thumbnails)