2022-02-17 01:53:11 +05:30
|
|
|
import Foundation
|
|
|
|
import SwiftUI
|
|
|
|
|
|
|
|
struct PlayerControls: View {
|
2022-02-22 02:27:12 +05:30
|
|
|
static let animation = Animation.easeInOut(duration: 0.2)
|
2022-02-17 01:53:11 +05:30
|
|
|
|
|
|
|
private var player: PlayerModel!
|
2022-02-17 03:21:37 +05:30
|
|
|
|
2022-02-17 01:53:11 +05:30
|
|
|
@EnvironmentObject<PlayerControlsModel> private var model
|
|
|
|
|
2022-02-28 02:01:17 +05:30
|
|
|
#if os(iOS)
|
|
|
|
@Environment(\.verticalSizeClass) private var verticalSizeClass
|
2022-03-28 00:52:13 +05:30
|
|
|
#elseif os(tvOS)
|
|
|
|
enum Field: Hashable {
|
|
|
|
case play
|
|
|
|
case backward
|
|
|
|
case forward
|
|
|
|
}
|
|
|
|
|
|
|
|
@FocusState private var focusedField: Field?
|
2022-02-28 02:01:17 +05:30
|
|
|
#endif
|
2022-02-17 01:53:11 +05:30
|
|
|
|
|
|
|
init(player: PlayerModel) {
|
|
|
|
self.player = player
|
|
|
|
}
|
|
|
|
|
|
|
|
var body: some View {
|
|
|
|
VStack {
|
|
|
|
ZStack(alignment: .bottom) {
|
|
|
|
VStack(spacing: 0) {
|
|
|
|
Group {
|
|
|
|
statusBar
|
|
|
|
.padding(3)
|
|
|
|
#if os(macOS)
|
|
|
|
.background(VisualEffectBlur(material: .hudWindow))
|
|
|
|
#elseif os(iOS)
|
|
|
|
.background(VisualEffectBlur(blurStyle: .systemThinMaterial))
|
|
|
|
#endif
|
|
|
|
.mask(RoundedRectangle(cornerRadius: 3))
|
|
|
|
|
|
|
|
buttonsBar
|
|
|
|
.padding(.top, 4)
|
|
|
|
.padding(.horizontal, 4)
|
|
|
|
}
|
|
|
|
|
|
|
|
Spacer()
|
|
|
|
|
|
|
|
mediumButtonsBar
|
|
|
|
|
|
|
|
Spacer()
|
|
|
|
|
|
|
|
timeline
|
|
|
|
.offset(y: 10)
|
|
|
|
.zIndex(1)
|
|
|
|
|
|
|
|
bottomBar
|
|
|
|
|
|
|
|
#if os(macOS)
|
|
|
|
.background(VisualEffectBlur(material: .hudWindow))
|
|
|
|
#elseif os(iOS)
|
|
|
|
.background(VisualEffectBlur(blurStyle: .systemThinMaterial))
|
|
|
|
#endif
|
|
|
|
.mask(RoundedRectangle(cornerRadius: 3))
|
|
|
|
}
|
|
|
|
}
|
|
|
|
.opacity(model.presentingControls ? 1 : 0)
|
|
|
|
}
|
2022-03-28 00:52:13 +05:30
|
|
|
#if os(tvOS)
|
|
|
|
.onChange(of: model.presentingControls) { _ in
|
|
|
|
if model.presentingControls {
|
|
|
|
focusedField = .play
|
|
|
|
}
|
|
|
|
}
|
|
|
|
.onChange(of: focusedField) { _ in
|
|
|
|
model.resetTimer()
|
|
|
|
}
|
|
|
|
#else
|
|
|
|
.background(controlsBackground)
|
|
|
|
#endif
|
|
|
|
.environment(\.colorScheme, .dark)
|
2022-02-17 01:53:11 +05:30
|
|
|
}
|
|
|
|
|
2022-03-28 00:52:13 +05:30
|
|
|
#if !os(tvOS)
|
|
|
|
var controlsBackground: some View {
|
|
|
|
PlayerGestures()
|
|
|
|
.background(Color.black.opacity(model.presentingControls ? 0.5 : 0))
|
|
|
|
}
|
|
|
|
#endif
|
2022-02-17 01:53:11 +05:30
|
|
|
|
|
|
|
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) }
|
|
|
|
)
|
|
|
|
}
|
|
|
|
|
|
|
|
var statusBar: some View {
|
|
|
|
HStack(spacing: 4) {
|
2022-02-28 02:01:17 +05:30
|
|
|
#if os(iOS)
|
|
|
|
hidePlayerButton
|
|
|
|
#endif
|
2022-02-17 01:53:11 +05:30
|
|
|
Text(playbackStatus)
|
2022-02-17 04:31:48 +05:30
|
|
|
|
2022-02-17 01:53:11 +05:30
|
|
|
Spacer()
|
2022-02-17 04:31:48 +05:30
|
|
|
|
2022-03-28 00:52:13 +05:30
|
|
|
#if !os(tvOS)
|
|
|
|
ToggleBackendButton()
|
|
|
|
Text("•")
|
|
|
|
|
|
|
|
StreamControl()
|
|
|
|
#if os(macOS)
|
|
|
|
.frame(maxWidth: 160)
|
|
|
|
#endif
|
|
|
|
#else
|
|
|
|
Text(player.stream?.description ?? "")
|
2022-02-28 02:01:17 +05:30
|
|
|
#endif
|
2022-02-17 01:53:11 +05:30
|
|
|
}
|
|
|
|
.foregroundColor(.primary)
|
|
|
|
.padding(.trailing, 4)
|
|
|
|
.font(.system(size: 14))
|
|
|
|
}
|
|
|
|
|
|
|
|
private var hidePlayerButton: some View {
|
|
|
|
Button {
|
|
|
|
player.hide()
|
|
|
|
} label: {
|
|
|
|
Image(systemName: "chevron.down.circle.fill")
|
|
|
|
}
|
2022-03-28 00:52:13 +05:30
|
|
|
#if !os(tvOS)
|
2022-02-17 04:31:48 +05:30
|
|
|
.keyboardShortcut(.cancelAction)
|
2022-03-28 00:52:13 +05:30
|
|
|
#endif
|
2022-02-17 01:53:11 +05:30
|
|
|
}
|
|
|
|
|
|
|
|
private var playbackStatus: String {
|
|
|
|
if player.live {
|
|
|
|
return "LIVE"
|
|
|
|
}
|
|
|
|
|
|
|
|
guard !player.isLoadingVideo else {
|
|
|
|
return "loading..."
|
|
|
|
}
|
|
|
|
|
|
|
|
let videoLengthAtRate = (player.currentVideo?.length ?? 0) / Double(player.currentRate)
|
|
|
|
let remainingSeconds = videoLengthAtRate - (player.time?.seconds ?? 0)
|
|
|
|
|
|
|
|
if remainingSeconds < 60 {
|
|
|
|
return "less than a minute"
|
|
|
|
}
|
|
|
|
|
|
|
|
let timeFinishAt = Date().addingTimeInterval(remainingSeconds)
|
|
|
|
|
|
|
|
return "ends at \(formattedTimeFinishAt(timeFinishAt))"
|
|
|
|
}
|
|
|
|
|
|
|
|
private func formattedTimeFinishAt(_ date: Date) -> String {
|
|
|
|
let dateFormatter = DateFormatter()
|
|
|
|
|
|
|
|
dateFormatter.dateStyle = .none
|
|
|
|
dateFormatter.timeStyle = .short
|
|
|
|
|
|
|
|
return dateFormatter.string(from: date)
|
|
|
|
}
|
|
|
|
|
|
|
|
var buttonsBar: some View {
|
|
|
|
HStack {
|
2022-03-28 00:52:13 +05:30
|
|
|
#if !os(tvOS)
|
|
|
|
fullscreenButton
|
2022-04-17 02:20:37 +05:30
|
|
|
rateButton
|
|
|
|
|
|
|
|
Spacer()
|
2022-03-28 00:52:13 +05:30
|
|
|
#endif
|
2022-02-22 02:27:12 +05:30
|
|
|
// button("Music Mode", systemImage: "music.note")
|
2022-02-17 01:53:11 +05:30
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
var fullscreenButton: some View {
|
|
|
|
button(
|
|
|
|
"Fullscreen",
|
|
|
|
systemImage: fullScreenLayout ? "arrow.down.right.and.arrow.up.left" : "arrow.up.left.and.arrow.down.right"
|
|
|
|
) {
|
2022-04-03 17:53:42 +05:30
|
|
|
player.toggleFullscreen(fullScreenLayout)
|
2022-02-17 01:53:11 +05:30
|
|
|
}
|
2022-03-28 00:52:13 +05:30
|
|
|
#if !os(tvOS)
|
2022-02-17 01:53:11 +05:30
|
|
|
.keyboardShortcut(fullScreenLayout ? .cancelAction : .defaultAction)
|
2022-03-28 00:52:13 +05:30
|
|
|
#endif
|
2022-02-17 01:53:11 +05:30
|
|
|
}
|
|
|
|
|
2022-04-17 02:20:37 +05:30
|
|
|
private var rateButton: some View {
|
|
|
|
#if os(macOS)
|
|
|
|
ratePicker
|
|
|
|
.labelsHidden()
|
|
|
|
.frame(maxWidth: 70)
|
|
|
|
#else
|
|
|
|
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)
|
|
|
|
.frame(maxWidth: .infinity)
|
|
|
|
}
|
|
|
|
|
|
|
|
#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 })
|
|
|
|
}
|
|
|
|
|
2022-02-17 01:53:11 +05:30
|
|
|
var mediumButtonsBar: some View {
|
|
|
|
HStack {
|
2022-03-28 00:52:13 +05:30
|
|
|
#if !os(tvOS)
|
|
|
|
button("Seek Backward", systemImage: "gobackward.10", size: 50, cornerRadius: 10) {
|
|
|
|
player.backend.seek(relative: .secondsInDefaultTimescale(-10))
|
|
|
|
}
|
|
|
|
|
|
|
|
#if os(tvOS)
|
|
|
|
.focused($focusedField, equals: .backward)
|
|
|
|
#else
|
2022-04-03 17:15:54 +05:30
|
|
|
.keyboardShortcut("k", modifiers: [])
|
|
|
|
.keyboardShortcut(KeyEquivalent.leftArrow, modifiers: [])
|
2022-03-28 00:52:13 +05:30
|
|
|
#endif
|
|
|
|
|
|
|
|
#endif
|
2022-02-17 01:53:11 +05:30
|
|
|
|
|
|
|
Spacer()
|
|
|
|
|
|
|
|
button(
|
|
|
|
model.isPlaying ? "Pause" : "Play",
|
|
|
|
systemImage: model.isPlaying ? "pause.fill" : "play.fill",
|
|
|
|
size: 50,
|
|
|
|
cornerRadius: 10
|
|
|
|
) {
|
|
|
|
player.backend.togglePlay()
|
|
|
|
}
|
2022-03-28 00:52:13 +05:30
|
|
|
#if os(tvOS)
|
|
|
|
.focused($focusedField, equals: .play)
|
|
|
|
#else
|
2022-02-17 01:53:11 +05:30
|
|
|
.keyboardShortcut("p")
|
2022-03-28 00:52:13 +05:30
|
|
|
.keyboardShortcut(.space)
|
|
|
|
#endif
|
2022-02-17 01:53:11 +05:30
|
|
|
.disabled(model.isLoadingVideo)
|
|
|
|
|
|
|
|
Spacer()
|
|
|
|
|
2022-03-28 00:52:13 +05:30
|
|
|
#if !os(tvOS)
|
|
|
|
button("Seek Forward", systemImage: "goforward.10", size: 50, cornerRadius: 10) {
|
|
|
|
player.backend.seek(relative: .secondsInDefaultTimescale(10))
|
|
|
|
}
|
|
|
|
#if os(tvOS)
|
|
|
|
.focused($focusedField, equals: .forward)
|
|
|
|
#else
|
2022-04-03 17:15:54 +05:30
|
|
|
.keyboardShortcut("l", modifiers: [])
|
|
|
|
.keyboardShortcut(KeyEquivalent.rightArrow, modifiers: [])
|
2022-03-28 00:52:13 +05:30
|
|
|
#endif
|
|
|
|
#endif
|
2022-02-17 01:53:11 +05:30
|
|
|
}
|
|
|
|
.font(.system(size: 30))
|
|
|
|
.padding(.horizontal, 4)
|
|
|
|
}
|
|
|
|
|
|
|
|
var bottomBar: some View {
|
|
|
|
HStack {
|
|
|
|
Spacer()
|
|
|
|
|
|
|
|
Text(model.playbackTime)
|
|
|
|
}
|
|
|
|
.font(.system(size: 15))
|
|
|
|
.padding(.horizontal, 5)
|
|
|
|
.padding(.vertical, 3)
|
|
|
|
.labelStyle(.iconOnly)
|
|
|
|
.foregroundColor(.primary)
|
|
|
|
}
|
|
|
|
|
|
|
|
func button(
|
|
|
|
_ label: String,
|
|
|
|
systemImage: String = "arrow.up.left.and.arrow.down.right",
|
|
|
|
size: Double = 30,
|
|
|
|
cornerRadius: Double = 3,
|
|
|
|
action: @escaping () -> Void = {}
|
|
|
|
) -> some View {
|
|
|
|
Button {
|
|
|
|
action()
|
|
|
|
model.resetTimer()
|
|
|
|
} label: {
|
|
|
|
Label(label, systemImage: systemImage)
|
|
|
|
.labelStyle(.iconOnly)
|
|
|
|
.padding()
|
|
|
|
.contentShape(Rectangle())
|
|
|
|
}
|
2022-02-28 02:01:17 +05:30
|
|
|
.buttonStyle(.plain)
|
2022-02-17 01:53:11 +05:30
|
|
|
.foregroundColor(.primary)
|
|
|
|
.frame(width: size, height: size)
|
|
|
|
#if os(macOS)
|
|
|
|
.background(VisualEffectBlur(material: .hudWindow))
|
|
|
|
#elseif os(iOS)
|
|
|
|
.background(VisualEffectBlur(blurStyle: .systemThinMaterial))
|
|
|
|
#endif
|
|
|
|
.mask(RoundedRectangle(cornerRadius: cornerRadius))
|
|
|
|
}
|
|
|
|
|
|
|
|
var fullScreenLayout: Bool {
|
2022-03-28 00:52:13 +05:30
|
|
|
#if os(iOS)
|
2022-02-28 02:01:17 +05:30
|
|
|
model.playingFullscreen || verticalSizeClass == .compact
|
|
|
|
#else
|
|
|
|
model.playingFullscreen
|
|
|
|
#endif
|
2022-02-17 01:53:11 +05:30
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
struct PlayerControls_Previews: PreviewProvider {
|
|
|
|
static var previews: some View {
|
|
|
|
PlayerControls(player: PlayerModel())
|
|
|
|
}
|
|
|
|
}
|