mirror of
https://github.com/yattee/yattee.git
synced 2025-08-05 02:04:07 +00:00
Controls layouts, gestures and settings
This commit is contained in:
@@ -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 {
|
||||
|
@@ -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)
|
||||
|
186
Shared/Player/Controls/OSD/Seek.swift
Normal file
186
Shared/Player/Controls/OSD/Seek.swift
Normal 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())
|
||||
}
|
||||
}
|
@@ -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))
|
||||
}
|
||||
|
248
Shared/Player/Controls/PlayerControlsLayout.swift
Normal file
248
Shared/Player/Controls/PlayerControlsLayout.swift
Normal 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
|
||||
}
|
||||
}
|
27
Shared/Player/Controls/ProgressBar.swift
Normal file
27
Shared/Player/Controls/ProgressBar.swift
Normal 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)
|
||||
}
|
||||
}
|
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -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 {
|
||||
|
99
Shared/Player/PlayerDragGesture.swift
Normal file
99
Shared/Player/PlayerDragGesture.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -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()
|
||||
|
69
Shared/Player/PlayerOrientation.swift
Normal file
69
Shared/Player/PlayerOrientation.swift
Normal 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)
|
||||
}
|
||||
}
|
@@ -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
|
||||
|
||||
|
@@ -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)
|
||||
|
Reference in New Issue
Block a user