mirror of
https://github.com/yattee/yattee.git
synced 2025-08-05 02:04:07 +00:00
Quality profiles
This commit is contained in:
@@ -6,43 +6,125 @@ struct ControlsOverlay: View {
|
||||
@EnvironmentObject<PlayerModel> private var player
|
||||
@EnvironmentObject<PlayerControlsModel> private var model
|
||||
|
||||
@State private var contentSize: CGSize = .zero
|
||||
|
||||
@Default(.showMPVPlaybackStats) private var showMPVPlaybackStats
|
||||
@Default(.qualityProfiles) private var qualityProfiles
|
||||
|
||||
#if os(tvOS)
|
||||
enum Field: Hashable {
|
||||
case qualityProfile
|
||||
case stream
|
||||
case increaseRate
|
||||
case decreaseRate
|
||||
case captions
|
||||
}
|
||||
|
||||
@FocusState private var focusedField: Field?
|
||||
#endif
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 6) {
|
||||
HStack {
|
||||
backendButtons
|
||||
}
|
||||
qualityButton
|
||||
VStack {
|
||||
Section(header: controlsHeader("Rate & Captions")) {
|
||||
HStack(spacing: rateButtonsSpacing) {
|
||||
decreaseRateButton
|
||||
#if os(tvOS)
|
||||
.focused($focusedField, equals: .decreaseRate)
|
||||
#endif
|
||||
rateButton
|
||||
increaseRateButton
|
||||
#if os(tvOS)
|
||||
.focused($focusedField, equals: .increaseRate)
|
||||
#endif
|
||||
}
|
||||
|
||||
if player.activeBackend == .mpv {
|
||||
captionsButton
|
||||
#if os(tvOS)
|
||||
.focused($focusedField, equals: .captions)
|
||||
#endif
|
||||
.disabled(player.activeBackend != .mpv)
|
||||
|
||||
#if os(iOS)
|
||||
.foregroundColor(.white)
|
||||
#endif
|
||||
}
|
||||
|
||||
HStack {
|
||||
decreaseRateButton
|
||||
rateButton
|
||||
increaseRateButton
|
||||
Section(header: controlsHeader("Quality Profile")) {
|
||||
qualityProfileButton
|
||||
#if os(tvOS)
|
||||
.focused($focusedField, equals: .qualityProfile)
|
||||
#endif
|
||||
}
|
||||
|
||||
Section(header: controlsHeader("Stream & Player")) {
|
||||
qualityButton
|
||||
#if os(tvOS)
|
||||
.focused($focusedField, equals: .stream)
|
||||
#endif
|
||||
|
||||
#if !os(tvOS)
|
||||
HStack {
|
||||
backendButtons
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#if os(iOS)
|
||||
.foregroundColor(.white)
|
||||
#endif
|
||||
|
||||
if player.activeBackend == .mpv,
|
||||
showMPVPlaybackStats
|
||||
{
|
||||
mpvPlaybackStats
|
||||
Section(header: controlsHeader("Statistics")) {
|
||||
mpvPlaybackStats
|
||||
}
|
||||
#if os(tvOS)
|
||||
.frame(width: 400)
|
||||
#else
|
||||
.frame(width: 240)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
.overlay(
|
||||
GeometryReader { geometry in
|
||||
Color.clear.onAppear {
|
||||
contentSize = geometry.size
|
||||
}
|
||||
}
|
||||
)
|
||||
#if os(tvOS)
|
||||
.padding(.horizontal, 40)
|
||||
#endif
|
||||
}
|
||||
.frame(maxHeight: overlayHeight)
|
||||
#if os(tvOS)
|
||||
.onAppear {
|
||||
focusedField = .qualityProfile
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private var overlayHeight: Double {
|
||||
#if os(tvOS)
|
||||
contentSize.height + 50.0
|
||||
#else
|
||||
contentSize.height
|
||||
#endif
|
||||
}
|
||||
|
||||
private func controlsHeader(_ text: String) -> some View {
|
||||
Text(text)
|
||||
.font(.system(.caption))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
private var backendButtons: some View {
|
||||
ForEach(PlayerBackendType.allCases, id: \.self) { backend in
|
||||
backendButton(backend)
|
||||
.frame(height: 40)
|
||||
#if os(iOS)
|
||||
.frame(maxWidth: 115)
|
||||
.modifier(ControlBackgroundModifier())
|
||||
.clipShape(RoundedRectangle(cornerRadius: 4))
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,11 +136,48 @@ struct ControlsOverlay: View {
|
||||
}
|
||||
} label: {
|
||||
Text(backend.label)
|
||||
.padding(6)
|
||||
.foregroundColor(player.activeBackend == backend ? .accentColor : .secondary)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
#if os(macOS)
|
||||
.buttonStyle(.bordered)
|
||||
#else
|
||||
.modifier(ControlBackgroundModifier())
|
||||
.clipShape(RoundedRectangle(cornerRadius: 4))
|
||||
#endif
|
||||
}
|
||||
|
||||
@ViewBuilder private var rateButton: some View {
|
||||
#if os(macOS)
|
||||
ratePicker
|
||||
.labelsHidden()
|
||||
.frame(maxWidth: 100)
|
||||
#elseif os(iOS)
|
||||
Menu {
|
||||
ratePicker
|
||||
} label: {
|
||||
Text(player.rateLabel(player.currentRate))
|
||||
.foregroundColor(.primary)
|
||||
.frame(width: 123)
|
||||
}
|
||||
.transaction { t in t.animation = .none }
|
||||
.buttonStyle(.plain)
|
||||
.foregroundColor(.primary)
|
||||
.frame(width: 123, height: 40)
|
||||
.modifier(ControlBackgroundModifier())
|
||||
.mask(RoundedRectangle(cornerRadius: 3))
|
||||
#else
|
||||
Text(player.rateLabel(player.currentRate))
|
||||
.frame(minWidth: 120)
|
||||
#endif
|
||||
}
|
||||
|
||||
var ratePicker: some View {
|
||||
Picker("Rate", selection: $player.currentRate) {
|
||||
ForEach(PlayerModel.availableRates, id: \.self) { rate in
|
||||
Text(player.rateLabel(rate)).tag(rate)
|
||||
}
|
||||
}
|
||||
.transaction { t in t.animation = .none }
|
||||
}
|
||||
|
||||
private var increaseRateButton: some View {
|
||||
@@ -72,12 +191,12 @@ struct ControlsOverlay: View {
|
||||
.foregroundColor(.primary)
|
||||
.labelStyle(.iconOnly)
|
||||
.padding(8)
|
||||
.frame(height: 30)
|
||||
.frame(width: 50, height: 40)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
#if os(macOS)
|
||||
.buttonStyle(.bordered)
|
||||
#else
|
||||
#elseif os(iOS)
|
||||
.modifier(ControlBackgroundModifier())
|
||||
.clipShape(RoundedRectangle(cornerRadius: 4))
|
||||
#endif
|
||||
@@ -96,18 +215,76 @@ struct ControlsOverlay: View {
|
||||
.foregroundColor(.primary)
|
||||
.labelStyle(.iconOnly)
|
||||
.padding(8)
|
||||
.frame(height: 30)
|
||||
.frame(width: 50, height: 40)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
#if os(macOS)
|
||||
.buttonStyle(.bordered)
|
||||
#else
|
||||
#elseif os(iOS)
|
||||
.modifier(ControlBackgroundModifier())
|
||||
.clipShape(RoundedRectangle(cornerRadius: 4))
|
||||
#endif
|
||||
.disabled(decreasedRate.isNil)
|
||||
}
|
||||
|
||||
private var rateButtonsSpacing: Double {
|
||||
#if os(tvOS)
|
||||
10
|
||||
#else
|
||||
8
|
||||
#endif
|
||||
}
|
||||
|
||||
@ViewBuilder private var qualityProfileButton: some View {
|
||||
#if os(macOS)
|
||||
qualityProfilePicker
|
||||
.labelsHidden()
|
||||
.frame(maxWidth: 300)
|
||||
#elseif os(iOS)
|
||||
Menu {
|
||||
qualityProfilePicker
|
||||
} label: {
|
||||
Text(player.qualityProfileSelection?.description ?? "Auto")
|
||||
.frame(maxWidth: 240)
|
||||
}
|
||||
.transaction { t in t.animation = .none }
|
||||
.buttonStyle(.plain)
|
||||
.foregroundColor(.primary)
|
||||
.frame(maxWidth: 240)
|
||||
.frame(height: 40)
|
||||
.modifier(ControlBackgroundModifier())
|
||||
.mask(RoundedRectangle(cornerRadius: 3))
|
||||
#else
|
||||
Button {} label: {
|
||||
Text(player.qualityProfileSelection?.description ?? "Auto")
|
||||
.lineLimit(1)
|
||||
.frame(maxWidth: 320)
|
||||
}
|
||||
.contextMenu {
|
||||
ForEach(qualityProfiles) { qualityProfile in
|
||||
Button("Default") { player.qualityProfileSelection = nil }
|
||||
Button {
|
||||
player.qualityProfileSelection = qualityProfile
|
||||
} label: {
|
||||
Text(qualityProfile.description)
|
||||
}
|
||||
|
||||
Button("Cancel", role: .cancel) {}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private var qualityProfilePicker: some View {
|
||||
Picker("Quality Profile", selection: $player.qualityProfileSelection) {
|
||||
Text("Automatic").tag(QualityProfile?.none)
|
||||
ForEach(qualityProfiles) { qualityProfile in
|
||||
Text(qualityProfile.description).tag(qualityProfile as QualityProfile?)
|
||||
}
|
||||
}
|
||||
.transaction { t in t.animation = .none }
|
||||
}
|
||||
|
||||
@ViewBuilder private var qualityButton: some View {
|
||||
#if os(macOS)
|
||||
StreamControl()
|
||||
@@ -116,23 +293,20 @@ struct ControlsOverlay: View {
|
||||
#elseif os(iOS)
|
||||
Menu {
|
||||
StreamControl()
|
||||
.frame(width: 45, height: 30)
|
||||
#if os(iOS)
|
||||
.modifier(ControlBackgroundModifier())
|
||||
#endif
|
||||
.mask(RoundedRectangle(cornerRadius: 3))
|
||||
} label: {
|
||||
Text(player.streamSelection?.shortQuality ?? "loading")
|
||||
.frame(width: 140, height: 30)
|
||||
.frame(width: 140, height: 40)
|
||||
.foregroundColor(.primary)
|
||||
}
|
||||
.transaction { t in t.animation = .none }
|
||||
|
||||
.buttonStyle(.plain)
|
||||
.foregroundColor(.primary)
|
||||
.frame(width: 140, height: 30)
|
||||
.frame(width: 240, height: 40)
|
||||
.modifier(ControlBackgroundModifier())
|
||||
.mask(RoundedRectangle(cornerRadius: 3))
|
||||
#else
|
||||
StreamControl()
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -144,8 +318,6 @@ struct ControlsOverlay: View {
|
||||
#elseif os(iOS)
|
||||
Menu {
|
||||
captionsPicker
|
||||
.frame(width: 140, height: 30)
|
||||
.mask(RoundedRectangle(cornerRadius: 3))
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "text.bubble")
|
||||
@@ -154,14 +326,32 @@ struct ControlsOverlay: View {
|
||||
.foregroundColor(.primary)
|
||||
}
|
||||
}
|
||||
.frame(width: 140, height: 30)
|
||||
.frame(width: 240)
|
||||
.frame(height: 40)
|
||||
}
|
||||
.transaction { t in t.animation = .none }
|
||||
.buttonStyle(.plain)
|
||||
.foregroundColor(.primary)
|
||||
.frame(width: 140, height: 30)
|
||||
.frame(width: 240)
|
||||
.modifier(ControlBackgroundModifier())
|
||||
.mask(RoundedRectangle(cornerRadius: 3))
|
||||
#else
|
||||
Button {} label: {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "text.bubble")
|
||||
if let captions = captionsBinding.wrappedValue {
|
||||
Text(captions.code)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: 320)
|
||||
}
|
||||
.contextMenu {
|
||||
ForEach(player.currentVideo?.captions ?? []) { caption in
|
||||
Button(caption.description) { captionsBinding.wrappedValue = caption }
|
||||
}
|
||||
Button("Cancel", role: .cancel) {}
|
||||
}
|
||||
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -190,58 +380,29 @@ struct ControlsOverlay: View {
|
||||
)
|
||||
}
|
||||
|
||||
@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)
|
||||
.frame(width: 80)
|
||||
}
|
||||
.transaction { t in t.animation = .none }
|
||||
.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%%", networkState.bufferingState))")
|
||||
Text("cache: \(String(format: "%.2fs", player.mpvBackend.cacheDuration))")
|
||||
}
|
||||
.mask(RoundedRectangle(cornerRadius: 3))
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
mpvPlaybackStatRow("Hardware decoder", player.mpvBackend.hwDecoder)
|
||||
mpvPlaybackStatRow("Dropped frames", String(player.mpvBackend.frameDropCount))
|
||||
mpvPlaybackStatRow("Stream FPS", String(format: "%.2ffps", player.mpvBackend.outputFps))
|
||||
mpvPlaybackStatRow("Cached time", String(format: "%.2fs", player.mpvBackend.cacheDuration))
|
||||
}
|
||||
#if !os(tvOS)
|
||||
.font(.system(size: 9))
|
||||
.padding(.top, 2)
|
||||
#if os(tvOS)
|
||||
.font(.system(size: 20))
|
||||
#else
|
||||
.font(.system(size: 11))
|
||||
#endif
|
||||
}
|
||||
|
||||
func mpvPlaybackStatRow(_ label: String, _ value: String) -> some View {
|
||||
HStack {
|
||||
Text(label)
|
||||
.foregroundColor(.secondary)
|
||||
Spacer()
|
||||
Text(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ControlsOverlay_Previews: PreviewProvider {
|
||||
|
@@ -18,6 +18,8 @@ struct PlayerControls: View {
|
||||
case play
|
||||
case backward
|
||||
case forward
|
||||
case settings
|
||||
case close
|
||||
}
|
||||
|
||||
@FocusState private var focusedField: Field?
|
||||
@@ -41,23 +43,25 @@ struct PlayerControls: View {
|
||||
|
||||
if model.presentingControls && !model.presentingOverlays {
|
||||
VStack(spacing: 4) {
|
||||
buttonsBar
|
||||
#if !os(tvOS)
|
||||
buttonsBar
|
||||
|
||||
HStack {
|
||||
if !player.currentVideo.isNil, fullScreenLayout {
|
||||
Button {
|
||||
withAnimation(Self.animation) {
|
||||
model.presentingDetailsOverlay = true
|
||||
HStack {
|
||||
if !player.currentVideo.isNil, fullScreenLayout {
|
||||
Button {
|
||||
withAnimation(Self.animation) {
|
||||
model.presentingDetailsOverlay = true
|
||||
}
|
||||
} label: {
|
||||
ControlsBar(fullScreen: $model.presentingDetailsOverlay, presentingControls: false, detailsTogglePlayer: false, detailsToggleFullScreen: false)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 4))
|
||||
.frame(maxWidth: 300, alignment: .leading)
|
||||
}
|
||||
} label: {
|
||||
ControlsBar(fullScreen: $model.presentingDetailsOverlay, presentingControls: false, detailsTogglePlayer: false, detailsToggleFullScreen: false)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 4))
|
||||
.frame(maxWidth: 300, alignment: .leading)
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
Spacer()
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
#endif
|
||||
|
||||
Spacer()
|
||||
|
||||
@@ -86,28 +90,15 @@ struct PlayerControls: View {
|
||||
.frame(maxHeight: .infinity)
|
||||
}
|
||||
#if os(tvOS)
|
||||
.onChange(of: model.presentingControls) { _ in
|
||||
if model.presentingControls {
|
||||
focusedField = .play
|
||||
}
|
||||
}
|
||||
.onChange(of: focusedField) { _ in
|
||||
model.resetTimer()
|
||||
.onChange(of: model.presentingControls) { newValue in
|
||||
if newValue { focusedField = .play }
|
||||
}
|
||||
.onChange(of: focusedField) { _ in model.resetTimer() }
|
||||
#else
|
||||
.background(PlayerGestures())
|
||||
.background(controlsBackground)
|
||||
.background(PlayerGestures())
|
||||
.background(controlsBackground)
|
||||
#endif
|
||||
|
||||
if model.presentingControlsOverlay {
|
||||
ControlsOverlay()
|
||||
.frame(height: overlayHeight)
|
||||
.padding()
|
||||
.modifier(ControlBackgroundModifier())
|
||||
.clipShape(RoundedRectangle(cornerRadius: 4))
|
||||
.transition(.opacity)
|
||||
}
|
||||
|
||||
if model.presentingDetailsOverlay {
|
||||
VideoDetailsOverlay()
|
||||
.frame(maxWidth: detailsWidth, maxHeight: detailsHeight)
|
||||
@@ -117,7 +108,7 @@ struct PlayerControls: View {
|
||||
}
|
||||
|
||||
if !model.presentingControls,
|
||||
!model.presentingControls,
|
||||
!model.presentingOverlays,
|
||||
let segment = player.lastSkipped
|
||||
{
|
||||
Button {
|
||||
@@ -140,18 +131,22 @@ struct PlayerControls: View {
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
.onChange(of: player.controls.presentingOverlays) { newValue in
|
||||
.onChange(of: model.presentingOverlays) { newValue in
|
||||
if newValue {
|
||||
player.backend.stopControlsUpdates()
|
||||
} else {
|
||||
#if os(tvOS)
|
||||
focusedField = .play
|
||||
#endif
|
||||
player.backend.startControlsUpdates()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var overlayHeight: Double {
|
||||
guard let player = player, player.playerSize.height.isFinite else { return 0 }
|
||||
return [0, [player.playerSize.height - 40, 140].min()!].max()!
|
||||
#if os(tvOS)
|
||||
.onReceive(model.reporter) { _ in
|
||||
model.show()
|
||||
model.resetTimer()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
var detailsWidth: Double {
|
||||
@@ -226,24 +221,17 @@ struct PlayerControls: View {
|
||||
|
||||
var buttonsBar: some View {
|
||||
HStack(spacing: 20) {
|
||||
#if !os(tvOS)
|
||||
fullscreenButton
|
||||
fullscreenButton
|
||||
|
||||
#if os(iOS)
|
||||
pipButton
|
||||
lockOrientationButton
|
||||
#endif
|
||||
|
||||
Spacer()
|
||||
|
||||
button("settings", systemImage: "gearshape", active: model.presentingControlsOverlay) {
|
||||
withAnimation(Self.animation) {
|
||||
model.presentingControlsOverlay.toggle()
|
||||
}
|
||||
}
|
||||
|
||||
closeVideoButton
|
||||
#if os(iOS)
|
||||
pipButton
|
||||
lockOrientationButton
|
||||
#endif
|
||||
|
||||
Spacer()
|
||||
|
||||
settingsButton
|
||||
closeVideoButton
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,10 +247,24 @@ struct PlayerControls: View {
|
||||
#endif
|
||||
}
|
||||
|
||||
private var settingsButton: some View {
|
||||
button("settings", systemImage: "gearshape", active: model.presentingControlsOverlay) {
|
||||
withAnimation(Self.animation) {
|
||||
model.presentingControlsOverlay.toggle()
|
||||
}
|
||||
}
|
||||
#if os(tvOS)
|
||||
.focused($focusedField, equals: .settings)
|
||||
#endif
|
||||
}
|
||||
|
||||
private var closeVideoButton: some View {
|
||||
button("Close", systemImage: "xmark") {
|
||||
player.closeCurrentItem()
|
||||
}
|
||||
#if os(tvOS)
|
||||
.focused($focusedField, equals: .close)
|
||||
#endif
|
||||
}
|
||||
|
||||
private var musicModeButton: some View {
|
||||
@@ -308,6 +310,9 @@ struct PlayerControls: View {
|
||||
advanceToNextItemButton
|
||||
#if !os(tvOS)
|
||||
musicModeButton
|
||||
#else
|
||||
settingsButton
|
||||
closeVideoButton
|
||||
#endif
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
@@ -388,7 +393,12 @@ struct PlayerControls: View {
|
||||
active: Bool = false,
|
||||
action: @escaping () -> Void = {}
|
||||
) -> some View {
|
||||
Button {
|
||||
#if os(tvOS)
|
||||
let useBackground = false
|
||||
#else
|
||||
let useBackground = background
|
||||
#endif
|
||||
return Button {
|
||||
action()
|
||||
model.resetTimer()
|
||||
} label: {
|
||||
@@ -408,7 +418,7 @@ struct PlayerControls: View {
|
||||
.buttonStyle(.plain)
|
||||
.foregroundColor(active ? Color("AppRedColor") : .primary)
|
||||
.frame(width: width ?? size, height: height ?? size)
|
||||
.modifier(ControlBackgroundModifier(enabled: background))
|
||||
.modifier(ControlBackgroundModifier(enabled: useBackground))
|
||||
.clipShape(RoundedRectangle(cornerRadius: cornerRadius))
|
||||
}
|
||||
|
||||
|
@@ -28,7 +28,7 @@ struct StreamControl: View {
|
||||
}
|
||||
.disabled(player.isLoadingAvailableStreams)
|
||||
|
||||
#else
|
||||
#elseif os(iOS)
|
||||
Picker("", selection: $player.streamSelection) {
|
||||
ForEach(InstancesModel.all) { instance in
|
||||
let instanceStreams = availableStreamsForInstance(instance)
|
||||
@@ -46,16 +46,26 @@ struct StreamControl: View {
|
||||
.frame(minWidth: 110)
|
||||
.fixedSize(horizontal: true, vertical: true)
|
||||
.disabled(player.isLoadingAvailableStreams)
|
||||
#else
|
||||
Button {} label: {
|
||||
Text(player.streamSelection?.shortQuality ?? "loading")
|
||||
.frame(maxWidth: 320)
|
||||
}
|
||||
.contextMenu {
|
||||
ForEach(player.availableStreamsSorted) { stream in
|
||||
Button(stream.description) { player.streamSelection = stream }
|
||||
}
|
||||
|
||||
Button("Close", role: .cancel) {}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
.transaction { t in t.animation = .none }
|
||||
.onChange(of: player.streamSelection) { selection in
|
||||
guard !selection.isNil else {
|
||||
return
|
||||
}
|
||||
|
||||
player.upgradeToStream(selection!)
|
||||
guard let selection = selection else { return }
|
||||
player.upgradeToStream(selection)
|
||||
player.controls.hideOverlays()
|
||||
}
|
||||
.frame(alignment: .trailing)
|
||||
}
|
||||
|
@@ -58,6 +58,42 @@ struct VideoPlayerView: View {
|
||||
@EnvironmentObject<ThumbnailsModel> private var thumbnails
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: overlayAlignment) {
|
||||
videoPlayer
|
||||
#if os(iOS)
|
||||
.gesture(playerControls.presentingControlsOverlay ? videoPlayerCloseControlsOverlayGesture : nil)
|
||||
#endif
|
||||
|
||||
if playerControls.presentingControlsOverlay {
|
||||
HStack {
|
||||
ControlsOverlay()
|
||||
#if os(tvOS)
|
||||
.onExitCommand {
|
||||
withAnimation(PlayerControls.animation) {
|
||||
playerControls.hideOverlays()
|
||||
}
|
||||
}
|
||||
.onPlayPauseCommand {
|
||||
player.togglePlay()
|
||||
}
|
||||
#else
|
||||
.frame(maxWidth: overlayWidth)
|
||||
#endif
|
||||
.padding()
|
||||
.modifier(ControlBackgroundModifier())
|
||||
.clipShape(RoundedRectangle(cornerRadius: 4))
|
||||
.transition(.opacity)
|
||||
}
|
||||
#if os(tvOS)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
#else
|
||||
.frame(maxWidth: player.playerSize.width)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var videoPlayer: some View {
|
||||
#if DEBUG
|
||||
// TODO: remove
|
||||
if #available(iOS 15.0, macOS 12.0, *) {
|
||||
@@ -66,8 +102,13 @@ struct VideoPlayerView: View {
|
||||
#endif
|
||||
|
||||
#if os(macOS)
|
||||
return HSplitView {
|
||||
content
|
||||
return GeometryReader { geometry in
|
||||
HSplitView {
|
||||
content
|
||||
.onAppear {
|
||||
playerSize = geometry.size
|
||||
}
|
||||
}
|
||||
}
|
||||
.alert(isPresented: $navigation.presentingAlertInVideoPlayer) { navigation.alert }
|
||||
.onOpenURL {
|
||||
@@ -124,7 +165,7 @@ struct VideoPlayerView: View {
|
||||
Orientation.lockOrientation(.allButUpsideDown)
|
||||
}
|
||||
stopOrientationUpdates()
|
||||
player.controls.hideOverlays()
|
||||
playerControls.hideOverlays()
|
||||
|
||||
player.lockedOrientation = nil
|
||||
#endif
|
||||
@@ -139,7 +180,28 @@ struct VideoPlayerView: View {
|
||||
#endif
|
||||
}
|
||||
|
||||
var overlayWidth: Double {
|
||||
guard playerSize.width.isFinite else { return 200 }
|
||||
return [playerSize.width - 50, 250].min()!
|
||||
}
|
||||
|
||||
var overlayAlignment: Alignment {
|
||||
#if os(tvOS)
|
||||
return .bottomTrailing
|
||||
#else
|
||||
return .top
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
var videoPlayerCloseControlsOverlayGesture: some Gesture {
|
||||
TapGesture().onEnded {
|
||||
withAnimation(PlayerControls.animation) {
|
||||
playerControls.hideOverlays()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var playerOffset: Double {
|
||||
dragGestureState ? dragGestureOffset.height : viewDragOffset
|
||||
}
|
||||
@@ -153,9 +215,14 @@ struct VideoPlayerView: View {
|
||||
}
|
||||
|
||||
var playerEdgesIgnoringSafeArea: Edge.Set {
|
||||
if let orientation = player.lockedOrientation, orientation.contains(.portrait) {
|
||||
return []
|
||||
}
|
||||
|
||||
if fullScreenLayout, UIDevice.current.orientation.isLandscape {
|
||||
return [.vertical]
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
#endif
|
||||
@@ -170,33 +237,6 @@ struct VideoPlayerView: View {
|
||||
tvControls
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
.onMoveCommand { direction in
|
||||
if direction == .up || direction == .down {
|
||||
playerControls.show()
|
||||
}
|
||||
|
||||
playerControls.resetTimer()
|
||||
|
||||
guard !playerControls.presentingControls else { return }
|
||||
|
||||
if direction == .left {
|
||||
player.backend.seek(relative: .secondsInDefaultTimescale(-10))
|
||||
}
|
||||
if direction == .right {
|
||||
player.backend.seek(relative: .secondsInDefaultTimescale(10))
|
||||
}
|
||||
}
|
||||
.onPlayPauseCommand {
|
||||
player.togglePlay()
|
||||
}
|
||||
|
||||
.onExitCommand {
|
||||
if playerControls.presentingControls {
|
||||
playerControls.hide()
|
||||
} else {
|
||||
player.hide()
|
||||
}
|
||||
}
|
||||
#else
|
||||
GeometryReader { geometry in
|
||||
PlayerBackendView()
|
||||
@@ -259,6 +299,41 @@ struct VideoPlayerView: View {
|
||||
#if os(macOS)
|
||||
.frame(minWidth: 650)
|
||||
#endif
|
||||
#if os(tvOS)
|
||||
.onMoveCommand { direction in
|
||||
if direction == .up {
|
||||
playerControls.show()
|
||||
} else if direction == .down, !playerControls.presentingControlsOverlay {
|
||||
withAnimation(PlayerControls.animation) {
|
||||
playerControls.presentingControlsOverlay = true
|
||||
}
|
||||
}
|
||||
|
||||
playerControls.resetTimer()
|
||||
|
||||
guard !playerControls.presentingControls else { return }
|
||||
|
||||
if direction == .left {
|
||||
player.backend.seek(relative: .secondsInDefaultTimescale(-10))
|
||||
}
|
||||
if direction == .right {
|
||||
player.backend.seek(relative: .secondsInDefaultTimescale(10))
|
||||
}
|
||||
}
|
||||
.onPlayPauseCommand {
|
||||
player.togglePlay()
|
||||
}
|
||||
.onExitCommand {
|
||||
if playerControls.presentingOverlays {
|
||||
playerControls.hideOverlays()
|
||||
}
|
||||
if playerControls.presentingControls {
|
||||
playerControls.hide()
|
||||
} else {
|
||||
player.hide()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
if !fullScreenLayout {
|
||||
#if os(iOS)
|
||||
if sidebarQueue {
|
||||
@@ -277,7 +352,7 @@ struct VideoPlayerView: View {
|
||||
}
|
||||
}
|
||||
.onChange(of: fullScreenLayout) { newValue in
|
||||
if !newValue { playerControls.presentingDetailsOverlay = false }
|
||||
if !newValue { playerControls.hideOverlays() }
|
||||
}
|
||||
#if os(iOS)
|
||||
.statusBar(hidden: fullScreenLayout)
|
||||
@@ -346,8 +421,8 @@ struct VideoPlayerView: View {
|
||||
guard player.presentingPlayer,
|
||||
!playerControls.presentingControlsOverlay else { return }
|
||||
|
||||
if player.controls.presentingControls {
|
||||
player.controls.presentingControls = false
|
||||
if playerControls.presentingControls {
|
||||
playerControls.presentingControls = false
|
||||
}
|
||||
|
||||
let drag = value.translation.height
|
||||
@@ -401,7 +476,7 @@ struct VideoPlayerView: View {
|
||||
!player.playingInPictureInPicture
|
||||
{
|
||||
DispatchQueue.main.async {
|
||||
player.controls.presentingControls = false
|
||||
playerControls.presentingControls = false
|
||||
player.enterFullScreen(showControls: false)
|
||||
}
|
||||
|
||||
@@ -435,7 +510,7 @@ struct VideoPlayerView: View {
|
||||
}
|
||||
|
||||
if orientation.isLandscape {
|
||||
player.controls.presentingControls = false
|
||||
playerControls.presentingControls = false
|
||||
player.enterFullScreen(showControls: false)
|
||||
Orientation.lockOrientation(OrientationTracker.shared.currentInterfaceOrientationMask, andRotateTo: orientation)
|
||||
} else {
|
||||
@@ -455,10 +530,6 @@ struct VideoPlayerView: View {
|
||||
#if os(tvOS)
|
||||
var tvControls: some View {
|
||||
TVControls(model: playerControls, player: player, thumbnails: thumbnails)
|
||||
.onReceive(playerControls.reporter) { _ in
|
||||
playerControls.show()
|
||||
playerControls.resetTimer()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
Reference in New Issue
Block a user