yattee/Shared/Player/Controls/PlayerControls.swift

451 lines
13 KiB
Swift
Raw Normal View History

2022-02-16 20:23:11 +00:00
import Foundation
2022-06-07 21:27:48 +00:00
import SDWebImageSwiftUI
2022-02-16 20:23:11 +00:00
import SwiftUI
struct PlayerControls: View {
2022-02-21 20:57:12 +00:00
static let animation = Animation.easeInOut(duration: 0.2)
2022-02-16 20:23:11 +00:00
private var player: PlayerModel!
2022-06-07 21:27:48 +00:00
private var thumbnails: ThumbnailsModel!
2022-02-16 21:51:37 +00:00
2022-02-16 20:23:11 +00:00
@EnvironmentObject<PlayerControlsModel> private var model
2022-02-27 20:31:17 +00:00
#if os(iOS)
@Environment(\.verticalSizeClass) private var verticalSizeClass
2022-03-27 19:22:13 +00:00
#elseif os(tvOS)
enum Field: Hashable {
case play
case backward
case forward
}
@FocusState private var focusedField: Field?
2022-02-27 20:31:17 +00:00
#endif
2022-02-16 20:23:11 +00:00
2022-06-07 21:27:48 +00:00
init(player: PlayerModel, thumbnails: ThumbnailsModel) {
2022-02-16 20:23:11 +00:00
self.player = player
2022-06-07 21:27:48 +00:00
self.thumbnails = thumbnails
2022-02-16 20:23:11 +00:00
}
var body: some View {
VStack {
ZStack(alignment: .bottom) {
2022-06-14 22:41:49 +00:00
VStack(alignment: .trailing, spacing: 4) {
#if !os(tvOS)
buttonsBar
2022-05-20 21:21:54 +00:00
2022-06-14 22:41:49 +00:00
HStack(spacing: 4) {
qualityButton
backendButton
2022-05-20 21:21:54 +00:00
}
2022-06-14 22:41:49 +00:00
#else
Text(player.stream?.description ?? "")
#endif
2022-02-16 20:23:11 +00:00
Spacer()
mediumButtonsBar
Spacer()
Group {
timeline
.offset(y: 10)
.zIndex(1)
2022-05-27 23:23:50 +00:00
HStack {
Spacer()
bottomBar
#if os(macOS)
.background(VisualEffectBlur(material: .hudWindow))
#elseif os(iOS)
.background(VisualEffectBlur(blurStyle: .systemThinMaterial))
#endif
.mask(RoundedRectangle(cornerRadius: 3))
}
}
.padding(.horizontal, 16)
2022-02-16 20:23:11 +00:00
}
}
2022-06-14 22:41:49 +00:00
.padding(.top, 4)
.padding(.horizontal, 4)
2022-02-16 20:23:11 +00:00
.opacity(model.presentingControls ? 1 : 0)
}
2022-03-27 19:22:13 +00:00
#if os(tvOS)
.onChange(of: model.presentingControls) { _ in
if model.presentingControls {
focusedField = .play
}
}
.onChange(of: focusedField) { _ in
model.resetTimer()
}
#else
2022-05-20 21:21:54 +00:00
.background(PlayerGestures())
2022-06-07 21:27:48 +00:00
.background(controlsBackground)
2022-03-27 19:22:13 +00:00
#endif
.environment(\.colorScheme, .dark)
2022-02-16 20:23:11 +00:00
}
2022-06-07 21:27:48 +00:00
@ViewBuilder var controlsBackground: some View {
if player.musicMode,
let item = self.player.currentItem,
let url = thumbnails.best(item.video)
{
WebImage(url: url)
.resizable()
.placeholder {
Rectangle().fill(Color("PlaceholderColor"))
}
.retryOnAppear(true)
.indicator(.activity)
}
}
2022-02-16 20:23:11 +00:00
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) }
)
}
private var hidePlayerButton: some View {
button("Hide", systemImage: "chevron.down") {
2022-02-16 20:23:11 +00:00
player.hide()
}
2022-03-27 19:22:13 +00:00
#if !os(tvOS)
2022-02-16 23:01:48 +00:00
.keyboardShortcut(.cancelAction)
2022-03-27 19:22:13 +00:00
#endif
2022-02-16 20:23:11 +00:00
}
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 {
2022-06-07 22:05:16 +00:00
HStack {
2022-03-27 19:22:13 +00:00
#if !os(tvOS)
2022-05-27 23:23:50 +00:00
#if os(iOS)
hidePlayerButton
#endif
2022-03-27 19:22:13 +00:00
fullscreenButton
2022-06-07 22:05:16 +00:00
2022-05-20 21:23:14 +00:00
#if os(iOS)
pipButton
#endif
2022-04-16 20:50:37 +00:00
2022-06-07 22:05:16 +00:00
Spacer()
2022-06-07 22:05:16 +00:00
rateButton
2022-06-07 21:27:48 +00:00
2022-06-07 22:05:16 +00:00
musicModeButton
closeVideoButton
2022-03-27 19:22:13 +00:00
#endif
2022-02-16 20:23:11 +00:00
}
}
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 12:23:42 +00:00
player.toggleFullscreen(fullScreenLayout)
2022-02-16 20:23:11 +00:00
}
2022-03-27 19:22:13 +00:00
#if !os(tvOS)
2022-02-16 20:23:11 +00:00
.keyboardShortcut(fullScreenLayout ? .cancelAction : .defaultAction)
2022-03-27 19:22:13 +00:00
#endif
2022-02-16 20:23:11 +00:00
}
2022-05-20 21:20:18 +00:00
@ViewBuilder private var rateButton: some View {
2022-04-16 20:50:37 +00:00
#if os(macOS)
ratePicker
.labelsHidden()
.frame(maxWidth: 70)
2022-05-20 21:20:18 +00:00
#elseif os(iOS)
2022-04-16 20:50:37 +00:00
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)
}
2022-05-20 21:20:18 +00:00
.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))
2022-04-16 20:50:37 +00:00
#endif
}
2022-06-14 22:41:49 +00:00
@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 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()
player.hide()
player.closePiP()
var delay = 0.2
2022-05-27 23:23:50 +00:00
#if os(macOS)
delay = 0.0
#endif
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
player.closeCurrentItem()
}
}
}
2022-06-07 22:05:16 +00:00
private var musicModeButton: some View {
button("Music Mode", systemImage: "music.note", active: player.musicMode, action: player.toggleMusicMode)
.disabled(player.activeBackend == .appleAVPlayer)
}
2022-04-16 20:50:37 +00:00
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-05-20 21:23:14 +00:00
private var pipButton: some View {
button("PiP", systemImage: "pip") {
2022-05-29 14:38:37 +00:00
model.startPiP()
2022-05-20 21:23:14 +00:00
}
}
2022-02-16 20:23:11 +00:00
var mediumButtonsBar: some View {
HStack {
2022-03-27 19:22:13 +00:00
#if !os(tvOS)
restartVideoButton
.padding(.trailing, 15)
button("Seek Backward", systemImage: "gobackward.10", size: 30, cornerRadius: 5) {
2022-03-27 19:22:13 +00:00
player.backend.seek(relative: .secondsInDefaultTimescale(-10))
}
#if os(tvOS)
.focused($focusedField, equals: .backward)
#else
2022-04-03 11:45:54 +00:00
.keyboardShortcut("k", modifiers: [])
.keyboardShortcut(KeyEquivalent.leftArrow, modifiers: [])
2022-03-27 19:22:13 +00:00
#endif
#endif
2022-02-16 20:23:11 +00:00
Spacer()
button(
model.isPlaying ? "Pause" : "Play",
systemImage: model.isPlaying ? "pause.fill" : "play.fill",
size: 30, cornerRadius: 5
2022-02-16 20:23:11 +00:00
) {
player.backend.togglePlay()
}
2022-03-27 19:22:13 +00:00
#if os(tvOS)
.focused($focusedField, equals: .play)
#else
2022-02-16 20:23:11 +00:00
.keyboardShortcut("p")
2022-03-27 19:22:13 +00:00
.keyboardShortcut(.space)
#endif
2022-02-16 20:23:11 +00:00
.disabled(model.isLoadingVideo)
Spacer()
2022-03-27 19:22:13 +00:00
#if !os(tvOS)
button("Seek Forward", systemImage: "goforward.10", size: 30, cornerRadius: 5) {
2022-03-27 19:22:13 +00:00
player.backend.seek(relative: .secondsInDefaultTimescale(10))
}
#if os(tvOS)
.focused($focusedField, equals: .forward)
#else
2022-04-03 11:45:54 +00:00
.keyboardShortcut("l", modifiers: [])
.keyboardShortcut(KeyEquivalent.rightArrow, modifiers: [])
2022-03-27 19:22:13 +00:00
#endif
advanceToNextItemButton
.padding(.leading, 15)
2022-03-27 19:22:13 +00:00
#endif
2022-02-16 20:23:11 +00:00
}
.font(.system(size: 20))
2022-02-16 20:23:11 +00:00
}
private var restartVideoButton: some View {
button("Restart video", systemImage: "backward.end.fill", size: 30, cornerRadius: 5) {
player.backend.seek(to: 0.0)
}
}
private var advanceToNextItemButton: some View {
button("Next", systemImage: "forward.fill", size: 30, cornerRadius: 5) {
player.advanceToNextItem()
}
.disabled(player.queue.isEmpty)
}
2022-02-16 20:23:11 +00:00
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,
2022-06-14 22:41:49 +00:00
systemImage: String? = nil,
2022-02-16 20:23:11 +00:00
size: Double = 30,
2022-06-14 22:41:49 +00:00
width: Double? = nil,
height: Double? = nil,
2022-02-16 20:23:11 +00:00
cornerRadius: Double = 3,
2022-06-07 21:27:48 +00:00
active: Bool = false,
2022-02-16 20:23:11 +00:00
action: @escaping () -> Void = {}
) -> some View {
Button {
action()
model.resetTimer()
} label: {
2022-06-14 22:41:49 +00:00
Group {
if let image = systemImage {
Label(label, systemImage: image)
.labelStyle(.iconOnly)
} else {
Label(label, systemImage: "")
.labelStyle(.titleOnly)
}
}
.padding()
.contentShape(Rectangle())
2022-02-16 20:23:11 +00:00
}
2022-02-27 20:31:17 +00:00
.buttonStyle(.plain)
2022-06-07 21:27:48 +00:00
.foregroundColor(active ? .accentColor : .primary)
2022-06-14 22:41:49 +00:00
.frame(width: width ?? size, height: height ?? size)
2022-02-16 20:23:11 +00:00
#if os(macOS)
.background(VisualEffectBlur(material: .hudWindow))
#elseif os(iOS)
.background(VisualEffectBlur(blurStyle: .systemThinMaterial))
#endif
.mask(RoundedRectangle(cornerRadius: cornerRadius))
}
var fullScreenLayout: Bool {
2022-03-27 19:22:13 +00:00
#if os(iOS)
2022-02-27 20:31:17 +00:00
model.playingFullscreen || verticalSizeClass == .compact
#else
model.playingFullscreen
#endif
2022-02-16 20:23:11 +00:00
}
}
struct PlayerControls_Previews: PreviewProvider {
static var previews: some View {
let model = PlayerControlsModel()
model.presentingControls = true
model.currentTime = .secondsInDefaultTimescale(0)
model.duration = .secondsInDefaultTimescale(120)
2022-06-07 22:05:02 +00:00
return ZStack {
2022-05-27 23:23:50 +00:00
Color.gray
2022-06-07 21:27:48 +00:00
PlayerControls(player: PlayerModel(), thumbnails: ThumbnailsModel())
.injectFixtureEnvironmentObjects()
.environmentObject(model)
}
2022-02-16 20:23:11 +00:00
}
}