mirror of
https://github.com/yattee/yattee.git
synced 2024-12-22 13:33:42 +00:00
Controls layouts, gestures and settings
This commit is contained in:
parent
5b785cc9c2
commit
0f7d826a3e
@ -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 {
|
||||
|
@ -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) {}
|
||||
|
@ -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()
|
||||
}
|
||||
|
||||
|
@ -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?()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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!
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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()
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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,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))
|
||||
}
|
||||
|
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,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
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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,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
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -227,7 +227,7 @@ struct SettingsView: View {
|
||||
case .browsing:
|
||||
return 400
|
||||
case .player:
|
||||
return 420
|
||||
return 620
|
||||
case .quality:
|
||||
return 420
|
||||
case .history:
|
||||
|
@ -80,7 +80,7 @@ struct VideoCell: View {
|
||||
}
|
||||
|
||||
if !playNowContinues {
|
||||
player.backend.seek(to: .zero)
|
||||
player.backend.seek(to: .zero, seekType: .userInteracted)
|
||||
}
|
||||
|
||||
player.play()
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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 */,
|
||||
|
Loading…
Reference in New Issue
Block a user