Quality profiles

This commit is contained in:
Arkadiusz Fal
2022-08-14 19:06:22 +02:00
parent 57d8698f86
commit ac9abaec5a
19 changed files with 1372 additions and 234 deletions

View File

@@ -6,18 +6,6 @@ import SwiftUI
#endif
extension Defaults.Keys {
#if os(tvOS)
static let defaultForPauseOnHidingPlayer = true
#else
static let defaultForPauseOnHidingPlayer = false
#endif
#if os(macOS)
static let defaultForPlayerDetailsPageButtonLabelStyle = PlayerDetailsPageButtonLabelStyle.iconAndText
#else
static let defaultForPlayerDetailsPageButtonLabelStyle = UIDevice.current.userInterfaceIdiom == .phone ? PlayerDetailsPageButtonLabelStyle.iconOnly : .iconAndText
#endif
static let instancesManifest = Key<String>("instancesManifest", default: "")
static let countryOfPublicInstances = Key<String?>("countryOfPublicInstances")
@@ -50,7 +38,12 @@ extension Defaults.Keys {
static let captionsLanguageCode = Key<String?>("captionsLanguageCode")
static let activeBackend = Key<PlayerBackendType>("activeBackend", default: .mpv)
static let quality = Key<ResolutionSetting>("quality", default: .best)
static let qualityProfiles = Key<[QualityProfile]>("qualityProfiles", default: [QualityProfile.defaultProfile, QualityProfile.highQualityProfile])
static let batteryCellularProfile = Key<QualityProfile.ID>("batteryCellularProfile", default: QualityProfile.defaultProfile.id)
static let batteryNonCellularProfile = Key<QualityProfile.ID>("batteryNonCellularProfile", default: QualityProfile.defaultProfile.id)
static let chargingCellularProfile = Key<QualityProfile.ID>("chargingCellularProfile", default: QualityProfile.defaultProfile.id)
static let chargingNonCellularProfile = Key<QualityProfile.ID>("chargingNonCellularProfile", default: QualityProfile.defaultProfile.id)
static let playerSidebar = Key<PlayerSidebarSetting>("playerSidebar", default: PlayerSidebarSetting.defaultValue)
static let playerInstanceID = Key<Instance.ID?>("playerInstance")
static let showKeywords = Key<Bool>("showKeywords", default: false)
@@ -58,12 +51,25 @@ extension Defaults.Keys {
#if !os(tvOS)
static let commentsPlacement = Key<CommentsPlacement>("commentsPlacement", default: .separate)
#endif
static let pauseOnHidingPlayer = Key<Bool>("pauseOnHidingPlayer", default: defaultForPauseOnHidingPlayer)
#if os(tvOS)
static let pauseOnHidingPlayerDefault = true
#else
static let pauseOnHidingPlayerDefault = false
#endif
static let pauseOnHidingPlayer = Key<Bool>("pauseOnHidingPlayer", default: pauseOnHidingPlayerDefault)
#if !os(macOS)
static let pauseOnEnteringBackground = Key<Bool>("pauseOnEnteringBackground", default: true)
#endif
static let closeLastItemOnPlaybackEnd = Key<Bool>("closeLastItemOnPlaybackEnd", default: false)
static let closePlayerOnItemClose = Key<Bool>("closePlayerOnItemClose", default: false)
#if os(tvOS)
static let closePlayerOnItemCloseDefault = true
#else
static let closePlayerOnItemCloseDefault = false
#endif
static let closePlayerOnItemClose = Key<Bool>("closePlayerOnItemClose", default: closePlayerOnItemCloseDefault)
static let closePiPOnNavigation = Key<Bool>("closePiPOnNavigation", default: false)
static let closePiPOnOpeningPlayer = Key<Bool>("closePiPOnOpeningPlayer", default: false)
@@ -100,7 +106,12 @@ extension Defaults.Keys {
static let showMPVPlaybackStats = Key<Bool>("showMPVPlaybackStats", default: false)
static let playerDetailsPageButtonLabelStyle = Key<PlayerDetailsPageButtonLabelStyle>("playerDetailsPageButtonLabelStyle", default: defaultForPlayerDetailsPageButtonLabelStyle)
#if os(macOS)
static let playerDetailsPageButtonLabelStyleDefault = PlayerDetailsPageButtonLabelStyle.iconAndText
#else
static let playerDetailsPageButtonLabelStyleDefault = UIDevice.current.userInterfaceIdiom == .phone ? PlayerDetailsPageButtonLabelStyle.iconOnly : .iconAndText
#endif
static let playerDetailsPageButtonLabelStyle = Key<PlayerDetailsPageButtonLabelStyle>("playerDetailsPageButtonLabelStyle", default: playerDetailsPageButtonLabelStyleDefault)
static let systemControlsCommands = Key<SystemControlsCommands>("systemControlsCommands", default: .restartAndAdvanceToNext)
static let mpvCacheSecs = Key<String>("mpvCacheSecs", default: "20")

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,7 +4,6 @@ import SwiftUI
struct PlayerSettings: View {
@Default(.instances) private var instances
@Default(.playerInstanceID) private var playerInstanceID
@Default(.quality) private var quality
@Default(.playerSidebar) private var playerSidebar
@Default(.showHistoryInPlayer) private var showHistory
@@ -59,7 +58,6 @@ struct PlayerSettings: View {
Group {
Section(header: SettingsHeader(text: "Playback")) {
sourcePicker
qualityPicker
pauseOnHidingPlayerToggle
#if !os(macOS)
pauseOnEnteringBackgroundToogle
@@ -107,7 +105,7 @@ struct PlayerSettings: View {
private var sourcePicker: some View {
Picker("Source", selection: $playerInstanceID) {
Text("Best available stream").tag(String?.none)
Text("Account Instance").tag(String?.none)
ForEach(instances) { instance in
Text(instance.description).tag(Optional(instance.id))
@@ -135,15 +133,6 @@ struct PlayerSettings: View {
.modifier(SettingsPickerModifier())
}
private var qualityPicker: some View {
Picker("Quality", selection: $quality) {
ForEach(ResolutionSetting.allCases, id: \.self) { resolution in
Text(resolution.description).tag(resolution)
}
}
.modifier(SettingsPickerModifier())
}
private var sidebarPicker: some View {
Picker("Sidebar", selection: $playerSidebar) {
#if os(macOS)

View File

@@ -0,0 +1,312 @@
import SwiftUI
struct QualityProfileForm: View {
var qualityProfileID: QualityProfile.ID!
@Environment(\.colorScheme) private var colorScheme
@Environment(\.presentationMode) private var presentationMode
@Environment(\.navigationStyle) private var navigationStyle
@State private var valid = false
@State private var name = ""
@State private var backend = PlayerBackendType.mpv
@State private var resolution = ResolutionSetting.best
@State private var formats = [QualityProfile.Format]()
var qualityProfile: QualityProfile! {
if let id = qualityProfileID {
return QualityProfilesModel.shared.find(id)
}
return nil
}
var body: some View {
ScrollView {
VStack {
Group {
header
#if os(iOS)
NavigationView {
EmptyView()
form
.navigationBarHidden(true)
.navigationBarTitle(Text("Back"))
.edgesIgnoringSafeArea([.top, .bottom])
}
.navigationViewStyle(.stack)
#else
form
#endif
footer
}
.frame(maxWidth: 1000)
}
#if os(tvOS)
.padding(20)
#endif
}
.onAppear(perform: initializeForm)
.onChange(of: backend, perform: backendChanged)
.onChange(of: formats) { _ in validate() }
#if os(iOS)
.padding(.vertical)
#elseif os(tvOS)
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity)
.background(Color.background(scheme: colorScheme))
#else
.frame(width: 400, height: 400)
.padding(.vertical, 10)
#endif
}
var header: some View {
HStack(alignment: .center) {
Text(editing ? "Edit Quality Profile" : "Add Quality Profile")
.font(.title2.bold())
Spacer()
Button("Cancel") {
presentationMode.wrappedValue.dismiss()
}
#if !os(tvOS)
.keyboardShortcut(.cancelAction)
#endif
}
.padding(.horizontal)
}
var form: some View {
#if !os(tvOS)
Form {
formFields
#if os(macOS)
.padding(.horizontal)
#endif
}
#else
formFields
#endif
}
var formFields: some View {
Group {
Section {
HStack {
nameHeader
TextField("Name", text: $name, onCommit: validate)
.labelsHidden()
}
#if os(tvOS)
Section(header: Text("Resolution")) {
qualityButton
}
#else
backendPicker
qualityPicker
#endif
}
Section(header: Text("Preferred Formats"), footer: formatsFooter) {
formatsPicker
}
}
#if os(tvOS)
.frame(maxWidth: .infinity, alignment: .leading)
#endif
}
@ViewBuilder var nameHeader: some View {
#if os(macOS)
Text("Name")
#else
EmptyView()
#endif
}
var formatsFooter: some View {
Text("Formats will be selected in order as listed.\nHLS is an adaptive format (resolution setting does not apply).")
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
var qualityPicker: some View {
Picker("Resolution", selection: $resolution) {
ForEach(availableResolutions, id: \.self) { resolution in
Text(resolution.description).tag(resolution)
}
}
.modifier(SettingsPickerModifier())
}
#if os(tvOS)
var qualityButton: some View {
Button(resolution.description) {
resolution = resolution.next()
}
.contextMenu {
ForEach(availableResolutions, id: \.self) { resolution in
Button(resolution.description) {
self.resolution = resolution
}
}
}
}
#endif
var availableResolutions: [ResolutionSetting] {
ResolutionSetting.allCases.filter { !isResolutionDisabled($0) }
}
var backendPicker: some View {
Picker("Backend", selection: $backend) {
ForEach(PlayerBackendType.allCases, id: \.self) { backend in
Text(backend.label).tag(backend)
}
}
.modifier(SettingsPickerModifier())
}
@ViewBuilder var formatsPicker: some View {
#if os(macOS)
let list = ForEach(QualityProfile.Format.allCases, id: \.self) { format in
MultiselectRow(
title: format.description,
selected: isFormatSelected(format),
disabled: isFormatDisabled(format)
) { value in
toggleFormat(format, value: value)
}
}
Group {
if #available(macOS 12.0, *) {
list
.listStyle(.inset(alternatesRowBackgrounds: true))
} else {
list
.listStyle(.inset)
}
}
Spacer()
#else
ForEach(QualityProfile.Format.allCases, id: \.self) { format in
MultiselectRow(
title: format.description,
selected: isFormatSelected(format),
disabled: isFormatDisabled(format)
) { value in
toggleFormat(format, value: value)
}
}
#endif
}
func isFormatSelected(_ format: QualityProfile.Format) -> Bool {
(editing && formats.isEmpty ? qualityProfile.formats : formats).contains(format)
}
func toggleFormat(_ format: QualityProfile.Format, value: Bool) {
if let index = formats.firstIndex(where: { $0 == format }), !value {
formats.remove(at: index)
} else if value {
formats.append(format)
}
}
var footer: some View {
HStack {
Spacer()
Button("Save", action: submitForm)
.disabled(!valid)
#if !os(tvOS)
.keyboardShortcut(.defaultAction)
#endif
}
.frame(minHeight: 35)
#if os(tvOS)
.padding(.top, 30)
#endif
.padding(.horizontal)
}
var editing: Bool {
!qualityProfile.isNil
}
func isFormatDisabled(_ format: QualityProfile.Format) -> Bool {
guard backend == .appleAVPlayer else { return false }
let avPlayerFormats = [QualityProfile.Format.hls, .stream]
return !avPlayerFormats.contains(format)
}
func isResolutionDisabled(_ resolution: ResolutionSetting) -> Bool {
guard backend == .appleAVPlayer else { return false }
return resolution != .best && resolution.value.height > 720
}
func initializeForm() {
guard editing else {
validate()
return
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
self.name = qualityProfile.name ?? ""
self.backend = qualityProfile.backend
self.resolution = qualityProfile.resolution
self.formats = .init(qualityProfile.formats)
}
validate()
}
func backendChanged(_: PlayerBackendType) {
formats.filter { isFormatDisabled($0) }.forEach { format in
if let index = formats.firstIndex(where: { $0 == format }) {
formats.remove(at: index)
}
}
if let newResolution = availableResolutions.first {
resolution = newResolution
}
}
func validate() {
valid = !formats.isEmpty
}
func submitForm() {
guard valid else { return }
formats = formats.unique()
let formProfile = QualityProfile(
id: qualityProfile?.id ?? UUID().uuidString,
name: name,
backend: backend,
resolution: resolution,
formats: Array(formats)
)
if editing {
QualityProfilesModel.shared.update(qualityProfile, formProfile)
} else {
QualityProfilesModel.shared.add(formProfile)
}
presentationMode.wrappedValue.dismiss()
}
}
struct QualityProfileForm_Previews: PreviewProvider {
static var previews: some View {
QualityProfileForm(qualityProfileID: QualityProfile.defaultProfile.id)
}
}

View File

@@ -0,0 +1,184 @@
import Defaults
import SwiftUI
struct QualitySettings: View {
@State private var presentingProfileForm = false
@State private var editedProfile: QualityProfile?
@Default(.qualityProfiles) private var qualityProfiles
@Default(.batteryCellularProfile) private var batteryCellularProfile
@Default(.batteryNonCellularProfile) private var batteryNonCellularProfile
@Default(.chargingCellularProfile) private var chargingCellularProfile
@Default(.chargingNonCellularProfile) private var chargingNonCellularProfile
var body: some View {
VStack {
#if os(macOS)
sections
Spacer()
#else
List {
sections
}
#endif
}
.sheet(isPresented: $presentingProfileForm) {
QualityProfileForm(qualityProfileID: editedProfile?.id)
}
#if os(tvOS)
.frame(maxWidth: 1000)
#elseif os(iOS)
.listStyle(.insetGrouped)
#endif
.navigationTitle("Quality")
}
var sections: some View {
Group {
Group {
#if os(tvOS)
Section(header: Text("Default Profile")) {
Text("\(QualityProfilesModel.shared.tvOSProfile?.description ?? "None")")
}
#elseif os(iOS)
if UIDevice.current.hasCellularCapabilites {
Section(header: Text("Battery")) {
Picker("Wi-Fi", selection: $batteryNonCellularProfile) { profilePickerOptions }
Picker("Cellular", selection: $batteryCellularProfile) { profilePickerOptions }
}
Section(header: Text("Charging")) {
Picker("Wi-Fi", selection: $chargingNonCellularProfile) { profilePickerOptions }
Picker("Cellular", selection: $chargingCellularProfile) { profilePickerOptions }
}
} else {
nonCellularBatteryDevicesProfilesPickers
}
#else
if Power.hasInternalBattery {
nonCellularBatteryDevicesProfilesPickers
} else {
Picker("Default", selection: $chargingNonCellularProfile) { profilePickerOptions }
}
#endif
}
.disabled(qualityProfiles.isEmpty)
Section(header: SettingsHeader(text: "Profiles"), footer: profilesFooter) {
profilesList
Button {
editedProfile = nil
presentingProfileForm = true
} label: {
Label("Add profile...", systemImage: "plus")
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
@ViewBuilder var nonCellularBatteryDevicesProfilesPickers: some View {
Picker("Battery", selection: $batteryNonCellularProfile) { profilePickerOptions }
Picker("Charging", selection: $chargingNonCellularProfile) { profilePickerOptions }
}
@ViewBuilder func profileControl(_ qualityProfile: QualityProfile) -> some View {
#if os(tvOS)
Button {
QualityProfilesModel.shared.applyToAll(qualityProfile)
} label: {
Text(qualityProfile.description)
}
#else
Text(qualityProfile.description)
#endif
}
var profilePickerOptions: some View {
ForEach(qualityProfiles) { qualityProfile in
Text(qualityProfile.description).tag(qualityProfile.id)
}
}
var profilesFooter: some View {
#if os(tvOS)
Text("You can switch between profiles in playback settings controls.")
#else
Text("You can use automatic profile selection based on current device status or switch it in video playback settings controls.")
.foregroundColor(.secondary)
#endif
}
@ViewBuilder var profilesList: some View {
let list = ForEach(qualityProfiles) { qualityProfile in
profileControl(qualityProfile)
.contextMenu {
Button {
QualityProfilesModel.shared.applyToAll(qualityProfile)
} label: {
#if os(tvOS)
Text("Make default")
#elseif os(iOS)
Label("Apply to all", systemImage: "wand.and.stars")
#else
if Power.hasInternalBattery {
Text("Apply to all")
} else {
Text("Make default")
}
#endif
}
Button {
editedProfile = qualityProfile
presentingProfileForm = true
} label: {
Label("Edit...", systemImage: "pencil")
}
Button {
QualityProfilesModel.shared.remove(qualityProfile)
} label: {
Label("Remove", systemImage: "trash")
}
#if os(tvOS)
Button("Cancel", role: .cancel) {}
#endif
}
}
if #available(macOS 12.0, *) {
#if os(macOS)
List {
list
}
.listStyle(.inset(alternatesRowBackgrounds: true))
#else
list
#endif
} else {
#if os(macOS)
List {
list
}
#else
list
#endif
}
}
}
struct QualitySettings_Previews: PreviewProvider {
static var previews: some View {
#if os(macOS)
QualitySettings()
#else
NavigationView {
EmptyView()
QualitySettings()
}
.navigationViewStyle(.stack)
#endif
}
}

View File

@@ -1,14 +1,13 @@
import Defaults
import Foundation
import SwiftUI
struct SettingsView: View {
static let matrixURL = URL(string: "https://tinyurl.com/matrix-yattee")!
static let discordURL = URL(string: "https://yattee.stream/discord")!
#if os(macOS)
private enum Tabs: Hashable {
case locations, browsing, player, history, sponsorBlock, advanced, help
case locations, browsing, player, quality, history, sponsorBlock, advanced, help
}
@State private var selection = Tabs.locations
@@ -59,6 +58,14 @@ struct SettingsView: View {
}
.tag(Tabs.player)
Form {
QualitySettings()
}
.tabItem {
Label("Quality", systemImage: "4k.tv")
}
.tag(Tabs.quality)
Form {
HistorySettings()
}
@@ -92,18 +99,14 @@ struct SettingsView: View {
.tag(Tabs.help)
}
.padding(20)
.frame(width: 480, height: windowHeight)
.frame(width: 520, height: windowHeight)
#else
Group {
NavigationView {
settingsList
#if os(tvOS)
settingsList
#else
NavigationView {
settingsList
}
.navigationBarHidden(true)
#endif
}
#endif
}
@@ -142,6 +145,12 @@ struct SettingsView: View {
Label("Player", systemImage: "play.rectangle")
}
NavigationLink {
QualitySettings()
} label: {
Label("Quality", systemImage: "4k.tv")
}
NavigationLink {
HistorySettings()
} label: {
@@ -219,6 +228,8 @@ struct SettingsView: View {
return 390
case .player:
return 420
case .quality:
return 400
case .history:
return 480
case .sponsorBlock: