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

@ -78,7 +78,7 @@ struct FixtureEnvironmentObjectsModifier: ViewModifier {
}
private var playerControls: PlayerControlsModel {
PlayerControlsModel(presentingControls: false, presentingControlsOverlay: true, player: player)
PlayerControlsModel(presentingControls: true, presentingControlsOverlay: false, player: player)
}
private var subscriptions: SubscriptionsModel {

View File

@ -145,7 +145,7 @@ final class AVPlayerBackend: PlayerBackend {
avPlayer.replaceCurrentItem(with: nil)
}
func seek(to time: CMTime, completionHandler: ((Bool) -> Void)?) {
func seek(to time: CMTime, seekType _: PlayerTimeModel.SeekType, completionHandler: ((Bool) -> Void)?) {
guard !model.live else { return }
avPlayer.seek(
@ -156,12 +156,6 @@ final class AVPlayerBackend: PlayerBackend {
)
}
func seek(relative time: CMTime, completionHandler: ((Bool) -> Void)? = nil) {
if let currentTime = currentTime {
seek(to: currentTime + time, completionHandler: completionHandler)
}
}
func setRate(_ rate: Float) {
avPlayer.rate = rate
}
@ -461,13 +455,14 @@ final class AVPlayerBackend: PlayerBackend {
if self.model.activeBackend != .appleAVPlayer {
self.startPictureInPictureOnSwitch = true
let seconds = self.model.mpvBackend.currentTime?.seconds ?? 0
self.seek(to: seconds) { finished in
guard finished else { return }
self.seek(to: seconds, seekType: .backendSync) { _ in
DispatchQueue.main.async {
self.model.pause()
self.model.changeActiveBackend(from: .mpv, to: .appleAVPlayer, changingStream: false)
}
}
}
}
case .failed:
DispatchQueue.main.async {
self.model.playerError = item.error
@ -537,9 +532,7 @@ final class AVPlayerBackend: PlayerBackend {
#endif
if self.controlsUpdates {
self.playerTime.duration = self.playerItemDuration ?? .zero
self.playerTime.currentTime = self.currentTime ?? .zero
self.model.objectWillChange.send()
self.updateControls()
}
}
}
@ -607,8 +600,6 @@ final class AVPlayerBackend: PlayerBackend {
}
}
func updateControls() {}
func startControlsUpdates() {
guard model.presentingPlayer, model.controls.presentingControls, !model.controls.presentingOverlays else {
logger.info("ignored controls update start")
@ -680,6 +671,7 @@ final class AVPlayerBackend: PlayerBackend {
}
}
func getTimeUpdates() {}
func setNeedsDrawing(_: Bool) {}
func setSize(_: Double, _: Double) {}
func setNeedsNetworkStateUpdates(_: Bool) {}

View File

@ -8,7 +8,7 @@ import Repeat
import SwiftUI
final class MPVBackend: PlayerBackend {
static var controlsUpdateInterval = 0.5
static var timeUpdateInterval = 0.5
static var networkStateUpdateInterval = 1.0
private var logger = Logger(label: "mpv-backend")
@ -131,8 +131,8 @@ final class MPVBackend: PlayerBackend {
self.playerTime = playerTime
self.networkState = networkState
clientTimer = .init(interval: .seconds(Self.controlsUpdateInterval), mode: .infinite) { [weak self] _ in
self?.getClientUpdates()
clientTimer = .init(interval: .seconds(Self.timeUpdateInterval), mode: .infinite) { [weak self] _ in
self?.getTimeUpdates()
}
networkStateTimer = .init(interval: .seconds(Self.networkStateUpdateInterval), mode: .infinite) { [weak self] _ in
@ -204,7 +204,7 @@ final class MPVBackend: PlayerBackend {
let segment = self.model.sponsorBlock.segments.first,
self.model.lastSkipped.isNil
{
self.seek(to: segment.endTime) { finished in
self.seek(to: segment.endTime, seekType: .segmentSkip(segment.category)) { finished in
guard finished else {
return
}
@ -299,17 +299,9 @@ final class MPVBackend: PlayerBackend {
client?.stop()
}
func seek(to time: CMTime, completionHandler: ((Bool) -> Void)?) {
func seek(to time: CMTime, seekType _: PlayerTimeModel.SeekType, completionHandler: ((Bool) -> Void)?) {
client?.seek(to: time) { [weak self] _ in
self?.getClientUpdates()
self?.updateControls()
completionHandler?(true)
}
}
func seek(relative time: CMTime, completionHandler: ((Bool) -> Void)? = nil) {
client?.seek(relative: time) { [weak self] _ in
self?.getClientUpdates()
self?.getTimeUpdates()
self?.updateControls()
completionHandler?(true)
}
@ -328,31 +320,6 @@ final class MPVBackend: PlayerBackend {
func closePiP() {}
func updateControls() {
self.logger.info("updating controls")
guard model.presentingPlayer, !model.controls.presentingOverlays else {
self.logger.info("ignored controls update")
return
}
DispatchQueue.main.async(qos: .userInteractive) { [weak self] in
guard let self = self else {
return
}
#if !os(macOS)
guard UIApplication.shared.applicationState != .background else {
self.logger.info("not performing controls updates in background")
return
}
#endif
self.playerTime.currentTime = self.currentTime ?? .zero
self.playerTime.duration = self.playerItemDuration ?? .zero
}
}
func startControlsUpdates() {
guard model.presentingPlayer, model.controls.presentingControls, !model.controls.presentingOverlays else {
self.logger.info("ignored controls update start")
@ -373,7 +340,7 @@ final class MPVBackend: PlayerBackend {
private var handleSegmentsThrottle = Throttle(interval: 1)
private func getClientUpdates() {
func getTimeUpdates() {
currentTime = client?.currentTime
playerItemDuration = client?.duration
@ -458,8 +425,7 @@ final class MPVBackend: PlayerBackend {
return
}
getClientUpdates()
getTimeUpdates()
eofPlaybackModeAction()
}

View File

@ -1,6 +1,9 @@
import CoreMedia
import Defaults
import Foundation
#if !os(macOS)
import UIKit
#endif
protocol PlayerBackend {
var model: PlayerModel! { get set }
@ -38,9 +41,8 @@ protocol PlayerBackend {
func stop()
func seek(to time: CMTime, completionHandler: ((Bool) -> Void)?)
func seek(to seconds: Double, completionHandler: ((Bool) -> Void)?)
func seek(relative time: CMTime, completionHandler: ((Bool) -> Void)?)
func seek(to time: CMTime, seekType: PlayerTimeModel.SeekType, completionHandler: ((Bool) -> Void)?)
func seek(to seconds: Double, seekType: PlayerTimeModel.SeekType, completionHandler: ((Bool) -> Void)?)
func setRate(_ rate: Float)
@ -51,7 +53,8 @@ protocol PlayerBackend {
func startMusicMode()
func stopMusicMode()
func updateControls()
func getTimeUpdates()
func updateControls(completionHandler: (() -> Void)?)
func startControlsUpdates()
func stopControlsUpdates()
@ -64,16 +67,23 @@ protocol PlayerBackend {
}
extension PlayerBackend {
func seek(to time: CMTime, completionHandler: ((Bool) -> Void)? = nil) {
seek(to: time, completionHandler: completionHandler)
func seek(to time: CMTime, seekType: PlayerTimeModel.SeekType, completionHandler: ((Bool) -> Void)? = nil) {
playerTime.registerSeek(at: time, type: seekType, restore: currentTime)
seek(to: time, seekType: seekType, completionHandler: completionHandler)
}
func seek(to seconds: Double, completionHandler: ((Bool) -> Void)? = nil) {
seek(to: .secondsInDefaultTimescale(seconds), completionHandler: completionHandler)
func seek(to seconds: Double, seekType: PlayerTimeModel.SeekType, completionHandler: ((Bool) -> Void)? = nil) {
let seconds = CMTime.secondsInDefaultTimescale(seconds)
playerTime.registerSeek(at: seconds, type: seekType, restore: currentTime)
seek(to: seconds, seekType: seekType, completionHandler: completionHandler)
}
func seek(relative time: CMTime, completionHandler: ((Bool) -> Void)? = nil) {
seek(relative: time, completionHandler: completionHandler)
func seek(relative time: CMTime, seekType: PlayerTimeModel.SeekType, completionHandler: ((Bool) -> Void)? = nil) {
if let currentTime = currentTime, let duration = playerItemDuration {
let seekTime = min(max(0, currentTime.seconds + time.seconds), duration.seconds)
playerTime.registerSeek(at: .secondsInDefaultTimescale(seekTime), type: seekType, restore: currentTime)
seek(to: seekTime, seekType: seekType, completionHandler: completionHandler)
}
}
func eofPlaybackModeAction() {
@ -92,7 +102,7 @@ extension PlayerBackend {
model.advanceToNextItem()
}
case .loopOne:
model.backend.seek(to: .zero) { _ in
model.backend.seek(to: .zero, seekType: .loopRestart) { _ in
self.model.play()
}
case .related:
@ -101,4 +111,27 @@ extension PlayerBackend {
model.advanceToItem(item)
}
}
func updateControls(completionHandler: (() -> Void)? = nil) {
print("updating controls")
guard model.presentingPlayer, !model.controls.presentingOverlays else {
print("ignored controls update")
completionHandler?()
return
}
DispatchQueue.main.async(qos: .userInteractive) {
#if !os(macOS)
guard UIApplication.shared.applicationState != .background else {
print("not performing controls updates in background")
completionHandler?()
return
}
#endif
self.playerTime.currentTime = self.currentTime ?? .zero
self.playerTime.duration = self.playerItemDuration ?? .zero
completionHandler?()
}
}
}

View File

@ -13,7 +13,7 @@ final class PlayerControlsModel: ObservableObject {
var timer: Timer?
#if os(tvOS)
var reporter = PassthroughSubject<String, Never>()
private(set) var reporter = PassthroughSubject<String, Never>()
#endif
var player: PlayerModel!

View File

@ -98,7 +98,7 @@ final class PlayerModel: ObservableObject {
@Published var queue = [PlayerQueueItem]() { didSet { handleQueueChange() } }
@Published var currentItem: PlayerQueueItem! { didSet { handleCurrentItemChange() } }
@Published var videoBeingOpened: Video?
@Published var videoBeingOpened: Video? { didSet { playerTime.reset() } }
@Published var historyVideos = [Video]()
@Published var preservedTime: CMTime?
@ -505,7 +505,16 @@ final class PlayerModel: ObservableObject {
self.backend.setNeedsDrawing(self.presentingPlayer)
}
#if os(tvOS)
if presentingPlayer {
controls.show()
Delay.by(1) { [weak self] in
self?.controls.hide()
}
}
#else
controls.hide()
#endif
#if !os(macOS)
UIApplication.shared.isIdleTimerDisabled = presentingPlayer
@ -531,6 +540,8 @@ final class PlayerModel: ObservableObject {
logger.info("changing backend from \(from.rawValue) to \(to.rawValue)")
let wasPlaying = isPlaying
if to == .mpv {
closePiP()
}
@ -543,19 +554,23 @@ final class PlayerModel: ObservableObject {
self.backend.didChangeTo()
if wasPlaying {
fromBackend.pause()
}
guard var stream = stream, changingStream else {
return
}
if let stream = toBackend.stream, toBackend.video == fromBackend.video {
toBackend.seek(to: fromBackend.currentTime?.seconds ?? .zero) { finished in
toBackend.seek(to: fromBackend.currentTime?.seconds ?? .zero, seekType: .backendSync) { finished in
guard finished else {
return
}
if wasPlaying {
toBackend.play()
}
}
self.stream = stream
streamSelection = stream
@ -764,17 +779,17 @@ final class PlayerModel: ObservableObject {
skipBackwardCommand.preferredIntervals = preferredIntervals
skipForwardCommand.addTarget { [weak self] _ in
self?.backend.seek(relative: .secondsInDefaultTimescale(10))
self?.backend.seek(relative: .secondsInDefaultTimescale(10), seekType: .userInteracted)
return .success
}
skipBackwardCommand.addTarget { [weak self] _ in
self?.backend.seek(relative: .secondsInDefaultTimescale(-10))
self?.backend.seek(relative: .secondsInDefaultTimescale(-10), seekType: .userInteracted)
return .success
}
previousTrackCommand.addTarget { [weak self] _ in
self?.backend.seek(to: .zero)
self?.backend.seek(to: .zero, seekType: .userInteracted)
return .success
}
@ -801,7 +816,7 @@ final class PlayerModel: ObservableObject {
MPRemoteCommandCenter.shared().changePlaybackPositionCommand.addTarget { [weak self] remoteEvent in
guard let event = remoteEvent as? MPChangePlaybackPositionCommandEvent else { return .commandFailed }
self?.backend.seek(to: event.positionTime)
self?.backend.seek(to: event.positionTime, seekType: .userInteracted)
return .success
}

View File

@ -49,7 +49,7 @@ extension PlayerModel {
return
}
backend.seek(to: segment.endTime)
backend.seek(to: segment.endTime, seekType: .segmentSkip(segment.category))
DispatchQueue.main.async { [weak self] in
withAnimation {
@ -79,7 +79,7 @@ extension PlayerModel {
}
restoredSegments.append(segment)
backend.seek(to: time)
backend.seek(to: time, seekType: .segmentRestore)
resetLastSegment()
}

View File

@ -1,13 +1,35 @@
import CoreMedia
import Foundation
import SwiftUI
final class PlayerTimeModel: ObservableObject {
enum SeekType: Equatable {
case segmentSkip(String)
case segmentRestore
case userInteracted
case loopRestart
case backendSync
var presentable: Bool {
self != .backendSync
}
}
static let timePlaceholder = "--:--"
@Published var currentTime = CMTime.zero
@Published var duration = CMTime.zero
var player: PlayerModel?
@Published var lastSeekTime: CMTime?
@Published var lastSeekType: SeekType?
@Published var restoreSeekTime: CMTime?
@Published var gestureSeek = 0.0
@Published var gestureStart = 0.0
@Published var seekOSDDismissed = true
var player: PlayerModel!
var forceHours: Bool {
duration.seconds >= 60 * 60
@ -30,15 +52,73 @@ final class PlayerTimeModel: ObservableObject {
}
var withoutSegmentsPlaybackTime: String {
guard let withoutSegmentsDuration = player?.playerItemDurationWithoutSponsorSegments?.seconds else {
return Self.timePlaceholder
guard let withoutSegmentsDuration = player?.playerItemDurationWithoutSponsorSegments?.seconds else { return Self.timePlaceholder }
return withoutSegmentsDuration.formattedAsPlaybackTime(forceHours: forceHours) ?? Self.timePlaceholder
}
return withoutSegmentsDuration.formattedAsPlaybackTime(forceHours: forceHours) ?? Self.timePlaceholder
var lastSeekPlaybackTime: String {
guard let time = lastSeekTime else { return 0.formattedAsPlaybackTime(allowZero: true, forceHours: forceHours) ?? Self.timePlaceholder }
return time.seconds.formattedAsPlaybackTime(allowZero: true, forceHours: forceHours) ?? Self.timePlaceholder
}
var restoreSeekPlaybackTime: String {
guard let time = restoreSeekTime else { return Self.timePlaceholder }
return time.seconds.formattedAsPlaybackTime(allowZero: true, forceHours: forceHours) ?? Self.timePlaceholder
}
var gestureSeekDestinationTime: Double {
min(duration.seconds, max(0, gestureStart + gestureSeek))
}
var gestureSeekDestinationPlaybackTime: String {
guard gestureSeek != 0 else { return Self.timePlaceholder }
return gestureSeekDestinationTime.formattedAsPlaybackTime(allowZero: true, forceHours: forceHours) ?? Self.timePlaceholder
}
func onSeekGestureStart(completionHandler: (() -> Void)? = nil) {
player.backend.getTimeUpdates()
player.backend.updateControls {
self.gestureStart = self.currentTime.seconds
completionHandler?()
}
}
func onSeekGestureEnd() {
player.backend.updateControls()
player.backend.seek(to: gestureSeekDestinationTime, seekType: .userInteracted)
}
func registerSeek(at time: CMTime, type: SeekType, restore restoreTime: CMTime? = nil) {
DispatchQueue.main.async { [weak self] in
withAnimation {
self?.lastSeekTime = time
self?.lastSeekType = type
self?.restoreSeekTime = restoreTime
}
}
}
func restoreTime() {
guard let time = restoreSeekTime else { return }
switch lastSeekType {
case .segmentSkip:
player.restoreLastSkippedSegment()
default:
player?.backend.seek(to: time, seekType: .userInteracted)
}
}
func resetSeek() {
withAnimation {
lastSeekTime = nil
lastSeekType = nil
}
}
func reset() {
currentTime = .zero
duration = .zero
resetSeek()
gestureSeek = 0
}
}

View File

@ -97,6 +97,22 @@ extension Defaults.Keys {
static let playerSidebar = Key<PlayerSidebarSetting>("playerSidebar", default: PlayerSidebarSetting.defaultValue)
static let playerInstanceID = Key<Instance.ID?>("playerInstance")
#if os(iOS)
static let playerControlsLayoutDefault = UIDevice.current.userInterfaceIdiom == .pad ? PlayerControlsLayout.medium : .small
static let fullScreenPlayerControlsLayoutDefault = UIDevice.current.userInterfaceIdiom == .pad ? PlayerControlsLayout.medium : .small
#elseif os(tvOS)
static let playerControlsLayoutDefault = PlayerControlsLayout.veryLarge
static let fullScreenPlayerControlsLayoutDefault = PlayerControlsLayout.veryLarge
#else
static let playerControlsLayoutDefault = PlayerControlsLayout.medium
static let fullScreenPlayerControlsLayoutDefault = PlayerControlsLayout.medium
#endif
static let playerControlsLayout = Key<PlayerControlsLayout>("playerControlsLayout", default: playerControlsLayoutDefault)
static let fullScreenPlayerControlsLayout = Key<PlayerControlsLayout>("fullScreenPlayerControlsLayout", default: fullScreenPlayerControlsLayoutDefault)
static let horizontalPlayerGestureEnabled = Key<Bool>("horizontalPlayerGestureEnabled", default: true)
static let seekGestureSpeed = Key<Double>("seekGestureSpeed", default: 0.5)
static let showKeywords = Key<Bool>("showKeywords", default: false)
static let showHistoryInPlayer = Key<Bool>("showHistoryInPlayer", default: false)
#if !os(tvOS)

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,19 +30,61 @@ 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) {
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()
}
if model.presentingControls && !model.presentingOverlays {
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
@ -65,31 +108,47 @@ struct PlayerControls: View {
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)
.frame(maxWidth: 1000)
.padding(.bottom, 2)
}
}
.zIndex(1)
.padding(.top, 2)
.padding(.horizontal, 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,19 +163,43 @@ 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)
}
.opacity(player.liveStreamInAVPlayer ? 0 : 1)
.overlay(GeometryReader { proxy in
Color.clear
.onAppear {
self.size = proxy.size
}
.onChange(of: proxy.size) { size in
self.size = size
}
})
.frame(maxHeight: playerControlsLayout.timelineHeight)
#if !os(tvOS)
.gesture(
DragGesture(minimumDistance: 0)
.gesture(DragGesture(minimumDistance: 0).onEnded { value in
let target = (value.location.x / size.width) * units
self.playerTime.currentTime = .secondsInDefaultTimescale(target)
player.backend.seek(to: target, seekType: .userInteracted)
})
#endif
durationView
.padding(.leading, playerControlsLayout.timeTrailingEdgePadding)
.padding(.trailing, playerControlsLayout.timeLeadingEdgePadding)
.frame(minWidth: 30, alignment: .trailing)
}
#if !os(tvOS)
.highPriorityGesture(
DragGesture(minimumDistance: 5, coordinateSpace: .global)
.onChanged { value in
if !dragging {
controls.removeTimer()
@ -173,14 +215,13 @@ struct TimelineView: View {
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)
player.backend.seek(to: projectedValue, seekType: .userInteracted)
}
dragging = false
@ -190,31 +231,9 @@ struct TimelineView: View {
}
)
#endif
}
.opacity(player.liveStreamInAVPlayer ? 0 : 1)
.overlay(GeometryReader { proxy in
Color.clear
.onAppear {
self.size = proxy.size
}
.onChange(of: proxy.size) { size in
self.size = size
}
})
.frame(maxHeight: 20)
#if !os(tvOS)
.gesture(DragGesture(minimumDistance: 0).onEnded { value in
let target = (value.location.x / size.width) * units
self.playerTime.currentTime = .secondsInDefaultTimescale(target)
player.backend.seek(to: target)
})
#endif
durationView
.frame(minWidth: 30, alignment: .trailing)
}
.clipShape(RoundedRectangle(cornerRadius: 3))
.font(.system(size: 9).monospacedDigit())
.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,7 +117,16 @@ struct VideoDescription: View {
label.preferredMaxLayoutWidth = (detailsSize?.width ?? 330) - 30
label.URLColor = UIColor(Color.accentColor)
label.timestampColor = UIColor(Color.accentColor)
label.handleURLTap { url in
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) {
@ -129,7 +138,7 @@ struct VideoDescription: View {
parser.videoID == player.currentVideo?.videoID,
let time = parser.time
{
player.backend.seek(to: Double(time))
player.backend.seek(to: Double(time), seekType: .userInteracted)
return
} else if destination != nil {
urlToOpen = yatteeURL
@ -139,14 +148,9 @@ struct VideoDescription: View {
openURL(urlToOpen)
}
label.handleTimestampTap { timestamp in
player.backend.seek(to: timestamp.timeInterval)
}
}
}
func updatePreferredMaxLayoutWidth() {
label.preferredMaxLayoutWidth = (detailsSize?.width ?? 330) - 30
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,10 +163,8 @@ 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)
@ -202,6 +174,45 @@ struct VideoPlayerView: View {
#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,9 +291,10 @@ struct VideoPlayerView: View {
hoveringPlayer = hovering
hovering ? playerControls.show() : playerControls.hide()
}
#if os(iOS)
#if !os(tvOS)
.gesture(playerControls.presentingOverlays ? nil : playerDragGesture)
#elseif os(macOS)
#endif
#if os(macOS)
.onAppear(perform: {
NSEvent.addLocalMonitorForEvents(matching: [.mouseMoved]) {
hoverThrottle.execute {
@ -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)

View File

@ -7,6 +7,10 @@ struct PlayerSettings: View {
@Default(.playerSidebar) private var playerSidebar
@Default(.showHistoryInPlayer) private var showHistory
@Default(.playerControlsLayout) private var playerControlsLayout
@Default(.fullScreenPlayerControlsLayout) private var fullScreenPlayerControlsLayout
@Default(.horizontalPlayerGestureEnabled) private var horizontalPlayerGestureEnabled
@Default(.seekGestureSpeed) private var seekGestureSpeed
@Default(.showKeywords) private var showKeywords
@Default(.pauseOnHidingPlayer) private var pauseOnHidingPlayer
@Default(.closeLastItemOnPlaybackEnd) private var closeLastItemOnPlaybackEnd
@ -68,6 +72,18 @@ struct PlayerSettings: View {
systemControlsCommandsPicker
}
Section(header: SettingsHeader(text: "Controls"), footer: controlsLayoutFooter) {
#if !os(tvOS)
horizontalPlayerGestureEnabledToggle
SettingsHeader(text: "Seek gesture sensitivity", secondary: true)
seekGestureSpeedPicker
SettingsHeader(text: "Regular size", secondary: true)
playerControlsLayoutPicker
SettingsHeader(text: "Fullscreen size", secondary: true)
#endif
fullScreenPlayerControlsLayoutPicker
}
Section(header: SettingsHeader(text: "Interface")) {
#if os(iOS)
if idiom == .pad {
@ -150,6 +166,44 @@ struct PlayerSettings: View {
.modifier(SettingsPickerModifier())
}
private var horizontalPlayerGestureEnabledToggle: some View {
Toggle("Seek with horizontal swipe on video", isOn: $horizontalPlayerGestureEnabled)
}
private var seekGestureSpeedPicker: some View {
Picker("Seek gesture sensitivity", selection: $seekGestureSpeed) {
ForEach([1, 0.75, 0.66, 0.5, 0.33, 0.25, 0.1], id: \.self) { value in
Text(String(format: "%.0f%%", value * 100)).tag(value)
}
}
.disabled(!horizontalPlayerGestureEnabled)
.modifier(SettingsPickerModifier())
}
@ViewBuilder private var controlsLayoutFooter: some View {
#if os(iOS)
Text("Large and very large sizes are not suitable for all devices and using them may cause controls not to fit on the screen.")
#endif
}
private var playerControlsLayoutPicker: some View {
Picker("Regular Size", selection: $playerControlsLayout) {
ForEach(PlayerControlsLayout.allCases, id: \.self) { layout in
Text(layout.description).tag(layout.rawValue)
}
}
.modifier(SettingsPickerModifier())
}
private var fullScreenPlayerControlsLayoutPicker: some View {
Picker("Fullscreen Size", selection: $fullScreenPlayerControlsLayout) {
ForEach(PlayerControlsLayout.allCases, id: \.self) { layout in
Text(layout.description).tag(layout.rawValue)
}
}
.modifier(SettingsPickerModifier())
}
private var keywordsToggle: some View {
Toggle("Show keywords", isOn: $showKeywords)
}

View File

@ -227,7 +227,7 @@ struct SettingsView: View {
case .browsing:
return 400
case .player:
return 420
return 620
case .quality:
return 420
case .history:

View File

@ -80,7 +80,7 @@ struct VideoCell: View {
}
if !playNowContinues {
player.backend.seek(to: .zero)
player.backend.seek(to: .zero, seekType: .userInteracted)
}
player.play()

View File

@ -27,6 +27,9 @@ struct BrowserPlayerControls<Content: View, Toolbar: View>: View {
}
var body: some View {
#if os(tvOS)
return content
#else
// TODO: remove
#if DEBUG
if #available(iOS 15.0, macOS 12.0, *) {
@ -53,6 +56,7 @@ struct BrowserPlayerControls<Content: View, Toolbar: View>: View {
}
#endif
}
#endif
}
}

View File

@ -52,6 +52,9 @@
37001563271B1F250049C794 /* AccountsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37001562271B1F250049C794 /* AccountsModel.swift */; };
37001564271B1F250049C794 /* AccountsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37001562271B1F250049C794 /* AccountsModel.swift */; };
37001565271B1F250049C794 /* AccountsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37001562271B1F250049C794 /* AccountsModel.swift */; };
370015A928BBAE7F000149FD /* ProgressBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 370015A828BBAE7F000149FD /* ProgressBar.swift */; };
370015AA28BBAE7F000149FD /* ProgressBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 370015A828BBAE7F000149FD /* ProgressBar.swift */; };
370015AB28BBAE7F000149FD /* ProgressBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 370015A828BBAE7F000149FD /* ProgressBar.swift */; };
37030FF727B0347C00ECDDAA /* MPVPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37030FF627B0347C00ECDDAA /* MPVPlayerView.swift */; };
37030FF827B0347C00ECDDAA /* MPVPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37030FF627B0347C00ECDDAA /* MPVPlayerView.swift */; };
37030FF927B0347C00ECDDAA /* MPVPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37030FF627B0347C00ECDDAA /* MPVPlayerView.swift */; };
@ -266,6 +269,9 @@
3743CA52270F284F00E4D32B /* View+Borders.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3743CA51270F284F00E4D32B /* View+Borders.swift */; };
3743CA53270F284F00E4D32B /* View+Borders.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3743CA51270F284F00E4D32B /* View+Borders.swift */; };
3743CA54270F284F00E4D32B /* View+Borders.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3743CA51270F284F00E4D32B /* View+Borders.swift */; };
3744A96028B99ADD005DE0A7 /* PlayerControlsLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3744A95F28B99ADD005DE0A7 /* PlayerControlsLayout.swift */; };
3744A96128B99ADD005DE0A7 /* PlayerControlsLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3744A95F28B99ADD005DE0A7 /* PlayerControlsLayout.swift */; };
3744A96228B99ADD005DE0A7 /* PlayerControlsLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3744A95F28B99ADD005DE0A7 /* PlayerControlsLayout.swift */; };
374710052755291C00CE0F87 /* SearchField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374710042755291C00CE0F87 /* SearchField.swift */; };
374710062755291C00CE0F87 /* SearchField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374710042755291C00CE0F87 /* SearchField.swift */; };
3748186626A7627F0084E870 /* Video+Fixtures.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3748186526A7627F0084E870 /* Video+Fixtures.swift */; };
@ -301,6 +307,9 @@
374C0540272472C0009BDDBE /* PlayerSponsorBlock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374C053E272472C0009BDDBE /* PlayerSponsorBlock.swift */; };
374C0541272472C0009BDDBE /* PlayerSponsorBlock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374C053E272472C0009BDDBE /* PlayerSponsorBlock.swift */; };
374C0543272496E4009BDDBE /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374C0542272496E4009BDDBE /* AppDelegate.swift */; };
374DE88028BB896C0062BBF2 /* PlayerDragGesture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374DE87F28BB896C0062BBF2 /* PlayerDragGesture.swift */; };
374DE88128BB896C0062BBF2 /* PlayerDragGesture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374DE87F28BB896C0062BBF2 /* PlayerDragGesture.swift */; };
374DE88328BB8A280062BBF2 /* PlayerOrientation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374DE88228BB8A280062BBF2 /* PlayerOrientation.swift */; };
375168D62700FAFF008F96A6 /* Debounce.swift in Sources */ = {isa = PBXBuildFile; fileRef = 375168D52700FAFF008F96A6 /* Debounce.swift */; };
375168D72700FDB8008F96A6 /* Debounce.swift in Sources */ = {isa = PBXBuildFile; fileRef = 375168D52700FAFF008F96A6 /* Debounce.swift */; };
375168D82700FDB9008F96A6 /* Debounce.swift in Sources */ = {isa = PBXBuildFile; fileRef = 375168D52700FAFF008F96A6 /* Debounce.swift */; };
@ -542,6 +551,9 @@
379775952689365600DD52A8 /* Array+Next.swift in Sources */ = {isa = PBXBuildFile; fileRef = 379775922689365600DD52A8 /* Array+Next.swift */; };
3799AC0928B03CED001376F9 /* ActiveLabel in Frameworks */ = {isa = PBXBuildFile; productRef = 3799AC0828B03CED001376F9 /* ActiveLabel */; };
379B0253287A1CDF001015B5 /* OrientationTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 379B0252287A1CDF001015B5 /* OrientationTracker.swift */; };
379DC3D128BA4EB400B09677 /* Seek.swift in Sources */ = {isa = PBXBuildFile; fileRef = 379DC3D028BA4EB400B09677 /* Seek.swift */; };
379DC3D228BA4EB400B09677 /* Seek.swift in Sources */ = {isa = PBXBuildFile; fileRef = 379DC3D028BA4EB400B09677 /* Seek.swift */; };
379DC3D328BA4EB400B09677 /* Seek.swift in Sources */ = {isa = PBXBuildFile; fileRef = 379DC3D028BA4EB400B09677 /* Seek.swift */; };
379F141F289ECE7F00DE48B5 /* QualitySettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 379F141E289ECE7F00DE48B5 /* QualitySettings.swift */; };
379F1420289ECE7F00DE48B5 /* QualitySettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 379F141E289ECE7F00DE48B5 /* QualitySettings.swift */; };
379F1421289ECE7F00DE48B5 /* QualitySettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 379F141E289ECE7F00DE48B5 /* QualitySettings.swift */; };
@ -957,6 +969,7 @@
3700155A271B0D4D0049C794 /* PipedAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PipedAPI.swift; sourceTree = "<group>"; };
3700155E271B12DD0049C794 /* SiestaConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiestaConfiguration.swift; sourceTree = "<group>"; };
37001562271B1F250049C794 /* AccountsModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountsModel.swift; sourceTree = "<group>"; };
370015A828BBAE7F000149FD /* ProgressBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressBar.swift; sourceTree = "<group>"; };
37030FF627B0347C00ECDDAA /* MPVPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MPVPlayerView.swift; sourceTree = "<group>"; };
37030FFA27B0398000ECDDAA /* MPVClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MPVClient.swift; sourceTree = "<group>"; };
37030FFE27B04DCC00ECDDAA /* PlayerControls.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerControls.swift; sourceTree = "<group>"; };
@ -1045,6 +1058,7 @@
3743B86727216D3600261544 /* ChannelCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelCell.swift; sourceTree = "<group>"; };
3743CA4D270EFE3400E4D32B /* PlayerQueueRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerQueueRow.swift; sourceTree = "<group>"; };
3743CA51270F284F00E4D32B /* View+Borders.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Borders.swift"; sourceTree = "<group>"; };
3744A95F28B99ADD005DE0A7 /* PlayerControlsLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerControlsLayout.swift; sourceTree = "<group>"; };
374710042755291C00CE0F87 /* SearchField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchField.swift; sourceTree = "<group>"; };
3748186526A7627F0084E870 /* Video+Fixtures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Video+Fixtures.swift"; sourceTree = "<group>"; };
3748186926A764FB0084E870 /* Thumbnail+Fixtures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Thumbnail+Fixtures.swift"; sourceTree = "<group>"; };
@ -1063,6 +1077,8 @@
374C053E272472C0009BDDBE /* PlayerSponsorBlock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerSponsorBlock.swift; sourceTree = "<group>"; };
374C0542272496E4009BDDBE /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = macOS/AppDelegate.swift; sourceTree = SOURCE_ROOT; };
374C0544272496FD009BDDBE /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
374DE87F28BB896C0062BBF2 /* PlayerDragGesture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerDragGesture.swift; sourceTree = "<group>"; };
374DE88228BB8A280062BBF2 /* PlayerOrientation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerOrientation.swift; sourceTree = "<group>"; };
375168D52700FAFF008F96A6 /* Debounce.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Debounce.swift; sourceTree = "<group>"; };
3751B4B127836902000B7DF4 /* SearchPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchPage.swift; sourceTree = "<group>"; };
3751BA7D27E63F1D007B1A60 /* MPVOGLView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MPVOGLView.swift; sourceTree = "<group>"; };
@ -1137,6 +1153,7 @@
379775922689365600DD52A8 /* Array+Next.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+Next.swift"; sourceTree = "<group>"; };
37992DC726CC50BC003D4C27 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
379B0252287A1CDF001015B5 /* OrientationTracker.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OrientationTracker.swift; sourceTree = "<group>"; };
379DC3D028BA4EB400B09677 /* Seek.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Seek.swift; sourceTree = "<group>"; };
379F141E289ECE7F00DE48B5 /* QualitySettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QualitySettings.swift; sourceTree = "<group>"; };
37A3B15727255E7F000FB5EE /* Open in Yattee - macOS.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "Open in Yattee - macOS.appex"; sourceTree = BUILT_PRODUCTS_DIR; };
37A3B15927255E7F000FB5EE /* SafariWebExtensionHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafariWebExtensionHandler.swift; sourceTree = "<group>"; };
@ -1520,6 +1537,8 @@
37E8B0EB27B326C00024006F /* TimelineView.swift */,
37648B68286CF5F1003D330B /* TVControls.swift */,
37E80F3B287B107F00561799 /* VideoDetailsOverlay.swift */,
3744A95F28B99ADD005DE0A7 /* PlayerControlsLayout.swift */,
370015A828BBAE7F000149FD /* ProgressBar.swift */,
);
path = Controls;
sourceTree = "<group>";
@ -1560,6 +1579,7 @@
37EF9A75275BEB8E0043B585 /* CommentView.swift */,
37DD9DA22785BBC900539416 /* NoCommentsView.swift */,
375F740F289DC35A00747050 /* PlayerBackendView.swift */,
374DE87F28BB896C0062BBF2 /* PlayerDragGesture.swift */,
3703100127B0713600ECDDAA /* PlayerGestures.swift */,
373031F22838388A000CFD59 /* PlayerLayerView.swift */,
3743CA4D270EFE3400E4D32B /* PlayerQueueRow.swift */,
@ -1572,6 +1592,7 @@
37B81AF826D2C9A700675966 /* VideoPlayerSizeModifier.swift */,
37BE0BCE26A0E2D50092E2DB /* VideoPlayerView.swift */,
37F9619A27BD89E000058149 /* TapRecognizerViewModifier.swift */,
374DE88228BB8A280062BBF2 /* PlayerOrientation.swift */,
);
path = Player;
sourceTree = "<group>";
@ -1799,6 +1820,7 @@
3756C2A52861131100E4B059 /* NetworkState.swift */,
37F4AD1A28612B23004D0F66 /* OpeningStream.swift */,
37F4AD1E28612DFD004D0F66 /* Buffering.swift */,
379DC3D028BA4EB400B09677 /* Seek.swift */,
);
path = OSD;
sourceTree = "<group>";
@ -2757,10 +2779,12 @@
buildActionMask = 2147483647;
files = (
374710052755291C00CE0F87 /* SearchField.swift in Sources */,
374DE88028BB896C0062BBF2 /* PlayerDragGesture.swift in Sources */,
37ECED56289FE166002BC2C9 /* SafeArea.swift in Sources */,
37CEE4BD2677B670005A1EFE /* SingleAssetStream.swift in Sources */,
37C2211D27ADA33300305B41 /* MPVViewController.swift in Sources */,
371B7E612759706A00D21217 /* CommentsView.swift in Sources */,
379DC3D128BA4EB400B09677 /* Seek.swift in Sources */,
371B7E6A2759791900D21217 /* CommentsModel.swift in Sources */,
37E8B0F027B326F30024006F /* Comparable+Clamped.swift in Sources */,
37CC3F45270CE30600608308 /* PlayerQueueItem.swift in Sources */,
@ -2785,6 +2809,7 @@
37DD9DCB2785E28C00539416 /* UIView+Extensions.swift in Sources */,
3782B94F27553A6700990149 /* SearchSuggestions.swift in Sources */,
378E50FF26FE8EEE00F49626 /* AccountsMenuView.swift in Sources */,
374DE88328BB8A280062BBF2 /* PlayerOrientation.swift in Sources */,
37169AA62729E2CC0011DE61 /* AccountsBridge.swift in Sources */,
37C7A1DA267CACF50010EAD6 /* TrendingCountry.swift in Sources */,
37E80F40287B472300561799 /* ScrollContentBackground+Backport.swift in Sources */,
@ -2919,6 +2944,7 @@
3751BA8327E6914F007B1A60 /* ReturnYouTubeDislikeAPI.swift in Sources */,
373031F528383A89000CFD59 /* PiPDelegate.swift in Sources */,
37DD9DC62785D63A00539416 /* UIResponder+Extensions.swift in Sources */,
370015A928BBAE7F000149FD /* ProgressBar.swift in Sources */,
37C3A24927235FAA0087A57A /* ChannelPlaylistCell.swift in Sources */,
373CFACB26966264003CB2C6 /* SearchQuery.swift in Sources */,
37F7AB4D28A9361F00FB46B5 /* UIDevice+Cellular.swift in Sources */,
@ -2927,6 +2953,7 @@
37B2631A2735EAAB00FE0D40 /* FavoriteResourceObserver.swift in Sources */,
3748186E26A769D60084E870 /* DetailBadge.swift in Sources */,
377ABC4C286E6A78009C986F /* LocationsSettings.swift in Sources */,
3744A96028B99ADD005DE0A7 /* PlayerControlsLayout.swift in Sources */,
376BE50B27349108009AD608 /* BrowsingSettings.swift in Sources */,
37F0F4EE286F734400C06C2E /* AdvancedSettings.swift in Sources */,
37AAF2A026741C97007FC770 /* SubscriptionsView.swift in Sources */,
@ -3054,6 +3081,7 @@
378E510026FE8EEE00F49626 /* AccountsMenuView.swift in Sources */,
370F4FA927CC163A001B35DC /* PlayerBackend.swift in Sources */,
37141670267A8ACC006CA35D /* TrendingView.swift in Sources */,
379DC3D228BA4EB400B09677 /* Seek.swift in Sources */,
376BE50727347B57009AD608 /* SettingsHeader.swift in Sources */,
378AE93C274EDFB2006A4EE1 /* Backport.swift in Sources */,
37152EEB26EFEB95004FB96D /* LazyView.swift in Sources */,
@ -3086,6 +3114,7 @@
37EF9A77275BEB8E0043B585 /* CommentView.swift in Sources */,
37BF661D27308859008CCFB0 /* DropFavorite.swift in Sources */,
3748186F26A769D60084E870 /* DetailBadge.swift in Sources */,
3744A96128B99ADD005DE0A7 /* PlayerControlsLayout.swift in Sources */,
372915E72687E3B900F5A35B /* Defaults.swift in Sources */,
37C3A242272359900087A57A /* Double+Format.swift in Sources */,
37B795912771DAE0001CF27B /* OpenURLHandler.swift in Sources */,
@ -3141,6 +3170,7 @@
371B7E5D27596B8400D21217 /* Comment.swift in Sources */,
37EF5C232739D37B00B03725 /* MenuModel.swift in Sources */,
37BC50A92778A84700510953 /* HistorySettings.swift in Sources */,
374DE88128BB896C0062BBF2 /* PlayerDragGesture.swift in Sources */,
37599F31272B42810087F250 /* FavoriteItem.swift in Sources */,
3730F75A2733481E00F385FC /* RelatedView.swift in Sources */,
37E04C0F275940FB00172673 /* VerticalScrollingFix.swift in Sources */,
@ -3176,6 +3206,7 @@
377ABC41286E4AD5009C986F /* InstancesManifest.swift in Sources */,
373CFAF02697A78B003CB2C6 /* AddToPlaylistView.swift in Sources */,
3763495226DFF59D00B9A393 /* AppSidebarRecents.swift in Sources */,
370015AA28BBAE7F000149FD /* ProgressBar.swift in Sources */,
371B7E672759786B00D21217 /* Comment+Fixtures.swift in Sources */,
3769C02F2779F18600DDB3EA /* PlaceholderProgressView.swift in Sources */,
37BA794426DBA973002A0235 /* PlaylistsModel.swift in Sources */,
@ -3244,6 +3275,7 @@
buildActionMask = 2147483647;
files = (
37579D5F27864F5F00FD0B98 /* Help.swift in Sources */,
370015AB28BBAE7F000149FD /* ProgressBar.swift in Sources */,
375EC95F289EEEE000751258 /* QualityProfile.swift in Sources */,
37EAD871267B9ED100D9E01B /* Segment.swift in Sources */,
373C8FE6275B955100CB5936 /* CommentsPage.swift in Sources */,
@ -3260,6 +3292,7 @@
37977585268922F600DD52A8 /* InvidiousAPI.swift in Sources */,
3769537928A877C4005D87C3 /* StreamControl.swift in Sources */,
3700155D271B0D4D0049C794 /* PipedAPI.swift in Sources */,
379DC3D328BA4EB400B09677 /* Seek.swift in Sources */,
375DFB5A26F9DA010013F468 /* InstancesModel.swift in Sources */,
3769C0302779F18600DDB3EA /* PlaceholderProgressView.swift in Sources */,
37F4AE7426828F0900BD60EA /* VerticalCells.swift in Sources */,
@ -3354,6 +3387,7 @@
37030FF927B0347C00ECDDAA /* MPVPlayerView.swift in Sources */,
37BA795126DC3E0E002A0235 /* Int+Format.swift in Sources */,
3748186C26A764FB0084E870 /* Thumbnail+Fixtures.swift in Sources */,
3744A96228B99ADD005DE0A7 /* PlayerControlsLayout.swift in Sources */,
377A20AB2693C9A2002842B8 /* TypedContentAccessors.swift in Sources */,
3748186826A7627F0084E870 /* Video+Fixtures.swift in Sources */,
37C3A253272366440087A57A /* ChannelPlaylistView.swift in Sources */,