Player controls UI changes

WIP on controls

Chapters

working

Add previews variable

Add lists ids

WIP
This commit is contained in:
Arkadiusz Fal
2022-06-18 14:39:49 +02:00
parent 803ee776dc
commit 3e15e586e3
60 changed files with 2524 additions and 1320 deletions

View File

@@ -1,5 +1,6 @@
import Defaults
import Foundation
import SwiftUI
#if os(iOS)
import UIKit
#endif
@@ -11,6 +12,12 @@ extension Defaults.Keys {
static let defaultForPauseOnHidingPlayer = false
#endif
#if os(macOS)
static let defaultForPlayerDetailsPageButtonLabelStyle = PlayerDetailsPageButtonLabelStyle.iconAndText
#else
static let defaultForPlayerDetailsPageButtonLabelStyle = UIDevice.current.userInterfaceIdiom == .phone ? PlayerDetailsPageButtonLabelStyle.iconOnly : .iconAndText
#endif
static let kavinPipedInstanceID = "kavin-piped"
static let instances = Key<[Instance]>("instances", default: [
.init(
@@ -89,6 +96,10 @@ extension Defaults.Keys {
#endif
static let showMPVPlaybackStats = Key<Bool>("showMPVPlaybackStats", default: false)
static let playerDetailsPageButtonLabelStyle = Key<PlayerDetailsPageButtonLabelStyle>("playerDetailsPageButtonLabelStyle", default: defaultForPlayerDetailsPageButtonLabelStyle)
static let controlsBarInPlayer = Key<Bool>("controlsBarInPlayer", default: true)
}
enum ResolutionSetting: String, CaseIterable, Defaults.Serializable {
@@ -200,3 +211,11 @@ enum WatchedVideoPlayNowBehavior: String, Defaults.Serializable {
case info, separate
}
#endif
enum PlayerDetailsPageButtonLabelStyle: String, CaseIterable, Defaults.Serializable {
case iconOnly, iconAndText
var text: Bool {
self == .iconAndText
}
}

View File

@@ -70,7 +70,13 @@ struct FavoritesView: View {
struct Favorites_Previews: PreviewProvider {
static var previews: some View {
FavoritesView()
.injectFixtureEnvironmentObjects()
TabView {
FavoritesView()
.overlay(VideoPlayerView().injectFixtureEnvironmentObjects())
.injectFixtureEnvironmentObjects()
.tabItem {
Label("a", systemImage: "")
}
}
}
}

View File

@@ -14,7 +14,6 @@ struct AppSidebarNavigation: View {
@EnvironmentObject<InstancesModel> private var instances
@EnvironmentObject<NavigationModel> private var navigation
@EnvironmentObject<PlayerModel> private var player
@EnvironmentObject<PlayerControlsModel> private var playerControls
@EnvironmentObject<PlaylistsModel> private var playlists
@EnvironmentObject<RecentsModel> private var recents
@EnvironmentObject<SearchModel> private var search
@@ -50,15 +49,13 @@ struct AppSidebarNavigation: View {
.frame(minWidth: sidebarMinWidth)
VStack {
BrowserPlayerControls {
HStack(alignment: .center) {
Spacer()
Image(systemName: "4k.tv")
.renderingMode(.original)
.font(.system(size: 60))
.foregroundColor(.accentColor)
Spacer()
}
HStack(alignment: .center) {
Spacer()
Image(systemName: "4k.tv")
.renderingMode(.original)
.font(.system(size: 60))
.foregroundColor(.accentColor)
Spacer()
}
}
}

View File

@@ -7,7 +7,6 @@ struct AppTabNavigation: View {
@EnvironmentObject<InstancesModel> private var instances
@EnvironmentObject<NavigationModel> private var navigation
@EnvironmentObject<PlayerModel> private var player
@EnvironmentObject<PlayerControlsModel> private var playerControls
@EnvironmentObject<PlaylistsModel> private var playlists
@EnvironmentObject<RecentsModel> private var recents
@EnvironmentObject<SearchModel> private var search
@@ -130,20 +129,6 @@ struct AppTabNavigation: View {
.tag(TabSelection.search)
}
private var videoPlayer: some View {
VideoPlayerView()
.environmentObject(accounts)
.environmentObject(comments)
.environmentObject(instances)
.environmentObject(navigation)
.environmentObject(player)
.environmentObject(playerControls)
.environmentObject(playlists)
.environmentObject(recents)
.environmentObject(subscriptions)
.environmentObject(thumbnailsModel)
}
var toolbarContent: some ToolbarContent {
#if os(iOS)
Group {

View File

@@ -12,8 +12,10 @@ struct ContentView: View {
@EnvironmentObject<CommentsModel> private var comments
@EnvironmentObject<InstancesModel> private var instances
@EnvironmentObject<NavigationModel> private var navigation
@EnvironmentObject<NetworkStateModel> private var networkState
@EnvironmentObject<PlayerModel> private var player
@EnvironmentObject<PlayerControlsModel> private var playerControls
@EnvironmentObject<PlayerTimeModel> private var playerTime
@EnvironmentObject<PlaylistsModel> private var playlists
@EnvironmentObject<RecentsModel> private var recents
@EnvironmentObject<SearchModel> private var search
@@ -42,7 +44,6 @@ struct ContentView: View {
TVNavigationView()
#endif
}
.onAppear(perform: configure)
.onChange(of: accounts.signedIn) { _ in
subscriptions.load(force: true)
playlists.load(force: true)
@@ -52,7 +53,9 @@ struct ContentView: View {
.environmentObject(comments)
.environmentObject(instances)
.environmentObject(navigation)
.environmentObject(networkState)
.environmentObject(player)
.environmentObject(playerTime)
.environmentObject(playlists)
.environmentObject(recents)
.environmentObject(search)
@@ -107,118 +110,9 @@ struct ContentView: View {
secondaryButton: .cancel()
)
}
}
func configure() {
SiestaLog.Category.enabled = .common
SDImageCodersManager.shared.addCoder(SDImageWebPCoder.shared)
SDWebImageManager.defaultImageCache = PINCache(name: "stream.yattee.app")
#if !os(macOS)
setupNowPlayingInfoCenter()
#endif
#if os(iOS)
if Defaults[.lockPortraitWhenBrowsing] {
Orientation.lockOrientation(.portrait, andRotateTo: .portrait)
.alert(isPresented: $navigation.presentingAlert) {
Alert(title: Text(navigation.alertTitle), message: Text(navigation.alertMessage))
}
#endif
if let account = accounts.lastUsed ??
instances.lastUsed?.anonymousAccount ??
InstancesModel.all.first?.anonymousAccount
{
accounts.setCurrent(account)
}
if accounts.current.isNil {
navigation.presentingWelcomeScreen = true
}
playlists.accounts = accounts
search.accounts = accounts
subscriptions.accounts = accounts
comments.player = player
menu.accounts = accounts
menu.navigation = navigation
menu.player = player
playerControls.player = player
player.accounts = accounts
player.comments = comments
player.controls = playerControls
if !accounts.current.isNil {
player.restoreQueue()
}
if !Defaults[.saveRecents] {
recents.clear()
}
var section = Defaults[.visibleSections].min()?.tabSelection
#if os(macOS)
if section == .playlists {
section = .search
}
#endif
navigation.tabSelection = section ?? .search
subscriptions.load()
playlists.load()
}
func setupNowPlayingInfoCenter() {
#if !os(macOS)
try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .moviePlayback)
UIApplication.shared.beginReceivingRemoteControlEvents()
#endif
MPRemoteCommandCenter.shared().playCommand.addTarget { _ in
player.play()
return .success
}
MPRemoteCommandCenter.shared().pauseCommand.addTarget { _ in
player.pause()
return .success
}
MPRemoteCommandCenter.shared().previousTrackCommand.isEnabled = false
MPRemoteCommandCenter.shared().nextTrackCommand.isEnabled = false
MPRemoteCommandCenter.shared().changePlaybackPositionCommand.addTarget { remoteEvent in
guard let event = remoteEvent as? MPChangePlaybackPositionCommandEvent
else {
return .commandFailed
}
player.backend.seek(to: event.positionTime)
return .success
}
let skipForwardCommand = MPRemoteCommandCenter.shared().skipForwardCommand
skipForwardCommand.isEnabled = true
skipForwardCommand.preferredIntervals = [10]
skipForwardCommand.addTarget { _ in
player.backend.seek(relative: .secondsInDefaultTimescale(10))
return .success
}
let skipBackwardCommand = MPRemoteCommandCenter.shared().skipBackwardCommand
skipBackwardCommand.isEnabled = true
skipBackwardCommand.preferredIntervals = [10]
skipBackwardCommand.addTarget { _ in
player.backend.seek(relative: .secondsInDefaultTimescale(-10))
return .success
}
}
func openWelcomeScreenIfAccountEmpty() {

View File

@@ -0,0 +1,83 @@
import Foundation
import SDWebImageSwiftUI
import SwiftUI
struct ChaptersView: View {
@EnvironmentObject<PlayerModel> private var player
var body: some View {
List {
if let chapters = player.currentVideo?.chapters, !chapters.isEmpty {
Section(header: Text("Chapters")) {
ForEach(chapters) { chapter in
Button {
player.backend.seek(to: chapter.start)
} label: {
chapterButtonLabel(chapter)
}
.buttonStyle(.plain)
}
}
} else {
Text(player.currentVideo?.title ?? "")
}
}
.id(UUID())
#if os(macOS)
.listStyle(.inset)
#elseif os(iOS)
.listStyle(.grouped)
#else
.listStyle(.plain)
#endif
}
@ViewBuilder func chapterButtonLabel(_ chapter: Chapter) -> some View {
HStack(spacing: 12) {
if !chapter.image.isNil {
smallImage(chapter)
}
VStack(alignment: .leading, spacing: 4) {
Text(chapter.title)
.font(.headline)
Text(chapter.start.formattedAsPlaybackTime(allowZero: true) ?? "")
.font(.system(.subheadline).monospacedDigit())
.foregroundColor(.secondary)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
}
@ViewBuilder func smallImage(_ chapter: Chapter) -> some View {
WebImage(url: chapter.image)
.resizable()
.placeholder {
ProgressView()
}
.indicator(.activity)
#if os(tvOS)
.frame(width: thumbnailWidth, height: 140)
.mask(RoundedRectangle(cornerRadius: 12))
#else
.frame(width: thumbnailWidth, height: 60)
.mask(RoundedRectangle(cornerRadius: 6))
#endif
}
private var thumbnailWidth: Double {
#if os(tvOS)
250
#else
100
#endif
}
}
struct ChaptersView_Preview: PreviewProvider {
static var previews: some View {
ChaptersView()
.injectFixtureEnvironmentObjects()
}
}

View File

@@ -14,9 +14,6 @@ struct CommentsView: View {
NoCommentsView(text: "No comments", systemImage: "0.circle.fill")
} else if !comments.loaded {
PlaceholderProgressView()
.onAppear {
comments.load()
}
} else {
let last = comments.all.last
let commentsStack = LazyVStack {

View File

@@ -0,0 +1,22 @@
import Foundation
import SwiftUI
struct ControlBackgroundModifier: ViewModifier {
var enabled = true
var edgesIgnoringSafeArea = Edge.Set()
func body(content: Content) -> some View {
if enabled {
content
#if os(macOS)
.background(VisualEffectBlur(material: .hudWindow))
#elseif os(iOS)
.background(VisualEffectBlur(blurStyle: .systemThinMaterial).edgesIgnoringSafeArea(edgesIgnoringSafeArea))
#else
.background(.thinMaterial)
#endif
} else {
content
}
}
}

View File

@@ -0,0 +1,185 @@
import Defaults
import SwiftUI
struct ControlsOverlay: View {
@EnvironmentObject<PlayerModel> private var player
@EnvironmentObject<PlayerControlsModel> private var model
@Default(.showMPVPlaybackStats) private var showMPVPlaybackStats
var body: some View {
VStack(spacing: 6) {
HStack {
backendButtons
}
qualityButton
HStack {
decreaseRateButton
rateButton
increaseRateButton
}
.foregroundColor(.white)
if player.activeBackend == .mpv,
showMPVPlaybackStats
{
mpvPlaybackStats
}
}
}
private var backendButtons: some View {
ForEach(PlayerBackendType.allCases, id: \.self) { backend in
backendButton(backend)
}
}
private func backendButton(_ backend: PlayerBackendType) -> some View {
Button {
player.saveTime {
player.changeActiveBackend(from: player.activeBackend, to: backend)
model.resetTimer()
}
} label: {
Text(backend.label)
.padding(6)
.foregroundColor(player.activeBackend == backend ? .accentColor : .secondary)
}
.buttonStyle(.plain)
}
private var increaseRateButton: some View {
let increasedRate = PlayerModel.availableRates.first { $0 > player.currentRate }
return Button {
if let rate = increasedRate {
player.currentRate = rate
}
} label: {
Label("Increase rate", systemImage: "plus")
.labelStyle(.iconOnly)
.padding(.horizontal, 8)
.contentShape(Rectangle())
}
#if os(macOS)
.buttonStyle(.bordered)
#else
.frame(height: 30)
.modifier(ControlBackgroundModifier())
.clipShape(RoundedRectangle(cornerRadius: 4))
#endif
.disabled(increasedRate.isNil)
}
private var decreaseRateButton: some View {
let decreasedRate = PlayerModel.availableRates.last { $0 < player.currentRate }
return Button {
if let rate = decreasedRate {
player.currentRate = rate
}
} label: {
Label("Decrease rate", systemImage: "minus")
.labelStyle(.iconOnly)
.padding(.horizontal, 8)
.contentShape(Rectangle())
}
#if os(macOS)
.buttonStyle(.bordered)
#else
.frame(height: 30)
.modifier(ControlBackgroundModifier())
.clipShape(RoundedRectangle(cornerRadius: 4))
#endif
.disabled(decreasedRate.isNil)
}
@ViewBuilder private var qualityButton: some View {
#if os(macOS)
StreamControl()
.labelsHidden()
.frame(maxWidth: 300)
#elseif os(iOS)
Menu {
StreamControl()
.frame(width: 45, height: 30)
#if os(iOS)
.background(VisualEffectBlur(blurStyle: .systemThinMaterial))
#endif
.mask(RoundedRectangle(cornerRadius: 3))
} label: {
Text(player.streamSelection?.shortQuality ?? "loading")
.frame(width: 140, height: 30)
.foregroundColor(.primary)
}
.buttonStyle(.plain)
.foregroundColor(.primary)
.frame(width: 140, height: 30)
#if os(macOS)
.background(VisualEffectBlur(material: .hudWindow))
#elseif os(iOS)
.background(VisualEffectBlur(blurStyle: .systemThinMaterial))
#endif
.mask(RoundedRectangle(cornerRadius: 3))
#endif
}
@ViewBuilder private var rateButton: some View {
#if os(macOS)
ratePicker
.labelsHidden()
.frame(maxWidth: 100)
#elseif os(iOS)
Menu {
ratePicker
.frame(width: 100, height: 30)
.mask(RoundedRectangle(cornerRadius: 3))
} label: {
Text(player.rateLabel(player.currentRate))
.foregroundColor(.primary)
}
.buttonStyle(.plain)
.foregroundColor(.primary)
.frame(width: 100, height: 30)
.modifier(ControlBackgroundModifier())
.mask(RoundedRectangle(cornerRadius: 3))
#endif
}
var ratePicker: some View {
Picker("Rate", selection: rateBinding) {
ForEach(PlayerModel.availableRates, id: \.self) { rate in
Text(player.rateLabel(rate)).tag(rate)
}
}
.transaction { t in t.animation = .none }
}
private var rateBinding: Binding<Float> {
.init(get: { player.currentRate }, set: { rate in player.currentRate = rate })
}
var mpvPlaybackStats: some View {
Group {
VStack(alignment: .leading, spacing: 6) {
Text("hw decoder: \(player.mpvBackend.hwDecoder)")
Text("dropped: \(player.mpvBackend.frameDropCount)")
Text("video: \(String(format: "%.2ffps", player.mpvBackend.outputFps))")
Text("buffering: \(String(format: "%.0f%%", player.mpvBackend.bufferingState))")
Text("cache: \(String(format: "%.2fs", player.mpvBackend.cacheDuration))")
}
.mask(RoundedRectangle(cornerRadius: 3))
}
#if !os(tvOS)
.font(.system(size: 9))
#endif
}
}
struct ControlsOverlay_Previews: PreviewProvider {
static var previews: some View {
ControlsOverlay()
.environmentObject(PlayerModel())
.environmentObject(PlayerControlsModel())
}
}

View File

@@ -0,0 +1,37 @@
import Foundation
import SwiftUI
struct Buffering: View {
var reason = "Buffering stream..."
var state: String?
var body: some View {
VStack(spacing: 2) {
ProgressView()
#if os(macOS)
.scaleEffect(0.4)
#else
.scaleEffect(0.7)
#endif
.frame(maxHeight: 14)
.progressViewStyle(.circular)
Text(reason)
.font(.caption)
if let state = state {
Text(state)
.font(.caption2.monospacedDigit())
}
}
.padding(8)
.modifier(ControlBackgroundModifier())
.clipShape(RoundedRectangle(cornerRadius: 4))
.foregroundColor(.secondary)
}
}
struct Buffering_Previews: PreviewProvider {
static var previews: some View {
Buffering(state: "100% (2.95s)")
}
}

View File

@@ -0,0 +1,22 @@
import SwiftUI
struct NetworkState: View {
@EnvironmentObject<PlayerModel> private var player
@EnvironmentObject<NetworkStateModel> private var model
var body: some View {
Buffering(state: model.fullStateText)
.opacity(model.pausedForCache || player.isSeeking ? 1 : 0)
}
}
struct NetworkState_Previews: PreviewProvider {
static var previews: some View {
let networkState = NetworkStateModel()
networkState.bufferingState = 30
return NetworkState()
.environmentObject(networkState)
.environmentObject(PlayerModel())
}
}

View File

@@ -0,0 +1,37 @@
import SwiftUI
struct OpeningStream: View {
@EnvironmentObject<PlayerModel> private var player
@EnvironmentObject<NetworkStateModel> private var model
var body: some View {
Buffering(reason: reason, state: state)
.opacity(visible ? 1 : 0)
}
var visible: Bool {
(!player.currentItem.isNil && !player.videoBeingOpened.isNil) || (player.isLoadingVideo && !model.pausedForCache && !player.isSeeking)
}
var reason: String {
player.videoBeingOpened.isNil ? "Opening\(streamQuality)stream..." : "Loading streams..."
}
var state: String? {
player.videoBeingOpened.isNil ? model.bufferingStateText : nil
}
var streamQuality: String {
guard let stream = player.streamSelection else { return " " }
guard !player.musicMode else { return " audio " }
return " \(stream.shortQuality) "
}
}
struct OpeningStream_Previews: PreviewProvider {
static var previews: some View {
OpeningStream()
.injectFixtureEnvironmentObjects()
}
}

View File

@@ -23,7 +23,7 @@ struct PlayerControls: View {
@FocusState private var focusedField: Field?
#endif
@Default(.showMPVPlaybackStats) private var showMPVPlaybackStats
@Default(.controlsBarInPlayer) private var controlsBarInPlayer
init(player: PlayerModel, thumbnails: ThumbnailsModel) {
self.player = player
@@ -31,74 +31,107 @@ struct PlayerControls: View {
}
var body: some View {
VStack {
ZStack(alignment: .bottom) {
VStack(alignment: .trailing, spacing: 4) {
#if !os(tvOS)
buttonsBar
HStack(spacing: 4) {
qualityButton
backendButton
}
#else
Text(player.stream?.description ?? "")
#endif
Spacer()
mediumButtonsBar
Spacer()
ZStack(alignment: .topTrailing) {
VStack {
ZStack(alignment: .center) {
OpeningStream()
NetworkState()
Group {
if player.activeBackend == .mpv, showMPVPlaybackStats {
mpvPlaybackStats
}
VStack(spacing: 4) {
buttonsBar
timeline
.offset(y: 10)
.zIndex(1)
if let video = player.currentVideo, player.playingFullScreen {
// if let video = Video.fixture {
VStack(alignment: .leading, spacing: 8) {
Text(video.title)
.font(.title2.bold())
Text(video.author)
.font(.title3)
.foregroundColor(.secondary)
}
.padding(12)
.modifier(ControlBackgroundModifier())
.clipShape(RoundedRectangle(cornerRadius: 3))
.frame(maxWidth: .infinity, alignment: .leading)
}
HStack {
Spacer()
bottomBar
#if os(macOS)
.background(VisualEffectBlur(material: .hudWindow))
#elseif os(iOS)
.background(VisualEffectBlur(blurStyle: .systemThinMaterial))
#endif
.mask(RoundedRectangle(cornerRadius: 3))
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)
}
.padding(.horizontal, 16)
.opacity(model.presentingControlsOverlay ? 1 : model.presentingControls ? 1 : 0)
}
}
.padding(.top, 4)
.padding(.horizontal, 4)
.opacity(model.presentingControls ? 1 : 0)
}
#if os(tvOS)
.onChange(of: model.presentingControls) { _ in
if model.presentingControls {
focusedField = .play
#if os(tvOS)
.onChange(of: model.presentingControls) { _ in
if model.presentingControls {
focusedField = .play
}
}
.onChange(of: focusedField) { _ in
model.resetTimer()
}
#else
.background(PlayerGestures())
.background(controlsBackground)
#endif
ControlsOverlay()
.padding()
.modifier(ControlBackgroundModifier(enabled: true))
.clipShape(RoundedRectangle(cornerRadius: 4))
.offset(x: -2, y: 40)
.opacity(model.presentingControlsOverlay ? 1 : 0)
Button {
player.restoreLastSkippedSegment()
} label: {
HStack(spacing: 10) {
if let segment = player.lastSkipped {
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))
.offset(x: -2, y: -2)
}
.buttonStyle(.plain)
.opacity(model.presentingControls ? 0 : player.lastSkipped.isNil ? 0 : 1)
}
.onChange(of: focusedField) { _ in
model.resetTimer()
}
#else
.background(PlayerGestures())
.background(controlsBackground)
#endif
.environment(\.colorScheme, .dark)
}
@ViewBuilder var controlsBackground: some View {
if player.musicMode,
let item = self.player.currentItem,
let url = thumbnails.best(item.video)
let video = item.video,
let url = thumbnails.best(video)
{
WebImage(url: url)
.resizable()
@@ -110,48 +143,8 @@ struct PlayerControls: View {
}
}
var mpvPlaybackStats: some View {
HStack {
Group {
Text("hw decoder: \(player.mpvBackend.hwDecoder)")
Text("dropped: \(player.mpvBackend.frameDropCount)")
Text("video: \(String(format: "%.2ffps", player.mpvBackend.outputFps))")
Text("buffering: \(String(format: "%.0f%%", player.mpvBackend.bufferingState))")
Text("cache: \(String(format: "%.2fs", player.mpvBackend.cacheDuration))")
}
.padding(4)
#if os(macOS)
.background(VisualEffectBlur(material: .hudWindow))
#elseif os(iOS)
.background(VisualEffectBlur(blurStyle: .systemThinMaterial))
#else
.background(.thinMaterial)
#endif
.mask(RoundedRectangle(cornerRadius: 3))
Spacer()
}
#if !os(tvOS)
.font(.system(size: 9))
#endif
}
var timeline: some View {
TimelineView(duration: durationBinding, current: currentTimeBinding, cornerRadius: 0)
}
var durationBinding: Binding<Double> {
Binding<Double>(
get: { model.duration.seconds },
set: { value in model.duration = .secondsInDefaultTimescale(value) }
)
}
var currentTimeBinding: Binding<Double> {
Binding<Double>(
get: { model.currentTime.seconds },
set: { value in model.currentTime = .secondsInDefaultTimescale(value) }
)
TimelineView(context: .player).foregroundColor(.primary)
}
private var hidePlayerButton: some View {
@@ -195,20 +188,20 @@ struct PlayerControls: View {
}
var buttonsBar: some View {
HStack {
HStack(spacing: 20) {
#if !os(tvOS)
fullscreenButton
#if os(iOS)
pipButton
.padding(.leading, 5)
#endif
pipButton
Spacer()
rateButton
button("overlay", systemImage: "info.circle") {}
musicModeButton
button("settings", systemImage: "gearshape", active: model.presentingControlsOverlay) {
withAnimation(Self.animation) {
model.presentingControlsOverlay.toggle()
}
}
closeVideoButton
#endif
@@ -227,74 +220,6 @@ struct PlayerControls: View {
#endif
}
@ViewBuilder private var rateButton: some View {
#if os(macOS)
ratePicker
.labelsHidden()
.frame(maxWidth: 70)
#elseif os(iOS)
Menu {
ratePicker
.frame(width: 45, height: 30)
#if os(iOS)
.background(VisualEffectBlur(blurStyle: .systemThinMaterial))
#endif
.mask(RoundedRectangle(cornerRadius: 3))
} label: {
Text(player.rateLabel(player.currentRate))
.foregroundColor(.primary)
}
.buttonStyle(.plain)
.foregroundColor(.primary)
.frame(width: 50, height: 30)
#if os(macOS)
.background(VisualEffectBlur(material: .hudWindow))
#elseif os(iOS)
.background(VisualEffectBlur(blurStyle: .systemThinMaterial))
#endif
.mask(RoundedRectangle(cornerRadius: 3))
#endif
}
@ViewBuilder private var qualityButton: some View {
#if os(macOS)
StreamControl()
.labelsHidden()
.frame(maxWidth: 300)
#elseif os(iOS)
Menu {
StreamControl()
.frame(width: 45, height: 30)
#if os(iOS)
.background(VisualEffectBlur(blurStyle: .systemThinMaterial))
#endif
.mask(RoundedRectangle(cornerRadius: 3))
} label: {
Text(player.streamSelection?.shortQuality ?? "loading")
.frame(width: 140, height: 30)
.foregroundColor(.primary)
}
.buttonStyle(.plain)
.foregroundColor(.primary)
.frame(width: 140, height: 30)
#if os(macOS)
.background(VisualEffectBlur(material: .hudWindow))
#elseif os(iOS)
.background(VisualEffectBlur(blurStyle: .systemThinMaterial))
#endif
.mask(RoundedRectangle(cornerRadius: 3))
#endif
}
private var backendButton: some View {
button(player.activeBackend.label, width: 100) {
player.saveTime {
player.changeActiveBackend(from: player.activeBackend, to: player.activeBackend.next())
model.resetTimer()
}
}
}
private var closeVideoButton: some View {
button("Close", systemImage: "xmark") {
player.pause()
@@ -313,116 +238,99 @@ struct PlayerControls: View {
}
private var musicModeButton: some View {
button("Music Mode", systemImage: "music.note", active: player.musicMode, action: player.toggleMusicMode)
button("Music Mode", systemImage: "music.note", background: false, active: player.musicMode, action: player.toggleMusicMode)
.disabled(player.activeBackend == .appleAVPlayer)
}
var ratePicker: some View {
Picker("Rate", selection: rateBinding) {
ForEach(PlayerModel.availableRates, id: \.self) { rate in
Text(player.rateLabel(rate)).tag(rate)
}
}
.transaction { t in t.animation = .none }
}
private var rateBinding: Binding<Float> {
.init(get: { player.currentRate }, set: { rate in player.currentRate = rate })
}
private var pipButton: some View {
button("PiP", systemImage: "pip") {
model.startPiP()
}
}
var mediumButtonsBar: some View {
var floatingControls: some View {
HStack {
#if !os(tvOS)
restartVideoButton
.padding(.trailing, 15)
button("Seek Backward", systemImage: "gobackward.10", size: 30, cornerRadius: 5) {
player.backend.seek(relative: .secondsInDefaultTimescale(-10))
}
#if os(tvOS)
.focused($focusedField, equals: .backward)
#else
.keyboardShortcut("k", modifiers: [])
.keyboardShortcut(KeyEquivalent.leftArrow, modifiers: [])
#endif
#endif
Spacer()
button(
model.isPlaying ? "Pause" : "Play",
systemImage: model.isPlaying ? "pause.fill" : "play.fill",
size: 30, cornerRadius: 5
) {
player.backend.togglePlay()
HStack(spacing: 20) {
togglePlayButton
seekBackwardButton
seekForwardButton
}
#if os(tvOS)
.focused($focusedField, equals: .play)
#else
.keyboardShortcut("p")
.keyboardShortcut(.space)
#endif
.disabled(model.isLoadingVideo)
.frame(maxWidth: .infinity, alignment: .leading)
Spacer()
#if !os(tvOS)
button("Seek Forward", systemImage: "goforward.10", size: 30, cornerRadius: 5) {
player.backend.seek(relative: .secondsInDefaultTimescale(10))
}
#if os(tvOS)
.focused($focusedField, equals: .forward)
#else
.keyboardShortcut("l", modifiers: [])
.keyboardShortcut(KeyEquivalent.rightArrow, modifiers: [])
#endif
HStack(spacing: 20) {
restartVideoButton
advanceToNextItemButton
.padding(.leading, 15)
#endif
musicModeButton
}
.frame(maxWidth: .infinity, alignment: .trailing)
}
.font(.system(size: 20))
}
var seekBackwardButton: some View {
button("Seek Backward", systemImage: "gobackward.10", size: 25, cornerRadius: 5, background: false) {
player.backend.seek(relative: .secondsInDefaultTimescale(-10))
}
#if os(tvOS)
.focused($focusedField, equals: .backward)
#else
.keyboardShortcut("k", modifiers: [])
.keyboardShortcut(KeyEquivalent.leftArrow, modifiers: [])
#endif
}
var seekForwardButton: some View {
button("Seek Forward", systemImage: "goforward.10", size: 25, cornerRadius: 5, background: false) {
player.backend.seek(relative: .secondsInDefaultTimescale(10))
}
#if os(tvOS)
.focused($focusedField, equals: .forward)
#else
.keyboardShortcut("l", modifiers: [])
.keyboardShortcut(KeyEquivalent.rightArrow, modifiers: [])
#endif
}
private var restartVideoButton: some View {
button("Restart video", systemImage: "backward.end.fill", size: 30, cornerRadius: 5) {
button("Restart video", systemImage: "backward.end.fill", size: 25, cornerRadius: 5, background: false) {
player.backend.seek(to: 0.0)
}
}
private var togglePlayButton: some View {
button(
model.isPlaying ? "Pause" : "Play",
systemImage: model.isPlaying ? "pause.fill" : "play.fill",
size: 25, cornerRadius: 5, background: false
) {
player.backend.togglePlay()
}
#if os(tvOS)
.focused($focusedField, equals: .play)
#else
.keyboardShortcut("p")
.keyboardShortcut(.space)
#endif
.disabled(model.isLoadingVideo)
}
private var advanceToNextItemButton: some View {
button("Next", systemImage: "forward.fill", size: 30, cornerRadius: 5) {
button("Next", systemImage: "forward.fill", size: 25, cornerRadius: 5, background: false) {
player.advanceToNextItem()
}
.disabled(player.queue.isEmpty)
}
var bottomBar: some View {
HStack {
Text(model.playbackTime)
}
.font(.system(size: 15))
.padding(.horizontal, 5)
.padding(.vertical, 3)
.labelStyle(.iconOnly)
.foregroundColor(.primary)
}
func button(
_ label: String,
systemImage: String? = nil,
size: Double = 30,
size: Double = 25,
width: Double? = nil,
height: Double? = nil,
cornerRadius: Double = 3,
background: Bool = true,
active: Bool = false,
action: @escaping () -> Void = {}
) -> some View {
@@ -442,39 +350,30 @@ struct PlayerControls: View {
.padding()
.contentShape(Rectangle())
}
.font(.system(size: 13))
.buttonStyle(.plain)
.foregroundColor(active ? .accentColor : .primary)
.foregroundColor(active ? Color("AppRedColor") : .primary)
.frame(width: width ?? size, height: height ?? size)
#if os(macOS)
.background(VisualEffectBlur(material: .hudWindow))
#elseif os(iOS)
.background(VisualEffectBlur(blurStyle: .systemThinMaterial))
#endif
.mask(RoundedRectangle(cornerRadius: cornerRadius))
.modifier(ControlBackgroundModifier(enabled: background))
.clipShape(RoundedRectangle(cornerRadius: cornerRadius))
}
var fullScreenLayout: Bool {
#if os(iOS)
model.playingFullscreen || verticalSizeClass == .compact
player.playingFullScreen || verticalSizeClass == .compact
#else
model.playingFullscreen
player.playingFullScreen
#endif
}
}
struct PlayerControls_Previews: PreviewProvider {
static var previews: some View {
let model = PlayerControlsModel()
model.presentingControls = true
model.currentTime = .secondsInDefaultTimescale(0)
model.duration = .secondsInDefaultTimescale(120)
return ZStack {
ZStack {
Color.gray
PlayerControls(player: PlayerModel(), thumbnails: ThumbnailsModel())
.injectFixtureEnvironmentObjects()
.environmentObject(model)
}
}
}

View File

@@ -11,7 +11,7 @@ final class MPVOGLView: GLKView {
var needsDrawing = true
override init(frame: CGRect) {
guard let context = EAGLContext(api: .openGLES3) else {
guard let context = EAGLContext(api: .openGLES2) else {
print("Failed to initialize OpenGLES 2.0 context")
exit(1)
}
@@ -20,10 +20,12 @@ final class MPVOGLView: GLKView {
super.init(frame: frame, context: context)
EAGLContext.setCurrent(context)
self.context = context
bindDrawable()
defaultFBO = -1
isOpaque = false
isOpaque = true
enableSetNeedsDisplay = false
fillBlack()
}

View File

@@ -2,7 +2,6 @@ import UIKit
final class MPVViewController: UIViewController {
var client: MPVClient!
var glView: MPVOGLView!
init() {
client = MPVClient()
@@ -17,9 +16,8 @@ final class MPVViewController: UIViewController {
super.loadView()
client.create(frame: view.frame)
glView = client.glView
view.addSubview(glView)
view.addSubview(client.glView)
super.viewDidLoad()
}

View File

@@ -14,7 +14,7 @@ struct NoCommentsView: View {
.font(.system(size: 12))
#endif
}
.frame(minWidth: 0, maxWidth: .infinity)
.frame(minWidth: 0, maxWidth: .infinity, maxHeight: .infinity)
#if !os(tvOS)
.foregroundColor(.secondary)
#endif

View File

@@ -6,7 +6,7 @@ import SwiftUI
struct PlayerQueueRow: View {
let item: PlayerQueueItem
var history = false
@Binding var fullScreen: Bool
var fullScreen: Bool
@EnvironmentObject<PlayerModel> private var player
@@ -14,10 +14,10 @@ struct PlayerQueueRow: View {
@FetchRequest private var watchRequest: FetchedResults<Watch>
init(item: PlayerQueueItem, history: Bool = false, fullScreen: Binding<Bool> = .constant(false)) {
init(item: PlayerQueueItem, history: Bool = false, fullScreen: Bool = false) {
self.item = item
self.history = history
_fullScreen = fullScreen
self.fullScreen = fullScreen
_watchRequest = FetchRequest<Watch>(
entity: Watch.entity(),
sortDescriptors: [],
@@ -32,6 +32,8 @@ struct PlayerQueueRow: View {
player.avPlayerBackend.startPictureInPictureOnPlay = player.playingInPictureInPicture
player.videoBeingOpened = item.video
if history {
player.playHistory(item, at: watchStoppedAt)
} else {
@@ -39,9 +41,9 @@ struct PlayerQueueRow: View {
}
if fullScreen {
withAnimation {
fullScreen = false
}
// withAnimation {
// fullScreen = false
// }
}
if closePiPOnNavigation, player.playingInPictureInPicture {

View File

@@ -3,8 +3,8 @@ import Foundation
import SwiftUI
struct PlayerQueueView: View {
@Binding var sidebarQueue: Bool
@Binding var fullScreen: Bool
var sidebarQueue: Bool
var fullScreen: Bool
@FetchRequest(sortDescriptors: [.init(key: "watchedAt", ascending: false)])
var watches: FetchedResults<Watch>
@@ -49,7 +49,7 @@ struct PlayerQueueView: View {
}
ForEach(player.queue) { item in
PlayerQueueRow(item: item, fullScreen: $fullScreen)
PlayerQueueRow(item: item, fullScreen: fullScreen)
.contextMenu {
removeButton(item)
removeAllButton()
@@ -70,7 +70,7 @@ struct PlayerQueueView: View {
PlayerQueueRow(
item: PlayerQueueItem.from(watch, video: player.historyVideo(watch.videoID)),
history: true,
fullScreen: $fullScreen
fullScreen: fullScreen
)
.onAppear {
player.loadHistoryVideoDetails(watch.videoID)
@@ -89,7 +89,7 @@ struct PlayerQueueView: View {
if !player.currentVideo.isNil, !player.currentVideo!.related.isEmpty {
Section(header: Text("Related")) {
ForEach(player.currentVideo!.related) { video in
PlayerQueueRow(item: PlayerQueueItem(video), fullScreen: $fullScreen)
PlayerQueueRow(item: PlayerQueueItem(video), fullScreen: fullScreen)
.contextMenu {
Button {
player.playNext(video)
@@ -137,7 +137,7 @@ struct PlayerQueueView: View {
struct PlayerQueueView_Previews: PreviewProvider {
static var previews: some View {
VStack {
PlayerQueueView(sidebarQueue: .constant(true), fullScreen: .constant(true))
PlayerQueueView(sidebarQueue: true, fullScreen: true)
}
.injectFixtureEnvironmentObjects()
}

View File

@@ -1,24 +1,48 @@
import Defaults
import SwiftUI
struct RelatedView: View {
@EnvironmentObject<AccountsModel> private var accounts
@EnvironmentObject<NavigationModel> private var navigation
@EnvironmentObject<PlayerModel> private var player
@EnvironmentObject<PlaylistsModel> private var playlists
var body: some View {
List {
if !player.currentVideo.isNil, !player.currentVideo!.related.isEmpty {
if let related = player.currentVideo?.related {
Section(header: Text("Related")) {
ForEach(player.currentVideo!.related) { video in
PlayerQueueRow(item: PlayerQueueItem(video), fullScreen: .constant(false))
ForEach(related) { video in
PlayerQueueRow(item: PlayerQueueItem(video))
.contextMenu {
Button {
player.playNext(video)
} label: {
Label("Play Next", systemImage: "text.insert")
Section {
Button {
player.playNext(video)
} label: {
Label("Play Next", systemImage: "text.insert")
}
Button {
player.enqueueVideo(video)
} label: {
Label("Play Last", systemImage: "text.append")
}
}
Button {
player.enqueueVideo(video)
} label: {
Label("Play Last", systemImage: "text.append")
if accounts.app.supportsUserPlaylists && accounts.signedIn {
Section {
Button {
navigation.presentAddToPlaylist(video)
} label: {
Label("Add to playlist...", systemImage: "text.badge.plus")
}
if let playlist = playlists.lastUsed {
Button {
playlists.addVideo(playlistID: playlist.id, videoID: video.videoID, navigation: navigation)
} label: {
Label("Add to \(playlist.title)", systemImage: "text.badge.star")
}
}
}
}
}
}

View File

@@ -1,11 +1,34 @@
import SwiftUI
struct TimelineView: View {
@Binding private var duration: Double
@Binding private var current: Double
enum Context {
case controls
case player
}
private var duration: Double {
playerTime.duration.seconds
}
private var current: Double {
get {
playerTime.currentTime.seconds
}
set(value) {
playerTime.currentTime = .secondsInDefaultTimescale(value)
}
}
@State private var size = CGSize.zero
@State private var dragging = false
@State private var tooltipSize = CGSize.zero
@State private var dragging = false { didSet {
if dragging {
player.backend.stopControlsUpdates()
} else {
player.backend.startControlsUpdates()
}
}}
@State private var dragOffset: Double = 0
@State private var draggedFrom: Double = 0
@@ -13,147 +36,277 @@ struct TimelineView: View {
private var height = 8.0
var cornerRadius: Double
var thumbTooltipWidth: Double = 100
var thumbAreaWidth: Double = 40
var context: Context
@EnvironmentObject<PlayerModel> private var player
@EnvironmentObject<PlayerControlsModel> private var controls
@EnvironmentObject<PlayerTimeModel> private var playerTime
init(duration: Binding<Double>, current: Binding<Double>, cornerRadius: Double = 10.0) {
_duration = duration
_current = current
var chapters: [Chapter] {
player.currentVideo?.chapters ?? []
}
init(
cornerRadius: Double = 10.0,
context: Context = .controls
) {
self.cornerRadius = cornerRadius
self.context = context
}
var body: some View {
ZStack(alignment: .leading) {
VStack {
Group {
RoundedRectangle(cornerRadius: cornerRadius)
.foregroundColor(.blue)
.frame(maxHeight: height)
RoundedRectangle(cornerRadius: cornerRadius)
.fill(Color.green)
.frame(maxHeight: height)
.frame(width: current * oneUnitWidth)
segmentsLayers
}
Circle()
.strokeBorder(.gray, lineWidth: 1)
.background(Circle().fill(dragging ? .gray : .white))
.offset(x: thumbOffset)
.foregroundColor(.red.opacity(0.6))
.frame(maxHeight: height * 4)
#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
}
VStack(spacing: 3) {
if dragging {
if let segment = projectedSegment,
let description = SponsorBlockAPI.categoryDescription(segment.category)
{
Text(description)
.font(.system(size: 8))
.fixedSize()
.lineLimit(1)
.foregroundColor(Color("AppRedColor"))
}
.onEnded { _ in
current = projectedValue
player.backend.seek(to: projectedValue)
dragging = false
dragOffset = 0.0
draggedFrom = 0.0
controls.resetTimer()
if let chapter = projectedChapter {
Text(chapter.title)
.lineLimit(3)
.font(.system(size: 11).bold())
.frame(maxWidth: 250)
.fixedSize()
}
}
Text((dragging ? projectedValue : current).formattedAsPlaybackTime(allowZero: true) ?? PlayerTimeModel.timePlaceholder)
.font(.system(size: 11).monospacedDigit())
}
.padding(.vertical, 3)
.padding(.horizontal, 8)
.background(
RoundedRectangle(cornerRadius: 3)
.foregroundColor(.black)
)
#endif
ZStack {
RoundedRectangle(cornerRadius: cornerRadius)
.frame(maxWidth: thumbTooltipWidth, maxHeight: 30)
Text(projectedValue.formattedAsPlaybackTime() ?? "--:--")
.foregroundColor(.black)
.foregroundColor(.white)
}
.animation(.linear(duration: 0.1))
.animation(.easeInOut(duration: 0.2))
.frame(maxHeight: 300, alignment: .bottom)
.offset(x: thumbTooltipOffset)
.overlay(GeometryReader { proxy in
Color.clear
.onAppear {
tooltipSize = proxy.size
}
.onChange(of: proxy.size) { _ in
tooltipSize = proxy.size
}
})
.frame(height: 80)
.opacity(dragging ? 1 : 0)
.offset(x: thumbTooltipOffset, y: -(height * 2) - 7)
.animation(.easeOut, value: thumbTooltipOffset)
HStack(spacing: 4) {
Text((dragging ? projectedValue : nil)?.formattedAsPlaybackTime(allowZero: true) ?? playerTime.currentPlaybackTime)
.frame(minWidth: 35)
ZStack(alignment: .center) {
ZStack(alignment: .leading) {
ZStack(alignment: .leading) {
Rectangle()
.fill(Color.gray.opacity(0.1))
.frame(maxHeight: height)
.zIndex(1)
Rectangle()
.fill(Color.gray.opacity(0.5))
.frame(maxHeight: height)
.frame(width: current * oneUnitWidth)
.zIndex(1)
segmentsLayers
.zIndex(2)
}
.clipShape(RoundedRectangle(cornerRadius: cornerRadius))
chaptersLayers
.zIndex(3)
}
Circle()
.contentShape(Rectangle())
.foregroundColor(.clear)
.background(
ZStack {
Circle()
.fill(dragging ? .white : .gray)
.frame(maxWidth: 8)
Circle()
.fill(dragging ? .gray : .white)
.frame(maxWidth: 6)
}
)
.offset(x: thumbOffset)
.frame(maxWidth: thumbAreaWidth, minHeight: 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
}
.background(GeometryReader { proxy in
Color.clear
.onAppear {
self.size = proxy.size
}
.onChange(of: proxy.size) { size in
self.size = size
}
})
.frame(maxHeight: 20)
#if !os(tvOS)
.gesture(DragGesture(minimumDistance: 0).onEnded { value in
let target = (value.location.x / size.width) * units
self.playerTime.currentTime = .secondsInDefaultTimescale(target)
player.backend.seek(to: target)
})
#endif
Text(dragging ? playerTime.durationPlaybackTime : playerTime.withoutSegmentsPlaybackTime)
.clipShape(RoundedRectangle(cornerRadius: 3))
.frame(minWidth: 35)
}
.clipShape(RoundedRectangle(cornerRadius: 3))
.font(.system(size: 9).monospacedDigit())
.zIndex(2)
}
.background(GeometryReader { proxy in
Color.clear
.onAppear {
self.size = proxy.size
}
.onChange(of: proxy.size) { size in
self.size = size
}
})
#if !os(tvOS)
.gesture(DragGesture(minimumDistance: 0).onEnded { value in
let target = (value.location.x / size.width) * units
current = target
player.backend.seek(to: target)
})
#endif
}
var tooltipVeritcalOffset: Double {
var offset = -20.0
if !projectedChapter.isNil {
offset -= 8.0
}
if !projectedSegment.isNil {
offset -= 6.5
}
return offset
}
var projectedValue: Double {
let change = (dragOffset / size.width) * units
let projected = draggedFrom + change
return projected.isFinite ? (duration - projected < (0.01 * duration) ? duration : projected) : start
guard projected.isFinite && projected >= 0 && projected <= duration else {
return 0.0
}
return projected.clamped(to: 0 ... duration)
}
var thumbOffset: Double {
let offset = dragging ? (draggedThumbHorizontalOffset + dragOffset) : thumbHorizontalOffset
let offset = dragging ? draggedThumbHorizontalOffset : thumbHorizontalOffset
return offset.isFinite ? offset : thumbLeadingOffset
}
var thumbTooltipOffset: Double {
let offset = (dragging ? ((current * oneUnitWidth) + dragOffset) : (current * oneUnitWidth)) - (thumbTooltipWidth / 2)
let leadingOffset = size.width / 2 - (tooltipSize.width / 2)
let offsetForThumb = thumbOffset - thumbLeadingOffset
return offset.clamped(to: minThumbTooltipOffset ... maxThumbTooltipOffset)
guard offsetForThumb > tooltipSize.width / 2 else {
return -leadingOffset
}
return thumbOffset.clamped(to: -leadingOffset ... leadingOffset)
}
var minThumbTooltipOffset: Double = -10
var minThumbTooltipOffset: Double {
60
}
var maxThumbTooltipOffset: Double {
max(minThumbTooltipOffset, (units * oneUnitWidth) - thumbTooltipWidth + 10)
max(minThumbTooltipOffset, units * oneUnitWidth)
}
var segments: [Segment] {
// [.init(category: "outro", segment: [25,30], uuid: UUID().uuidString, videoDuration: 100)] ??
player.sponsorBlock.segments
}
var segmentsLayers: some View {
ForEach(player.sponsorBlock.segments, id: \.uuid) { segment in
RoundedRectangle(cornerRadius: cornerRadius)
ForEach(segments, id: \.uuid) { segment in
Rectangle()
.offset(x: segmentLayerHorizontalOffset(segment))
.foregroundColor(.red)
.foregroundColor(Color("AppRedColor"))
.frame(maxHeight: height)
.frame(width: segmentLayerWidth(segment))
}
}
var projectedSegment: Segment? {
segments.first { $0.timeInSegment(.secondsInDefaultTimescale(projectedValue)) }
}
var projectedChapter: Chapter? {
chapters.last { $0.start <= projectedValue }
}
var chaptersLayers: some View {
ForEach(chapters) { chapter in
RoundedRectangle(cornerRadius: 4)
.fill(Color("AppBlueColor"))
.frame(maxWidth: 2, maxHeight: 12)
.offset(x: (chapter.start * oneUnitWidth) - 1)
}
}
func segmentLayerHorizontalOffset(_ segment: Segment) -> Double {
segment.start * oneUnitWidth
}
func segmentLayerWidth(_ segment: Segment) -> Double {
let width = segment.duration * oneUnitWidth
return width.isFinite ? width : thumbLeadingOffset
return width.isFinite ? width : 1
}
var draggedThumbHorizontalOffset: Double {
thumbLeadingOffset + (draggedFrom * oneUnitWidth)
thumbLeadingOffset + (draggedFrom * oneUnitWidth) + dragOffset
}
var thumbHorizontalOffset: Double {
@@ -161,7 +314,7 @@ struct TimelineView: View {
}
var thumbLeadingOffset: Double {
-(size.width / 2)
-size.width / 2
}
var oneUnitWidth: Double {
@@ -172,26 +325,33 @@ struct TimelineView: View {
var units: Double {
duration - start
}
func setCurrent(_ current: Double) {
withAnimation {
self.current = current
}
}
}
struct TimelineView_Previews: PreviewProvider {
static var duration = 100.0
static var current = 0.0
static var durationBinding: Binding<Double> = .init(
get: { duration },
set: { value in duration = value }
)
static var currentBinding = Binding<Double>(
get: { current },
set: { value in current = value }
)
static var previews: some View {
VStack(spacing: 40) {
TimelineView(duration: .constant(100), current: .constant(0))
TimelineView(duration: .constant(100), current: .constant(1))
TimelineView(duration: .constant(100), current: .constant(30))
TimelineView(duration: .constant(100), current: .constant(50))
TimelineView(duration: .constant(100), current: .constant(66))
TimelineView(duration: .constant(100), current: .constant(90))
TimelineView(duration: .constant(100), current: .constant(100))
let playerModel = PlayerModel()
playerModel.currentItem = .init(Video.fixture)
let playerTimeModel = PlayerTimeModel()
playerTimeModel.player = playerModel
playerTimeModel.currentTime = .secondsInDefaultTimescale(33)
playerTimeModel.duration = .secondsInDefaultTimescale(100)
return VStack(spacing: 40) {
TimelineView()
}
.environmentObject(PlayerModel())
.environmentObject(playerModel)
.environmentObject(playerTimeModel)
.environmentObject(PlayerControlsModel())
.padding()
}
}

View File

@@ -2,14 +2,30 @@ import Defaults
import Foundation
import SDWebImageSwiftUI
import SwiftUI
import SwiftUIPager
struct VideoDetails: View {
enum Page {
case info, comments, related, queue
enum DetailsPage: CaseIterable {
case info, chapters, comments, related, queue
var index: Int {
switch self {
case .info:
return 0
case .chapters:
return 1
case .comments:
return 2
case .related:
return 3
case .queue:
return 4
}
}
}
@Binding var sidebarQueue: Bool
@Binding var fullScreen: Bool
var sidebarQueue: Bool
var fullScreen: Bool
@State private var subscribed = false
@State private var subscriptionToggleButtonDisabled = false
@@ -18,89 +34,82 @@ struct VideoDetails: View {
@State private var presentingShareSheet = false
@State private var shareURL: URL?
@State private var currentPage = Page.info
@StateObject private var page: Page = .first()
@Environment(\.presentationMode) private var presentationMode
@Environment(\.navigationStyle) private var navigationStyle
@EnvironmentObject<AccountsModel> private var accounts
@EnvironmentObject<CommentsModel> private var comments
@EnvironmentObject<NavigationModel> private var navigation
@EnvironmentObject<PlayerModel> private var player
@EnvironmentObject<RecentsModel> private var recents
@EnvironmentObject<SubscriptionsModel> private var subscriptions
@Default(.showKeywords) private var showKeywords
@Default(.playerDetailsPageButtonLabelStyle) private var playerDetailsPageButtonLabelStyle
@Default(.controlsBarInPlayer) private var controlsBarInPlayer
init(
sidebarQueue: Binding<Bool>? = nil,
fullScreen: Binding<Bool>? = nil
) {
_sidebarQueue = sidebarQueue ?? .constant(true)
_fullScreen = fullScreen ?? .constant(false)
var currentPage: DetailsPage {
DetailsPage.allCases.first { $0.index == page.index } ?? .info
}
var video: Video? {
player.currentVideo
}
var body: some View {
VStack(alignment: .leading) {
Group {
Group {
HStack(spacing: 0) {
title
func pageButton(
_ label: String,
_ symbolName: String,
_ destination: DetailsPage,
pageChangeAction: (() -> Void)? = nil
) -> some View {
Button(action: {
page.update(.new(index: destination.index))
pageChangeAction?()
}) {
HStack {
Spacer()
toggleFullScreenDetailsButton
HStack(spacing: 4) {
Image(systemName: symbolName)
if playerDetailsPageButtonLabelStyle.text {
Text(label)
}
#if os(macOS)
.padding(.top, 10)
#endif
if !video.isNil {
Divider()
}
subscriptionsSection
.onChange(of: video) { video in
if let video = video {
subscribed = subscriptions.isSubscribing(video.channel.id)
}
}
}
.padding(.horizontal)
.frame(minHeight: 15)
.lineLimit(1)
.padding(.vertical, 4)
.foregroundColor(currentPage == destination ? .white : .accentColor)
if !sidebarQueue ||
(CommentsModel.enabled && CommentsModel.placement == .separate)
{
pagePicker
.padding(.horizontal)
}
Spacer()
}
.contentShape(Rectangle())
.onSwipeGesture(
up: {
withAnimation {
fullScreen = true
}
},
down: {
withAnimation {
if fullScreen {
fullScreen = false
} else {
self.player.hide()
}
}
}
)
}
.background(currentPage == destination ? Color.accentColor : .clear)
.buttonStyle(.plain)
.font(.system(size: 10).bold())
.overlay(
RoundedRectangle(cornerRadius: 2)
.stroke(Color.accentColor, lineWidth: 2)
.foregroundColor(.clear)
)
.frame(maxWidth: .infinity)
}
switch currentPage {
@ViewBuilder func detailsByPage(_ page: DetailsPage) -> some View {
Group {
switch page {
case .info:
ScrollView(.vertical, showsIndicators: false) {
detailsPage
}
case .chapters:
ChaptersView()
.edgesIgnoringSafeArea(.horizontal)
case .queue:
PlayerQueueView(sidebarQueue: $sidebarQueue, fullScreen: $fullScreen)
PlayerQueueView(sidebarQueue: sidebarQueue, fullScreen: fullScreen)
.edgesIgnoringSafeArea(.horizontal)
case .related:
@@ -111,9 +120,54 @@ struct VideoDetails: View {
.edgesIgnoringSafeArea(.horizontal)
}
}
.contentShape(Rectangle())
}
var body: some View {
VStack(alignment: .leading) {
Group {
// Group {
// subscriptionsSection
// .border(.red, width: 4)
//
// .onChange(of: video) { video in
// if let video = video {
// subscribed = subscriptions.isSubscribing(video.channel.id)
// }
// }
// }
// .padding(.top, 4)
// .padding(.horizontal)
HStack(spacing: 4) {
pageButton("Info", "info.circle", .info)
pageButton("Chapters", "bookmark", .chapters)
pageButton("Comments", "text.bubble", .comments) { comments.load() }
pageButton("Related", "rectangle.stack.fill", .related)
pageButton("Queue", "list.number", .queue)
}
.onChange(of: player.currentItem) { _ in
page.update(.moveToFirst)
}
.padding(.horizontal)
.padding(.top, 8)
}
.contentShape(Rectangle())
Pager(page: page, data: DetailsPage.allCases, id: \.self) {
detailsByPage($0)
}
.onPageWillChange { pageIndex in
if pageIndex == DetailsPage.comments.index {
comments.load()
} else {
print("comments not loading")
}
}
}
.onAppear {
if video.isNil && !sidebarQueue {
currentPage = .queue
page.update(.new(index: DetailsPage.queue.index))
}
guard video != nil, accounts.app.supportsSubscriptions else {
@@ -124,91 +178,56 @@ struct VideoDetails: View {
.onChange(of: sidebarQueue) { queue in
if queue {
if currentPage == .related || currentPage == .queue {
currentPage = .info
page.update(.moveToFirst)
}
} else if video.isNil {
currentPage = .queue
page.update(.moveToLast)
}
}
.edgesIgnoringSafeArea(.horizontal)
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity, alignment: .topLeading)
}
var title: some View {
Group {
if video != nil {
Text(video!.title)
.onAppear {
currentPage = .info
}
.contextMenu {
Button {
player.closeCurrentItem()
if !sidebarQueue {
currentPage = .queue
} else {
currentPage = .info
}
} label: {
Label("Close Video", systemImage: "xmark.circle")
}
.disabled(player.currentItem.isNil)
}
.font(.title2.bold())
} else {
Text("Not playing")
.foregroundColor(.secondary)
}
Spacer()
}
}
var toggleFullScreenDetailsButton: some View {
Button {
withAnimation {
fullScreen.toggle()
}
} label: {
Label("Resize", systemImage: fullScreen ? "chevron.down" : "chevron.up")
.labelStyle(.iconOnly)
}
.help("Toggle fullscreen details")
.buttonStyle(.plain)
.keyboardShortcut("t")
var showAddToPlaylistButton: Bool {
accounts.app.supportsUserPlaylists && accounts.signedIn
}
var subscriptionsSection: some View {
Group {
if video != nil {
if let video = video {
HStack(alignment: .center) {
HStack(spacing: 10) {
Group {
ZStack(alignment: .bottomTrailing) {
authorAvatar
// ZStack(alignment: .bottomTrailing) {
// authorAvatar
//
// if subscribed {
// Image(systemName: "star.circle.fill")
// .background(Color.background)
// .clipShape(Circle())
// .foregroundColor(.secondary)
// }
// }
if subscribed {
Image(systemName: "star.circle.fill")
.background(Color.background)
.clipShape(Circle())
.foregroundColor(.secondary)
}
}
VStack(alignment: .leading) {
Text(video!.channel.name)
.font(.system(size: 14))
.bold()
Group {
if let subscribers = video!.channel.subscriptionsString {
Text("\(subscribers) subscribers")
}
}
.foregroundColor(.secondary)
.font(.caption2)
}
// VStack(alignment: .leading, spacing: 4) {
// Text(video.title)
// .font(.system(size: 11))
// .fontWeight(.bold)
//
// HStack(spacing: 4) {
// Text(video.channel.name)
//
// if let subscribers = video.channel.subscriptionsString {
// Text("")
// .foregroundColor(.secondary)
// .opacity(0.3)
//
// Text("\(subscribers) subscribers")
// }
// }
// .foregroundColor(.secondary)
// .font(.caption2)
// }
}
}
.contentShape(RoundedRectangle(cornerRadius: 12))
@@ -227,83 +246,11 @@ struct VideoDetails: View {
}
}
}
if accounts.app.supportsSubscriptions, accounts.signedIn {
Spacer()
Section {
if subscribed {
Button("Unsubscribe") {
presentingUnsubscribeAlert = true
}
#if os(iOS)
.backport
.tint(.gray)
#endif
.alert(isPresented: $presentingUnsubscribeAlert) {
Alert(
title: Text(
"Are you sure you want to unsubscribe from \(video!.channel.name)?"
),
primaryButton: .destructive(Text("Unsubscribe")) {
subscriptionToggleButtonDisabled = true
subscriptions.unsubscribe(video!.channel.id) {
withAnimation {
subscriptionToggleButtonDisabled = false
subscribed.toggle()
}
}
},
secondaryButton: .cancel()
)
}
} else {
Button("Subscribe") {
subscriptionToggleButtonDisabled = true
subscriptions.subscribe(video!.channel.id) {
withAnimation {
subscriptionToggleButtonDisabled = false
subscribed.toggle()
}
}
}
.backport
.tint(subscriptionToggleButtonDisabled ? .gray : .blue)
}
}
.disabled(subscriptionToggleButtonDisabled)
.font(.system(size: 13))
.buttonStyle(.borderless)
}
}
}
}
}
var pagePicker: some View {
Picker("Page", selection: $currentPage) {
if !video.isNil {
Text("Info").tag(Page.info)
if CommentsModel.enabled, CommentsModel.placement == .separate {
Text("Comments").tag(Page.comments)
}
if !sidebarQueue {
Text("Related").tag(Page.related)
}
}
if !sidebarQueue {
Text("Queue").tag(Page.queue)
}
}
.labelsHidden()
.pickerStyle(.segmented)
.onDisappear {
currentPage = .info
}
}
var publishedDateSection: some View {
Group {
if let video = player.currentVideo {
@@ -311,32 +258,11 @@ struct VideoDetails: View {
if let published = video.publishedDate {
Text(published)
}
if let date = video.publishedAt {
if video.publishedDate != nil {
Text("")
.foregroundColor(.secondary)
.opacity(0.3)
}
Text(formattedPublishedAt(date))
}
}
.font(.system(size: 12))
.padding(.bottom, -1)
.foregroundColor(.secondary)
}
}
}
func formattedPublishedAt(_ date: Date) -> String {
let dateFormatter = DateFormatter()
dateFormatter.dateStyle = .short
dateFormatter.timeStyle = .none
return dateFormatter.string(from: date)
}
var countsSection: some View {
Group {
if let video = player.currentVideo {
@@ -386,13 +312,6 @@ struct VideoDetails: View {
.foregroundColor(.secondary)
}
}
.background(
EmptyView().sheet(isPresented: $presentingAddToPlaylist) {
if let video = video {
AddToPlaylistView(video: video)
}
}
)
#if os(iOS)
.background(
EmptyView().sheet(isPresented: $presentingShareSheet) {
@@ -419,31 +338,60 @@ struct VideoDetails: View {
.retryOnAppear(true)
.indicator(.activity)
.clipShape(Circle())
.frame(width: 45, height: 45, alignment: .leading)
.frame(width: 35, height: 35, alignment: .leading)
}
}
}
var videoProperties: some View {
HStack(spacing: 2) {
publishedDateSection
Spacer()
HStack(spacing: 4) {
if let views = video?.viewsCount {
Image(systemName: "eye")
Text(views)
}
if let likes = video?.likesCount {
Image(systemName: "hand.thumbsup")
Text(likes)
}
if let likes = video?.dislikesCount {
Image(systemName: "hand.thumbsdown")
Text(likes)
}
}
}
.font(.system(size: 12))
.foregroundColor(.secondary)
}
var detailsPage: some View {
Group {
VStack(alignment: .leading, spacing: 0) {
if let video = player.currentVideo {
VStack(spacing: 6) {
HStack {
publishedDateSection
Spacer()
}
Divider()
countsSection
videoProperties
Divider()
}
.padding(.bottom, 6)
VStack(alignment: .leading, spacing: 10) {
if let description = video.description {
if !player.videoBeingOpened.isNil && (video.description.isNil || video.description!.isEmpty) {
VStack(alignment: .leading, spacing: 0) {
ForEach(1 ... Int.random(in: 3 ... 5), id: \.self) { _ in
Text(String(repeating: Video.fixture.description!, count: Int.random(in: 1 ... 4)))
.redacted(reason: .placeholder)
}
}
} else if let description = video.description {
Group {
if #available(iOS 15.0, macOS 12.0, tvOS 15.0, *) {
Text(description)
@@ -531,7 +479,7 @@ struct VideoDetails: View {
struct VideoDetails_Previews: PreviewProvider {
static var previews: some View {
VideoDetails(sidebarQueue: .constant(true))
VideoDetails(sidebarQueue: true, fullScreen: false)
.injectFixtureEnvironmentObjects()
}
}

View File

@@ -8,7 +8,7 @@ import SwiftUI
struct VideoPlayerView: View {
#if os(iOS)
static let hiddenOffset = max(UIScreen.main.bounds.height, UIScreen.main.bounds.width) + 100
static let hiddenOffset = YatteeApp.isForPreviews ? 0 : max(UIScreen.main.bounds.height, UIScreen.main.bounds.width) + 100
#endif
static let defaultAspectRatio = 16 / 9.0
@@ -20,20 +20,22 @@ struct VideoPlayerView: View {
#endif
}
@State private var playerSize: CGSize = .zero
@State private var playerSize: CGSize = .zero { didSet {
if playerSize.width > 900 && Defaults[.playerSidebar] == .whenFits {
sidebarQueue = true
} else {
sidebarQueue = false
}
}}
@State private var hoveringPlayer = false
@State private var fullScreenDetails = false
@State private var sidebarQueue = false
@Environment(\.colorScheme) private var colorScheme
#if os(iOS)
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@Environment(\.verticalSizeClass) private var verticalSizeClass
@Default(.enterFullscreenInLandscape) private var enterFullscreenInLandscape
@Default(.honorSystemOrientationLock) private var honorSystemOrientationLock
@Default(.lockOrientationInFullScreen) private var lockOrientationInFullScreen
@State private var motionManager: CMMotionManager!
@State private var orientation = UIInterfaceOrientation.portrait
@State private var lastOrientation: UIInterfaceOrientation?
@@ -46,19 +48,29 @@ struct VideoPlayerView: View {
#endif
@EnvironmentObject<AccountsModel> private var accounts
@EnvironmentObject<PlayerControlsModel> private var playerControls
@EnvironmentObject<PlayerModel> private var player
@EnvironmentObject<ThumbnailsModel> private var thumbnails
init() {
if Defaults[.playerSidebar] == .always {
sidebarQueue = true
}
}
var body: some View {
// TODO: remove
if #available(iOS 15.0, macOS 12.0, *) {
_ = Self._printChanges()
}
#if os(macOS)
HSplitView {
return HSplitView {
content
}
.onOpenURL { OpenURLHandler(accounts: accounts, player: player).handle($0) }
.frame(minWidth: 950, minHeight: 700)
#else
GeometryReader { geometry in
return GeometryReader { geometry in
HStack(spacing: 0) {
content
.onAppear {
@@ -79,6 +91,11 @@ struct VideoPlayerView: View {
if newValue {
viewVerticalOffset = 0
configureOrientationUpdatesBasedOnAccelerometer()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak player] in
player?.onPresentPlayer?()
player?.onPresentPlayer = nil
}
} else {
if Defaults[.lockPortraitWhenBrowsing] {
Orientation.lockOrientation(.portrait, andRotateTo: .portrait)
@@ -95,7 +112,7 @@ struct VideoPlayerView: View {
}
#if os(iOS)
.offset(y: viewVerticalOffset)
.animation(.easeIn(duration: 0.2), value: viewVerticalOffset)
.animation(.easeOut(duration: 0.3), value: viewVerticalOffset)
.backport
.persistentSystemOverlays(!fullScreenLayout)
#endif
@@ -104,7 +121,7 @@ struct VideoPlayerView: View {
var content: some View {
Group {
Group {
ZStack(alignment: .bottomLeading) {
#if os(tvOS)
playerView
.ignoresSafeArea(.all, edges: .all)
@@ -138,17 +155,17 @@ struct VideoPlayerView: View {
VideoPlayerSizeModifier(
geometry: geometry,
aspectRatio: player.avPlayerBackend.controller?.aspectRatio,
fullScreen: playerControls.playingFullscreen
fullScreen: player.playingFullScreen
)
)
.overlay(playerPlaceholder(geometry: geometry))
// .overlay(playerPlaceholder(geometry: geometry))
#endif
}
}
.frame(maxWidth: fullScreenLayout ? .infinity : nil, maxHeight: fullScreenLayout ? .infinity : nil)
.onHover { hovering in
hoveringPlayer = hovering
hovering ? playerControls.show() : playerControls.hide()
// hovering ? playerControls.show() : playerControls.hide()
}
#if !os(macOS)
.gesture(
@@ -169,9 +186,7 @@ struct VideoPlayerView: View {
return
}
withAnimation(.easeInOut(duration: 0.2)) {
viewVerticalOffset = drag
}
viewVerticalOffset = drag
}
.onEnded { _ in
if viewVerticalOffset > 100 {
@@ -185,29 +200,30 @@ struct VideoPlayerView: View {
}
)
#else
.onAppear(perform: {
NSEvent.addLocalMonitorForEvents(matching: [.mouseMoved]) {
if hoveringPlayer {
playerControls.resetTimer()
}
return $0
}
})
// .onAppear(perform: {
// NSEvent.addLocalMonitorForEvents(matching: [.mouseMoved]) {
// if hoveringPlayer {
// playerControls.resetTimer()
// }
//
// return $0
// }
// })
#endif
.background(Color.black)
.background(Color.black)
#if !os(tvOS)
if !playerControls.playingFullscreen {
Group {
if !player.playingFullScreen {
VStack(spacing: 0) {
#if os(iOS)
if verticalSizeClass == .regular {
VideoDetails(sidebarQueue: sidebarQueueBinding, fullScreen: $fullScreenDetails)
VideoDetails(sidebarQueue: sidebarQueue, fullScreen: fullScreenDetails)
}
#else
VideoDetails(sidebarQueue: sidebarQueueBinding, fullScreen: $fullScreenDetails)
VideoDetails(sidebarQueue: sidebarQueue, fullScreen: fullScreenDetails)
#endif
}
.background(colorScheme == .dark ? Color.black : Color.white)
@@ -220,28 +236,35 @@ struct VideoPlayerView: View {
#endif
}
#endif
#if !os(tvOS)
if !fullScreenLayout {
ControlsBar()
}
#endif
}
.background(((colorScheme == .dark || fullScreenLayout) ? Color.black : Color.white).edgesIgnoringSafeArea(.all))
#if os(macOS)
.frame(minWidth: 650)
#endif
if !playerControls.playingFullscreen {
if !player.playingFullScreen {
#if os(iOS)
if sidebarQueue {
PlayerQueueView(sidebarQueue: .constant(true), fullScreen: $fullScreenDetails)
PlayerQueueView(sidebarQueue: true, fullScreen: fullScreenDetails)
.frame(maxWidth: 350)
}
#elseif os(macOS)
if Defaults[.playerSidebar] != .never {
PlayerQueueView(sidebarQueue: sidebarQueueBinding, fullScreen: $fullScreenDetails)
PlayerQueueView(sidebarQueue: true, fullScreen: fullScreenDetails)
.frame(minWidth: 300)
}
#endif
}
}
.transition(.asymmetric(insertion: .slide, removal: .identity))
.ignoresSafeArea(.all, edges: fullScreenLayout ? .vertical : Edge.Set())
#if os(iOS)
.statusBar(hidden: playerControls.playingFullscreen)
.statusBar(hidden: player.playingFullScreen)
.navigationBarHidden(true)
#endif
}
@@ -285,9 +308,9 @@ struct VideoPlayerView: View {
var fullScreenLayout: Bool {
#if os(iOS)
playerControls.playingFullscreen || verticalSizeClass == .compact
player.playingFullScreen || verticalSizeClass == .compact
#else
playerControls.playingFullscreen
player.playingFullScreen
#endif
}
@@ -357,29 +380,11 @@ struct VideoPlayerView: View {
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: geometry.size.width / Self.defaultAspectRatio)
}
var sidebarQueue: Bool {
switch Defaults[.playerSidebar] {
case .never:
return false
case .always:
return true
case .whenFits:
return playerSize.width > 900
}
}
var sidebarQueueBinding: Binding<Bool> {
Binding(
get: { sidebarQueue },
set: { _ in }
)
}
#if os(iOS)
private func configureOrientationUpdatesBasedOnAccelerometer() {
if UIDevice.current.orientation.isLandscape,
enterFullscreenInLandscape,
!playerControls.playingFullscreen,
Defaults[.enterFullscreenInLandscape],
!player.playingFullScreen,
!player.playingInPictureInPicture
{
DispatchQueue.main.async {
@@ -387,7 +392,7 @@ struct VideoPlayerView: View {
}
}
guard !honorSystemOrientationLock, motionManager.isNil else {
guard !Defaults[.honorSystemOrientationLock], motionManager.isNil else {
return
}
@@ -422,7 +427,7 @@ struct VideoPlayerView: View {
if orientation.isLandscape {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) {
guard enterFullscreenInLandscape else {
guard Defaults[.enterFullscreenInLandscape] else {
return
}
@@ -433,7 +438,7 @@ struct VideoPlayerView: View {
Orientation.lockOrientation(orientationLockMask, andRotateTo: orientation)
guard lockOrientationInFullScreen else {
guard Defaults[.lockOrientationInFullScreen] else {
return
}
@@ -442,8 +447,8 @@ struct VideoPlayerView: View {
} else {
guard abs(acceleration.z) <= 0.74,
player.lockedOrientation.isNil,
enterFullscreenInLandscape,
!lockOrientationInFullScreen
Defaults[.enterFullscreenInLandscape],
!Defaults[.lockOrientationInFullScreen]
else {
return
}
@@ -462,14 +467,14 @@ struct VideoPlayerView: View {
let newOrientation = UIApplication.shared.windows.first?.windowScene?.interfaceOrientation
if newOrientation?.isLandscape ?? false,
player.presentingPlayer,
lockOrientationInFullScreen,
Defaults[.lockOrientationInFullScreen],
!player.lockedOrientation.isNil
{
Orientation.lockOrientation(.landscape, andRotateTo: newOrientation)
return
}
guard player.presentingPlayer, enterFullscreenInLandscape, honorSystemOrientationLock else {
guard player.presentingPlayer, Defaults[.enterFullscreenInLandscape], Defaults[.honorSystemOrientationLock] else {
return
}

View File

@@ -9,11 +9,11 @@ struct AddToPlaylistView: View {
@State private var error = ""
@State private var presentingErrorAlert = false
@State private var submitButtonDisabled = false
@Environment(\.colorScheme) private var colorScheme
@Environment(\.presentationMode) private var presentationMode
@EnvironmentObject<NavigationModel> private var navigation
@EnvironmentObject<PlaylistsModel> private var model
var body: some View {
@@ -123,14 +123,8 @@ struct AddToPlaylistView: View {
HStack {
Spacer()
Button("Add to Playlist", action: addToPlaylist)
.disabled(submitButtonDisabled || selectedPlaylist.isNil)
.disabled(selectedPlaylist.isNil)
.padding(.top, 30)
.alert(isPresented: $presentingErrorAlert) {
Alert(
title: Text("Error when accessing playlist"),
message: Text(error)
)
}
#if !os(tvOS)
.keyboardShortcut(.defaultAction)
#endif
@@ -166,20 +160,9 @@ struct AddToPlaylistView: View {
Defaults[.lastUsedPlaylistID] = id
submitButtonDisabled = true
model.addVideo(playlistID: id, videoID: video.videoID, navigation: navigation)
model.addVideo(
playlistID: id,
videoID: video.videoID,
onSuccess: {
presentationMode.wrappedValue.dismiss()
},
onFailure: { requestError in
error = "(\(requestError.httpStatusCode ?? -1)) \(requestError.userMessage)"
presentingErrorAlert = true
submitButtonDisabled = false
}
)
presentationMode.wrappedValue.dismiss()
}
private var selectedPlaylist: Playlist? {

View File

@@ -66,8 +66,9 @@ struct SearchSuggestions: View {
#endif
}
}
.id(UUID())
#if os(macOS)
.buttonStyle(.link)
.buttonStyle(.link)
#endif
}

View File

@@ -297,6 +297,7 @@ struct SearchView: View {
}
.redrawOn(change: recentsChanged)
}
.id(UUID())
}
#if os(iOS)
.listStyle(.insetGrouped)

View File

@@ -1,146 +1,230 @@
import Foundation
import SDWebImageSwiftUI
import SwiftUI
struct BrowserPlayerControls<Content: View, Toolbar: View>: View {
let content: Content
let toolbar: Toolbar?
@Environment(\.navigationStyle) private var navigationStyle
@EnvironmentObject<PlayerControlsModel> private var playerControls
@EnvironmentObject<PlayerModel> private var model
init(@ViewBuilder toolbar: @escaping () -> Toolbar? = { nil }, @ViewBuilder content: @escaping () -> Content) {
self.content = content()
self.toolbar = toolbar()
enum Context {
case browser, player
}
init(@ViewBuilder content: @escaping () -> Content) where Toolbar == EmptyView {
self.init(toolbar: { EmptyView() }, content: content)
let content: Content
init(
context _: Context? = nil,
@ViewBuilder toolbar: @escaping () -> Toolbar? = { nil },
@ViewBuilder content: @escaping () -> Content
) {
self.content = content()
}
init(
context: Context? = nil,
@ViewBuilder content: @escaping () -> Content
) where Toolbar == EmptyView {
self.init(context: context, toolbar: { EmptyView() }, content: content)
}
var body: some View {
ZStack(alignment: .bottomLeading) {
if #available(iOS 15.0, macOS 12.0, *) {
_ = Self._printChanges()
}
return VStack(spacing: 0) {
content
#if !os(tvOS)
.frame(minHeight: 0, maxHeight: .infinity)
#endif
Group {
#if !os(tvOS)
#if !os(macOS)
toolbar
.frame(height: 100)
.offset(x: 0, y: -28)
#endif
controls
#endif
}
.borderTop(height: 0.4, color: Color("ControlsBorderColor"))
#if os(macOS)
.background(VisualEffectBlur(material: .sidebar))
#elseif os(iOS)
.background(VisualEffectBlur(blurStyle: .systemThinMaterial).edgesIgnoringSafeArea(.all))
ControlsBar()
.edgesIgnoringSafeArea(.bottom)
#endif
}
}
private var controls: some View {
HStack {
Button(action: {
model.togglePlayer()
}) {
HStack {
VStack(alignment: .leading, spacing: 3) {
Text(model.currentVideo?.title ?? "Not playing")
.font(.system(size: 14).bold())
.foregroundColor(model.currentItem.isNil ? .secondary : .accentColor)
.lineLimit(1)
if let video = model.currentVideo {
Text(video.author)
.fontWeight(.bold)
.font(.system(size: 10))
.foregroundColor(.secondary)
.lineLimit(1)
}
}
Spacer()
}
.padding(.vertical)
.contentShape(Rectangle())
}
.padding(.vertical, 20)
HStack {
Group {
if !model.currentItem.isNil {
Button {
model.closeCurrentItem()
model.closePiP()
} label: {
Label("Close Video", systemImage: "xmark")
}
}
if playerControls.isPlaying {
Button(action: {
model.pause()
}) {
Label("Pause", systemImage: "pause.fill")
}
} else {
Button(action: {
model.play()
}) {
Label("Play", systemImage: "play.fill")
}
}
}
.disabled(playerControls.isLoadingVideo || model.currentItem.isNil)
.font(.system(size: 30))
.frame(minWidth: 30)
Button(action: { model.advanceToNextItem() }) {
Label("Next", systemImage: "forward.fill")
.padding(.vertical)
.contentShape(Rectangle())
}
.disabled(model.queue.isEmpty)
}
}
.buttonStyle(.plain)
.labelStyle(.iconOnly)
.padding(.horizontal)
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: 55)
.padding(.vertical, 0)
.borderTop(height: 0.4, color: Color("ControlsBorderColor"))
.borderBottom(height: navigationStyle == .sidebar ? 0 : 0.4, color: Color("ControlsBorderColor"))
#if !os(tvOS)
.onSwipeGesture(up: {
model.show()
})
#endif
}
private var progressViewValue: Double {
[model.time?.seconds, model.videoDuration].compactMap { $0 }.min() ?? 0
}
private var progressViewTotal: Double {
model.videoDuration ?? 100
}
}
// struct BrowserPlayerControls<Content: View, Toolbar: View>: View {
// enum Context {
// case browser, player
// }
//
// let context: Context
// let content: Content
// let toolbar: Toolbar?
//
// @Environment(\.navigationStyle) private var navigationStyle
// @EnvironmentObject<PlayerControlsModel> private var playerControls
// @EnvironmentObject<PlayerModel> private var model
//
// var barHeight: Double {
// 75
// }
//
// init(
// context: Context? = nil,
// @ViewBuilder toolbar: @escaping () -> Toolbar? = { nil },
// @ViewBuilder content: @escaping () -> Content
// ) {
// self.context = context ?? .browser
// self.content = content()
// self.toolbar = toolbar()
// }
//
// init(
// context: Context? = nil,
// @ViewBuilder content: @escaping () -> Content
// ) where Toolbar == EmptyView {
// self.init(context: context, toolbar: { EmptyView() }, content: content)
// }
//
// var body: some View {
// ZStack(alignment: .bottomLeading) {
// VStack(spacing: 0) {
// content
//
// Color.clear.frame(height: barHeight)
// }
// #if !os(tvOS)
// .frame(minHeight: 0, maxHeight: .infinity)
// #endif
//
//
// VStack {
// #if !os(tvOS)
// #if !os(macOS)
// toolbar
// .frame(height: 100)
// .offset(x: 0, y: -28)
// #endif
//
// if context != .player || !playerControls.playingFullscreen {
// controls
// }
// #endif
// }
// .borderTop(height: 0.4, color: Color("ControlsBorderColor"))
// #if os(macOS)
// .background(VisualEffectBlur(material: .sidebar))
// #elseif os(iOS)
// .background(VisualEffectBlur(blurStyle: .systemThinMaterial).edgesIgnoringSafeArea(.all))
// #endif
// }
// .background(Color.debug)
// }
//
// private var controls: some View {
// VStack(spacing: 0) {
// TimelineView(duration: playerControls.durationBinding, current: playerControls.currentTimeBinding)
// .foregroundColor(.secondary)
//
// Button(action: {
// model.togglePlayer()
// }) {
// HStack(spacing: 8) {
// authorAvatar
//
// VStack(alignment: .leading, spacing: 5) {
// Text(model.currentVideo?.title ?? "Not playing")
// .font(.headline)
// .foregroundColor(model.currentVideo.isNil ? .secondary : .accentColor)
// .lineLimit(1)
//
// Text(model.currentVideo?.author ?? "")
// .font(.subheadline)
// .foregroundColor(.secondary)
// .lineLimit(1)
// }
//
// Spacer()
//
// HStack {
// Group {
// if !model.currentItem.isNil {
// Button {
// model.closeCurrentItem()
// model.closePiP()
// } label: {
// Label("Close Video", systemImage: "xmark")
// .padding(.horizontal, 4)
// .contentShape(Rectangle())
// }
// }
//
// if playerControls.isPlaying {
// Button(action: {
// model.pause()
// }) {
// Label("Pause", systemImage: "pause.fill")
// .padding(.horizontal, 4)
// .contentShape(Rectangle())
// }
// } else {
// Button(action: {
// model.play()
// }) {
// Label("Play", systemImage: "play.fill")
// .padding(.horizontal, 4)
// .contentShape(Rectangle())
// }
// }
// }
// .disabled(playerControls.isLoadingVideo || model.currentItem.isNil)
// .font(.system(size: 30))
// .frame(minWidth: 30)
//
// Button(action: { model.advanceToNextItem() }) {
// Label("Next", systemImage: "forward.fill")
// .padding(.vertical)
// .contentShape(Rectangle())
// }
// .disabled(model.queue.isEmpty)
// }
// }
// .buttonStyle(.plain)
// .contentShape(Rectangle())
// }
// }
// .buttonStyle(.plain)
// .labelStyle(.iconOnly)
// .padding(.horizontal)
// .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: barHeight)
// .borderTop(height: 0.4, color: Color("ControlsBorderColor"))
// .borderBottom(height: navigationStyle == .sidebar ? 0 : 0.4, color: Color("ControlsBorderColor"))
// }
//
// private var authorAvatar: some View {
// Group {
// if let video = model.currentItem?.video, let url = video.channel.thumbnailURL {
// WebImage(url: url)
// .resizable()
// .placeholder {
// Rectangle().fill(Color("PlaceholderColor"))
// }
// .retryOnAppear(true)
// .indicator(.activity)
// .clipShape(Circle())
// .frame(width: 44, height: 44, alignment: .leading)
// }
// }
// }
//
// private var progressViewValue: Double {
// [model.time?.seconds, model.videoDuration].compactMap { $0 }.min() ?? 0
// }
//
// private var progressViewTotal: Double {
// model.videoDuration ?? 100
// }
// }
//
struct PlayerControlsView_Previews: PreviewProvider {
static var previews: some View {
BrowserPlayerControls {
VStack {
Spacer()
Text("Hello")
Spacer()
BrowserPlayerControls(context: .player) {
BrowserPlayerControls {
VStack {
Spacer()
Text("Hello")
Spacer()
}
}
.offset(y: -100)
}
.injectFixtureEnvironmentObjects()
}

View File

@@ -0,0 +1,221 @@
import Defaults
import SDWebImageSwiftUI
import SwiftUI
import SwiftUIPager
struct ControlsBar: View {
enum Pages: CaseIterable {
case details, controls
}
@Environment(\.navigationStyle) private var navigationStyle
@EnvironmentObject<AccountsModel> private var accounts
@EnvironmentObject<NavigationModel> private var navigation
@EnvironmentObject<PlayerControlsModel> private var playerControls
@EnvironmentObject<PlayerModel> private var model
@EnvironmentObject<PlaylistsModel> private var playlists
@EnvironmentObject<RecentsModel> private var recents
@StateObject private var controlsPage = Page.first()
var body: some View {
VStack(spacing: 0) {
Pager(page: controlsPage, data: Pages.allCases, id: \.self) { index in
switch index {
case .details:
details
default:
controls
}
}
.pagingPriority(.simultaneous)
}
.buttonStyle(.plain)
.labelStyle(.iconOnly)
.padding(.horizontal)
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: barHeight)
.borderTop(height: 0.4, color: Color("ControlsBorderColor"))
.borderBottom(height: navigationStyle == .sidebar ? 0 : 0.4, color: Color("ControlsBorderColor"))
.modifier(ControlBackgroundModifier(edgesIgnoringSafeArea: .bottom))
}
var controls: some View {
HStack(spacing: 4) {
Group {
Button {
model.closeCurrentItem()
model.closePiP()
} label: {
Label("Close Video", systemImage: "xmark")
.padding(.horizontal, 4)
.contentShape(Rectangle())
}
Spacer()
Button(action: { model.backend.seek(to: 0) }) {
Label("Restart", systemImage: "backward.end.fill")
.contentShape(Rectangle())
}
Spacer()
Button {
model.backend.seek(relative: .secondsInDefaultTimescale(-10))
} label: {
Label("Backward", systemImage: "gobackward.10")
}
Spacer()
if playerControls.isPlaying {
Button(action: {
model.pause()
}) {
Label("Pause", systemImage: "pause.fill")
.padding(.horizontal, 4)
.contentShape(Rectangle())
}
} else {
Button(action: {
model.play()
}) {
Label("Play", systemImage: "play.fill")
.padding(.horizontal, 4)
.contentShape(Rectangle())
}
}
Spacer()
Button {
model.backend.seek(relative: .secondsInDefaultTimescale(10))
} label: {
Label("Forward", systemImage: "goforward.10")
}
Spacer()
}
.disabled(playerControls.isLoadingVideo || model.currentItem.isNil)
Button(action: { model.advanceToNextItem() }) {
Label("Next", systemImage: "forward.fill")
.contentShape(Rectangle())
}
.disabled(model.queue.isEmpty)
Spacer()
}
.padding(.vertical)
.font(.system(size: 24))
.frame(maxWidth: .infinity)
}
var barHeight: Double {
75
}
var details: some View {
HStack {
HStack(spacing: 8) {
authorAvatar
.contextMenu {
if let video = model.currentVideo {
Group {
Section {
Text(video.title)
if accounts.app.supportsUserPlaylists && accounts.signedIn {
Section {
Button {
navigation.presentAddToPlaylist(video)
} label: {
Label("Add to Playlist...", systemImage: "text.badge.plus")
}
if let playlist = playlists.lastUsed, let video = model.currentVideo {
Button {
playlists.addVideo(playlistID: playlist.id, videoID: video.videoID, navigation: navigation)
} label: {
Label("Add to \(playlist.title)", systemImage: "text.badge.star")
}
}
Button {} label: {
Label("Share", systemImage: "square.and.arrow.up")
}
}
}
Section {
Button {
NavigationModel.openChannel(
video.channel,
player: model,
recents: recents,
navigation: navigation,
navigationStyle: navigationStyle
)
} label: {
Label("\(video.author) Channel", systemImage: "rectangle.stack.fill.badge.person.crop")
}
Button {} label: {
Label("Unsubscribe", systemImage: "xmark.circle")
}
}
}
}
.labelStyle(.automatic)
}
}
VStack(alignment: .leading, spacing: 5) {
Text(model.currentVideo?.title ?? "Not playing")
.font(.system(size: 14))
.fontWeight(.semibold)
.foregroundColor(model.currentVideo.isNil ? .secondary : .accentColor)
.lineLimit(1)
Text(model.currentVideo?.author ?? "")
.font(.system(size: 12))
.foregroundColor(.secondary)
.lineLimit(1)
}
}
.buttonStyle(.plain)
.padding(.vertical)
Spacer()
}
}
private var authorAvatar: some View {
Button {
model.togglePlayer()
} label: {
if let video = model.currentItem?.video, let url = video.channel.thumbnailURL {
WebImage(url: url)
.resizable()
.placeholder {
Rectangle().fill(Color("PlaceholderColor"))
}
.retryOnAppear(true)
.indicator(.activity)
} else {
Image(systemName: "play.rectangle")
.foregroundColor(.accentColor)
.font(.system(size: 30))
}
}
.frame(width: 44, height: 44, alignment: .leading)
.clipShape(Circle())
}
}
struct ControlsBar_Previews: PreviewProvider {
static var previews: some View {
ControlsBar()
.injectFixtureEnvironmentObjects()
}
}

View File

@@ -77,9 +77,10 @@ struct VideoContextMenuView: View {
}
}
if accounts.app.supportsUserPlaylists {
if accounts.app.supportsUserPlaylists, accounts.signedIn {
Section {
addToPlaylistButton
addToLastPlaylistButton
if let id = navigation.tabSelection?.playlistID ?? playlistID {
removeFromPlaylistButton(playlistID: id)
@@ -116,7 +117,7 @@ struct VideoContextMenuView: View {
Button {
player.play(video, at: .secondsInDefaultTimescale(watch!.stoppedAt))
} label: {
Label("Continue from \(watch!.stoppedAt.formattedAsPlaybackTime() ?? "where I left off")", systemImage: "playpause")
Label("Continue from \(watch!.stoppedAt.formattedAsPlaybackTime(allowZero: true) ?? "where I left off")", systemImage: "playpause")
}
}
@@ -230,6 +231,16 @@ struct VideoContextMenuView: View {
}
}
@ViewBuilder private var addToLastPlaylistButton: some View {
if let playlist = playlists.lastUsed {
Button {
playlists.addVideo(playlistID: playlist.id, videoID: video.videoID, navigation: navigation)
} label: {
Label("Add to \(playlist.title)", systemImage: "text.badge.star")
}
}
}
func removeFromPlaylistButton(playlistID: String) -> some View {
Button {
playlists.removeVideo(index: video.indexID!, playlistID: playlistID)

View File

@@ -1,4 +1,9 @@
import Defaults
import MediaPlayer
import PINCache
import SDWebImage
import SDWebImageWebPCoder
import Siesta
import SwiftUI
@main
@@ -11,19 +16,27 @@ struct YatteeApp: App {
Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "unknown"
}
static var isForPreviews: Bool {
ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1"
}
#if os(macOS)
@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
#elseif os(iOS)
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
#endif
@State private var configured = false
@StateObject private var accounts = AccountsModel()
@StateObject private var comments = CommentsModel()
@StateObject private var instances = InstancesModel()
@StateObject private var menu = MenuModel()
@StateObject private var navigation = NavigationModel()
@StateObject private var networkState = NetworkStateModel()
@StateObject private var player = PlayerModel()
@StateObject private var playerControls = PlayerControlsModel()
@StateObject private var playerTime = PlayerTimeModel()
@StateObject private var playlists = PlaylistsModel()
@StateObject private var recents = RecentsModel()
@StateObject private var search = SearchModel()
@@ -35,13 +48,16 @@ struct YatteeApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.onAppear(perform: configure)
.environment(\.managedObjectContext, persistenceController.container.viewContext)
.environmentObject(accounts)
.environmentObject(comments)
.environmentObject(instances)
.environmentObject(navigation)
.environmentObject(networkState)
.environmentObject(player)
.environmentObject(playerControls)
.environmentObject(playerTime)
.environmentObject(playlists)
.environmentObject(recents)
.environmentObject(subscriptions)
@@ -86,6 +102,7 @@ struct YatteeApp: App {
#if os(macOS)
WindowGroup(player.windowTitle) {
VideoPlayerView()
.onAppear(perform: configure)
.background(
HostingWindowFinder { window in
Windows.playerWindow = window
@@ -96,7 +113,7 @@ struct YatteeApp: App {
queue: OperationQueue.main
) { _ in
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
self.player.controls.playingFullscreen = false
self.player.playingFullScreen = false
}
}
}
@@ -109,8 +126,10 @@ struct YatteeApp: App {
.environmentObject(comments)
.environmentObject(instances)
.environmentObject(navigation)
.environmentObject(networkState)
.environmentObject(player)
.environmentObject(playerControls)
.environmentObject(playerTime)
.environmentObject(playlists)
.environmentObject(recents)
.environmentObject(subscriptions)
@@ -129,4 +148,132 @@ struct YatteeApp: App {
}
#endif
}
func configure() {
guard !Self.isForPreviews, !configured else {
return
}
configured = true
SiestaLog.Category.enabled = .common
SDImageCodersManager.shared.addCoder(SDImageWebPCoder.shared)
SDWebImageManager.defaultImageCache = PINCache(name: "stream.yattee.app")
#if !os(macOS)
configureNowPlayingInfoCenter()
#endif
#if os(iOS)
if Defaults[.lockPortraitWhenBrowsing] {
Orientation.lockOrientation(.portrait, andRotateTo: .portrait)
}
#endif
if let account = accounts.lastUsed ??
instances.lastUsed?.anonymousAccount ??
InstancesModel.all.first?.anonymousAccount
{
accounts.setCurrent(account)
}
if accounts.current.isNil {
navigation.presentingWelcomeScreen = true
}
playlists.accounts = accounts
search.accounts = accounts
subscriptions.accounts = accounts
comments.player = player
menu.accounts = accounts
menu.navigation = navigation
menu.player = player
playerControls.player = player
player.accounts = accounts
player.comments = comments
player.controls = playerControls
player.networkState = networkState
player.playerTime = playerTime
if !accounts.current.isNil {
player.restoreQueue()
}
if !Defaults[.saveRecents] {
recents.clear()
}
var section = Defaults[.visibleSections].min()?.tabSelection
#if os(macOS)
if section == .playlists {
section = .search
}
#endif
navigation.tabSelection = section ?? .search
subscriptions.load()
playlists.load()
#if os(macOS)
Windows.player.open()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
Windows.main.focus()
}
#endif
}
func configureNowPlayingInfoCenter() {
#if !os(macOS)
try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .moviePlayback)
UIApplication.shared.beginReceivingRemoteControlEvents()
#endif
MPRemoteCommandCenter.shared().playCommand.addTarget { _ in
player.play()
return .success
}
MPRemoteCommandCenter.shared().pauseCommand.addTarget { _ in
player.pause()
return .success
}
MPRemoteCommandCenter.shared().previousTrackCommand.isEnabled = false
MPRemoteCommandCenter.shared().nextTrackCommand.isEnabled = false
MPRemoteCommandCenter.shared().changePlaybackPositionCommand.addTarget { remoteEvent in
guard let event = remoteEvent as? MPChangePlaybackPositionCommandEvent
else {
return .commandFailed
}
player.backend.seek(to: event.positionTime)
return .success
}
let skipForwardCommand = MPRemoteCommandCenter.shared().skipForwardCommand
skipForwardCommand.isEnabled = true
skipForwardCommand.preferredIntervals = [10]
skipForwardCommand.addTarget { _ in
player.backend.seek(relative: .secondsInDefaultTimescale(10))
return .success
}
let skipBackwardCommand = MPRemoteCommandCenter.shared().skipBackwardCommand
skipBackwardCommand.isEnabled = true
skipBackwardCommand.preferredIntervals = [10]
skipBackwardCommand.addTarget { _ in
player.backend.seek(relative: .secondsInDefaultTimescale(-10))
return .success
}
}
}