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 {
|
private var playerControls: PlayerControlsModel {
|
||||||
PlayerControlsModel(presentingControls: false, presentingControlsOverlay: true, player: player)
|
PlayerControlsModel(presentingControls: true, presentingControlsOverlay: false, player: player)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var subscriptions: SubscriptionsModel {
|
private var subscriptions: SubscriptionsModel {
|
||||||
|
@ -145,7 +145,7 @@ final class AVPlayerBackend: PlayerBackend {
|
|||||||
avPlayer.replaceCurrentItem(with: nil)
|
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 }
|
guard !model.live else { return }
|
||||||
|
|
||||||
avPlayer.seek(
|
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) {
|
func setRate(_ rate: Float) {
|
||||||
avPlayer.rate = rate
|
avPlayer.rate = rate
|
||||||
}
|
}
|
||||||
@ -461,10 +455,11 @@ final class AVPlayerBackend: PlayerBackend {
|
|||||||
if self.model.activeBackend != .appleAVPlayer {
|
if self.model.activeBackend != .appleAVPlayer {
|
||||||
self.startPictureInPictureOnSwitch = true
|
self.startPictureInPictureOnSwitch = true
|
||||||
let seconds = self.model.mpvBackend.currentTime?.seconds ?? 0
|
let seconds = self.model.mpvBackend.currentTime?.seconds ?? 0
|
||||||
self.seek(to: seconds) { finished in
|
self.seek(to: seconds, seekType: .backendSync) { _ in
|
||||||
guard finished else { return }
|
DispatchQueue.main.async {
|
||||||
self.model.pause()
|
self.model.pause()
|
||||||
self.model.changeActiveBackend(from: .mpv, to: .appleAVPlayer, changingStream: false)
|
self.model.changeActiveBackend(from: .mpv, to: .appleAVPlayer, changingStream: false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -537,9 +532,7 @@ final class AVPlayerBackend: PlayerBackend {
|
|||||||
#endif
|
#endif
|
||||||
|
|
||||||
if self.controlsUpdates {
|
if self.controlsUpdates {
|
||||||
self.playerTime.duration = self.playerItemDuration ?? .zero
|
self.updateControls()
|
||||||
self.playerTime.currentTime = self.currentTime ?? .zero
|
|
||||||
self.model.objectWillChange.send()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -607,8 +600,6 @@ final class AVPlayerBackend: PlayerBackend {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateControls() {}
|
|
||||||
|
|
||||||
func startControlsUpdates() {
|
func startControlsUpdates() {
|
||||||
guard model.presentingPlayer, model.controls.presentingControls, !model.controls.presentingOverlays else {
|
guard model.presentingPlayer, model.controls.presentingControls, !model.controls.presentingOverlays else {
|
||||||
logger.info("ignored controls update start")
|
logger.info("ignored controls update start")
|
||||||
@ -680,6 +671,7 @@ final class AVPlayerBackend: PlayerBackend {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getTimeUpdates() {}
|
||||||
func setNeedsDrawing(_: Bool) {}
|
func setNeedsDrawing(_: Bool) {}
|
||||||
func setSize(_: Double, _: Double) {}
|
func setSize(_: Double, _: Double) {}
|
||||||
func setNeedsNetworkStateUpdates(_: Bool) {}
|
func setNeedsNetworkStateUpdates(_: Bool) {}
|
||||||
|
@ -8,7 +8,7 @@ import Repeat
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
final class MPVBackend: PlayerBackend {
|
final class MPVBackend: PlayerBackend {
|
||||||
static var controlsUpdateInterval = 0.5
|
static var timeUpdateInterval = 0.5
|
||||||
static var networkStateUpdateInterval = 1.0
|
static var networkStateUpdateInterval = 1.0
|
||||||
|
|
||||||
private var logger = Logger(label: "mpv-backend")
|
private var logger = Logger(label: "mpv-backend")
|
||||||
@ -131,8 +131,8 @@ final class MPVBackend: PlayerBackend {
|
|||||||
self.playerTime = playerTime
|
self.playerTime = playerTime
|
||||||
self.networkState = networkState
|
self.networkState = networkState
|
||||||
|
|
||||||
clientTimer = .init(interval: .seconds(Self.controlsUpdateInterval), mode: .infinite) { [weak self] _ in
|
clientTimer = .init(interval: .seconds(Self.timeUpdateInterval), mode: .infinite) { [weak self] _ in
|
||||||
self?.getClientUpdates()
|
self?.getTimeUpdates()
|
||||||
}
|
}
|
||||||
|
|
||||||
networkStateTimer = .init(interval: .seconds(Self.networkStateUpdateInterval), mode: .infinite) { [weak self] _ in
|
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,
|
let segment = self.model.sponsorBlock.segments.first,
|
||||||
self.model.lastSkipped.isNil
|
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 {
|
guard finished else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -299,17 +299,9 @@ final class MPVBackend: PlayerBackend {
|
|||||||
client?.stop()
|
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
|
client?.seek(to: time) { [weak self] _ in
|
||||||
self?.getClientUpdates()
|
self?.getTimeUpdates()
|
||||||
self?.updateControls()
|
|
||||||
completionHandler?(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func seek(relative time: CMTime, completionHandler: ((Bool) -> Void)? = nil) {
|
|
||||||
client?.seek(relative: time) { [weak self] _ in
|
|
||||||
self?.getClientUpdates()
|
|
||||||
self?.updateControls()
|
self?.updateControls()
|
||||||
completionHandler?(true)
|
completionHandler?(true)
|
||||||
}
|
}
|
||||||
@ -328,31 +320,6 @@ final class MPVBackend: PlayerBackend {
|
|||||||
|
|
||||||
func closePiP() {}
|
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() {
|
func startControlsUpdates() {
|
||||||
guard model.presentingPlayer, model.controls.presentingControls, !model.controls.presentingOverlays else {
|
guard model.presentingPlayer, model.controls.presentingControls, !model.controls.presentingOverlays else {
|
||||||
self.logger.info("ignored controls update start")
|
self.logger.info("ignored controls update start")
|
||||||
@ -373,7 +340,7 @@ final class MPVBackend: PlayerBackend {
|
|||||||
|
|
||||||
private var handleSegmentsThrottle = Throttle(interval: 1)
|
private var handleSegmentsThrottle = Throttle(interval: 1)
|
||||||
|
|
||||||
private func getClientUpdates() {
|
func getTimeUpdates() {
|
||||||
currentTime = client?.currentTime
|
currentTime = client?.currentTime
|
||||||
playerItemDuration = client?.duration
|
playerItemDuration = client?.duration
|
||||||
|
|
||||||
@ -458,8 +425,7 @@ final class MPVBackend: PlayerBackend {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
getClientUpdates()
|
getTimeUpdates()
|
||||||
|
|
||||||
eofPlaybackModeAction()
|
eofPlaybackModeAction()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,6 +1,9 @@
|
|||||||
import CoreMedia
|
import CoreMedia
|
||||||
import Defaults
|
import Defaults
|
||||||
import Foundation
|
import Foundation
|
||||||
|
#if !os(macOS)
|
||||||
|
import UIKit
|
||||||
|
#endif
|
||||||
|
|
||||||
protocol PlayerBackend {
|
protocol PlayerBackend {
|
||||||
var model: PlayerModel! { get set }
|
var model: PlayerModel! { get set }
|
||||||
@ -38,9 +41,8 @@ protocol PlayerBackend {
|
|||||||
|
|
||||||
func stop()
|
func stop()
|
||||||
|
|
||||||
func seek(to time: CMTime, completionHandler: ((Bool) -> Void)?)
|
func seek(to time: CMTime, seekType: PlayerTimeModel.SeekType, completionHandler: ((Bool) -> Void)?)
|
||||||
func seek(to seconds: Double, completionHandler: ((Bool) -> Void)?)
|
func seek(to seconds: Double, seekType: PlayerTimeModel.SeekType, completionHandler: ((Bool) -> Void)?)
|
||||||
func seek(relative time: CMTime, completionHandler: ((Bool) -> Void)?)
|
|
||||||
|
|
||||||
func setRate(_ rate: Float)
|
func setRate(_ rate: Float)
|
||||||
|
|
||||||
@ -51,7 +53,8 @@ protocol PlayerBackend {
|
|||||||
func startMusicMode()
|
func startMusicMode()
|
||||||
func stopMusicMode()
|
func stopMusicMode()
|
||||||
|
|
||||||
func updateControls()
|
func getTimeUpdates()
|
||||||
|
func updateControls(completionHandler: (() -> Void)?)
|
||||||
func startControlsUpdates()
|
func startControlsUpdates()
|
||||||
func stopControlsUpdates()
|
func stopControlsUpdates()
|
||||||
|
|
||||||
@ -64,16 +67,23 @@ protocol PlayerBackend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
extension PlayerBackend {
|
extension PlayerBackend {
|
||||||
func seek(to time: CMTime, completionHandler: ((Bool) -> Void)? = nil) {
|
func seek(to time: CMTime, seekType: PlayerTimeModel.SeekType, completionHandler: ((Bool) -> Void)? = nil) {
|
||||||
seek(to: time, completionHandler: completionHandler)
|
playerTime.registerSeek(at: time, type: seekType, restore: currentTime)
|
||||||
|
seek(to: time, seekType: seekType, completionHandler: completionHandler)
|
||||||
}
|
}
|
||||||
|
|
||||||
func seek(to seconds: Double, completionHandler: ((Bool) -> Void)? = nil) {
|
func seek(to seconds: Double, seekType: PlayerTimeModel.SeekType, completionHandler: ((Bool) -> Void)? = nil) {
|
||||||
seek(to: .secondsInDefaultTimescale(seconds), completionHandler: completionHandler)
|
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) {
|
func seek(relative time: CMTime, seekType: PlayerTimeModel.SeekType, completionHandler: ((Bool) -> Void)? = nil) {
|
||||||
seek(relative: time, completionHandler: completionHandler)
|
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() {
|
func eofPlaybackModeAction() {
|
||||||
@ -92,7 +102,7 @@ extension PlayerBackend {
|
|||||||
model.advanceToNextItem()
|
model.advanceToNextItem()
|
||||||
}
|
}
|
||||||
case .loopOne:
|
case .loopOne:
|
||||||
model.backend.seek(to: .zero) { _ in
|
model.backend.seek(to: .zero, seekType: .loopRestart) { _ in
|
||||||
self.model.play()
|
self.model.play()
|
||||||
}
|
}
|
||||||
case .related:
|
case .related:
|
||||||
@ -101,4 +111,27 @@ extension PlayerBackend {
|
|||||||
model.advanceToItem(item)
|
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?
|
var timer: Timer?
|
||||||
|
|
||||||
#if os(tvOS)
|
#if os(tvOS)
|
||||||
var reporter = PassthroughSubject<String, Never>()
|
private(set) var reporter = PassthroughSubject<String, Never>()
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
var player: PlayerModel!
|
var player: PlayerModel!
|
||||||
|
@ -98,7 +98,7 @@ final class PlayerModel: ObservableObject {
|
|||||||
|
|
||||||
@Published var queue = [PlayerQueueItem]() { didSet { handleQueueChange() } }
|
@Published var queue = [PlayerQueueItem]() { didSet { handleQueueChange() } }
|
||||||
@Published var currentItem: PlayerQueueItem! { didSet { handleCurrentItemChange() } }
|
@Published var currentItem: PlayerQueueItem! { didSet { handleCurrentItemChange() } }
|
||||||
@Published var videoBeingOpened: Video?
|
@Published var videoBeingOpened: Video? { didSet { playerTime.reset() } }
|
||||||
@Published var historyVideos = [Video]()
|
@Published var historyVideos = [Video]()
|
||||||
|
|
||||||
@Published var preservedTime: CMTime?
|
@Published var preservedTime: CMTime?
|
||||||
@ -505,7 +505,16 @@ final class PlayerModel: ObservableObject {
|
|||||||
self.backend.setNeedsDrawing(self.presentingPlayer)
|
self.backend.setNeedsDrawing(self.presentingPlayer)
|
||||||
}
|
}
|
||||||
|
|
||||||
controls.hide()
|
#if os(tvOS)
|
||||||
|
if presentingPlayer {
|
||||||
|
controls.show()
|
||||||
|
Delay.by(1) { [weak self] in
|
||||||
|
self?.controls.hide()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
controls.hide()
|
||||||
|
#endif
|
||||||
|
|
||||||
#if !os(macOS)
|
#if !os(macOS)
|
||||||
UIApplication.shared.isIdleTimerDisabled = presentingPlayer
|
UIApplication.shared.isIdleTimerDisabled = presentingPlayer
|
||||||
@ -531,6 +540,8 @@ final class PlayerModel: ObservableObject {
|
|||||||
|
|
||||||
logger.info("changing backend from \(from.rawValue) to \(to.rawValue)")
|
logger.info("changing backend from \(from.rawValue) to \(to.rawValue)")
|
||||||
|
|
||||||
|
let wasPlaying = isPlaying
|
||||||
|
|
||||||
if to == .mpv {
|
if to == .mpv {
|
||||||
closePiP()
|
closePiP()
|
||||||
}
|
}
|
||||||
@ -543,18 +554,22 @@ final class PlayerModel: ObservableObject {
|
|||||||
|
|
||||||
self.backend.didChangeTo()
|
self.backend.didChangeTo()
|
||||||
|
|
||||||
fromBackend.pause()
|
if wasPlaying {
|
||||||
|
fromBackend.pause()
|
||||||
|
}
|
||||||
|
|
||||||
guard var stream = stream, changingStream else {
|
guard var stream = stream, changingStream else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if let stream = toBackend.stream, toBackend.video == fromBackend.video {
|
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 {
|
guard finished else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
toBackend.play()
|
if wasPlaying {
|
||||||
|
toBackend.play()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
self.stream = stream
|
self.stream = stream
|
||||||
@ -764,17 +779,17 @@ final class PlayerModel: ObservableObject {
|
|||||||
skipBackwardCommand.preferredIntervals = preferredIntervals
|
skipBackwardCommand.preferredIntervals = preferredIntervals
|
||||||
|
|
||||||
skipForwardCommand.addTarget { [weak self] _ in
|
skipForwardCommand.addTarget { [weak self] _ in
|
||||||
self?.backend.seek(relative: .secondsInDefaultTimescale(10))
|
self?.backend.seek(relative: .secondsInDefaultTimescale(10), seekType: .userInteracted)
|
||||||
return .success
|
return .success
|
||||||
}
|
}
|
||||||
|
|
||||||
skipBackwardCommand.addTarget { [weak self] _ in
|
skipBackwardCommand.addTarget { [weak self] _ in
|
||||||
self?.backend.seek(relative: .secondsInDefaultTimescale(-10))
|
self?.backend.seek(relative: .secondsInDefaultTimescale(-10), seekType: .userInteracted)
|
||||||
return .success
|
return .success
|
||||||
}
|
}
|
||||||
|
|
||||||
previousTrackCommand.addTarget { [weak self] _ in
|
previousTrackCommand.addTarget { [weak self] _ in
|
||||||
self?.backend.seek(to: .zero)
|
self?.backend.seek(to: .zero, seekType: .userInteracted)
|
||||||
return .success
|
return .success
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -801,7 +816,7 @@ final class PlayerModel: ObservableObject {
|
|||||||
MPRemoteCommandCenter.shared().changePlaybackPositionCommand.addTarget { [weak self] remoteEvent in
|
MPRemoteCommandCenter.shared().changePlaybackPositionCommand.addTarget { [weak self] remoteEvent in
|
||||||
guard let event = remoteEvent as? MPChangePlaybackPositionCommandEvent else { return .commandFailed }
|
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
|
return .success
|
||||||
}
|
}
|
||||||
|
@ -49,7 +49,7 @@ extension PlayerModel {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
backend.seek(to: segment.endTime)
|
backend.seek(to: segment.endTime, seekType: .segmentSkip(segment.category))
|
||||||
|
|
||||||
DispatchQueue.main.async { [weak self] in
|
DispatchQueue.main.async { [weak self] in
|
||||||
withAnimation {
|
withAnimation {
|
||||||
@ -79,7 +79,7 @@ extension PlayerModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
restoredSegments.append(segment)
|
restoredSegments.append(segment)
|
||||||
backend.seek(to: time)
|
backend.seek(to: time, seekType: .segmentRestore)
|
||||||
resetLastSegment()
|
resetLastSegment()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,13 +1,35 @@
|
|||||||
import CoreMedia
|
import CoreMedia
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
final class PlayerTimeModel: ObservableObject {
|
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 = "--:--"
|
static let timePlaceholder = "--:--"
|
||||||
|
|
||||||
@Published var currentTime = CMTime.zero
|
@Published var currentTime = CMTime.zero
|
||||||
@Published var duration = 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 {
|
var forceHours: Bool {
|
||||||
duration.seconds >= 60 * 60
|
duration.seconds >= 60 * 60
|
||||||
@ -30,15 +52,73 @@ final class PlayerTimeModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var withoutSegmentsPlaybackTime: String {
|
var withoutSegmentsPlaybackTime: String {
|
||||||
guard let withoutSegmentsDuration = player?.playerItemDurationWithoutSponsorSegments?.seconds else {
|
guard let withoutSegmentsDuration = player?.playerItemDurationWithoutSponsorSegments?.seconds else { return Self.timePlaceholder }
|
||||||
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() {
|
func reset() {
|
||||||
currentTime = .zero
|
currentTime = .zero
|
||||||
duration = .zero
|
duration = .zero
|
||||||
|
resetSeek()
|
||||||
|
gestureSeek = 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -97,6 +97,22 @@ extension Defaults.Keys {
|
|||||||
|
|
||||||
static let playerSidebar = Key<PlayerSidebarSetting>("playerSidebar", default: PlayerSidebarSetting.defaultValue)
|
static let playerSidebar = Key<PlayerSidebarSetting>("playerSidebar", default: PlayerSidebarSetting.defaultValue)
|
||||||
static let playerInstanceID = Key<Instance.ID?>("playerInstance")
|
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 showKeywords = Key<Bool>("showKeywords", default: false)
|
||||||
static let showHistoryInPlayer = Key<Bool>("showHistoryInPlayer", default: false)
|
static let showHistoryInPlayer = Key<Bool>("showHistoryInPlayer", default: false)
|
||||||
#if !os(tvOS)
|
#if !os(tvOS)
|
||||||
|
@ -9,7 +9,7 @@ struct ChapterView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Button {
|
Button {
|
||||||
player.backend.seek(to: chapter.start)
|
player.backend.seek(to: chapter.start, seekType: .userInteracted)
|
||||||
} label: {
|
} label: {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
if !chapter.image.isNil {
|
if !chapter.image.isNil {
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import Defaults
|
||||||
import Foundation
|
import Foundation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
@ -5,6 +6,27 @@ struct Buffering: View {
|
|||||||
var reason = "Buffering stream..."
|
var reason = "Buffering stream..."
|
||||||
var state: String?
|
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 {
|
var body: some View {
|
||||||
VStack(spacing: 2) {
|
VStack(spacing: 2) {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
@ -17,10 +39,10 @@ struct Buffering: View {
|
|||||||
.progressViewStyle(.circular)
|
.progressViewStyle(.circular)
|
||||||
|
|
||||||
Text(reason)
|
Text(reason)
|
||||||
.font(.caption)
|
.font(.system(size: playerControlsLayout.timeFontSize))
|
||||||
if let state = state {
|
if let state = state {
|
||||||
Text(state)
|
Text(state)
|
||||||
.font(.caption2.monospacedDigit())
|
.font(.system(size: playerControlsLayout.bufferingStateFontSize).monospacedDigit())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(8)
|
.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
|
@Environment(\.verticalSizeClass) private var verticalSizeClass
|
||||||
#elseif os(tvOS)
|
#elseif os(tvOS)
|
||||||
enum Field: Hashable {
|
enum Field: Hashable {
|
||||||
|
case seekOSD
|
||||||
case play
|
case play
|
||||||
case backward
|
case backward
|
||||||
case forward
|
case forward
|
||||||
@ -29,67 +30,125 @@ struct PlayerControls: View {
|
|||||||
@Default(.closePlayerOnItemClose) private var closePlayerOnItemClose
|
@Default(.closePlayerOnItemClose) private var closePlayerOnItemClose
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
@Default(.playerControlsLayout) private var regularPlayerControlsLayout
|
||||||
|
@Default(.fullScreenPlayerControlsLayout) private var fullScreenPlayerControlsLayout
|
||||||
|
|
||||||
|
var playerControlsLayout: PlayerControlsLayout {
|
||||||
|
fullScreenLayout ? fullScreenPlayerControlsLayout : regularPlayerControlsLayout
|
||||||
|
}
|
||||||
|
|
||||||
init(player: PlayerModel, thumbnails: ThumbnailsModel) {
|
init(player: PlayerModel, thumbnails: ThumbnailsModel) {
|
||||||
self.player = player
|
self.player = player
|
||||||
self.thumbnails = thumbnails
|
self.thumbnails = thumbnails
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack(alignment: .center) {
|
ZStack(alignment: .topLeading) {
|
||||||
VStack {
|
Seek()
|
||||||
ZStack(alignment: .center) {
|
.zIndex(4)
|
||||||
OpeningStream()
|
.transition(.opacity)
|
||||||
NetworkState()
|
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||||
|
#if os(tvOS)
|
||||||
if model.presentingControls && !model.presentingOverlays {
|
.offset(x: 10, y: 5)
|
||||||
VStack(spacing: 4) {
|
.focused($focusedField, equals: .seekOSD)
|
||||||
#if !os(tvOS)
|
.onChange(of: player.playerTime.lastSeekTime) { _ in
|
||||||
buttonsBar
|
if !model.presentingControls {
|
||||||
|
focusedField = .seekOSD
|
||||||
HStack {
|
}
|
||||||
if !player.currentVideo.isNil, fullScreenLayout {
|
}
|
||||||
Button {
|
#else
|
||||||
withAnimation(Self.animation) {
|
.offset(y: 2)
|
||||||
model.presentingDetailsOverlay = true
|
#endif
|
||||||
}
|
|
||||||
} label: {
|
VStack {
|
||||||
ControlsBar(fullScreen: $model.presentingDetailsOverlay, presentingControls: false, detailsTogglePlayer: false, detailsToggleFullScreen: false)
|
ZStack(alignment: .center) {
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 4))
|
VStack(spacing: 0) {
|
||||||
.frame(maxWidth: 300, alignment: .leading)
|
ZStack {
|
||||||
}
|
OpeningStream()
|
||||||
.buttonStyle(.plain)
|
NetworkState()
|
||||||
}
|
}
|
||||||
Spacer()
|
|
||||||
}
|
Spacer()
|
||||||
#endif
|
}
|
||||||
|
.offset(y: playerControlsLayout.osdVerticalOffset + 5)
|
||||||
Spacer()
|
|
||||||
|
if model.presentingControls, !model.presentingOverlays {
|
||||||
Group {
|
#if !os(tvOS)
|
||||||
ZStack(alignment: .bottom) {
|
HStack {
|
||||||
floatingControls
|
seekBackwardButton
|
||||||
.padding(.top, 20)
|
Spacer()
|
||||||
.padding(4)
|
togglePlayButton
|
||||||
.modifier(ControlBackgroundModifier())
|
Spacer()
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 4))
|
seekForwardButton
|
||||||
|
}
|
||||||
timeline
|
.font(.system(size: playerControlsLayout.bigButtonFontSize))
|
||||||
.padding(4)
|
#endif
|
||||||
.offset(y: -25)
|
|
||||||
.zIndex(1)
|
ZStack(alignment: .bottom) {
|
||||||
}
|
VStack(spacing: 4) {
|
||||||
.frame(maxWidth: 500)
|
#if !os(tvOS)
|
||||||
.padding(.bottom, 2)
|
buttonsBar
|
||||||
}
|
|
||||||
}
|
HStack {
|
||||||
.padding(.top, 2)
|
if !player.currentVideo.isNil, fullScreenLayout {
|
||||||
.padding(.horizontal, 2)
|
Button {
|
||||||
.transition(.opacity)
|
withAnimation(Self.animation) {
|
||||||
|
model.presentingDetailsOverlay = true
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
ControlsBar(fullScreen: $model.presentingDetailsOverlay, presentingControls: false, detailsTogglePlayer: false, detailsToggleFullScreen: false)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 4))
|
||||||
|
.frame(maxWidth: 300, alignment: .leading)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
timeline
|
||||||
|
.frame(maxWidth: 1000)
|
||||||
|
.padding(.bottom, 2)
|
||||||
|
}
|
||||||
|
.zIndex(1)
|
||||||
|
.padding(.top, 2)
|
||||||
|
.transition(.opacity)
|
||||||
|
|
||||||
|
HStack(spacing: playerControlsLayout.buttonsSpacing) {
|
||||||
|
#if os(tvOS)
|
||||||
|
togglePlayButton
|
||||||
|
seekBackwardButton
|
||||||
|
seekForwardButton
|
||||||
|
#endif
|
||||||
|
restartVideoButton
|
||||||
|
advanceToNextItemButton
|
||||||
|
Spacer()
|
||||||
|
#if os(tvOS)
|
||||||
|
settingsButton
|
||||||
|
#endif
|
||||||
|
playbackModeButton
|
||||||
|
#if os(tvOS)
|
||||||
|
closeVideoButton
|
||||||
|
#else
|
||||||
|
musicModeButton
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
#if os(tvOS)
|
||||||
|
.frame(width: 1200)
|
||||||
|
#endif
|
||||||
|
.zIndex(0)
|
||||||
|
#if os(tvOS)
|
||||||
|
.offset(y: -playerControlsLayout.timelineHeight - 30)
|
||||||
|
#else
|
||||||
|
.offset(y: -playerControlsLayout.timelineHeight - 5)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxHeight: .infinity)
|
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
#if os(tvOS)
|
#if os(tvOS)
|
||||||
.onChange(of: model.presentingControls) { newValue in
|
.onChange(of: model.presentingControls) { newValue in
|
||||||
if newValue { focusedField = .play }
|
if newValue { focusedField = .play }
|
||||||
@ -108,31 +167,6 @@ struct PlayerControls: View {
|
|||||||
}
|
}
|
||||||
.frame(maxHeight: .infinity, alignment: .top)
|
.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
|
.onChange(of: model.presentingOverlays) { newValue in
|
||||||
if newValue {
|
if newValue {
|
||||||
@ -141,6 +175,7 @@ struct PlayerControls: View {
|
|||||||
}
|
}
|
||||||
#if os(tvOS)
|
#if os(tvOS)
|
||||||
.onReceive(model.reporter) { value in
|
.onReceive(model.reporter) { value in
|
||||||
|
guard player.presentingPlayer else { return }
|
||||||
if value == "swipe down", !model.presentingControls, !model.presentingOverlays {
|
if value == "swipe down", !model.presentingControls, !model.presentingOverlays {
|
||||||
withAnimation(Self.animation) {
|
withAnimation(Self.animation) {
|
||||||
model.presentingControlsOverlay = true
|
model.presentingControlsOverlay = true
|
||||||
@ -225,7 +260,7 @@ struct PlayerControls: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var buttonsBar: some View {
|
var buttonsBar: some View {
|
||||||
HStack(spacing: 20) {
|
HStack(spacing: playerControlsLayout.buttonsSpacing) {
|
||||||
fullscreenButton
|
fullscreenButton
|
||||||
|
|
||||||
pipButton
|
pipButton
|
||||||
@ -273,7 +308,7 @@ struct PlayerControls: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var musicModeButton: some 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 {
|
private var pipButton: some View {
|
||||||
@ -299,43 +334,25 @@ struct PlayerControls: View {
|
|||||||
}
|
}
|
||||||
#endif
|
#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 {
|
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()
|
player.playbackMode = player.playbackMode.next()
|
||||||
model.objectWillChange.send()
|
model.objectWillChange.send()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var seekBackwardButton: some View {
|
var seekBackwardButton: some View {
|
||||||
button("Seek Backward", systemImage: "gobackward.10", size: 25, cornerRadius: 5, background: false) {
|
var foregroundColor: Color?
|
||||||
player.backend.seek(relative: .secondsInDefaultTimescale(-10))
|
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)
|
.disabled(player.liveStreamInAVPlayer)
|
||||||
#if os(tvOS)
|
#if os(tvOS)
|
||||||
@ -347,8 +364,17 @@ struct PlayerControls: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var seekForwardButton: some View {
|
var seekForwardButton: some View {
|
||||||
button("Seek Forward", systemImage: "goforward.10", size: 25, cornerRadius: 5, background: false) {
|
var foregroundColor: Color?
|
||||||
player.backend.seek(relative: .secondsInDefaultTimescale(10))
|
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)
|
.disabled(player.liveStreamInAVPlayer)
|
||||||
#if os(tvOS)
|
#if os(tvOS)
|
||||||
@ -360,16 +386,27 @@ struct PlayerControls: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var restartVideoButton: some View {
|
private var restartVideoButton: some View {
|
||||||
button("Restart video", systemImage: "backward.end.fill", size: 25, cornerRadius: 5, background: false) {
|
button("Restart video", systemImage: "backward.end.fill", cornerRadius: 5) {
|
||||||
player.backend.seek(to: 0.0)
|
player.backend.seek(to: 0.0, seekType: .userInteracted)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var togglePlayButton: some View {
|
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",
|
model.isPlaying ? "Pause" : "Play",
|
||||||
systemImage: model.isPlaying ? "pause.fill" : "play.fill",
|
systemImage: model.isPlaying ? "pause.fill" : "play.fill",
|
||||||
size: 25, cornerRadius: 5, background: false
|
fontSize: fontSize,
|
||||||
|
size: size,
|
||||||
|
background: false, foregroundColor: foregroundColor
|
||||||
) {
|
) {
|
||||||
player.backend.togglePlay()
|
player.backend.togglePlay()
|
||||||
}
|
}
|
||||||
@ -383,7 +420,7 @@ struct PlayerControls: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var advanceToNextItemButton: some 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()
|
player.advanceToNextItem()
|
||||||
}
|
}
|
||||||
.disabled(!player.isAdvanceToNextItemAvailable)
|
.disabled(!player.isAdvanceToNextItemAvailable)
|
||||||
@ -392,11 +429,13 @@ struct PlayerControls: View {
|
|||||||
func button(
|
func button(
|
||||||
_ label: String,
|
_ label: String,
|
||||||
systemImage: String? = nil,
|
systemImage: String? = nil,
|
||||||
size: Double = 25,
|
fontSize: Double? = nil,
|
||||||
width: Double? = nil,
|
size: Double? = nil,
|
||||||
height: Double? = nil,
|
width _: Double? = nil,
|
||||||
|
height _: Double? = nil,
|
||||||
cornerRadius: Double = 3,
|
cornerRadius: Double = 3,
|
||||||
background: Bool = true,
|
background: Bool = true,
|
||||||
|
foregroundColor: Color? = nil,
|
||||||
active: Bool = false,
|
active: Bool = false,
|
||||||
action: @escaping () -> Void = {}
|
action: @escaping () -> Void = {}
|
||||||
) -> some View {
|
) -> some View {
|
||||||
@ -420,11 +459,12 @@ struct PlayerControls: View {
|
|||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
|
.shadow(radius: (foregroundColor == .white || !useBackground) ? 3 : 0)
|
||||||
}
|
}
|
||||||
.font(.system(size: 13))
|
.font(.system(size: fontSize ?? playerControlsLayout.buttonFontSize))
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.foregroundColor(active ? Color("AppRedColor") : .primary)
|
.foregroundColor(foregroundColor.isNil ? (active ? Color("AppRedColor") : .primary) : foregroundColor)
|
||||||
.frame(width: width ?? size, height: height ?? size)
|
.frame(width: size ?? playerControlsLayout.buttonSize, height: size ?? playerControlsLayout.buttonSize)
|
||||||
.modifier(ControlBackgroundModifier(enabled: useBackground))
|
.modifier(ControlBackgroundModifier(enabled: useBackground))
|
||||||
.clipShape(RoundedRectangle(cornerRadius: cornerRadius))
|
.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:)))
|
let downSwipe = UISwipeGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.handleSwipeDown(sender:)))
|
||||||
downSwipe.direction = .down
|
downSwipe.direction = .down
|
||||||
|
|
||||||
|
let tap = UITapGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.handleTap(sender:)))
|
||||||
|
|
||||||
controlsArea.addGestureRecognizer(leftSwipe)
|
controlsArea.addGestureRecognizer(leftSwipe)
|
||||||
controlsArea.addGestureRecognizer(rightSwipe)
|
controlsArea.addGestureRecognizer(rightSwipe)
|
||||||
controlsArea.addGestureRecognizer(upSwipe)
|
controlsArea.addGestureRecognizer(upSwipe)
|
||||||
controlsArea.addGestureRecognizer(downSwipe)
|
controlsArea.addGestureRecognizer(downSwipe)
|
||||||
|
controlsArea.addGestureRecognizer(tap)
|
||||||
|
|
||||||
let controls = UIHostingController(rootView: PlayerControls(player: player, thumbnails: thumbnails))
|
let controls = UIHostingController(rootView: PlayerControls(player: player, thumbnails: thumbnails))
|
||||||
controls.view.frame = .init(
|
controls.view.frame = .init(
|
||||||
@ -67,5 +70,11 @@ struct TVControls: UIViewRepresentable {
|
|||||||
@objc func handleSwipeDown(sender _: UISwipeGestureRecognizer) {
|
@objc func handleSwipeDown(sender _: UISwipeGestureRecognizer) {
|
||||||
model.reporter.send("swipe down")
|
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
|
import SwiftUI
|
||||||
|
|
||||||
struct TimelineView: View {
|
struct TimelineView: View {
|
||||||
@ -39,10 +40,29 @@ struct TimelineView: View {
|
|||||||
var thumbAreaWidth: Double = 40
|
var thumbAreaWidth: Double = 40
|
||||||
var context: Context
|
var context: Context
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
@Environment(\.verticalSizeClass) private var verticalSizeClass
|
||||||
|
#endif
|
||||||
|
|
||||||
@EnvironmentObject<PlayerModel> private var player
|
@EnvironmentObject<PlayerModel> private var player
|
||||||
@EnvironmentObject<PlayerControlsModel> private var controls
|
@EnvironmentObject<PlayerControlsModel> private var controls
|
||||||
@EnvironmentObject<PlayerTimeModel> private var playerTime
|
@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] {
|
var chapters: [Chapter] {
|
||||||
player.currentVideo?.chapters ?? []
|
player.currentVideo?.chapters ?? []
|
||||||
}
|
}
|
||||||
@ -64,23 +84,23 @@ struct TimelineView: View {
|
|||||||
let description = SponsorBlockAPI.categoryDescription(segment.category)
|
let description = SponsorBlockAPI.categoryDescription(segment.category)
|
||||||
{
|
{
|
||||||
Text(description)
|
Text(description)
|
||||||
.font(.system(size: 8))
|
.font(.system(size: playerControlsLayout.segmentFontSize))
|
||||||
.fixedSize()
|
.fixedSize()
|
||||||
.lineLimit(1)
|
|
||||||
.foregroundColor(Color("AppRedColor"))
|
.foregroundColor(Color("AppRedColor"))
|
||||||
}
|
}
|
||||||
if let chapter = projectedChapter {
|
if let chapter = projectedChapter {
|
||||||
Text(chapter.title)
|
Text(chapter.title)
|
||||||
.lineLimit(3)
|
.lineLimit(3)
|
||||||
.font(.system(size: 11).bold())
|
.font(.system(size: playerControlsLayout.chapterFontSize).bold())
|
||||||
.frame(maxWidth: 250)
|
.frame(maxWidth: player.playerSize.width - 100)
|
||||||
.fixedSize()
|
.fixedSize()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Text((dragging ? projectedValue : current).formattedAsPlaybackTime(allowZero: true, forceHours: playerTime.forceHours) ?? PlayerTimeModel.timePlaceholder)
|
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(.vertical, 3)
|
||||||
.padding(.horizontal, 8)
|
.padding(.horizontal, 8)
|
||||||
.background(
|
.background(
|
||||||
@ -90,7 +110,6 @@ struct TimelineView: View {
|
|||||||
|
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
}
|
}
|
||||||
.animation(.easeInOut(duration: 0.2))
|
|
||||||
.frame(maxHeight: 300, alignment: .bottom)
|
.frame(maxHeight: 300, alignment: .bottom)
|
||||||
.offset(x: thumbTooltipOffset)
|
.offset(x: thumbTooltipOffset)
|
||||||
.overlay(GeometryReader { proxy in
|
.overlay(GeometryReader { proxy in
|
||||||
@ -110,9 +129,8 @@ struct TimelineView: View {
|
|||||||
Text((dragging ? projectedValue : nil)?.formattedAsPlaybackTime(allowZero: true, forceHours: playerTime.forceHours) ?? playerTime.currentPlaybackTime)
|
Text((dragging ? projectedValue : nil)?.formattedAsPlaybackTime(allowZero: true, forceHours: playerTime.forceHours) ?? playerTime.currentPlaybackTime)
|
||||||
.opacity(player.liveStreamInAVPlayer ? 0 : 1)
|
.opacity(player.liveStreamInAVPlayer ? 0 : 1)
|
||||||
.frame(minWidth: 35)
|
.frame(minWidth: 35)
|
||||||
#if os(tvOS)
|
.padding(.leading, playerControlsLayout.timeLeadingEdgePadding)
|
||||||
.font(.system(size: 20))
|
.padding(.trailing, playerControlsLayout.timeTrailingEdgePadding)
|
||||||
#endif
|
|
||||||
|
|
||||||
ZStack(alignment: .center) {
|
ZStack(alignment: .center) {
|
||||||
ZStack(alignment: .leading) {
|
ZStack(alignment: .leading) {
|
||||||
@ -145,51 +163,15 @@ struct TimelineView: View {
|
|||||||
ZStack {
|
ZStack {
|
||||||
Circle()
|
Circle()
|
||||||
.fill(dragging ? .white : .gray)
|
.fill(dragging ? .white : .gray)
|
||||||
.frame(width: 13)
|
.frame(width: playerControlsLayout.thumbSize)
|
||||||
|
|
||||||
Circle()
|
Circle()
|
||||||
.fill(dragging ? .gray : .white)
|
.fill(dragging ? .gray : .white)
|
||||||
.frame(width: 11)
|
.frame(width: playerControlsLayout.thumbSize * 0.95)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.offset(x: thumbOffset)
|
.offset(x: thumbOffset)
|
||||||
.frame(width: thumbAreaWidth, height: thumbAreaWidth)
|
.frame(width: thumbAreaWidth, height: thumbAreaWidth)
|
||||||
|
|
||||||
#if !os(tvOS)
|
|
||||||
.gesture(
|
|
||||||
DragGesture(minimumDistance: 0)
|
|
||||||
.onChanged { value in
|
|
||||||
if !dragging {
|
|
||||||
controls.removeTimer()
|
|
||||||
draggedFrom = current
|
|
||||||
}
|
|
||||||
|
|
||||||
dragging = true
|
|
||||||
|
|
||||||
let drag = value.translation.width
|
|
||||||
let change = (drag / size.width) * units
|
|
||||||
let changedCurrent = current + change
|
|
||||||
|
|
||||||
guard changedCurrent >= start, changedCurrent <= duration else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
withAnimation(Animation.linear(duration: 0.2)) {
|
|
||||||
dragOffset = drag
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onEnded { _ in
|
|
||||||
if abs(dragOffset) > 0 {
|
|
||||||
playerTime.currentTime = .secondsInDefaultTimescale(projectedValue)
|
|
||||||
player.backend.seek(to: projectedValue)
|
|
||||||
}
|
|
||||||
|
|
||||||
dragging = false
|
|
||||||
dragOffset = 0.0
|
|
||||||
draggedFrom = 0.0
|
|
||||||
controls.resetTimer()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
.opacity(player.liveStreamInAVPlayer ? 0 : 1)
|
.opacity(player.liveStreamInAVPlayer ? 0 : 1)
|
||||||
.overlay(GeometryReader { proxy in
|
.overlay(GeometryReader { proxy in
|
||||||
@ -201,20 +183,57 @@ struct TimelineView: View {
|
|||||||
self.size = size
|
self.size = size
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.frame(maxHeight: 20)
|
.frame(maxHeight: playerControlsLayout.timelineHeight)
|
||||||
#if !os(tvOS)
|
#if !os(tvOS)
|
||||||
.gesture(DragGesture(minimumDistance: 0).onEnded { value in
|
.gesture(DragGesture(minimumDistance: 0).onEnded { value in
|
||||||
let target = (value.location.x / size.width) * units
|
let target = (value.location.x / size.width) * units
|
||||||
self.playerTime.currentTime = .secondsInDefaultTimescale(target)
|
self.playerTime.currentTime = .secondsInDefaultTimescale(target)
|
||||||
player.backend.seek(to: target)
|
player.backend.seek(to: target, seekType: .userInteracted)
|
||||||
})
|
})
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
durationView
|
durationView
|
||||||
|
.padding(.leading, playerControlsLayout.timeTrailingEdgePadding)
|
||||||
|
.padding(.trailing, playerControlsLayout.timeLeadingEdgePadding)
|
||||||
.frame(minWidth: 30, alignment: .trailing)
|
.frame(minWidth: 30, alignment: .trailing)
|
||||||
}
|
}
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 3))
|
#if !os(tvOS)
|
||||||
.font(.system(size: 9).monospacedDigit())
|
.highPriorityGesture(
|
||||||
|
DragGesture(minimumDistance: 5, coordinateSpace: .global)
|
||||||
|
.onChanged { value in
|
||||||
|
if !dragging {
|
||||||
|
controls.removeTimer()
|
||||||
|
draggedFrom = current
|
||||||
|
}
|
||||||
|
|
||||||
|
dragging = true
|
||||||
|
|
||||||
|
let drag = value.translation.width
|
||||||
|
let change = (drag / size.width) * units
|
||||||
|
let changedCurrent = current + change
|
||||||
|
|
||||||
|
guard changedCurrent >= start, changedCurrent <= duration else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dragOffset = drag
|
||||||
|
}
|
||||||
|
.onEnded { _ in
|
||||||
|
if abs(dragOffset) > 0 {
|
||||||
|
playerTime.currentTime = .secondsInDefaultTimescale(projectedValue)
|
||||||
|
player.backend.seek(to: projectedValue, seekType: .userInteracted)
|
||||||
|
}
|
||||||
|
|
||||||
|
dragging = false
|
||||||
|
dragOffset = 0.0
|
||||||
|
draggedFrom = 0.0
|
||||||
|
controls.resetTimer()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
#endif
|
||||||
|
.modifier(ControlBackgroundModifier())
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 4))
|
||||||
|
.font(.system(size: playerControlsLayout.timeFontSize).monospacedDigit())
|
||||||
.zIndex(2)
|
.zIndex(2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -230,7 +249,7 @@ struct TimelineView: View {
|
|||||||
} else {
|
} else {
|
||||||
Button {
|
Button {
|
||||||
if let duration = player.videoDuration {
|
if let duration = player.videoDuration {
|
||||||
player.backend.seek(to: duration - 5)
|
player.backend.seek(to: duration - 5, seekType: .userInteracted)
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Text("LIVE")
|
Text("LIVE")
|
||||||
@ -244,9 +263,6 @@ struct TimelineView: View {
|
|||||||
Text(dragging ? playerTime.durationPlaybackTime : playerTime.withoutSegmentsPlaybackTime)
|
Text(dragging ? playerTime.durationPlaybackTime : playerTime.withoutSegmentsPlaybackTime)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 3))
|
.clipShape(RoundedRectangle(cornerRadius: 3))
|
||||||
.frame(minWidth: 35)
|
.frame(minWidth: 35)
|
||||||
#if os(tvOS)
|
|
||||||
.font(.system(size: 20))
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -34,8 +34,6 @@ struct PlayerBackendView: View {
|
|||||||
.padding(.top, controlsTopPadding)
|
.padding(.top, controlsTopPadding)
|
||||||
.padding(.bottom, controlsBottomPadding)
|
.padding(.bottom, controlsBottomPadding)
|
||||||
#endif
|
#endif
|
||||||
#else
|
|
||||||
hiddenControlsButton
|
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
@ -72,22 +70,6 @@ struct PlayerBackendView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#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 {
|
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,
|
tapSensitivity: 0.2,
|
||||||
singleTapAction: { singleTapAction() },
|
singleTapAction: { singleTapAction() },
|
||||||
doubleTapAction: {
|
doubleTapAction: {
|
||||||
player.backend.seek(relative: .secondsInDefaultTimescale(-10))
|
player.backend.seek(relative: .secondsInDefaultTimescale(-10), seekType: .userInteracted)
|
||||||
},
|
},
|
||||||
anyTapAction: {
|
anyTapAction: {
|
||||||
model.update()
|
model.update()
|
||||||
@ -35,7 +35,7 @@ struct PlayerGestures: View {
|
|||||||
tapSensitivity: 0.2,
|
tapSensitivity: 0.2,
|
||||||
singleTapAction: { singleTapAction() },
|
singleTapAction: { singleTapAction() },
|
||||||
doubleTapAction: {
|
doubleTapAction: {
|
||||||
player.backend.seek(relative: .secondsInDefaultTimescale(10))
|
player.backend.seek(relative: .secondsInDefaultTimescale(10), seekType: .userInteracted)
|
||||||
},
|
},
|
||||||
anyTapAction: {
|
anyTapAction: {
|
||||||
model.update()
|
model.update()
|
||||||
|
69
Shared/Player/PlayerOrientation.swift
Normal file
69
Shared/Player/PlayerOrientation.swift
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
import Defaults
|
||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
extension VideoPlayerView {
|
||||||
|
func configureOrientationUpdatesBasedOnAccelerometer() {
|
||||||
|
let currentOrientation = OrientationTracker.shared.currentInterfaceOrientation
|
||||||
|
if currentOrientation.isLandscape,
|
||||||
|
Defaults[.enterFullscreenInLandscape],
|
||||||
|
!player.playingFullScreen,
|
||||||
|
!player.playingInPictureInPicture
|
||||||
|
{
|
||||||
|
guard player.presentingPlayer else { return }
|
||||||
|
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
playerControls.presentingControls = false
|
||||||
|
player.enterFullScreen(showControls: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
player.onPresentPlayer.append {
|
||||||
|
Orientation.lockOrientation(.allButUpsideDown, andRotateTo: currentOrientation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
orientationObserver = NotificationCenter.default.addObserver(
|
||||||
|
forName: OrientationTracker.deviceOrientationChangedNotification,
|
||||||
|
object: nil,
|
||||||
|
queue: .main
|
||||||
|
) { _ in
|
||||||
|
guard !Defaults[.honorSystemOrientationLock],
|
||||||
|
player.presentingPlayer,
|
||||||
|
!player.playingInPictureInPicture,
|
||||||
|
player.lockedOrientation.isNil
|
||||||
|
else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let orientation = OrientationTracker.shared.currentInterfaceOrientation
|
||||||
|
|
||||||
|
guard lastOrientation != orientation else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
lastOrientation = orientation
|
||||||
|
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
guard Defaults[.enterFullscreenInLandscape],
|
||||||
|
player.presentingPlayer
|
||||||
|
else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if orientation.isLandscape {
|
||||||
|
playerControls.presentingControls = false
|
||||||
|
player.enterFullScreen(showControls: false)
|
||||||
|
Orientation.lockOrientation(OrientationTracker.shared.currentInterfaceOrientationMask, andRotateTo: orientation)
|
||||||
|
} else {
|
||||||
|
player.exitFullScreen(showControls: false)
|
||||||
|
Orientation.lockOrientation(.allButUpsideDown, andRotateTo: .portrait)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopOrientationUpdates() {
|
||||||
|
guard let observer = orientationObserver else { return }
|
||||||
|
NotificationCenter.default.removeObserver(observer)
|
||||||
|
}
|
||||||
|
}
|
@ -117,37 +117,41 @@ struct VideoDescription: View {
|
|||||||
label.preferredMaxLayoutWidth = (detailsSize?.width ?? 330) - 30
|
label.preferredMaxLayoutWidth = (detailsSize?.width ?? 330) - 30
|
||||||
label.URLColor = UIColor(Color.accentColor)
|
label.URLColor = UIColor(Color.accentColor)
|
||||||
label.timestampColor = UIColor(Color.accentColor)
|
label.timestampColor = UIColor(Color.accentColor)
|
||||||
label.handleURLTap { url in
|
label.handleURLTap(urlTapHandler(_:))
|
||||||
var urlToOpen = url
|
label.handleTimestampTap(timestampTapHandler(_:))
|
||||||
|
|
||||||
if var components = URLComponents(url: url, resolvingAgainstBaseURL: false) {
|
|
||||||
components.scheme = "yattee"
|
|
||||||
if let yatteeURL = components.url {
|
|
||||||
let parser = URLParser(url: urlToOpen)
|
|
||||||
let destination = parser.destination
|
|
||||||
if destination == .video,
|
|
||||||
parser.videoID == player.currentVideo?.videoID,
|
|
||||||
let time = parser.time
|
|
||||||
{
|
|
||||||
player.backend.seek(to: Double(time))
|
|
||||||
return
|
|
||||||
} else if destination != nil {
|
|
||||||
urlToOpen = yatteeURL
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
openURL(urlToOpen)
|
|
||||||
}
|
|
||||||
label.handleTimestampTap { timestamp in
|
|
||||||
player.backend.seek(to: timestamp.timeInterval)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func updatePreferredMaxLayoutWidth() {
|
func updatePreferredMaxLayoutWidth() {
|
||||||
label.preferredMaxLayoutWidth = (detailsSize?.width ?? 330) - 30
|
label.preferredMaxLayoutWidth = (detailsSize?.width ?? 330) - 30
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func urlTapHandler(_ url: URL) {
|
||||||
|
var urlToOpen = url
|
||||||
|
|
||||||
|
if var components = URLComponents(url: url, resolvingAgainstBaseURL: false) {
|
||||||
|
components.scheme = "yattee"
|
||||||
|
if let yatteeURL = components.url {
|
||||||
|
let parser = URLParser(url: urlToOpen)
|
||||||
|
let destination = parser.destination
|
||||||
|
if destination == .video,
|
||||||
|
parser.videoID == player.currentVideo?.videoID,
|
||||||
|
let time = parser.time
|
||||||
|
{
|
||||||
|
player.backend.seek(to: Double(time), seekType: .userInteracted)
|
||||||
|
return
|
||||||
|
} else if destination != nil {
|
||||||
|
urlToOpen = yatteeURL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
openURL(urlToOpen)
|
||||||
|
}
|
||||||
|
|
||||||
|
func timestampTapHandler(_ timestamp: Timestamp) {
|
||||||
|
player.backend.seek(to: timestamp.timeInterval, seekType: .userInteracted)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
@ -14,6 +14,10 @@ struct VideoPlayerView: View {
|
|||||||
static let defaultSidebarQueueValue = Defaults[.playerSidebar] != .never
|
static let defaultSidebarQueueValue = Defaults[.playerSidebar] != .never
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
#if os(macOS)
|
||||||
|
static let hiddenOffset = 0.0
|
||||||
|
#endif
|
||||||
|
|
||||||
static let defaultAspectRatio = 16 / 9.0
|
static let defaultAspectRatio = 16 / 9.0
|
||||||
static var defaultMinimumHeightLeft: Double {
|
static var defaultMinimumHeightLeft: Double {
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
@ -35,27 +39,32 @@ struct VideoPlayerView: View {
|
|||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
@Environment(\.verticalSizeClass) private var verticalSizeClass
|
@Environment(\.verticalSizeClass) private var verticalSizeClass
|
||||||
|
|
||||||
@State private var orientation = UIInterfaceOrientation.portrait
|
@State internal var orientation = UIInterfaceOrientation.portrait
|
||||||
@State private var lastOrientation: UIInterfaceOrientation?
|
@State internal var lastOrientation: UIInterfaceOrientation?
|
||||||
#elseif os(macOS)
|
#elseif os(macOS)
|
||||||
var hoverThrottle = Throttle(interval: 0.5)
|
var hoverThrottle = Throttle(interval: 0.5)
|
||||||
var mouseLocation: CGPoint { NSEvent.mouseLocation }
|
var mouseLocation: CGPoint { NSEvent.mouseLocation }
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
#if os(iOS)
|
#if !os(tvOS)
|
||||||
@GestureState private var dragGestureState = false
|
@GestureState internal var dragGestureState = false
|
||||||
@GestureState private var dragGestureOffset = CGSize.zero
|
@GestureState internal var dragGestureOffset = CGSize.zero
|
||||||
@State private var viewDragOffset = Self.hiddenOffset
|
@State internal var isHorizontalDrag = false
|
||||||
@State private var orientationObserver: Any?
|
@State internal var isVerticalDrag = false
|
||||||
|
@State internal var viewDragOffset = Self.hiddenOffset
|
||||||
|
@State internal var orientationObserver: Any?
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
@EnvironmentObject<AccountsModel> private var accounts
|
@EnvironmentObject<AccountsModel> internal var accounts
|
||||||
@EnvironmentObject<NavigationModel> private var navigation
|
@EnvironmentObject<NavigationModel> internal var navigation
|
||||||
@EnvironmentObject<PlayerModel> private var player
|
@EnvironmentObject<PlayerModel> internal var player
|
||||||
@EnvironmentObject<PlayerControlsModel> private var playerControls
|
@EnvironmentObject<PlayerControlsModel> internal var playerControls
|
||||||
@EnvironmentObject<RecentsModel> private var recents
|
@EnvironmentObject<RecentsModel> internal var recents
|
||||||
@EnvironmentObject<SearchModel> private var search
|
@EnvironmentObject<SearchModel> internal var search
|
||||||
@EnvironmentObject<ThumbnailsModel> private var thumbnails
|
@EnvironmentObject<ThumbnailsModel> internal var thumbnails
|
||||||
|
|
||||||
|
@Default(.horizontalPlayerGestureEnabled) var horizontalPlayerGestureEnabled
|
||||||
|
@Default(.seekGestureSpeed) var seekGestureSpeed
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack(alignment: overlayAlignment) {
|
ZStack(alignment: overlayAlignment) {
|
||||||
@ -65,42 +74,7 @@ struct VideoPlayerView: View {
|
|||||||
.gesture(playerControls.presentingControlsOverlay ? videoPlayerCloseControlsOverlayGesture : nil)
|
.gesture(playerControls.presentingControlsOverlay ? videoPlayerCloseControlsOverlayGesture : nil)
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
VStack {
|
overlay
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.animation(nil, value: player.playerSize)
|
.animation(nil, value: player.playerSize)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
@ -189,19 +163,56 @@ struct VideoPlayerView: View {
|
|||||||
player.hide(animate: false)
|
player.hide(animate: false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
.compositingGroup()
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
.offset(y: playerOffset)
|
.offset(y: playerOffset)
|
||||||
.animation(dragGestureState ? .interactiveSpring(response: 0.05) : .easeOut(duration: 0.2), value: playerOffset)
|
.animation(dragGestureState ? .interactiveSpring(response: 0.05) : .easeOut(duration: 0.2), value: playerOffset)
|
||||||
.backport
|
.backport
|
||||||
.persistentSystemOverlays(!fullScreenLayout)
|
.persistentSystemOverlays(!fullScreenLayout)
|
||||||
#endif
|
#endif
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var overlay: some View {
|
||||||
|
VStack {
|
||||||
|
if playerControls.presentingControlsOverlay {
|
||||||
|
HStack {
|
||||||
|
HStack {
|
||||||
|
ControlsOverlay()
|
||||||
|
#if os(tvOS)
|
||||||
|
.onExitCommand {
|
||||||
|
withAnimation(PlayerControls.animation) {
|
||||||
|
playerControls.hideOverlays()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onPlayPauseCommand {
|
||||||
|
player.togglePlay()
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
.padding()
|
||||||
|
.modifier(ControlBackgroundModifier())
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 4))
|
||||||
|
}
|
||||||
|
#if !os(tvOS)
|
||||||
|
.frame(maxWidth: fullScreenLayout ? .infinity : player.playerSize.width)
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if !os(tvOS)
|
||||||
|
if !fullScreenLayout && sidebarQueue {
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
#if os(tvOS)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
|
#endif
|
||||||
|
.zIndex(1)
|
||||||
|
.transition(.opacity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var overlayWidth: Double {
|
var overlayWidth: Double {
|
||||||
guard playerSize.width.isFinite else { return 200 }
|
guard playerSize.width.isFinite else { return 200 }
|
||||||
return [playerSize.width - 50, 250].min()!
|
return [playerSize.width - 50, 250].min()!
|
||||||
@ -225,7 +236,7 @@ struct VideoPlayerView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var playerOffset: Double {
|
var playerOffset: Double {
|
||||||
dragGestureState ? dragGestureOffset.height : viewDragOffset
|
dragGestureState && !isHorizontalDrag ? dragGestureOffset.height : viewDragOffset
|
||||||
}
|
}
|
||||||
|
|
||||||
var playerWidth: Double? {
|
var playerWidth: Double? {
|
||||||
@ -280,23 +291,24 @@ struct VideoPlayerView: View {
|
|||||||
hoveringPlayer = hovering
|
hoveringPlayer = hovering
|
||||||
hovering ? playerControls.show() : playerControls.hide()
|
hovering ? playerControls.show() : playerControls.hide()
|
||||||
}
|
}
|
||||||
#if os(iOS)
|
#if !os(tvOS)
|
||||||
.gesture(playerControls.presentingOverlays ? nil : playerDragGesture)
|
.gesture(playerControls.presentingOverlays ? nil : playerDragGesture)
|
||||||
#elseif os(macOS)
|
#endif
|
||||||
.onAppear(perform: {
|
#if os(macOS)
|
||||||
NSEvent.addLocalMonitorForEvents(matching: [.mouseMoved]) {
|
.onAppear(perform: {
|
||||||
hoverThrottle.execute {
|
NSEvent.addLocalMonitorForEvents(matching: [.mouseMoved]) {
|
||||||
if !player.currentItem.isNil, hoveringPlayer {
|
hoverThrottle.execute {
|
||||||
playerControls.resetTimer()
|
if !player.currentItem.isNil, hoveringPlayer {
|
||||||
}
|
playerControls.resetTimer()
|
||||||
}
|
}
|
||||||
|
|
||||||
return $0
|
|
||||||
}
|
}
|
||||||
})
|
|
||||||
|
return $0
|
||||||
|
}
|
||||||
|
})
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
.background(Color.black)
|
.background(Color.black)
|
||||||
|
|
||||||
#if !os(tvOS)
|
#if !os(tvOS)
|
||||||
if !fullScreenLayout {
|
if !fullScreenLayout {
|
||||||
@ -338,10 +350,10 @@ struct VideoPlayerView: View {
|
|||||||
guard !playerControls.presentingControls else { return }
|
guard !playerControls.presentingControls else { return }
|
||||||
|
|
||||||
if direction == .left {
|
if direction == .left {
|
||||||
player.backend.seek(relative: .secondsInDefaultTimescale(-10))
|
player.backend.seek(relative: .secondsInDefaultTimescale(-10), seekType: .userInteracted)
|
||||||
}
|
}
|
||||||
if direction == .right {
|
if direction == .right {
|
||||||
player.backend.seek(relative: .secondsInDefaultTimescale(10))
|
player.backend.seek(relative: .secondsInDefaultTimescale(10), seekType: .userInteracted)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onPlayPauseCommand {
|
.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)
|
#if os(tvOS)
|
||||||
var tvControls: some View {
|
var tvControls: some View {
|
||||||
TVControls(model: playerControls, player: player, thumbnails: thumbnails)
|
TVControls(model: playerControls, player: player, thumbnails: thumbnails)
|
||||||
|
@ -7,6 +7,10 @@ struct PlayerSettings: View {
|
|||||||
|
|
||||||
@Default(.playerSidebar) private var playerSidebar
|
@Default(.playerSidebar) private var playerSidebar
|
||||||
@Default(.showHistoryInPlayer) private var showHistory
|
@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(.showKeywords) private var showKeywords
|
||||||
@Default(.pauseOnHidingPlayer) private var pauseOnHidingPlayer
|
@Default(.pauseOnHidingPlayer) private var pauseOnHidingPlayer
|
||||||
@Default(.closeLastItemOnPlaybackEnd) private var closeLastItemOnPlaybackEnd
|
@Default(.closeLastItemOnPlaybackEnd) private var closeLastItemOnPlaybackEnd
|
||||||
@ -68,6 +72,18 @@ struct PlayerSettings: View {
|
|||||||
systemControlsCommandsPicker
|
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")) {
|
Section(header: SettingsHeader(text: "Interface")) {
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
if idiom == .pad {
|
if idiom == .pad {
|
||||||
@ -150,6 +166,44 @@ struct PlayerSettings: View {
|
|||||||
.modifier(SettingsPickerModifier())
|
.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 {
|
private var keywordsToggle: some View {
|
||||||
Toggle("Show keywords", isOn: $showKeywords)
|
Toggle("Show keywords", isOn: $showKeywords)
|
||||||
}
|
}
|
||||||
|
@ -227,7 +227,7 @@ struct SettingsView: View {
|
|||||||
case .browsing:
|
case .browsing:
|
||||||
return 400
|
return 400
|
||||||
case .player:
|
case .player:
|
||||||
return 420
|
return 620
|
||||||
case .quality:
|
case .quality:
|
||||||
return 420
|
return 420
|
||||||
case .history:
|
case .history:
|
||||||
|
@ -80,7 +80,7 @@ struct VideoCell: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !playNowContinues {
|
if !playNowContinues {
|
||||||
player.backend.seek(to: .zero)
|
player.backend.seek(to: .zero, seekType: .userInteracted)
|
||||||
}
|
}
|
||||||
|
|
||||||
player.play()
|
player.play()
|
||||||
|
@ -27,32 +27,36 @@ struct BrowserPlayerControls<Content: View, Toolbar: View>: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
// TODO: remove
|
#if os(tvOS)
|
||||||
#if DEBUG
|
return content
|
||||||
if #available(iOS 15.0, macOS 12.0, *) {
|
#else
|
||||||
Self._printChanges()
|
// TODO: remove
|
||||||
}
|
#if DEBUG
|
||||||
#endif
|
if #available(iOS 15.0, macOS 12.0, *) {
|
||||||
|
Self._printChanges()
|
||||||
return ZStack(alignment: .bottomLeading) {
|
|
||||||
content
|
|
||||||
.frame(maxHeight: .infinity)
|
|
||||||
|
|
||||||
#if !os(tvOS)
|
|
||||||
VStack(spacing: 0) {
|
|
||||||
#if os(iOS)
|
|
||||||
toolbar
|
|
||||||
.frame(height: 35)
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.borderTop(height: 0.4, color: Color("ControlsBorderColor"))
|
|
||||||
.modifier(ControlBackgroundModifier())
|
|
||||||
#endif
|
|
||||||
|
|
||||||
ControlsBar(fullScreen: .constant(false))
|
|
||||||
.edgesIgnoringSafeArea(.bottom)
|
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
|
||||||
|
return ZStack(alignment: .bottomLeading) {
|
||||||
|
content
|
||||||
|
.frame(maxHeight: .infinity)
|
||||||
|
|
||||||
|
#if !os(tvOS)
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
#if os(iOS)
|
||||||
|
toolbar
|
||||||
|
.frame(height: 35)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.borderTop(height: 0.4, color: Color("ControlsBorderColor"))
|
||||||
|
.modifier(ControlBackgroundModifier())
|
||||||
|
#endif
|
||||||
|
|
||||||
|
ControlsBar(fullScreen: .constant(false))
|
||||||
|
.edgesIgnoringSafeArea(.bottom)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -52,6 +52,9 @@
|
|||||||
37001563271B1F250049C794 /* AccountsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37001562271B1F250049C794 /* AccountsModel.swift */; };
|
37001563271B1F250049C794 /* AccountsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37001562271B1F250049C794 /* AccountsModel.swift */; };
|
||||||
37001564271B1F250049C794 /* 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 */; };
|
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 */; };
|
37030FF727B0347C00ECDDAA /* MPVPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37030FF627B0347C00ECDDAA /* MPVPlayerView.swift */; };
|
||||||
37030FF827B0347C00ECDDAA /* 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 */; };
|
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 */; };
|
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 */; };
|
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 */; };
|
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 */; };
|
374710052755291C00CE0F87 /* SearchField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374710042755291C00CE0F87 /* SearchField.swift */; };
|
||||||
374710062755291C00CE0F87 /* 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 */; };
|
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 */; };
|
374C0540272472C0009BDDBE /* PlayerSponsorBlock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374C053E272472C0009BDDBE /* PlayerSponsorBlock.swift */; };
|
||||||
374C0541272472C0009BDDBE /* 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 */; };
|
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 */; };
|
375168D62700FAFF008F96A6 /* Debounce.swift in Sources */ = {isa = PBXBuildFile; fileRef = 375168D52700FAFF008F96A6 /* Debounce.swift */; };
|
||||||
375168D72700FDB8008F96A6 /* 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 */; };
|
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 */; };
|
379775952689365600DD52A8 /* Array+Next.swift in Sources */ = {isa = PBXBuildFile; fileRef = 379775922689365600DD52A8 /* Array+Next.swift */; };
|
||||||
3799AC0928B03CED001376F9 /* ActiveLabel in Frameworks */ = {isa = PBXBuildFile; productRef = 3799AC0828B03CED001376F9 /* ActiveLabel */; };
|
3799AC0928B03CED001376F9 /* ActiveLabel in Frameworks */ = {isa = PBXBuildFile; productRef = 3799AC0828B03CED001376F9 /* ActiveLabel */; };
|
||||||
379B0253287A1CDF001015B5 /* OrientationTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 379B0252287A1CDF001015B5 /* OrientationTracker.swift */; };
|
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 */; };
|
379F141F289ECE7F00DE48B5 /* QualitySettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 379F141E289ECE7F00DE48B5 /* QualitySettings.swift */; };
|
||||||
379F1420289ECE7F00DE48B5 /* 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 */; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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; };
|
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>"; };
|
37A3B15927255E7F000FB5EE /* SafariWebExtensionHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafariWebExtensionHandler.swift; sourceTree = "<group>"; };
|
||||||
@ -1520,6 +1537,8 @@
|
|||||||
37E8B0EB27B326C00024006F /* TimelineView.swift */,
|
37E8B0EB27B326C00024006F /* TimelineView.swift */,
|
||||||
37648B68286CF5F1003D330B /* TVControls.swift */,
|
37648B68286CF5F1003D330B /* TVControls.swift */,
|
||||||
37E80F3B287B107F00561799 /* VideoDetailsOverlay.swift */,
|
37E80F3B287B107F00561799 /* VideoDetailsOverlay.swift */,
|
||||||
|
3744A95F28B99ADD005DE0A7 /* PlayerControlsLayout.swift */,
|
||||||
|
370015A828BBAE7F000149FD /* ProgressBar.swift */,
|
||||||
);
|
);
|
||||||
path = Controls;
|
path = Controls;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@ -1560,6 +1579,7 @@
|
|||||||
37EF9A75275BEB8E0043B585 /* CommentView.swift */,
|
37EF9A75275BEB8E0043B585 /* CommentView.swift */,
|
||||||
37DD9DA22785BBC900539416 /* NoCommentsView.swift */,
|
37DD9DA22785BBC900539416 /* NoCommentsView.swift */,
|
||||||
375F740F289DC35A00747050 /* PlayerBackendView.swift */,
|
375F740F289DC35A00747050 /* PlayerBackendView.swift */,
|
||||||
|
374DE87F28BB896C0062BBF2 /* PlayerDragGesture.swift */,
|
||||||
3703100127B0713600ECDDAA /* PlayerGestures.swift */,
|
3703100127B0713600ECDDAA /* PlayerGestures.swift */,
|
||||||
373031F22838388A000CFD59 /* PlayerLayerView.swift */,
|
373031F22838388A000CFD59 /* PlayerLayerView.swift */,
|
||||||
3743CA4D270EFE3400E4D32B /* PlayerQueueRow.swift */,
|
3743CA4D270EFE3400E4D32B /* PlayerQueueRow.swift */,
|
||||||
@ -1572,6 +1592,7 @@
|
|||||||
37B81AF826D2C9A700675966 /* VideoPlayerSizeModifier.swift */,
|
37B81AF826D2C9A700675966 /* VideoPlayerSizeModifier.swift */,
|
||||||
37BE0BCE26A0E2D50092E2DB /* VideoPlayerView.swift */,
|
37BE0BCE26A0E2D50092E2DB /* VideoPlayerView.swift */,
|
||||||
37F9619A27BD89E000058149 /* TapRecognizerViewModifier.swift */,
|
37F9619A27BD89E000058149 /* TapRecognizerViewModifier.swift */,
|
||||||
|
374DE88228BB8A280062BBF2 /* PlayerOrientation.swift */,
|
||||||
);
|
);
|
||||||
path = Player;
|
path = Player;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@ -1799,6 +1820,7 @@
|
|||||||
3756C2A52861131100E4B059 /* NetworkState.swift */,
|
3756C2A52861131100E4B059 /* NetworkState.swift */,
|
||||||
37F4AD1A28612B23004D0F66 /* OpeningStream.swift */,
|
37F4AD1A28612B23004D0F66 /* OpeningStream.swift */,
|
||||||
37F4AD1E28612DFD004D0F66 /* Buffering.swift */,
|
37F4AD1E28612DFD004D0F66 /* Buffering.swift */,
|
||||||
|
379DC3D028BA4EB400B09677 /* Seek.swift */,
|
||||||
);
|
);
|
||||||
path = OSD;
|
path = OSD;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@ -2757,10 +2779,12 @@
|
|||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
374710052755291C00CE0F87 /* SearchField.swift in Sources */,
|
374710052755291C00CE0F87 /* SearchField.swift in Sources */,
|
||||||
|
374DE88028BB896C0062BBF2 /* PlayerDragGesture.swift in Sources */,
|
||||||
37ECED56289FE166002BC2C9 /* SafeArea.swift in Sources */,
|
37ECED56289FE166002BC2C9 /* SafeArea.swift in Sources */,
|
||||||
37CEE4BD2677B670005A1EFE /* SingleAssetStream.swift in Sources */,
|
37CEE4BD2677B670005A1EFE /* SingleAssetStream.swift in Sources */,
|
||||||
37C2211D27ADA33300305B41 /* MPVViewController.swift in Sources */,
|
37C2211D27ADA33300305B41 /* MPVViewController.swift in Sources */,
|
||||||
371B7E612759706A00D21217 /* CommentsView.swift in Sources */,
|
371B7E612759706A00D21217 /* CommentsView.swift in Sources */,
|
||||||
|
379DC3D128BA4EB400B09677 /* Seek.swift in Sources */,
|
||||||
371B7E6A2759791900D21217 /* CommentsModel.swift in Sources */,
|
371B7E6A2759791900D21217 /* CommentsModel.swift in Sources */,
|
||||||
37E8B0F027B326F30024006F /* Comparable+Clamped.swift in Sources */,
|
37E8B0F027B326F30024006F /* Comparable+Clamped.swift in Sources */,
|
||||||
37CC3F45270CE30600608308 /* PlayerQueueItem.swift in Sources */,
|
37CC3F45270CE30600608308 /* PlayerQueueItem.swift in Sources */,
|
||||||
@ -2785,6 +2809,7 @@
|
|||||||
37DD9DCB2785E28C00539416 /* UIView+Extensions.swift in Sources */,
|
37DD9DCB2785E28C00539416 /* UIView+Extensions.swift in Sources */,
|
||||||
3782B94F27553A6700990149 /* SearchSuggestions.swift in Sources */,
|
3782B94F27553A6700990149 /* SearchSuggestions.swift in Sources */,
|
||||||
378E50FF26FE8EEE00F49626 /* AccountsMenuView.swift in Sources */,
|
378E50FF26FE8EEE00F49626 /* AccountsMenuView.swift in Sources */,
|
||||||
|
374DE88328BB8A280062BBF2 /* PlayerOrientation.swift in Sources */,
|
||||||
37169AA62729E2CC0011DE61 /* AccountsBridge.swift in Sources */,
|
37169AA62729E2CC0011DE61 /* AccountsBridge.swift in Sources */,
|
||||||
37C7A1DA267CACF50010EAD6 /* TrendingCountry.swift in Sources */,
|
37C7A1DA267CACF50010EAD6 /* TrendingCountry.swift in Sources */,
|
||||||
37E80F40287B472300561799 /* ScrollContentBackground+Backport.swift in Sources */,
|
37E80F40287B472300561799 /* ScrollContentBackground+Backport.swift in Sources */,
|
||||||
@ -2919,6 +2944,7 @@
|
|||||||
3751BA8327E6914F007B1A60 /* ReturnYouTubeDislikeAPI.swift in Sources */,
|
3751BA8327E6914F007B1A60 /* ReturnYouTubeDislikeAPI.swift in Sources */,
|
||||||
373031F528383A89000CFD59 /* PiPDelegate.swift in Sources */,
|
373031F528383A89000CFD59 /* PiPDelegate.swift in Sources */,
|
||||||
37DD9DC62785D63A00539416 /* UIResponder+Extensions.swift in Sources */,
|
37DD9DC62785D63A00539416 /* UIResponder+Extensions.swift in Sources */,
|
||||||
|
370015A928BBAE7F000149FD /* ProgressBar.swift in Sources */,
|
||||||
37C3A24927235FAA0087A57A /* ChannelPlaylistCell.swift in Sources */,
|
37C3A24927235FAA0087A57A /* ChannelPlaylistCell.swift in Sources */,
|
||||||
373CFACB26966264003CB2C6 /* SearchQuery.swift in Sources */,
|
373CFACB26966264003CB2C6 /* SearchQuery.swift in Sources */,
|
||||||
37F7AB4D28A9361F00FB46B5 /* UIDevice+Cellular.swift in Sources */,
|
37F7AB4D28A9361F00FB46B5 /* UIDevice+Cellular.swift in Sources */,
|
||||||
@ -2927,6 +2953,7 @@
|
|||||||
37B2631A2735EAAB00FE0D40 /* FavoriteResourceObserver.swift in Sources */,
|
37B2631A2735EAAB00FE0D40 /* FavoriteResourceObserver.swift in Sources */,
|
||||||
3748186E26A769D60084E870 /* DetailBadge.swift in Sources */,
|
3748186E26A769D60084E870 /* DetailBadge.swift in Sources */,
|
||||||
377ABC4C286E6A78009C986F /* LocationsSettings.swift in Sources */,
|
377ABC4C286E6A78009C986F /* LocationsSettings.swift in Sources */,
|
||||||
|
3744A96028B99ADD005DE0A7 /* PlayerControlsLayout.swift in Sources */,
|
||||||
376BE50B27349108009AD608 /* BrowsingSettings.swift in Sources */,
|
376BE50B27349108009AD608 /* BrowsingSettings.swift in Sources */,
|
||||||
37F0F4EE286F734400C06C2E /* AdvancedSettings.swift in Sources */,
|
37F0F4EE286F734400C06C2E /* AdvancedSettings.swift in Sources */,
|
||||||
37AAF2A026741C97007FC770 /* SubscriptionsView.swift in Sources */,
|
37AAF2A026741C97007FC770 /* SubscriptionsView.swift in Sources */,
|
||||||
@ -3054,6 +3081,7 @@
|
|||||||
378E510026FE8EEE00F49626 /* AccountsMenuView.swift in Sources */,
|
378E510026FE8EEE00F49626 /* AccountsMenuView.swift in Sources */,
|
||||||
370F4FA927CC163A001B35DC /* PlayerBackend.swift in Sources */,
|
370F4FA927CC163A001B35DC /* PlayerBackend.swift in Sources */,
|
||||||
37141670267A8ACC006CA35D /* TrendingView.swift in Sources */,
|
37141670267A8ACC006CA35D /* TrendingView.swift in Sources */,
|
||||||
|
379DC3D228BA4EB400B09677 /* Seek.swift in Sources */,
|
||||||
376BE50727347B57009AD608 /* SettingsHeader.swift in Sources */,
|
376BE50727347B57009AD608 /* SettingsHeader.swift in Sources */,
|
||||||
378AE93C274EDFB2006A4EE1 /* Backport.swift in Sources */,
|
378AE93C274EDFB2006A4EE1 /* Backport.swift in Sources */,
|
||||||
37152EEB26EFEB95004FB96D /* LazyView.swift in Sources */,
|
37152EEB26EFEB95004FB96D /* LazyView.swift in Sources */,
|
||||||
@ -3086,6 +3114,7 @@
|
|||||||
37EF9A77275BEB8E0043B585 /* CommentView.swift in Sources */,
|
37EF9A77275BEB8E0043B585 /* CommentView.swift in Sources */,
|
||||||
37BF661D27308859008CCFB0 /* DropFavorite.swift in Sources */,
|
37BF661D27308859008CCFB0 /* DropFavorite.swift in Sources */,
|
||||||
3748186F26A769D60084E870 /* DetailBadge.swift in Sources */,
|
3748186F26A769D60084E870 /* DetailBadge.swift in Sources */,
|
||||||
|
3744A96128B99ADD005DE0A7 /* PlayerControlsLayout.swift in Sources */,
|
||||||
372915E72687E3B900F5A35B /* Defaults.swift in Sources */,
|
372915E72687E3B900F5A35B /* Defaults.swift in Sources */,
|
||||||
37C3A242272359900087A57A /* Double+Format.swift in Sources */,
|
37C3A242272359900087A57A /* Double+Format.swift in Sources */,
|
||||||
37B795912771DAE0001CF27B /* OpenURLHandler.swift in Sources */,
|
37B795912771DAE0001CF27B /* OpenURLHandler.swift in Sources */,
|
||||||
@ -3141,6 +3170,7 @@
|
|||||||
371B7E5D27596B8400D21217 /* Comment.swift in Sources */,
|
371B7E5D27596B8400D21217 /* Comment.swift in Sources */,
|
||||||
37EF5C232739D37B00B03725 /* MenuModel.swift in Sources */,
|
37EF5C232739D37B00B03725 /* MenuModel.swift in Sources */,
|
||||||
37BC50A92778A84700510953 /* HistorySettings.swift in Sources */,
|
37BC50A92778A84700510953 /* HistorySettings.swift in Sources */,
|
||||||
|
374DE88128BB896C0062BBF2 /* PlayerDragGesture.swift in Sources */,
|
||||||
37599F31272B42810087F250 /* FavoriteItem.swift in Sources */,
|
37599F31272B42810087F250 /* FavoriteItem.swift in Sources */,
|
||||||
3730F75A2733481E00F385FC /* RelatedView.swift in Sources */,
|
3730F75A2733481E00F385FC /* RelatedView.swift in Sources */,
|
||||||
37E04C0F275940FB00172673 /* VerticalScrollingFix.swift in Sources */,
|
37E04C0F275940FB00172673 /* VerticalScrollingFix.swift in Sources */,
|
||||||
@ -3176,6 +3206,7 @@
|
|||||||
377ABC41286E4AD5009C986F /* InstancesManifest.swift in Sources */,
|
377ABC41286E4AD5009C986F /* InstancesManifest.swift in Sources */,
|
||||||
373CFAF02697A78B003CB2C6 /* AddToPlaylistView.swift in Sources */,
|
373CFAF02697A78B003CB2C6 /* AddToPlaylistView.swift in Sources */,
|
||||||
3763495226DFF59D00B9A393 /* AppSidebarRecents.swift in Sources */,
|
3763495226DFF59D00B9A393 /* AppSidebarRecents.swift in Sources */,
|
||||||
|
370015AA28BBAE7F000149FD /* ProgressBar.swift in Sources */,
|
||||||
371B7E672759786B00D21217 /* Comment+Fixtures.swift in Sources */,
|
371B7E672759786B00D21217 /* Comment+Fixtures.swift in Sources */,
|
||||||
3769C02F2779F18600DDB3EA /* PlaceholderProgressView.swift in Sources */,
|
3769C02F2779F18600DDB3EA /* PlaceholderProgressView.swift in Sources */,
|
||||||
37BA794426DBA973002A0235 /* PlaylistsModel.swift in Sources */,
|
37BA794426DBA973002A0235 /* PlaylistsModel.swift in Sources */,
|
||||||
@ -3244,6 +3275,7 @@
|
|||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
37579D5F27864F5F00FD0B98 /* Help.swift in Sources */,
|
37579D5F27864F5F00FD0B98 /* Help.swift in Sources */,
|
||||||
|
370015AB28BBAE7F000149FD /* ProgressBar.swift in Sources */,
|
||||||
375EC95F289EEEE000751258 /* QualityProfile.swift in Sources */,
|
375EC95F289EEEE000751258 /* QualityProfile.swift in Sources */,
|
||||||
37EAD871267B9ED100D9E01B /* Segment.swift in Sources */,
|
37EAD871267B9ED100D9E01B /* Segment.swift in Sources */,
|
||||||
373C8FE6275B955100CB5936 /* CommentsPage.swift in Sources */,
|
373C8FE6275B955100CB5936 /* CommentsPage.swift in Sources */,
|
||||||
@ -3260,6 +3292,7 @@
|
|||||||
37977585268922F600DD52A8 /* InvidiousAPI.swift in Sources */,
|
37977585268922F600DD52A8 /* InvidiousAPI.swift in Sources */,
|
||||||
3769537928A877C4005D87C3 /* StreamControl.swift in Sources */,
|
3769537928A877C4005D87C3 /* StreamControl.swift in Sources */,
|
||||||
3700155D271B0D4D0049C794 /* PipedAPI.swift in Sources */,
|
3700155D271B0D4D0049C794 /* PipedAPI.swift in Sources */,
|
||||||
|
379DC3D328BA4EB400B09677 /* Seek.swift in Sources */,
|
||||||
375DFB5A26F9DA010013F468 /* InstancesModel.swift in Sources */,
|
375DFB5A26F9DA010013F468 /* InstancesModel.swift in Sources */,
|
||||||
3769C0302779F18600DDB3EA /* PlaceholderProgressView.swift in Sources */,
|
3769C0302779F18600DDB3EA /* PlaceholderProgressView.swift in Sources */,
|
||||||
37F4AE7426828F0900BD60EA /* VerticalCells.swift in Sources */,
|
37F4AE7426828F0900BD60EA /* VerticalCells.swift in Sources */,
|
||||||
@ -3354,6 +3387,7 @@
|
|||||||
37030FF927B0347C00ECDDAA /* MPVPlayerView.swift in Sources */,
|
37030FF927B0347C00ECDDAA /* MPVPlayerView.swift in Sources */,
|
||||||
37BA795126DC3E0E002A0235 /* Int+Format.swift in Sources */,
|
37BA795126DC3E0E002A0235 /* Int+Format.swift in Sources */,
|
||||||
3748186C26A764FB0084E870 /* Thumbnail+Fixtures.swift in Sources */,
|
3748186C26A764FB0084E870 /* Thumbnail+Fixtures.swift in Sources */,
|
||||||
|
3744A96228B99ADD005DE0A7 /* PlayerControlsLayout.swift in Sources */,
|
||||||
377A20AB2693C9A2002842B8 /* TypedContentAccessors.swift in Sources */,
|
377A20AB2693C9A2002842B8 /* TypedContentAccessors.swift in Sources */,
|
||||||
3748186826A7627F0084E870 /* Video+Fixtures.swift in Sources */,
|
3748186826A7627F0084E870 /* Video+Fixtures.swift in Sources */,
|
||||||
37C3A253272366440087A57A /* ChannelPlaylistView.swift in Sources */,
|
37C3A253272366440087A57A /* ChannelPlaylistView.swift in Sources */,
|
||||||
|
Loading…
Reference in New Issue
Block a user