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 9c98cf9558
commit 321c265a11
60 changed files with 2524 additions and 1320 deletions

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)