mirror of
https://github.com/yattee/yattee.git
synced 2025-08-09 20:24:06 +00:00
Player controls UI changes
WIP on controls Chapters working Add previews variable Add lists ids WIP
This commit is contained in:
22
Shared/Player/Controls/ControlBackgroundModifier.swift
Normal file
22
Shared/Player/Controls/ControlBackgroundModifier.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
185
Shared/Player/Controls/ControlsOverlay.swift
Normal file
185
Shared/Player/Controls/ControlsOverlay.swift
Normal 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())
|
||||
}
|
||||
}
|
37
Shared/Player/Controls/OSD/Buffering.swift
Normal file
37
Shared/Player/Controls/OSD/Buffering.swift
Normal 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)")
|
||||
}
|
||||
}
|
22
Shared/Player/Controls/OSD/NetworkState.swift
Normal file
22
Shared/Player/Controls/OSD/NetworkState.swift
Normal 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())
|
||||
}
|
||||
}
|
37
Shared/Player/Controls/OSD/OpeningStream.swift
Normal file
37
Shared/Player/Controls/OSD/OpeningStream.swift
Normal 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()
|
||||
}
|
||||
}
|
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user