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

@@ -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
}