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