1
0
mirror of https://github.com/yattee/yattee.git synced 2024-12-14 22:30:32 +05:30
yattee/Shared/Player/Controls/PlayerControls.swift

554 lines
20 KiB
Swift
Raw Normal View History

import Defaults
2022-02-17 01:53:11 +05:30
import Foundation
2022-06-08 02:57:48 +05:30
import SDWebImageSwiftUI
2022-02-17 01:53:11 +05:30
import SwiftUI
struct PlayerControls: View {
2022-02-22 02:27:12 +05:30
static let animation = Animation.easeInOut(duration: 0.2)
2022-02-17 01:53:11 +05:30
private var player: PlayerModel { .shared }
private var thumbnails: ThumbnailsModel { .shared }
2022-02-17 03:21:37 +05:30
2022-09-02 04:35:31 +05:30
@ObservedObject private var model = PlayerControlsModel.shared
2022-02-17 01:53:11 +05:30
2022-02-28 02:01:17 +05:30
#if os(iOS)
@Environment(\.verticalSizeClass) private var verticalSizeClass
2022-03-28 00:52:13 +05:30
#elseif os(tvOS)
enum Field: Hashable {
case seekOSD
2022-03-28 00:52:13 +05:30
case play
case backward
case forward
2022-08-14 22:36:22 +05:30
case settings
case close
2022-03-28 00:52:13 +05:30
}
@FocusState private var focusedField: Field?
2022-02-28 02:01:17 +05:30
#endif
2022-02-17 01:53:11 +05:30
@Default(.playerControlsLayout) private var regularPlayerControlsLayout
@Default(.fullScreenPlayerControlsLayout) private var fullScreenPlayerControlsLayout
2022-12-19 16:38:27 +05:30
@Default(.buttonBackwardSeekDuration) private var buttonBackwardSeekDuration
@Default(.buttonForwardSeekDuration) private var buttonForwardSeekDuration
2022-12-19 18:05:37 +05:30
#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
2022-09-02 04:35:31 +05:30
private let controlsOverlayModel = ControlOverlaysModel.shared
2022-12-22 01:46:47 +05:30
private var navigation = NavigationModel.shared
2022-09-02 04:35:31 +05:30
var playerControlsLayout: PlayerControlsLayout {
2022-09-02 04:35:31 +05:30
player.playingFullScreen ? fullScreenPlayerControlsLayout : regularPlayerControlsLayout
}
2022-02-17 01:53:11 +05:30
var body: some View {
ZStack(alignment: .topLeading) {
Seek()
.zIndex(4)
.transition(.opacity)
.frame(maxWidth: .infinity, alignment: .topLeading)
#if os(tvOS)
.focused($focusedField, equals: .seekOSD)
2022-08-29 17:25:23 +05:30
.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)
2022-09-01 00:54:46 +05:30
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 {
2022-09-02 04:35:31 +05:30
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)
2022-08-14 22:36:22 +05:30
}
.buttonStyle(.plain)
2022-07-10 23:21:46 +05:30
}
Spacer()
2022-07-10 23:21:46 +05:30
}
#endif
Spacer()
2022-08-29 03:51:12 +05:30
if playerControlsLayout.displaysTitleLine {
VStack(alignment: .leading) {
2022-12-18 17:41:06 +05:30
Text(player.videoForDisplay?.displayTitle ?? "Not Playing")
2022-08-29 03:51:12 +05:30
.shadow(radius: 10)
.font(.system(size: playerControlsLayout.titleLineFontSize).bold())
.lineLimit(1)
2022-11-11 23:49:48 +05:30
Text(player.currentVideo?.displayAuthor ?? "")
2022-08-29 03:51:12 +05:30
.fontWeight(.semibold)
.shadow(radius: 10)
.foregroundColor(.init(white: 0.8))
2022-08-29 03:51:12 +05:30
.font(.system(size: playerControlsLayout.authorLineFontSize))
.lineLimit(1)
}
.foregroundColor(.white)
2022-08-29 03:51:12 +05:30
.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
2022-12-19 18:05:37 +05:30
if playerControlsAdvanceToNextEnabled {
restartVideoButton
}
if playerControlsAdvanceToNextEnabled {
advanceToNextItemButton
}
Spacer()
#if os(tvOS)
2022-12-19 18:05:37 +05:30
if playerControlsSettingsEnabled {
settingsButton
}
#endif
2022-12-19 18:05:37 +05:30
if playerControlsPlaybackModeEnabled {
playbackModeButton
}
#if os(tvOS)
closeVideoButton
#else
2022-12-19 18:05:37 +05:30
if playerControlsMusicModeEnabled {
musicModeButton
}
#endif
}
.zIndex(0)
#if os(tvOS)
.offset(y: -playerControlsLayout.timelineHeight - 30)
#else
.offset(y: -playerControlsLayout.timelineHeight - 5)
#endif
2022-05-28 04:53:50 +05:30
}
2022-09-02 04:35:31 +05:30
}
2023-04-22 14:25:36 +05:30
.opacity(model.presentingControls && !player.availableStreams.isEmpty ? 1 : 0)
2022-02-17 01:53:11 +05:30
}
}
.frame(maxWidth: .infinity)
#if os(tvOS)
2022-08-21 02:35:40 +05:30
.onChange(of: model.presentingControls) { newValue in
if newValue { focusedField = .play }
2022-12-19 00:09:03 +05:30
else { focusedField = nil }
2022-08-21 02:35:40 +05:30
}
.onChange(of: focusedField) { _ in model.resetTimer() }
#else
2022-08-21 02:35:40 +05:30
.background(PlayerGestures())
.background(controlsBackground)
#endif
2022-08-08 23:01:13 +05:30
if model.presentingDetailsOverlay {
2022-08-22 04:27:01 +05:30
Section {
VideoDetailsOverlay()
.frame(maxWidth: detailsWidth, maxHeight: detailsHeight)
.transition(.opacity)
}
.frame(maxHeight: .infinity, alignment: .top)
2022-08-08 23:01:13 +05:30
}
}
2022-08-14 22:36:22 +05:30
.onChange(of: model.presentingOverlays) { newValue in
2022-08-08 23:01:13 +05:30
if newValue {
2022-08-24 02:44:13 +05:30
model.hide()
}
2022-03-28 00:52:13 +05:30
}
2022-08-14 22:36:22 +05:30
#if os(tvOS)
.onReceive(model.reporter) { value in
guard player.presentingPlayer else { return }
if value == "swipe down", !model.presentingControls, !model.presentingOverlays {
withAnimation(Self.animation) {
2022-11-18 03:17:45 +05:30
controlsOverlayModel.hide()
}
} else {
model.show()
}
2022-08-14 22:36:22 +05:30
model.resetTimer()
}
#endif
2022-07-10 23:21:46 +05:30
}
var detailsWidth: Double {
guard player.playerSize.width.isFinite else { return 200 }
2022-07-10 23:21:46 +05:30
return [player.playerSize.width, 600].min()!
2022-07-05 22:50:25 +05:30
}
2022-07-11 22:13:23 +05:30
var detailsHeight: Double {
guard player.playerSize.height.isFinite else { return 200 }
2022-11-13 23:22:15 +05:30
var inset = 0.0
#if os(iOS)
inset = SafeArea.insets.bottom
#endif
return [player.playerSize.height - inset, 500].min()!
2022-07-11 22:13:23 +05:30
}
2022-06-08 02:57:48 +05:30
@ViewBuilder var controlsBackground: some View {
2022-12-19 05:01:56 +05:30
ZStack {
if player.musicMode,
let url = controlsBackgroundURL
{
ThumbnailView(url: url)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.transition(.opacity)
.animation(.default)
} else if player.videoForDisplay == nil {
Color.black
}
2022-06-08 02:57:48 +05:30
}
}
2022-12-18 17:41:06 +05:30
var controlsBackgroundURL: URL? {
if let video = player.videoForDisplay,
let url = thumbnails.best(video)
{
return url
}
return nil
}
2022-02-17 01:53:11 +05:30
var timeline: some View {
TimelineView(context: .player).foregroundColor(.primary)
2022-02-17 01:53:11 +05:30
}
private var hidePlayerButton: some View {
button("Hide", systemImage: "chevron.down") {
2022-02-17 01:53:11 +05:30
player.hide()
}
2022-03-28 00:52:13 +05:30
#if !os(tvOS)
2022-02-17 04:31:48 +05:30
.keyboardShortcut(.cancelAction)
2022-03-28 00:52:13 +05:30
#endif
2022-02-17 01:53:11 +05:30
}
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) {
2022-08-14 22:36:22 +05:30
fullscreenButton
2022-04-17 02:20:37 +05:30
2022-08-19 04:10:46 +05:30
pipButton
2022-08-14 22:36:22 +05:30
#if os(iOS)
2022-12-19 18:05:37 +05:30
if playerControlsLockOrientationEnabled {
lockOrientationButton
}
2022-08-14 22:36:22 +05:30
#endif
2022-06-08 02:57:48 +05:30
2022-08-14 22:36:22 +05:30
Spacer()
2022-06-08 03:35:16 +05:30
2022-12-19 18:05:37 +05:30
if playerControlsSettingsEnabled {
settingsButton
}
if playerControlsCloseEnabled {
closeVideoButton
}
2022-02-17 01:53:11 +05:30
}
}
var fullscreenButton: some View {
button(
"Fullscreen",
2022-09-02 04:35:31 +05:30
systemImage: player.playingFullScreen ? "arrow.down.right.and.arrow.up.left" : "arrow.up.left.and.arrow.down.right"
2022-02-17 01:53:11 +05:30
) {
2022-12-18 17:41:06 +05:30
player.toggleFullscreen(player.playingFullScreen, showControls: false)
2022-02-17 01:53:11 +05:30
}
2022-03-28 00:52:13 +05:30
#if !os(tvOS)
2022-09-02 04:35:31 +05:30
.keyboardShortcut(player.playingFullScreen ? .cancelAction : .defaultAction)
2022-03-28 00:52:13 +05:30
#endif
2022-02-17 01:53:11 +05:30
}
2022-08-14 22:36:22 +05:30
private var settingsButton: some View {
2022-09-01 04:11:31 +05:30
button("settings", systemImage: "gearshape") {
2022-08-14 22:36:22 +05:30
withAnimation(Self.animation) {
2022-12-22 01:46:47 +05:30
#if os(tvOS)
controlsOverlayModel.toggle()
#else
navigation.presentingPlaybackSettings = true
#endif
2022-08-14 22:36:22 +05:30
}
}
#if os(tvOS)
.focused($focusedField, equals: .settings)
#endif
}
private var closeVideoButton: some View {
button("Close", systemImage: "xmark") {
2023-04-22 17:26:25 +05:30
player.closeCurrentItem()
}
2022-08-14 22:36:22 +05:30
#if os(tvOS)
.focused($focusedField, equals: .close)
#endif
}
2022-06-08 03:35:16 +05:30
private var musicModeButton: some View {
button("Music Mode", systemImage: "music.note", active: player.musicMode, action: player.toggleMusicMode)
2022-06-08 03:35:16 +05:30
}
2022-05-21 02:53:14 +05:30
private var pipButton: some View {
2022-08-27 01:47:21 +05:30
let image = player.transitioningToPiP ? "pip.fill" : player.pipController?.isPictureInPictureActive ?? false ? "pip.exit" : "pip.enter"
return button("PiP", systemImage: image) {
(player.pipController?.isPictureInPictureActive ?? false) ? player.closePiP() : player.startPiP()
2022-05-21 02:53:14 +05:30
}
2022-08-27 01:47:21 +05:30
.disabled(!player.pipPossible)
2022-05-21 02:53:14 +05:30
}
2022-07-11 04:56:35 +05:30
#if os(iOS)
private var lockOrientationButton: some View {
button("Lock Rotation", systemImage: player.lockedOrientation.isNil ? "lock.rotation.open" : "lock.rotation", active: !player.lockedOrientation.isNil) {
if player.lockedOrientation.isNil {
let orientationMask = OrientationTracker.shared.currentInterfaceOrientationMask
player.lockedOrientation = orientationMask
let orientation = OrientationTracker.shared.currentInterfaceOrientation
Orientation.lockOrientation(orientationMask, andRotateTo: .landscapeLeft)
// iOS 16 workaround
Orientation.lockOrientation(orientationMask, andRotateTo: orientation)
2022-07-11 04:56:35 +05:30
} else {
player.lockedOrientation = nil
Orientation.lockOrientation(.allButUpsideDown, andRotateTo: OrientationTracker.shared.currentInterfaceOrientation)
}
}
}
#endif
2022-07-11 03:54:56 +05:30
var playbackModeButton: some View {
button("Playback Mode", systemImage: player.playbackMode.systemImage) {
2022-07-11 03:54:56 +05:30
player.playbackMode = player.playbackMode.next()
model.objectWillChange.send()
2022-07-11 03:54:56 +05:30
}
}
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
2022-12-19 16:38:27 +05:30
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)
}
2022-07-22 04:14:21 +05:30
.disabled(player.liveStreamInAVPlayer)
#if os(tvOS)
2022-07-22 04:14:21 +05:30
.focused($focusedField, equals: .backward)
#else
2022-07-22 04:14:21 +05:30
.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
2022-12-19 16:38:27 +05:30
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)
}
2022-07-22 04:14:21 +05:30
.disabled(player.liveStreamInAVPlayer)
#if os(tvOS)
2022-07-22 04:14:21 +05:30
.focused($focusedField, equals: .forward)
#else
2022-07-22 04:14:21 +05:30
.keyboardShortcut("l", modifiers: [])
.keyboardShortcut(KeyEquivalent.rightArrow, modifiers: [])
#endif
}
private var restartVideoButton: some View {
button("Restart video", systemImage: "backward.end.fill", cornerRadius: 5) {
player.backend.seek(to: 0.0, seekType: .userInteracted)
}
}
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)
}
2022-02-17 01:53:11 +05:30
private var advanceToNextItemButton: some View {
button("Next", systemImage: "forward.fill", cornerRadius: 5) {
player.advanceToNextItem()
2022-02-17 01:53:11 +05:30
}
2022-07-11 03:54:56 +05:30
.disabled(!player.isAdvanceToNextItemAvailable)
2022-02-17 01:53:11 +05:30
}
func button(
_ label: String,
2022-06-15 04:11:49 +05:30
systemImage: String? = nil,
fontSize: Double? = nil,
size: Double? = nil,
width _: Double? = nil,
height _: Double? = nil,
2022-02-17 01:53:11 +05:30
cornerRadius: Double = 3,
2022-12-03 20:03:32 +05:30
background: Bool = false,
foregroundColor: Color? = nil,
2022-06-08 02:57:48 +05:30
active: Bool = false,
2022-02-17 01:53:11 +05:30
action: @escaping () -> Void = {}
) -> some View {
2022-08-14 22:36:22 +05:30
#if os(tvOS)
let useBackground = false
#else
let useBackground = background
#endif
return Button {
2022-02-17 01:53:11 +05:30
action()
model.resetTimer()
} label: {
2022-06-15 04:11:49 +05:30
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)
2022-02-17 01:53:11 +05:30
}
.font(.system(size: fontSize ?? playerControlsLayout.buttonFontSize))
2022-02-28 02:01:17 +05:30
.buttonStyle(.plain)
.foregroundColor(foregroundColor.isNil ? (active ? Color("AppRedColor") : .primary) : foregroundColor)
.frame(width: size ?? playerControlsLayout.buttonSize, height: size ?? playerControlsLayout.buttonSize)
2022-08-14 22:36:22 +05:30
.modifier(ControlBackgroundModifier(enabled: useBackground))
.clipShape(RoundedRectangle(cornerRadius: cornerRadius))
2022-12-03 20:03:32 +05:30
.environment(\.colorScheme, .dark)
2022-02-17 01:53:11 +05:30
}
}
struct PlayerControls_Previews: PreviewProvider {
static var previews: some View {
ZStack {
2022-05-28 04:53:50 +05:30
Color.gray
PlayerControls()
.injectFixtureEnvironmentObjects()
}
2022-02-17 01:53:11 +05:30
}
}