mirror of
https://github.com/yattee/yattee.git
synced 2025-11-16 06:58:43 +00:00
The orientation lock feature is not applicable on iPad devices, so the lock orientation button and settings are now hidden when running on iPad.
561 lines
20 KiB
Swift
561 lines
20 KiB
Swift
import Defaults
|
|
import Foundation
|
|
import SDWebImageSwiftUI
|
|
import SwiftUI
|
|
|
|
struct PlayerControls: View {
|
|
static let animation = Animation.easeInOut(duration: 0.2)
|
|
|
|
private var player: PlayerModel { .shared }
|
|
private var thumbnails: ThumbnailsModel { .shared }
|
|
|
|
@ObservedObject private var model = PlayerControlsModel.shared
|
|
|
|
#if os(iOS)
|
|
@Environment(\.verticalSizeClass) private var verticalSizeClass
|
|
@ObservedObject private var safeAreaModel = SafeAreaModel.shared
|
|
#elseif os(tvOS)
|
|
enum Field: Hashable {
|
|
case seekOSD
|
|
case play
|
|
case backward
|
|
case forward
|
|
case settings
|
|
case close
|
|
}
|
|
|
|
@FocusState private var focusedField: Field?
|
|
#endif
|
|
|
|
@Default(.playerControlsLayout) private var regularPlayerControlsLayout
|
|
@Default(.fullScreenPlayerControlsLayout) private var fullScreenPlayerControlsLayout
|
|
@Default(.playerControlsBackgroundOpacity) private var playerControlsBackgroundOpacity
|
|
@Default(.buttonBackwardSeekDuration) private var buttonBackwardSeekDuration
|
|
@Default(.buttonForwardSeekDuration) private var buttonForwardSeekDuration
|
|
|
|
#if os(iOS)
|
|
@Default(.playerControlsLockOrientationEnabled) private var playerControlsLockOrientationEnabled
|
|
#endif
|
|
@Default(.playerControlsSettingsEnabled) private var playerControlsSettingsEnabled
|
|
@Default(.playerControlsCloseEnabled) private var playerControlsCloseEnabled
|
|
@Default(.playerControlsRestartEnabled) private var playerControlsRestartEnabled
|
|
@Default(.playerControlsAdvanceToNextEnabled) private var playerControlsAdvanceToNextEnabled
|
|
@Default(.playerControlsPlaybackModeEnabled) private var playerControlsPlaybackModeEnabled
|
|
@Default(.playerControlsMusicModeEnabled) private var playerControlsMusicModeEnabled
|
|
|
|
@Default(.avPlayerUsesSystemControls) private var avPlayerUsesSystemControls
|
|
|
|
private let controlsOverlayModel = ControlOverlaysModel.shared
|
|
private var navigation = NavigationModel.shared
|
|
|
|
var playerControlsLayout: PlayerControlsLayout {
|
|
player.playingFullScreen ? fullScreenPlayerControlsLayout : regularPlayerControlsLayout
|
|
}
|
|
|
|
var showControls: Bool {
|
|
player.activeBackend == .mpv || !avPlayerUsesSystemControls || player.musicMode
|
|
}
|
|
|
|
var body: some View {
|
|
Group {
|
|
if showControls {
|
|
controlsContent
|
|
}
|
|
}
|
|
.onChange(of: model.presentingOverlays) { newValue in
|
|
if newValue {
|
|
model.hide()
|
|
}
|
|
}
|
|
#if os(tvOS)
|
|
.onReceive(model.reporter) { value in
|
|
guard player.presentingPlayer else { return }
|
|
if value == "swipe down", !model.presentingControls, !model.presentingOverlays {
|
|
withAnimation(Self.animation) {
|
|
controlsOverlayModel.hide()
|
|
}
|
|
} else {
|
|
model.show()
|
|
}
|
|
model.resetTimer()
|
|
}
|
|
#endif
|
|
}
|
|
|
|
var controlsContent: some View {
|
|
ZStack(alignment: .topLeading) {
|
|
Seek()
|
|
.zIndex(4)
|
|
.transition(.opacity)
|
|
.frame(maxWidth: .infinity, alignment: .topLeading)
|
|
#if os(tvOS)
|
|
.focused($focusedField, equals: .seekOSD)
|
|
.onChange(of: player.seek.lastSeekTime) { _ in
|
|
if !model.presentingControls {
|
|
focusedField = .seekOSD
|
|
}
|
|
}
|
|
#else
|
|
.offset(y: 2)
|
|
#endif
|
|
|
|
VStack {
|
|
ZStack {
|
|
VStack(spacing: 0) {
|
|
ZStack {
|
|
OpeningStream()
|
|
NetworkState()
|
|
}
|
|
|
|
Spacer()
|
|
}
|
|
.offset(y: playerControlsLayout.osdVerticalOffset + 5)
|
|
|
|
Section {
|
|
#if !os(tvOS)
|
|
HStack {
|
|
seekBackwardButton
|
|
Spacer()
|
|
togglePlayButton
|
|
Spacer()
|
|
seekForwardButton
|
|
}
|
|
.font(.system(size: playerControlsLayout.bigButtonFontSize))
|
|
#endif
|
|
|
|
ZStack(alignment: .bottom) {
|
|
VStack(spacing: 4) {
|
|
#if !os(tvOS)
|
|
buttonsBar
|
|
|
|
HStack {
|
|
if !player.currentVideo.isNil, player.playingFullScreen {
|
|
Button {
|
|
withAnimation(Self.animation) {
|
|
model.presentingDetailsOverlay = true
|
|
}
|
|
} label: {
|
|
ControlsBar(fullScreen: $model.presentingDetailsOverlay, expansionState: .constant(.full), presentingControls: false, detailsTogglePlayer: false, detailsToggleFullScreen: false)
|
|
.clipShape(RoundedRectangle(cornerRadius: 4))
|
|
.frame(maxWidth: 300, alignment: .leading)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
Spacer()
|
|
}
|
|
#endif
|
|
|
|
Spacer()
|
|
|
|
if playerControlsLayout.displaysTitleLine {
|
|
VStack(alignment: .leading) {
|
|
Text(player.videoForDisplay?.displayTitle ?? "Not Playing")
|
|
.shadow(radius: 10)
|
|
.font(.system(size: playerControlsLayout.titleLineFontSize).bold())
|
|
.lineLimit(1)
|
|
|
|
Text(player.currentVideo?.displayAuthor ?? "")
|
|
.fontWeight(.semibold)
|
|
.shadow(radius: 10)
|
|
.foregroundColor(.init(white: 0.8))
|
|
.font(.system(size: playerControlsLayout.authorLineFontSize))
|
|
.lineLimit(1)
|
|
}
|
|
.foregroundColor(.white)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.offset(y: -40)
|
|
}
|
|
|
|
timeline
|
|
.padding(.bottom, 2)
|
|
}
|
|
.zIndex(1)
|
|
.padding(.top, 2)
|
|
.transition(.opacity)
|
|
|
|
HStack(spacing: playerControlsLayout.buttonsSpacing) {
|
|
#if os(tvOS)
|
|
togglePlayButton
|
|
seekBackwardButton
|
|
seekForwardButton
|
|
#endif
|
|
if playerControlsRestartEnabled {
|
|
restartVideoButton
|
|
}
|
|
if playerControlsAdvanceToNextEnabled {
|
|
advanceToNextItemButton
|
|
}
|
|
Spacer()
|
|
#if os(tvOS)
|
|
if playerControlsSettingsEnabled {
|
|
settingsButton
|
|
}
|
|
#endif
|
|
if playerControlsPlaybackModeEnabled {
|
|
playbackModeButton
|
|
}
|
|
#if os(tvOS)
|
|
closeVideoButton
|
|
#else
|
|
if playerControlsMusicModeEnabled {
|
|
musicModeButton
|
|
}
|
|
#endif
|
|
}
|
|
.zIndex(0)
|
|
#if os(tvOS)
|
|
.offset(y: -playerControlsLayout.timelineHeight - 30)
|
|
#else
|
|
.offset(y: -playerControlsLayout.timelineHeight - 5)
|
|
#endif
|
|
}
|
|
}
|
|
.opacity(model.presentingControls && !player.availableStreams.isEmpty ? 1 : 0)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
#if os(tvOS)
|
|
.onChange(of: model.presentingControls) { newValue in
|
|
if newValue {
|
|
focusedField = .play
|
|
} else {
|
|
focusedField = nil
|
|
}
|
|
}
|
|
.onChange(of: focusedField) { _ in model.resetTimer() }
|
|
#else
|
|
.background(PlayerGestures())
|
|
.background(controlsBackground)
|
|
#endif
|
|
|
|
if model.presentingDetailsOverlay {
|
|
Section {
|
|
VideoDetailsOverlay()
|
|
.frame(maxWidth: detailsWidth, maxHeight: detailsHeight)
|
|
.transition(.opacity)
|
|
}
|
|
.frame(maxHeight: .infinity, alignment: .top)
|
|
}
|
|
}
|
|
}
|
|
|
|
var detailsWidth: Double {
|
|
guard player.playerSize.width.isFinite else { return 200 }
|
|
return [player.playerSize.width, 600].min()!
|
|
}
|
|
|
|
var detailsHeight: Double {
|
|
guard player.playerSize.height.isFinite else { return 200 }
|
|
var inset = 0.0
|
|
#if os(iOS)
|
|
inset = safeAreaModel.safeArea.bottom
|
|
#endif
|
|
return [player.playerSize.height - inset, 500].min()!
|
|
}
|
|
|
|
@ViewBuilder
|
|
var controlsBackground: some View {
|
|
GeometryReader { geometry in
|
|
ZStack {
|
|
if player.musicMode,
|
|
let video = player.videoForDisplay
|
|
{
|
|
let thumbnail = thumbnails.best(video)
|
|
if let url = thumbnail.url,
|
|
let quality = thumbnail.quality
|
|
{
|
|
let aspectRatio = (quality == .default || quality == .high) ? Constants.aspectRatio4x3 : Constants.aspectRatio16x9
|
|
|
|
ThumbnailView(url: url)
|
|
.aspectRatio(aspectRatio, contentMode: .fill)
|
|
.frame(width: geometry.size.width, height: geometry.size.height)
|
|
.transition(.opacity)
|
|
.animation(.default)
|
|
.clipped()
|
|
}
|
|
} else if player.videoForDisplay == nil {
|
|
Color.black
|
|
} else if model.presentingControls {
|
|
Color.black.opacity(playerControlsBackgroundOpacity)
|
|
.edgesIgnoringSafeArea(.all)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
var timeline: some View {
|
|
TimelineView(context: .player).foregroundColor(.primary)
|
|
}
|
|
|
|
private var hidePlayerButton: some View {
|
|
button("Hide", systemImage: "chevron.down") {
|
|
player.hide()
|
|
}
|
|
|
|
#if !os(tvOS)
|
|
.keyboardShortcut(.cancelAction)
|
|
#endif
|
|
}
|
|
|
|
private var playbackStatus: String {
|
|
if player.live {
|
|
return "LIVE"
|
|
}
|
|
|
|
guard !player.isLoadingVideo else {
|
|
return "loading..."
|
|
}
|
|
|
|
let videoLengthAtRate = (player.currentVideo?.length ?? 0) / Double(player.currentRate)
|
|
let remainingSeconds = videoLengthAtRate - (player.time?.seconds ?? 0)
|
|
|
|
if remainingSeconds < 60 {
|
|
return "less than a minute"
|
|
}
|
|
|
|
let timeFinishAt = Date().addingTimeInterval(remainingSeconds)
|
|
|
|
return "ends at \(formattedTimeFinishAt(timeFinishAt))"
|
|
}
|
|
|
|
private func formattedTimeFinishAt(_ date: Date) -> String {
|
|
let dateFormatter = DateFormatter()
|
|
|
|
dateFormatter.dateStyle = .none
|
|
dateFormatter.timeStyle = .short
|
|
|
|
return dateFormatter.string(from: date)
|
|
}
|
|
|
|
var buttonsBar: some View {
|
|
HStack(spacing: playerControlsLayout.buttonsSpacing) {
|
|
fullscreenButton
|
|
|
|
pipButton
|
|
#if os(iOS)
|
|
if playerControlsLockOrientationEnabled, !Constants.isIPad {
|
|
lockOrientationButton
|
|
}
|
|
#endif
|
|
|
|
Spacer()
|
|
|
|
if playerControlsSettingsEnabled {
|
|
settingsButton
|
|
}
|
|
|
|
if playerControlsCloseEnabled {
|
|
closeVideoButton
|
|
}
|
|
}
|
|
}
|
|
|
|
var fullscreenButton: some View {
|
|
button(
|
|
"Fullscreen",
|
|
systemImage: player.fullscreenImage
|
|
) {
|
|
player.toggleFullscreen(player.playingFullScreen, showControls: false)
|
|
}
|
|
#if !os(tvOS)
|
|
.keyboardShortcut(player.playingFullScreen ? .cancelAction : .defaultAction)
|
|
#endif
|
|
}
|
|
|
|
private var settingsButton: some View {
|
|
button("settings", systemImage: "gearshape") {
|
|
withAnimation(Self.animation) {
|
|
#if os(tvOS)
|
|
controlsOverlayModel.toggle()
|
|
#else
|
|
navigation.presentingPlaybackSettings = true
|
|
#endif
|
|
}
|
|
}
|
|
#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 {
|
|
button("Music Mode", systemImage: "music.note", active: player.musicMode, action: player.toggleMusicMode)
|
|
}
|
|
|
|
private var pipButton: some View {
|
|
button("PiP", systemImage: player.pipImage, active: player.playingInPictureInPicture, action: player.togglePiPAction)
|
|
.disabled(!player.pipPossible)
|
|
}
|
|
|
|
#if os(iOS)
|
|
private var lockOrientationButton: some View {
|
|
button("Lock Rotation", systemImage: player.lockOrientationImage, active: player.isOrientationLocked, action: player.lockOrientationAction)
|
|
}
|
|
#endif
|
|
|
|
var playbackModeButton: some View {
|
|
button("Playback Mode", systemImage: player.playbackMode.systemImage) {
|
|
player.playbackMode = player.playbackMode.next()
|
|
model.objectWillChange.send()
|
|
}
|
|
}
|
|
|
|
var seekBackwardButton: some View {
|
|
var foregroundColor: Color?
|
|
var fontSize: Double?
|
|
var size: Double?
|
|
#if !os(tvOS)
|
|
foregroundColor = .white
|
|
fontSize = playerControlsLayout.bigButtonFontSize
|
|
size = playerControlsLayout.bigButtonSize
|
|
#endif
|
|
|
|
let interval = TimeInterval(buttonBackwardSeekDuration) ?? 10
|
|
|
|
return button(
|
|
"Seek Backward",
|
|
systemImage: Constants.seekIcon("backward", interval),
|
|
fontSize: fontSize, size: size, cornerRadius: 5, background: false, foregroundColor: foregroundColor
|
|
) {
|
|
player.backend.seek(relative: .secondsInDefaultTimescale(-interval), seekType: .userInteracted)
|
|
}
|
|
.disabled(player.liveStreamInAVPlayer)
|
|
#if os(tvOS)
|
|
.focused($focusedField, equals: .backward)
|
|
#else
|
|
.keyboardShortcut("k", modifiers: [])
|
|
.keyboardShortcut(KeyEquivalent.leftArrow, modifiers: [])
|
|
#endif
|
|
}
|
|
|
|
var seekForwardButton: some View {
|
|
var foregroundColor: Color?
|
|
var fontSize: Double?
|
|
var size: Double?
|
|
#if !os(tvOS)
|
|
foregroundColor = .white
|
|
fontSize = playerControlsLayout.bigButtonFontSize
|
|
size = playerControlsLayout.bigButtonSize
|
|
#endif
|
|
|
|
let interval = TimeInterval(buttonForwardSeekDuration) ?? 10
|
|
|
|
return button(
|
|
"Seek Forward",
|
|
systemImage: Constants.seekIcon("forward", interval),
|
|
fontSize: fontSize, size: size, cornerRadius: 5, background: false, foregroundColor: foregroundColor
|
|
) {
|
|
player.backend.seek(relative: .secondsInDefaultTimescale(interval), seekType: .userInteracted)
|
|
}
|
|
.disabled(player.liveStreamInAVPlayer)
|
|
#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", cornerRadius: 5, action: player.replayAction)
|
|
}
|
|
|
|
private var togglePlayButton: some View {
|
|
var foregroundColor: Color?
|
|
var fontSize: Double?
|
|
var size: Double?
|
|
#if !os(tvOS)
|
|
foregroundColor = .white
|
|
fontSize = playerControlsLayout.bigButtonFontSize
|
|
size = playerControlsLayout.bigButtonSize
|
|
#endif
|
|
|
|
return button(
|
|
model.isPlaying ? "Pause" : "Play",
|
|
systemImage: model.isPlaying ? "pause.fill" : "play.fill",
|
|
fontSize: fontSize,
|
|
size: size,
|
|
background: false, foregroundColor: foregroundColor
|
|
) {
|
|
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", cornerRadius: 5) {
|
|
player.advanceToNextItem()
|
|
}
|
|
.disabled(!player.isAdvanceToNextItemAvailable)
|
|
}
|
|
|
|
func button(
|
|
_ label: String,
|
|
systemImage: String? = nil,
|
|
fontSize: Double? = nil,
|
|
size: Double? = nil,
|
|
width _: Double? = nil,
|
|
height _: Double? = nil,
|
|
cornerRadius: Double = 3,
|
|
background: Bool = false,
|
|
foregroundColor: Color? = nil,
|
|
active: Bool = false,
|
|
action: @escaping () -> Void = {}
|
|
) -> some View {
|
|
#if os(tvOS)
|
|
let useBackground = false
|
|
#else
|
|
let useBackground = background
|
|
#endif
|
|
return Button {
|
|
action()
|
|
model.resetTimer()
|
|
} label: {
|
|
Group {
|
|
if let image = systemImage {
|
|
Label(label, systemImage: image)
|
|
.labelStyle(.iconOnly)
|
|
} else {
|
|
Label(label, systemImage: "")
|
|
.labelStyle(.titleOnly)
|
|
}
|
|
}
|
|
.padding()
|
|
.contentShape(Rectangle())
|
|
.shadow(radius: (foregroundColor == .white || !useBackground) ? 3 : 0)
|
|
}
|
|
.font(.system(size: fontSize ?? playerControlsLayout.buttonFontSize))
|
|
.buttonStyle(.plain)
|
|
.foregroundColor(foregroundColor.isNil ? (active ? Color("AppRedColor") : .primary) : foregroundColor)
|
|
.frame(width: size ?? playerControlsLayout.buttonSize, height: size ?? playerControlsLayout.buttonSize)
|
|
.modifier(ControlBackgroundModifier(enabled: useBackground))
|
|
.clipShape(RoundedRectangle(cornerRadius: cornerRadius))
|
|
.environment(\.colorScheme, .dark)
|
|
}
|
|
}
|
|
|
|
struct PlayerControls_Previews: PreviewProvider {
|
|
static var previews: some View {
|
|
ZStack {
|
|
Color.gray
|
|
|
|
PlayerControls()
|
|
.injectFixtureEnvironmentObjects()
|
|
}
|
|
}
|
|
}
|