mirror of
https://github.com/yattee/yattee.git
synced 2025-08-09 20:24:06 +00:00
Player controls UI changes
WIP on controls Chapters working Add previews variable Add lists ids WIP
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import Defaults
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
#endif
|
||||
@@ -11,6 +12,12 @@ extension Defaults.Keys {
|
||||
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 kavinPipedInstanceID = "kavin-piped"
|
||||
static let instances = Key<[Instance]>("instances", default: [
|
||||
.init(
|
||||
@@ -89,6 +96,10 @@ extension Defaults.Keys {
|
||||
#endif
|
||||
|
||||
static let showMPVPlaybackStats = Key<Bool>("showMPVPlaybackStats", default: false)
|
||||
|
||||
static let playerDetailsPageButtonLabelStyle = Key<PlayerDetailsPageButtonLabelStyle>("playerDetailsPageButtonLabelStyle", default: defaultForPlayerDetailsPageButtonLabelStyle)
|
||||
|
||||
static let controlsBarInPlayer = Key<Bool>("controlsBarInPlayer", default: true)
|
||||
}
|
||||
|
||||
enum ResolutionSetting: String, CaseIterable, Defaults.Serializable {
|
||||
@@ -200,3 +211,11 @@ enum WatchedVideoPlayNowBehavior: String, Defaults.Serializable {
|
||||
case info, separate
|
||||
}
|
||||
#endif
|
||||
|
||||
enum PlayerDetailsPageButtonLabelStyle: String, CaseIterable, Defaults.Serializable {
|
||||
case iconOnly, iconAndText
|
||||
|
||||
var text: Bool {
|
||||
self == .iconAndText
|
||||
}
|
||||
}
|
||||
|
@@ -70,7 +70,13 @@ struct FavoritesView: View {
|
||||
|
||||
struct Favorites_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
FavoritesView()
|
||||
.injectFixtureEnvironmentObjects()
|
||||
TabView {
|
||||
FavoritesView()
|
||||
.overlay(VideoPlayerView().injectFixtureEnvironmentObjects())
|
||||
.injectFixtureEnvironmentObjects()
|
||||
.tabItem {
|
||||
Label("a", systemImage: "")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -14,7 +14,6 @@ struct AppSidebarNavigation: View {
|
||||
@EnvironmentObject<InstancesModel> private var instances
|
||||
@EnvironmentObject<NavigationModel> private var navigation
|
||||
@EnvironmentObject<PlayerModel> private var player
|
||||
@EnvironmentObject<PlayerControlsModel> private var playerControls
|
||||
@EnvironmentObject<PlaylistsModel> private var playlists
|
||||
@EnvironmentObject<RecentsModel> private var recents
|
||||
@EnvironmentObject<SearchModel> private var search
|
||||
@@ -50,15 +49,13 @@ struct AppSidebarNavigation: View {
|
||||
.frame(minWidth: sidebarMinWidth)
|
||||
|
||||
VStack {
|
||||
BrowserPlayerControls {
|
||||
HStack(alignment: .center) {
|
||||
Spacer()
|
||||
Image(systemName: "4k.tv")
|
||||
.renderingMode(.original)
|
||||
.font(.system(size: 60))
|
||||
.foregroundColor(.accentColor)
|
||||
Spacer()
|
||||
}
|
||||
HStack(alignment: .center) {
|
||||
Spacer()
|
||||
Image(systemName: "4k.tv")
|
||||
.renderingMode(.original)
|
||||
.font(.system(size: 60))
|
||||
.foregroundColor(.accentColor)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -7,7 +7,6 @@ struct AppTabNavigation: View {
|
||||
@EnvironmentObject<InstancesModel> private var instances
|
||||
@EnvironmentObject<NavigationModel> private var navigation
|
||||
@EnvironmentObject<PlayerModel> private var player
|
||||
@EnvironmentObject<PlayerControlsModel> private var playerControls
|
||||
@EnvironmentObject<PlaylistsModel> private var playlists
|
||||
@EnvironmentObject<RecentsModel> private var recents
|
||||
@EnvironmentObject<SearchModel> private var search
|
||||
@@ -130,20 +129,6 @@ struct AppTabNavigation: View {
|
||||
.tag(TabSelection.search)
|
||||
}
|
||||
|
||||
private var videoPlayer: some View {
|
||||
VideoPlayerView()
|
||||
.environmentObject(accounts)
|
||||
.environmentObject(comments)
|
||||
.environmentObject(instances)
|
||||
.environmentObject(navigation)
|
||||
.environmentObject(player)
|
||||
.environmentObject(playerControls)
|
||||
.environmentObject(playlists)
|
||||
.environmentObject(recents)
|
||||
.environmentObject(subscriptions)
|
||||
.environmentObject(thumbnailsModel)
|
||||
}
|
||||
|
||||
var toolbarContent: some ToolbarContent {
|
||||
#if os(iOS)
|
||||
Group {
|
||||
|
@@ -12,8 +12,10 @@ struct ContentView: View {
|
||||
@EnvironmentObject<CommentsModel> private var comments
|
||||
@EnvironmentObject<InstancesModel> private var instances
|
||||
@EnvironmentObject<NavigationModel> private var navigation
|
||||
@EnvironmentObject<NetworkStateModel> private var networkState
|
||||
@EnvironmentObject<PlayerModel> private var player
|
||||
@EnvironmentObject<PlayerControlsModel> private var playerControls
|
||||
@EnvironmentObject<PlayerTimeModel> private var playerTime
|
||||
@EnvironmentObject<PlaylistsModel> private var playlists
|
||||
@EnvironmentObject<RecentsModel> private var recents
|
||||
@EnvironmentObject<SearchModel> private var search
|
||||
@@ -42,7 +44,6 @@ struct ContentView: View {
|
||||
TVNavigationView()
|
||||
#endif
|
||||
}
|
||||
.onAppear(perform: configure)
|
||||
.onChange(of: accounts.signedIn) { _ in
|
||||
subscriptions.load(force: true)
|
||||
playlists.load(force: true)
|
||||
@@ -52,7 +53,9 @@ struct ContentView: View {
|
||||
.environmentObject(comments)
|
||||
.environmentObject(instances)
|
||||
.environmentObject(navigation)
|
||||
.environmentObject(networkState)
|
||||
.environmentObject(player)
|
||||
.environmentObject(playerTime)
|
||||
.environmentObject(playlists)
|
||||
.environmentObject(recents)
|
||||
.environmentObject(search)
|
||||
@@ -107,118 +110,9 @@ struct ContentView: View {
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func configure() {
|
||||
SiestaLog.Category.enabled = .common
|
||||
SDImageCodersManager.shared.addCoder(SDImageWebPCoder.shared)
|
||||
SDWebImageManager.defaultImageCache = PINCache(name: "stream.yattee.app")
|
||||
#if !os(macOS)
|
||||
setupNowPlayingInfoCenter()
|
||||
#endif
|
||||
|
||||
#if os(iOS)
|
||||
if Defaults[.lockPortraitWhenBrowsing] {
|
||||
Orientation.lockOrientation(.portrait, andRotateTo: .portrait)
|
||||
.alert(isPresented: $navigation.presentingAlert) {
|
||||
Alert(title: Text(navigation.alertTitle), message: Text(navigation.alertMessage))
|
||||
}
|
||||
#endif
|
||||
|
||||
if let account = accounts.lastUsed ??
|
||||
instances.lastUsed?.anonymousAccount ??
|
||||
InstancesModel.all.first?.anonymousAccount
|
||||
{
|
||||
accounts.setCurrent(account)
|
||||
}
|
||||
|
||||
if accounts.current.isNil {
|
||||
navigation.presentingWelcomeScreen = true
|
||||
}
|
||||
|
||||
playlists.accounts = accounts
|
||||
search.accounts = accounts
|
||||
subscriptions.accounts = accounts
|
||||
|
||||
comments.player = player
|
||||
|
||||
menu.accounts = accounts
|
||||
menu.navigation = navigation
|
||||
menu.player = player
|
||||
playerControls.player = player
|
||||
|
||||
player.accounts = accounts
|
||||
player.comments = comments
|
||||
player.controls = playerControls
|
||||
|
||||
if !accounts.current.isNil {
|
||||
player.restoreQueue()
|
||||
}
|
||||
|
||||
if !Defaults[.saveRecents] {
|
||||
recents.clear()
|
||||
}
|
||||
|
||||
var section = Defaults[.visibleSections].min()?.tabSelection
|
||||
|
||||
#if os(macOS)
|
||||
if section == .playlists {
|
||||
section = .search
|
||||
}
|
||||
#endif
|
||||
|
||||
navigation.tabSelection = section ?? .search
|
||||
|
||||
subscriptions.load()
|
||||
playlists.load()
|
||||
}
|
||||
|
||||
func setupNowPlayingInfoCenter() {
|
||||
#if !os(macOS)
|
||||
try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .moviePlayback)
|
||||
|
||||
UIApplication.shared.beginReceivingRemoteControlEvents()
|
||||
#endif
|
||||
|
||||
MPRemoteCommandCenter.shared().playCommand.addTarget { _ in
|
||||
player.play()
|
||||
return .success
|
||||
}
|
||||
|
||||
MPRemoteCommandCenter.shared().pauseCommand.addTarget { _ in
|
||||
player.pause()
|
||||
return .success
|
||||
}
|
||||
|
||||
MPRemoteCommandCenter.shared().previousTrackCommand.isEnabled = false
|
||||
MPRemoteCommandCenter.shared().nextTrackCommand.isEnabled = false
|
||||
|
||||
MPRemoteCommandCenter.shared().changePlaybackPositionCommand.addTarget { remoteEvent in
|
||||
guard let event = remoteEvent as? MPChangePlaybackPositionCommandEvent
|
||||
else {
|
||||
return .commandFailed
|
||||
}
|
||||
|
||||
player.backend.seek(to: event.positionTime)
|
||||
|
||||
return .success
|
||||
}
|
||||
|
||||
let skipForwardCommand = MPRemoteCommandCenter.shared().skipForwardCommand
|
||||
skipForwardCommand.isEnabled = true
|
||||
skipForwardCommand.preferredIntervals = [10]
|
||||
|
||||
skipForwardCommand.addTarget { _ in
|
||||
player.backend.seek(relative: .secondsInDefaultTimescale(10))
|
||||
return .success
|
||||
}
|
||||
|
||||
let skipBackwardCommand = MPRemoteCommandCenter.shared().skipBackwardCommand
|
||||
skipBackwardCommand.isEnabled = true
|
||||
skipBackwardCommand.preferredIntervals = [10]
|
||||
|
||||
skipBackwardCommand.addTarget { _ in
|
||||
player.backend.seek(relative: .secondsInDefaultTimescale(-10))
|
||||
return .success
|
||||
}
|
||||
}
|
||||
|
||||
func openWelcomeScreenIfAccountEmpty() {
|
||||
|
83
Shared/Player/ChaptersView.swift
Normal file
83
Shared/Player/ChaptersView.swift
Normal file
@@ -0,0 +1,83 @@
|
||||
import Foundation
|
||||
import SDWebImageSwiftUI
|
||||
import SwiftUI
|
||||
|
||||
struct ChaptersView: View {
|
||||
@EnvironmentObject<PlayerModel> private var player
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
if let chapters = player.currentVideo?.chapters, !chapters.isEmpty {
|
||||
Section(header: Text("Chapters")) {
|
||||
ForEach(chapters) { chapter in
|
||||
Button {
|
||||
player.backend.seek(to: chapter.start)
|
||||
} label: {
|
||||
chapterButtonLabel(chapter)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text(player.currentVideo?.title ?? "")
|
||||
}
|
||||
}
|
||||
.id(UUID())
|
||||
#if os(macOS)
|
||||
.listStyle(.inset)
|
||||
#elseif os(iOS)
|
||||
.listStyle(.grouped)
|
||||
#else
|
||||
.listStyle(.plain)
|
||||
#endif
|
||||
}
|
||||
|
||||
@ViewBuilder func chapterButtonLabel(_ chapter: Chapter) -> some View {
|
||||
HStack(spacing: 12) {
|
||||
if !chapter.image.isNil {
|
||||
smallImage(chapter)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(chapter.title)
|
||||
.font(.headline)
|
||||
Text(chapter.start.formattedAsPlaybackTime(allowZero: true) ?? "")
|
||||
.font(.system(.subheadline).monospacedDigit())
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
|
||||
@ViewBuilder func smallImage(_ chapter: Chapter) -> some View {
|
||||
WebImage(url: chapter.image)
|
||||
.resizable()
|
||||
.placeholder {
|
||||
ProgressView()
|
||||
}
|
||||
.indicator(.activity)
|
||||
#if os(tvOS)
|
||||
.frame(width: thumbnailWidth, height: 140)
|
||||
.mask(RoundedRectangle(cornerRadius: 12))
|
||||
#else
|
||||
.frame(width: thumbnailWidth, height: 60)
|
||||
.mask(RoundedRectangle(cornerRadius: 6))
|
||||
#endif
|
||||
}
|
||||
|
||||
private var thumbnailWidth: Double {
|
||||
#if os(tvOS)
|
||||
250
|
||||
#else
|
||||
100
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
struct ChaptersView_Preview: PreviewProvider {
|
||||
static var previews: some View {
|
||||
ChaptersView()
|
||||
.injectFixtureEnvironmentObjects()
|
||||
}
|
||||
}
|
@@ -14,9 +14,6 @@ struct CommentsView: View {
|
||||
NoCommentsView(text: "No comments", systemImage: "0.circle.fill")
|
||||
} else if !comments.loaded {
|
||||
PlaceholderProgressView()
|
||||
.onAppear {
|
||||
comments.load()
|
||||
}
|
||||
} else {
|
||||
let last = comments.all.last
|
||||
let commentsStack = LazyVStack {
|
||||
|
22
Shared/Player/Controls/ControlBackgroundModifier.swift
Normal file
22
Shared/Player/Controls/ControlBackgroundModifier.swift
Normal file
@@ -0,0 +1,22 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
struct ControlBackgroundModifier: ViewModifier {
|
||||
var enabled = true
|
||||
var edgesIgnoringSafeArea = Edge.Set()
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
if enabled {
|
||||
content
|
||||
#if os(macOS)
|
||||
.background(VisualEffectBlur(material: .hudWindow))
|
||||
#elseif os(iOS)
|
||||
.background(VisualEffectBlur(blurStyle: .systemThinMaterial).edgesIgnoringSafeArea(edgesIgnoringSafeArea))
|
||||
#else
|
||||
.background(.thinMaterial)
|
||||
#endif
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
185
Shared/Player/Controls/ControlsOverlay.swift
Normal file
185
Shared/Player/Controls/ControlsOverlay.swift
Normal file
@@ -0,0 +1,185 @@
|
||||
import Defaults
|
||||
import SwiftUI
|
||||
|
||||
struct ControlsOverlay: View {
|
||||
@EnvironmentObject<PlayerModel> private var player
|
||||
@EnvironmentObject<PlayerControlsModel> private var model
|
||||
|
||||
@Default(.showMPVPlaybackStats) private var showMPVPlaybackStats
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 6) {
|
||||
HStack {
|
||||
backendButtons
|
||||
}
|
||||
qualityButton
|
||||
HStack {
|
||||
decreaseRateButton
|
||||
rateButton
|
||||
increaseRateButton
|
||||
}
|
||||
.foregroundColor(.white)
|
||||
|
||||
if player.activeBackend == .mpv,
|
||||
showMPVPlaybackStats
|
||||
{
|
||||
mpvPlaybackStats
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var backendButtons: some View {
|
||||
ForEach(PlayerBackendType.allCases, id: \.self) { backend in
|
||||
backendButton(backend)
|
||||
}
|
||||
}
|
||||
|
||||
private func backendButton(_ backend: PlayerBackendType) -> some View {
|
||||
Button {
|
||||
player.saveTime {
|
||||
player.changeActiveBackend(from: player.activeBackend, to: backend)
|
||||
model.resetTimer()
|
||||
}
|
||||
} label: {
|
||||
Text(backend.label)
|
||||
.padding(6)
|
||||
.foregroundColor(player.activeBackend == backend ? .accentColor : .secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private var increaseRateButton: some View {
|
||||
let increasedRate = PlayerModel.availableRates.first { $0 > player.currentRate }
|
||||
return Button {
|
||||
if let rate = increasedRate {
|
||||
player.currentRate = rate
|
||||
}
|
||||
} label: {
|
||||
Label("Increase rate", systemImage: "plus")
|
||||
.labelStyle(.iconOnly)
|
||||
.padding(.horizontal, 8)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
#if os(macOS)
|
||||
.buttonStyle(.bordered)
|
||||
#else
|
||||
.frame(height: 30)
|
||||
.modifier(ControlBackgroundModifier())
|
||||
.clipShape(RoundedRectangle(cornerRadius: 4))
|
||||
#endif
|
||||
.disabled(increasedRate.isNil)
|
||||
}
|
||||
|
||||
private var decreaseRateButton: some View {
|
||||
let decreasedRate = PlayerModel.availableRates.last { $0 < player.currentRate }
|
||||
|
||||
return Button {
|
||||
if let rate = decreasedRate {
|
||||
player.currentRate = rate
|
||||
}
|
||||
} label: {
|
||||
Label("Decrease rate", systemImage: "minus")
|
||||
.labelStyle(.iconOnly)
|
||||
.padding(.horizontal, 8)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
#if os(macOS)
|
||||
.buttonStyle(.bordered)
|
||||
#else
|
||||
.frame(height: 30)
|
||||
.modifier(ControlBackgroundModifier())
|
||||
.clipShape(RoundedRectangle(cornerRadius: 4))
|
||||
#endif
|
||||
.disabled(decreasedRate.isNil)
|
||||
}
|
||||
|
||||
@ViewBuilder private var qualityButton: some View {
|
||||
#if os(macOS)
|
||||
StreamControl()
|
||||
.labelsHidden()
|
||||
.frame(maxWidth: 300)
|
||||
#elseif os(iOS)
|
||||
Menu {
|
||||
StreamControl()
|
||||
.frame(width: 45, height: 30)
|
||||
#if os(iOS)
|
||||
.background(VisualEffectBlur(blurStyle: .systemThinMaterial))
|
||||
#endif
|
||||
.mask(RoundedRectangle(cornerRadius: 3))
|
||||
} label: {
|
||||
Text(player.streamSelection?.shortQuality ?? "loading")
|
||||
.frame(width: 140, height: 30)
|
||||
.foregroundColor(.primary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundColor(.primary)
|
||||
.frame(width: 140, height: 30)
|
||||
#if os(macOS)
|
||||
.background(VisualEffectBlur(material: .hudWindow))
|
||||
#elseif os(iOS)
|
||||
.background(VisualEffectBlur(blurStyle: .systemThinMaterial))
|
||||
#endif
|
||||
.mask(RoundedRectangle(cornerRadius: 3))
|
||||
#endif
|
||||
}
|
||||
|
||||
@ViewBuilder private var rateButton: some View {
|
||||
#if os(macOS)
|
||||
ratePicker
|
||||
.labelsHidden()
|
||||
.frame(maxWidth: 100)
|
||||
#elseif os(iOS)
|
||||
Menu {
|
||||
ratePicker
|
||||
.frame(width: 100, height: 30)
|
||||
|
||||
.mask(RoundedRectangle(cornerRadius: 3))
|
||||
} label: {
|
||||
Text(player.rateLabel(player.currentRate))
|
||||
.foregroundColor(.primary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundColor(.primary)
|
||||
.frame(width: 100, height: 30)
|
||||
.modifier(ControlBackgroundModifier())
|
||||
.mask(RoundedRectangle(cornerRadius: 3))
|
||||
#endif
|
||||
}
|
||||
|
||||
var ratePicker: some View {
|
||||
Picker("Rate", selection: rateBinding) {
|
||||
ForEach(PlayerModel.availableRates, id: \.self) { rate in
|
||||
Text(player.rateLabel(rate)).tag(rate)
|
||||
}
|
||||
}
|
||||
.transaction { t in t.animation = .none }
|
||||
}
|
||||
|
||||
private var rateBinding: Binding<Float> {
|
||||
.init(get: { player.currentRate }, set: { rate in player.currentRate = rate })
|
||||
}
|
||||
|
||||
var mpvPlaybackStats: some View {
|
||||
Group {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("hw decoder: \(player.mpvBackend.hwDecoder)")
|
||||
Text("dropped: \(player.mpvBackend.frameDropCount)")
|
||||
Text("video: \(String(format: "%.2ffps", player.mpvBackend.outputFps))")
|
||||
Text("buffering: \(String(format: "%.0f%%", player.mpvBackend.bufferingState))")
|
||||
Text("cache: \(String(format: "%.2fs", player.mpvBackend.cacheDuration))")
|
||||
}
|
||||
.mask(RoundedRectangle(cornerRadius: 3))
|
||||
}
|
||||
#if !os(tvOS)
|
||||
.font(.system(size: 9))
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
struct ControlsOverlay_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
ControlsOverlay()
|
||||
.environmentObject(PlayerModel())
|
||||
.environmentObject(PlayerControlsModel())
|
||||
}
|
||||
}
|
37
Shared/Player/Controls/OSD/Buffering.swift
Normal file
37
Shared/Player/Controls/OSD/Buffering.swift
Normal file
@@ -0,0 +1,37 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
struct Buffering: View {
|
||||
var reason = "Buffering stream..."
|
||||
var state: String?
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 2) {
|
||||
ProgressView()
|
||||
#if os(macOS)
|
||||
.scaleEffect(0.4)
|
||||
#else
|
||||
.scaleEffect(0.7)
|
||||
#endif
|
||||
.frame(maxHeight: 14)
|
||||
.progressViewStyle(.circular)
|
||||
|
||||
Text(reason)
|
||||
.font(.caption)
|
||||
if let state = state {
|
||||
Text(state)
|
||||
.font(.caption2.monospacedDigit())
|
||||
}
|
||||
}
|
||||
.padding(8)
|
||||
.modifier(ControlBackgroundModifier())
|
||||
.clipShape(RoundedRectangle(cornerRadius: 4))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
struct Buffering_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
Buffering(state: "100% (2.95s)")
|
||||
}
|
||||
}
|
22
Shared/Player/Controls/OSD/NetworkState.swift
Normal file
22
Shared/Player/Controls/OSD/NetworkState.swift
Normal file
@@ -0,0 +1,22 @@
|
||||
import SwiftUI
|
||||
|
||||
struct NetworkState: View {
|
||||
@EnvironmentObject<PlayerModel> private var player
|
||||
@EnvironmentObject<NetworkStateModel> private var model
|
||||
|
||||
var body: some View {
|
||||
Buffering(state: model.fullStateText)
|
||||
.opacity(model.pausedForCache || player.isSeeking ? 1 : 0)
|
||||
}
|
||||
}
|
||||
|
||||
struct NetworkState_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
let networkState = NetworkStateModel()
|
||||
networkState.bufferingState = 30
|
||||
|
||||
return NetworkState()
|
||||
.environmentObject(networkState)
|
||||
.environmentObject(PlayerModel())
|
||||
}
|
||||
}
|
37
Shared/Player/Controls/OSD/OpeningStream.swift
Normal file
37
Shared/Player/Controls/OSD/OpeningStream.swift
Normal file
@@ -0,0 +1,37 @@
|
||||
import SwiftUI
|
||||
|
||||
struct OpeningStream: View {
|
||||
@EnvironmentObject<PlayerModel> private var player
|
||||
@EnvironmentObject<NetworkStateModel> private var model
|
||||
|
||||
var body: some View {
|
||||
Buffering(reason: reason, state: state)
|
||||
.opacity(visible ? 1 : 0)
|
||||
}
|
||||
|
||||
var visible: Bool {
|
||||
(!player.currentItem.isNil && !player.videoBeingOpened.isNil) || (player.isLoadingVideo && !model.pausedForCache && !player.isSeeking)
|
||||
}
|
||||
|
||||
var reason: String {
|
||||
player.videoBeingOpened.isNil ? "Opening\(streamQuality)stream..." : "Loading streams..."
|
||||
}
|
||||
|
||||
var state: String? {
|
||||
player.videoBeingOpened.isNil ? model.bufferingStateText : nil
|
||||
}
|
||||
|
||||
var streamQuality: String {
|
||||
guard let stream = player.streamSelection else { return " " }
|
||||
guard !player.musicMode else { return " audio " }
|
||||
|
||||
return " \(stream.shortQuality) "
|
||||
}
|
||||
}
|
||||
|
||||
struct OpeningStream_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
OpeningStream()
|
||||
.injectFixtureEnvironmentObjects()
|
||||
}
|
||||
}
|
@@ -23,7 +23,7 @@ struct PlayerControls: View {
|
||||
@FocusState private var focusedField: Field?
|
||||
#endif
|
||||
|
||||
@Default(.showMPVPlaybackStats) private var showMPVPlaybackStats
|
||||
@Default(.controlsBarInPlayer) private var controlsBarInPlayer
|
||||
|
||||
init(player: PlayerModel, thumbnails: ThumbnailsModel) {
|
||||
self.player = player
|
||||
@@ -31,74 +31,107 @@ struct PlayerControls: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
ZStack(alignment: .bottom) {
|
||||
VStack(alignment: .trailing, spacing: 4) {
|
||||
#if !os(tvOS)
|
||||
buttonsBar
|
||||
|
||||
HStack(spacing: 4) {
|
||||
qualityButton
|
||||
backendButton
|
||||
}
|
||||
#else
|
||||
Text(player.stream?.description ?? "")
|
||||
#endif
|
||||
|
||||
Spacer()
|
||||
|
||||
mediumButtonsBar
|
||||
|
||||
Spacer()
|
||||
ZStack(alignment: .topTrailing) {
|
||||
VStack {
|
||||
ZStack(alignment: .center) {
|
||||
OpeningStream()
|
||||
NetworkState()
|
||||
|
||||
Group {
|
||||
if player.activeBackend == .mpv, showMPVPlaybackStats {
|
||||
mpvPlaybackStats
|
||||
}
|
||||
VStack(spacing: 4) {
|
||||
buttonsBar
|
||||
|
||||
timeline
|
||||
.offset(y: 10)
|
||||
.zIndex(1)
|
||||
if let video = player.currentVideo, player.playingFullScreen {
|
||||
// if let video = Video.fixture {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(video.title)
|
||||
.font(.title2.bold())
|
||||
|
||||
Text(video.author)
|
||||
.font(.title3)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.padding(12)
|
||||
.modifier(ControlBackgroundModifier())
|
||||
.clipShape(RoundedRectangle(cornerRadius: 3))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Spacer()
|
||||
|
||||
bottomBar
|
||||
#if os(macOS)
|
||||
.background(VisualEffectBlur(material: .hudWindow))
|
||||
#elseif os(iOS)
|
||||
.background(VisualEffectBlur(blurStyle: .systemThinMaterial))
|
||||
#endif
|
||||
.mask(RoundedRectangle(cornerRadius: 3))
|
||||
Group {
|
||||
ZStack(alignment: .bottom) {
|
||||
floatingControls
|
||||
.padding(.top, 20)
|
||||
.padding(4)
|
||||
.modifier(ControlBackgroundModifier())
|
||||
.clipShape(RoundedRectangle(cornerRadius: 4))
|
||||
|
||||
timeline
|
||||
.padding(4)
|
||||
.offset(y: -25)
|
||||
.zIndex(1)
|
||||
}
|
||||
.frame(maxWidth: 500)
|
||||
.padding(.bottom, 2)
|
||||
}
|
||||
}
|
||||
.padding(.top, 2)
|
||||
.padding(.horizontal, 2)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.opacity(model.presentingControlsOverlay ? 1 : model.presentingControls ? 1 : 0)
|
||||
}
|
||||
}
|
||||
.padding(.top, 4)
|
||||
.padding(.horizontal, 4)
|
||||
.opacity(model.presentingControls ? 1 : 0)
|
||||
}
|
||||
#if os(tvOS)
|
||||
.onChange(of: model.presentingControls) { _ in
|
||||
if model.presentingControls {
|
||||
focusedField = .play
|
||||
#if os(tvOS)
|
||||
.onChange(of: model.presentingControls) { _ in
|
||||
if model.presentingControls {
|
||||
focusedField = .play
|
||||
}
|
||||
}
|
||||
.onChange(of: focusedField) { _ in
|
||||
model.resetTimer()
|
||||
}
|
||||
#else
|
||||
.background(PlayerGestures())
|
||||
.background(controlsBackground)
|
||||
#endif
|
||||
|
||||
ControlsOverlay()
|
||||
.padding()
|
||||
.modifier(ControlBackgroundModifier(enabled: true))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 4))
|
||||
.offset(x: -2, y: 40)
|
||||
.opacity(model.presentingControlsOverlay ? 1 : 0)
|
||||
|
||||
Button {
|
||||
player.restoreLastSkippedSegment()
|
||||
} label: {
|
||||
HStack(spacing: 10) {
|
||||
if let segment = player.lastSkipped {
|
||||
Image(systemName: "arrow.counterclockwise")
|
||||
|
||||
Text("Skipped \(segment.durationText) seconds of \(SponsorBlockAPI.categoryDescription(segment.category)?.lowercased() ?? "segment")")
|
||||
.frame(alignment: .bottomLeading)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
.padding(.horizontal, 5)
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(.secondary)
|
||||
.modifier(ControlBackgroundModifier())
|
||||
.clipShape(RoundedRectangle(cornerRadius: 2))
|
||||
.offset(x: -2, y: -2)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.opacity(model.presentingControls ? 0 : player.lastSkipped.isNil ? 0 : 1)
|
||||
}
|
||||
.onChange(of: focusedField) { _ in
|
||||
model.resetTimer()
|
||||
}
|
||||
#else
|
||||
.background(PlayerGestures())
|
||||
.background(controlsBackground)
|
||||
#endif
|
||||
.environment(\.colorScheme, .dark)
|
||||
}
|
||||
|
||||
@ViewBuilder var controlsBackground: some View {
|
||||
if player.musicMode,
|
||||
let item = self.player.currentItem,
|
||||
let url = thumbnails.best(item.video)
|
||||
let video = item.video,
|
||||
let url = thumbnails.best(video)
|
||||
{
|
||||
WebImage(url: url)
|
||||
.resizable()
|
||||
@@ -110,48 +143,8 @@ struct PlayerControls: View {
|
||||
}
|
||||
}
|
||||
|
||||
var mpvPlaybackStats: some View {
|
||||
HStack {
|
||||
Group {
|
||||
Text("hw decoder: \(player.mpvBackend.hwDecoder)")
|
||||
Text("dropped: \(player.mpvBackend.frameDropCount)")
|
||||
Text("video: \(String(format: "%.2ffps", player.mpvBackend.outputFps))")
|
||||
Text("buffering: \(String(format: "%.0f%%", player.mpvBackend.bufferingState))")
|
||||
Text("cache: \(String(format: "%.2fs", player.mpvBackend.cacheDuration))")
|
||||
}
|
||||
.padding(4)
|
||||
#if os(macOS)
|
||||
.background(VisualEffectBlur(material: .hudWindow))
|
||||
#elseif os(iOS)
|
||||
.background(VisualEffectBlur(blurStyle: .systemThinMaterial))
|
||||
#else
|
||||
.background(.thinMaterial)
|
||||
#endif
|
||||
.mask(RoundedRectangle(cornerRadius: 3))
|
||||
|
||||
Spacer()
|
||||
}
|
||||
#if !os(tvOS)
|
||||
.font(.system(size: 9))
|
||||
#endif
|
||||
}
|
||||
|
||||
var timeline: some View {
|
||||
TimelineView(duration: durationBinding, current: currentTimeBinding, cornerRadius: 0)
|
||||
}
|
||||
|
||||
var durationBinding: Binding<Double> {
|
||||
Binding<Double>(
|
||||
get: { model.duration.seconds },
|
||||
set: { value in model.duration = .secondsInDefaultTimescale(value) }
|
||||
)
|
||||
}
|
||||
|
||||
var currentTimeBinding: Binding<Double> {
|
||||
Binding<Double>(
|
||||
get: { model.currentTime.seconds },
|
||||
set: { value in model.currentTime = .secondsInDefaultTimescale(value) }
|
||||
)
|
||||
TimelineView(context: .player).foregroundColor(.primary)
|
||||
}
|
||||
|
||||
private var hidePlayerButton: some View {
|
||||
@@ -195,20 +188,20 @@ struct PlayerControls: View {
|
||||
}
|
||||
|
||||
var buttonsBar: some View {
|
||||
HStack {
|
||||
HStack(spacing: 20) {
|
||||
#if !os(tvOS)
|
||||
fullscreenButton
|
||||
|
||||
#if os(iOS)
|
||||
pipButton
|
||||
.padding(.leading, 5)
|
||||
#endif
|
||||
pipButton
|
||||
|
||||
Spacer()
|
||||
|
||||
rateButton
|
||||
button("overlay", systemImage: "info.circle") {}
|
||||
|
||||
musicModeButton
|
||||
button("settings", systemImage: "gearshape", active: model.presentingControlsOverlay) {
|
||||
withAnimation(Self.animation) {
|
||||
model.presentingControlsOverlay.toggle()
|
||||
}
|
||||
}
|
||||
|
||||
closeVideoButton
|
||||
#endif
|
||||
@@ -227,74 +220,6 @@ struct PlayerControls: View {
|
||||
#endif
|
||||
}
|
||||
|
||||
@ViewBuilder private var rateButton: some View {
|
||||
#if os(macOS)
|
||||
ratePicker
|
||||
.labelsHidden()
|
||||
.frame(maxWidth: 70)
|
||||
#elseif os(iOS)
|
||||
Menu {
|
||||
ratePicker
|
||||
.frame(width: 45, height: 30)
|
||||
#if os(iOS)
|
||||
.background(VisualEffectBlur(blurStyle: .systemThinMaterial))
|
||||
#endif
|
||||
.mask(RoundedRectangle(cornerRadius: 3))
|
||||
} label: {
|
||||
Text(player.rateLabel(player.currentRate))
|
||||
.foregroundColor(.primary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundColor(.primary)
|
||||
.frame(width: 50, height: 30)
|
||||
#if os(macOS)
|
||||
.background(VisualEffectBlur(material: .hudWindow))
|
||||
#elseif os(iOS)
|
||||
.background(VisualEffectBlur(blurStyle: .systemThinMaterial))
|
||||
#endif
|
||||
.mask(RoundedRectangle(cornerRadius: 3))
|
||||
#endif
|
||||
}
|
||||
|
||||
@ViewBuilder private var qualityButton: some View {
|
||||
#if os(macOS)
|
||||
StreamControl()
|
||||
.labelsHidden()
|
||||
.frame(maxWidth: 300)
|
||||
#elseif os(iOS)
|
||||
Menu {
|
||||
StreamControl()
|
||||
.frame(width: 45, height: 30)
|
||||
#if os(iOS)
|
||||
.background(VisualEffectBlur(blurStyle: .systemThinMaterial))
|
||||
#endif
|
||||
.mask(RoundedRectangle(cornerRadius: 3))
|
||||
} label: {
|
||||
Text(player.streamSelection?.shortQuality ?? "loading")
|
||||
.frame(width: 140, height: 30)
|
||||
.foregroundColor(.primary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundColor(.primary)
|
||||
.frame(width: 140, height: 30)
|
||||
#if os(macOS)
|
||||
.background(VisualEffectBlur(material: .hudWindow))
|
||||
#elseif os(iOS)
|
||||
.background(VisualEffectBlur(blurStyle: .systemThinMaterial))
|
||||
#endif
|
||||
.mask(RoundedRectangle(cornerRadius: 3))
|
||||
#endif
|
||||
}
|
||||
|
||||
private var backendButton: some View {
|
||||
button(player.activeBackend.label, width: 100) {
|
||||
player.saveTime {
|
||||
player.changeActiveBackend(from: player.activeBackend, to: player.activeBackend.next())
|
||||
model.resetTimer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var closeVideoButton: some View {
|
||||
button("Close", systemImage: "xmark") {
|
||||
player.pause()
|
||||
@@ -313,116 +238,99 @@ struct PlayerControls: View {
|
||||
}
|
||||
|
||||
private var musicModeButton: some View {
|
||||
button("Music Mode", systemImage: "music.note", active: player.musicMode, action: player.toggleMusicMode)
|
||||
button("Music Mode", systemImage: "music.note", background: false, active: player.musicMode, action: player.toggleMusicMode)
|
||||
.disabled(player.activeBackend == .appleAVPlayer)
|
||||
}
|
||||
|
||||
var ratePicker: some View {
|
||||
Picker("Rate", selection: rateBinding) {
|
||||
ForEach(PlayerModel.availableRates, id: \.self) { rate in
|
||||
Text(player.rateLabel(rate)).tag(rate)
|
||||
}
|
||||
}
|
||||
.transaction { t in t.animation = .none }
|
||||
}
|
||||
|
||||
private var rateBinding: Binding<Float> {
|
||||
.init(get: { player.currentRate }, set: { rate in player.currentRate = rate })
|
||||
}
|
||||
|
||||
private var pipButton: some View {
|
||||
button("PiP", systemImage: "pip") {
|
||||
model.startPiP()
|
||||
}
|
||||
}
|
||||
|
||||
var mediumButtonsBar: some View {
|
||||
var floatingControls: some View {
|
||||
HStack {
|
||||
#if !os(tvOS)
|
||||
restartVideoButton
|
||||
.padding(.trailing, 15)
|
||||
|
||||
button("Seek Backward", systemImage: "gobackward.10", size: 30, cornerRadius: 5) {
|
||||
player.backend.seek(relative: .secondsInDefaultTimescale(-10))
|
||||
}
|
||||
|
||||
#if os(tvOS)
|
||||
.focused($focusedField, equals: .backward)
|
||||
#else
|
||||
.keyboardShortcut("k", modifiers: [])
|
||||
.keyboardShortcut(KeyEquivalent.leftArrow, modifiers: [])
|
||||
#endif
|
||||
|
||||
#endif
|
||||
|
||||
Spacer()
|
||||
|
||||
button(
|
||||
model.isPlaying ? "Pause" : "Play",
|
||||
systemImage: model.isPlaying ? "pause.fill" : "play.fill",
|
||||
size: 30, cornerRadius: 5
|
||||
) {
|
||||
player.backend.togglePlay()
|
||||
HStack(spacing: 20) {
|
||||
togglePlayButton
|
||||
seekBackwardButton
|
||||
seekForwardButton
|
||||
}
|
||||
#if os(tvOS)
|
||||
.focused($focusedField, equals: .play)
|
||||
#else
|
||||
.keyboardShortcut("p")
|
||||
.keyboardShortcut(.space)
|
||||
#endif
|
||||
.disabled(model.isLoadingVideo)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
Spacer()
|
||||
|
||||
#if !os(tvOS)
|
||||
button("Seek Forward", systemImage: "goforward.10", size: 30, cornerRadius: 5) {
|
||||
player.backend.seek(relative: .secondsInDefaultTimescale(10))
|
||||
}
|
||||
#if os(tvOS)
|
||||
.focused($focusedField, equals: .forward)
|
||||
#else
|
||||
.keyboardShortcut("l", modifiers: [])
|
||||
.keyboardShortcut(KeyEquivalent.rightArrow, modifiers: [])
|
||||
#endif
|
||||
|
||||
HStack(spacing: 20) {
|
||||
restartVideoButton
|
||||
advanceToNextItemButton
|
||||
.padding(.leading, 15)
|
||||
#endif
|
||||
musicModeButton
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
}
|
||||
.font(.system(size: 20))
|
||||
}
|
||||
|
||||
var seekBackwardButton: some View {
|
||||
button("Seek Backward", systemImage: "gobackward.10", size: 25, cornerRadius: 5, background: false) {
|
||||
player.backend.seek(relative: .secondsInDefaultTimescale(-10))
|
||||
}
|
||||
#if os(tvOS)
|
||||
.focused($focusedField, equals: .backward)
|
||||
#else
|
||||
.keyboardShortcut("k", modifiers: [])
|
||||
.keyboardShortcut(KeyEquivalent.leftArrow, modifiers: [])
|
||||
#endif
|
||||
}
|
||||
|
||||
var seekForwardButton: some View {
|
||||
button("Seek Forward", systemImage: "goforward.10", size: 25, cornerRadius: 5, background: false) {
|
||||
player.backend.seek(relative: .secondsInDefaultTimescale(10))
|
||||
}
|
||||
#if os(tvOS)
|
||||
.focused($focusedField, equals: .forward)
|
||||
#else
|
||||
.keyboardShortcut("l", modifiers: [])
|
||||
.keyboardShortcut(KeyEquivalent.rightArrow, modifiers: [])
|
||||
#endif
|
||||
}
|
||||
|
||||
private var restartVideoButton: some View {
|
||||
button("Restart video", systemImage: "backward.end.fill", size: 30, cornerRadius: 5) {
|
||||
button("Restart video", systemImage: "backward.end.fill", size: 25, cornerRadius: 5, background: false) {
|
||||
player.backend.seek(to: 0.0)
|
||||
}
|
||||
}
|
||||
|
||||
private var togglePlayButton: some View {
|
||||
button(
|
||||
model.isPlaying ? "Pause" : "Play",
|
||||
systemImage: model.isPlaying ? "pause.fill" : "play.fill",
|
||||
size: 25, cornerRadius: 5, background: false
|
||||
) {
|
||||
player.backend.togglePlay()
|
||||
}
|
||||
#if os(tvOS)
|
||||
.focused($focusedField, equals: .play)
|
||||
#else
|
||||
.keyboardShortcut("p")
|
||||
.keyboardShortcut(.space)
|
||||
#endif
|
||||
.disabled(model.isLoadingVideo)
|
||||
}
|
||||
|
||||
private var advanceToNextItemButton: some View {
|
||||
button("Next", systemImage: "forward.fill", size: 30, cornerRadius: 5) {
|
||||
button("Next", systemImage: "forward.fill", size: 25, cornerRadius: 5, background: false) {
|
||||
player.advanceToNextItem()
|
||||
}
|
||||
.disabled(player.queue.isEmpty)
|
||||
}
|
||||
|
||||
var bottomBar: some View {
|
||||
HStack {
|
||||
Text(model.playbackTime)
|
||||
}
|
||||
.font(.system(size: 15))
|
||||
.padding(.horizontal, 5)
|
||||
.padding(.vertical, 3)
|
||||
.labelStyle(.iconOnly)
|
||||
.foregroundColor(.primary)
|
||||
}
|
||||
|
||||
func button(
|
||||
_ label: String,
|
||||
systemImage: String? = nil,
|
||||
size: Double = 30,
|
||||
size: Double = 25,
|
||||
width: Double? = nil,
|
||||
height: Double? = nil,
|
||||
cornerRadius: Double = 3,
|
||||
background: Bool = true,
|
||||
active: Bool = false,
|
||||
action: @escaping () -> Void = {}
|
||||
) -> some View {
|
||||
@@ -442,39 +350,30 @@ struct PlayerControls: View {
|
||||
.padding()
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.font(.system(size: 13))
|
||||
.buttonStyle(.plain)
|
||||
.foregroundColor(active ? .accentColor : .primary)
|
||||
.foregroundColor(active ? Color("AppRedColor") : .primary)
|
||||
.frame(width: width ?? size, height: height ?? size)
|
||||
#if os(macOS)
|
||||
.background(VisualEffectBlur(material: .hudWindow))
|
||||
#elseif os(iOS)
|
||||
.background(VisualEffectBlur(blurStyle: .systemThinMaterial))
|
||||
#endif
|
||||
.mask(RoundedRectangle(cornerRadius: cornerRadius))
|
||||
.modifier(ControlBackgroundModifier(enabled: background))
|
||||
.clipShape(RoundedRectangle(cornerRadius: cornerRadius))
|
||||
}
|
||||
|
||||
var fullScreenLayout: Bool {
|
||||
#if os(iOS)
|
||||
model.playingFullscreen || verticalSizeClass == .compact
|
||||
player.playingFullScreen || verticalSizeClass == .compact
|
||||
#else
|
||||
model.playingFullscreen
|
||||
player.playingFullScreen
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
struct PlayerControls_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
let model = PlayerControlsModel()
|
||||
model.presentingControls = true
|
||||
model.currentTime = .secondsInDefaultTimescale(0)
|
||||
model.duration = .secondsInDefaultTimescale(120)
|
||||
|
||||
return ZStack {
|
||||
ZStack {
|
||||
Color.gray
|
||||
|
||||
PlayerControls(player: PlayerModel(), thumbnails: ThumbnailsModel())
|
||||
.injectFixtureEnvironmentObjects()
|
||||
.environmentObject(model)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -11,7 +11,7 @@ final class MPVOGLView: GLKView {
|
||||
var needsDrawing = true
|
||||
|
||||
override init(frame: CGRect) {
|
||||
guard let context = EAGLContext(api: .openGLES3) else {
|
||||
guard let context = EAGLContext(api: .openGLES2) else {
|
||||
print("Failed to initialize OpenGLES 2.0 context")
|
||||
exit(1)
|
||||
}
|
||||
@@ -20,10 +20,12 @@ final class MPVOGLView: GLKView {
|
||||
|
||||
super.init(frame: frame, context: context)
|
||||
|
||||
EAGLContext.setCurrent(context)
|
||||
self.context = context
|
||||
bindDrawable()
|
||||
|
||||
defaultFBO = -1
|
||||
isOpaque = false
|
||||
isOpaque = true
|
||||
enableSetNeedsDisplay = false
|
||||
|
||||
fillBlack()
|
||||
}
|
||||
|
@@ -2,7 +2,6 @@ import UIKit
|
||||
|
||||
final class MPVViewController: UIViewController {
|
||||
var client: MPVClient!
|
||||
var glView: MPVOGLView!
|
||||
|
||||
init() {
|
||||
client = MPVClient()
|
||||
@@ -17,9 +16,8 @@ final class MPVViewController: UIViewController {
|
||||
super.loadView()
|
||||
|
||||
client.create(frame: view.frame)
|
||||
glView = client.glView
|
||||
|
||||
view.addSubview(glView)
|
||||
view.addSubview(client.glView)
|
||||
|
||||
super.viewDidLoad()
|
||||
}
|
||||
|
@@ -14,7 +14,7 @@ struct NoCommentsView: View {
|
||||
.font(.system(size: 12))
|
||||
#endif
|
||||
}
|
||||
.frame(minWidth: 0, maxWidth: .infinity)
|
||||
.frame(minWidth: 0, maxWidth: .infinity, maxHeight: .infinity)
|
||||
#if !os(tvOS)
|
||||
.foregroundColor(.secondary)
|
||||
#endif
|
||||
|
@@ -6,7 +6,7 @@ import SwiftUI
|
||||
struct PlayerQueueRow: View {
|
||||
let item: PlayerQueueItem
|
||||
var history = false
|
||||
@Binding var fullScreen: Bool
|
||||
var fullScreen: Bool
|
||||
|
||||
@EnvironmentObject<PlayerModel> private var player
|
||||
|
||||
@@ -14,10 +14,10 @@ struct PlayerQueueRow: View {
|
||||
|
||||
@FetchRequest private var watchRequest: FetchedResults<Watch>
|
||||
|
||||
init(item: PlayerQueueItem, history: Bool = false, fullScreen: Binding<Bool> = .constant(false)) {
|
||||
init(item: PlayerQueueItem, history: Bool = false, fullScreen: Bool = false) {
|
||||
self.item = item
|
||||
self.history = history
|
||||
_fullScreen = fullScreen
|
||||
self.fullScreen = fullScreen
|
||||
_watchRequest = FetchRequest<Watch>(
|
||||
entity: Watch.entity(),
|
||||
sortDescriptors: [],
|
||||
@@ -32,6 +32,8 @@ struct PlayerQueueRow: View {
|
||||
|
||||
player.avPlayerBackend.startPictureInPictureOnPlay = player.playingInPictureInPicture
|
||||
|
||||
player.videoBeingOpened = item.video
|
||||
|
||||
if history {
|
||||
player.playHistory(item, at: watchStoppedAt)
|
||||
} else {
|
||||
@@ -39,9 +41,9 @@ struct PlayerQueueRow: View {
|
||||
}
|
||||
|
||||
if fullScreen {
|
||||
withAnimation {
|
||||
fullScreen = false
|
||||
}
|
||||
// withAnimation {
|
||||
// fullScreen = false
|
||||
// }
|
||||
}
|
||||
|
||||
if closePiPOnNavigation, player.playingInPictureInPicture {
|
||||
|
@@ -3,8 +3,8 @@ import Foundation
|
||||
import SwiftUI
|
||||
|
||||
struct PlayerQueueView: View {
|
||||
@Binding var sidebarQueue: Bool
|
||||
@Binding var fullScreen: Bool
|
||||
var sidebarQueue: Bool
|
||||
var fullScreen: Bool
|
||||
|
||||
@FetchRequest(sortDescriptors: [.init(key: "watchedAt", ascending: false)])
|
||||
var watches: FetchedResults<Watch>
|
||||
@@ -49,7 +49,7 @@ struct PlayerQueueView: View {
|
||||
}
|
||||
|
||||
ForEach(player.queue) { item in
|
||||
PlayerQueueRow(item: item, fullScreen: $fullScreen)
|
||||
PlayerQueueRow(item: item, fullScreen: fullScreen)
|
||||
.contextMenu {
|
||||
removeButton(item)
|
||||
removeAllButton()
|
||||
@@ -70,7 +70,7 @@ struct PlayerQueueView: View {
|
||||
PlayerQueueRow(
|
||||
item: PlayerQueueItem.from(watch, video: player.historyVideo(watch.videoID)),
|
||||
history: true,
|
||||
fullScreen: $fullScreen
|
||||
fullScreen: fullScreen
|
||||
)
|
||||
.onAppear {
|
||||
player.loadHistoryVideoDetails(watch.videoID)
|
||||
@@ -89,7 +89,7 @@ struct PlayerQueueView: View {
|
||||
if !player.currentVideo.isNil, !player.currentVideo!.related.isEmpty {
|
||||
Section(header: Text("Related")) {
|
||||
ForEach(player.currentVideo!.related) { video in
|
||||
PlayerQueueRow(item: PlayerQueueItem(video), fullScreen: $fullScreen)
|
||||
PlayerQueueRow(item: PlayerQueueItem(video), fullScreen: fullScreen)
|
||||
.contextMenu {
|
||||
Button {
|
||||
player.playNext(video)
|
||||
@@ -137,7 +137,7 @@ struct PlayerQueueView: View {
|
||||
struct PlayerQueueView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
VStack {
|
||||
PlayerQueueView(sidebarQueue: .constant(true), fullScreen: .constant(true))
|
||||
PlayerQueueView(sidebarQueue: true, fullScreen: true)
|
||||
}
|
||||
.injectFixtureEnvironmentObjects()
|
||||
}
|
||||
|
@@ -1,24 +1,48 @@
|
||||
import Defaults
|
||||
import SwiftUI
|
||||
|
||||
struct RelatedView: View {
|
||||
@EnvironmentObject<AccountsModel> private var accounts
|
||||
@EnvironmentObject<NavigationModel> private var navigation
|
||||
@EnvironmentObject<PlayerModel> private var player
|
||||
@EnvironmentObject<PlaylistsModel> private var playlists
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
if !player.currentVideo.isNil, !player.currentVideo!.related.isEmpty {
|
||||
if let related = player.currentVideo?.related {
|
||||
Section(header: Text("Related")) {
|
||||
ForEach(player.currentVideo!.related) { video in
|
||||
PlayerQueueRow(item: PlayerQueueItem(video), fullScreen: .constant(false))
|
||||
ForEach(related) { video in
|
||||
PlayerQueueRow(item: PlayerQueueItem(video))
|
||||
.contextMenu {
|
||||
Button {
|
||||
player.playNext(video)
|
||||
} label: {
|
||||
Label("Play Next", systemImage: "text.insert")
|
||||
Section {
|
||||
Button {
|
||||
player.playNext(video)
|
||||
} label: {
|
||||
Label("Play Next", systemImage: "text.insert")
|
||||
}
|
||||
Button {
|
||||
player.enqueueVideo(video)
|
||||
} label: {
|
||||
Label("Play Last", systemImage: "text.append")
|
||||
}
|
||||
}
|
||||
Button {
|
||||
player.enqueueVideo(video)
|
||||
} label: {
|
||||
Label("Play Last", systemImage: "text.append")
|
||||
|
||||
if accounts.app.supportsUserPlaylists && accounts.signedIn {
|
||||
Section {
|
||||
Button {
|
||||
navigation.presentAddToPlaylist(video)
|
||||
} label: {
|
||||
Label("Add to playlist...", systemImage: "text.badge.plus")
|
||||
}
|
||||
|
||||
if let playlist = playlists.lastUsed {
|
||||
Button {
|
||||
playlists.addVideo(playlistID: playlist.id, videoID: video.videoID, navigation: navigation)
|
||||
} label: {
|
||||
Label("Add to \(playlist.title)", systemImage: "text.badge.star")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,11 +1,34 @@
|
||||
import SwiftUI
|
||||
|
||||
struct TimelineView: View {
|
||||
@Binding private var duration: Double
|
||||
@Binding private var current: Double
|
||||
enum Context {
|
||||
case controls
|
||||
case player
|
||||
}
|
||||
|
||||
private var duration: Double {
|
||||
playerTime.duration.seconds
|
||||
}
|
||||
|
||||
private var current: Double {
|
||||
get {
|
||||
playerTime.currentTime.seconds
|
||||
}
|
||||
|
||||
set(value) {
|
||||
playerTime.currentTime = .secondsInDefaultTimescale(value)
|
||||
}
|
||||
}
|
||||
|
||||
@State private var size = CGSize.zero
|
||||
@State private var dragging = false
|
||||
@State private var tooltipSize = CGSize.zero
|
||||
@State private var dragging = false { didSet {
|
||||
if dragging {
|
||||
player.backend.stopControlsUpdates()
|
||||
} else {
|
||||
player.backend.startControlsUpdates()
|
||||
}
|
||||
}}
|
||||
@State private var dragOffset: Double = 0
|
||||
@State private var draggedFrom: Double = 0
|
||||
|
||||
@@ -13,147 +36,277 @@ struct TimelineView: View {
|
||||
private var height = 8.0
|
||||
|
||||
var cornerRadius: Double
|
||||
var thumbTooltipWidth: Double = 100
|
||||
var thumbAreaWidth: Double = 40
|
||||
var context: Context
|
||||
|
||||
@EnvironmentObject<PlayerModel> private var player
|
||||
@EnvironmentObject<PlayerControlsModel> private var controls
|
||||
@EnvironmentObject<PlayerTimeModel> private var playerTime
|
||||
|
||||
init(duration: Binding<Double>, current: Binding<Double>, cornerRadius: Double = 10.0) {
|
||||
_duration = duration
|
||||
_current = current
|
||||
var chapters: [Chapter] {
|
||||
player.currentVideo?.chapters ?? []
|
||||
}
|
||||
|
||||
init(
|
||||
cornerRadius: Double = 10.0,
|
||||
context: Context = .controls
|
||||
) {
|
||||
self.cornerRadius = cornerRadius
|
||||
self.context = context
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .leading) {
|
||||
VStack {
|
||||
Group {
|
||||
RoundedRectangle(cornerRadius: cornerRadius)
|
||||
.foregroundColor(.blue)
|
||||
.frame(maxHeight: height)
|
||||
|
||||
RoundedRectangle(cornerRadius: cornerRadius)
|
||||
.fill(Color.green)
|
||||
.frame(maxHeight: height)
|
||||
.frame(width: current * oneUnitWidth)
|
||||
|
||||
segmentsLayers
|
||||
}
|
||||
|
||||
Circle()
|
||||
.strokeBorder(.gray, lineWidth: 1)
|
||||
.background(Circle().fill(dragging ? .gray : .white))
|
||||
.offset(x: thumbOffset)
|
||||
.foregroundColor(.red.opacity(0.6))
|
||||
.frame(maxHeight: height * 4)
|
||||
|
||||
#if !os(tvOS)
|
||||
.gesture(
|
||||
DragGesture(minimumDistance: 0)
|
||||
.onChanged { value in
|
||||
if !dragging {
|
||||
controls.removeTimer()
|
||||
draggedFrom = current
|
||||
}
|
||||
|
||||
dragging = true
|
||||
|
||||
let drag = value.translation.width
|
||||
let change = (drag / size.width) * units
|
||||
let changedCurrent = current + change
|
||||
|
||||
guard changedCurrent >= start, changedCurrent <= duration else {
|
||||
return
|
||||
}
|
||||
withAnimation(Animation.linear(duration: 0.2)) {
|
||||
dragOffset = drag
|
||||
}
|
||||
VStack(spacing: 3) {
|
||||
if dragging {
|
||||
if let segment = projectedSegment,
|
||||
let description = SponsorBlockAPI.categoryDescription(segment.category)
|
||||
{
|
||||
Text(description)
|
||||
.font(.system(size: 8))
|
||||
.fixedSize()
|
||||
.lineLimit(1)
|
||||
.foregroundColor(Color("AppRedColor"))
|
||||
}
|
||||
.onEnded { _ in
|
||||
current = projectedValue
|
||||
|
||||
player.backend.seek(to: projectedValue)
|
||||
|
||||
dragging = false
|
||||
dragOffset = 0.0
|
||||
draggedFrom = 0.0
|
||||
controls.resetTimer()
|
||||
if let chapter = projectedChapter {
|
||||
Text(chapter.title)
|
||||
.lineLimit(3)
|
||||
.font(.system(size: 11).bold())
|
||||
.frame(maxWidth: 250)
|
||||
.fixedSize()
|
||||
}
|
||||
}
|
||||
Text((dragging ? projectedValue : current).formattedAsPlaybackTime(allowZero: true) ?? PlayerTimeModel.timePlaceholder)
|
||||
.font(.system(size: 11).monospacedDigit())
|
||||
}
|
||||
|
||||
.padding(.vertical, 3)
|
||||
.padding(.horizontal, 8)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 3)
|
||||
.foregroundColor(.black)
|
||||
)
|
||||
#endif
|
||||
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: cornerRadius)
|
||||
.frame(maxWidth: thumbTooltipWidth, maxHeight: 30)
|
||||
|
||||
Text(projectedValue.formattedAsPlaybackTime() ?? "--:--")
|
||||
.foregroundColor(.black)
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
.animation(.linear(duration: 0.1))
|
||||
.animation(.easeInOut(duration: 0.2))
|
||||
.frame(maxHeight: 300, alignment: .bottom)
|
||||
.offset(x: thumbTooltipOffset)
|
||||
.overlay(GeometryReader { proxy in
|
||||
Color.clear
|
||||
.onAppear {
|
||||
tooltipSize = proxy.size
|
||||
}
|
||||
.onChange(of: proxy.size) { _ in
|
||||
tooltipSize = proxy.size
|
||||
}
|
||||
})
|
||||
|
||||
.frame(height: 80)
|
||||
.opacity(dragging ? 1 : 0)
|
||||
.offset(x: thumbTooltipOffset, y: -(height * 2) - 7)
|
||||
.animation(.easeOut, value: thumbTooltipOffset)
|
||||
HStack(spacing: 4) {
|
||||
Text((dragging ? projectedValue : nil)?.formattedAsPlaybackTime(allowZero: true) ?? playerTime.currentPlaybackTime)
|
||||
.frame(minWidth: 35)
|
||||
|
||||
ZStack(alignment: .center) {
|
||||
ZStack(alignment: .leading) {
|
||||
ZStack(alignment: .leading) {
|
||||
Rectangle()
|
||||
.fill(Color.gray.opacity(0.1))
|
||||
.frame(maxHeight: height)
|
||||
.zIndex(1)
|
||||
|
||||
Rectangle()
|
||||
.fill(Color.gray.opacity(0.5))
|
||||
.frame(maxHeight: height)
|
||||
.frame(width: current * oneUnitWidth)
|
||||
.zIndex(1)
|
||||
|
||||
segmentsLayers
|
||||
.zIndex(2)
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: cornerRadius))
|
||||
|
||||
chaptersLayers
|
||||
.zIndex(3)
|
||||
}
|
||||
|
||||
Circle()
|
||||
.contentShape(Rectangle())
|
||||
.foregroundColor(.clear)
|
||||
.background(
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(dragging ? .white : .gray)
|
||||
.frame(maxWidth: 8)
|
||||
|
||||
Circle()
|
||||
.fill(dragging ? .gray : .white)
|
||||
.frame(maxWidth: 6)
|
||||
}
|
||||
)
|
||||
.offset(x: thumbOffset)
|
||||
.frame(maxWidth: thumbAreaWidth, minHeight: thumbAreaWidth)
|
||||
|
||||
#if !os(tvOS)
|
||||
.gesture(
|
||||
DragGesture(minimumDistance: 0)
|
||||
.onChanged { value in
|
||||
if !dragging {
|
||||
controls.removeTimer()
|
||||
draggedFrom = current
|
||||
}
|
||||
|
||||
dragging = true
|
||||
|
||||
let drag = value.translation.width
|
||||
let change = (drag / size.width) * units
|
||||
let changedCurrent = current + change
|
||||
|
||||
guard changedCurrent >= start, changedCurrent <= duration else {
|
||||
return
|
||||
}
|
||||
withAnimation(Animation.linear(duration: 0.2)) {
|
||||
dragOffset = drag
|
||||
}
|
||||
}
|
||||
.onEnded { _ in
|
||||
if abs(dragOffset) > 0 {
|
||||
playerTime.currentTime = .secondsInDefaultTimescale(projectedValue)
|
||||
player.backend.seek(to: projectedValue)
|
||||
}
|
||||
|
||||
dragging = false
|
||||
dragOffset = 0.0
|
||||
draggedFrom = 0.0
|
||||
controls.resetTimer()
|
||||
}
|
||||
)
|
||||
#endif
|
||||
}
|
||||
|
||||
.background(GeometryReader { proxy in
|
||||
Color.clear
|
||||
.onAppear {
|
||||
self.size = proxy.size
|
||||
}
|
||||
.onChange(of: proxy.size) { size in
|
||||
self.size = size
|
||||
}
|
||||
})
|
||||
.frame(maxHeight: 20)
|
||||
#if !os(tvOS)
|
||||
.gesture(DragGesture(minimumDistance: 0).onEnded { value in
|
||||
let target = (value.location.x / size.width) * units
|
||||
self.playerTime.currentTime = .secondsInDefaultTimescale(target)
|
||||
player.backend.seek(to: target)
|
||||
})
|
||||
#endif
|
||||
|
||||
Text(dragging ? playerTime.durationPlaybackTime : playerTime.withoutSegmentsPlaybackTime)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 3))
|
||||
.frame(minWidth: 35)
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: 3))
|
||||
.font(.system(size: 9).monospacedDigit())
|
||||
.zIndex(2)
|
||||
}
|
||||
.background(GeometryReader { proxy in
|
||||
Color.clear
|
||||
.onAppear {
|
||||
self.size = proxy.size
|
||||
}
|
||||
.onChange(of: proxy.size) { size in
|
||||
self.size = size
|
||||
}
|
||||
})
|
||||
#if !os(tvOS)
|
||||
.gesture(DragGesture(minimumDistance: 0).onEnded { value in
|
||||
let target = (value.location.x / size.width) * units
|
||||
current = target
|
||||
player.backend.seek(to: target)
|
||||
})
|
||||
#endif
|
||||
}
|
||||
|
||||
var tooltipVeritcalOffset: Double {
|
||||
var offset = -20.0
|
||||
|
||||
if !projectedChapter.isNil {
|
||||
offset -= 8.0
|
||||
}
|
||||
|
||||
if !projectedSegment.isNil {
|
||||
offset -= 6.5
|
||||
}
|
||||
|
||||
return offset
|
||||
}
|
||||
|
||||
var projectedValue: Double {
|
||||
let change = (dragOffset / size.width) * units
|
||||
let projected = draggedFrom + change
|
||||
return projected.isFinite ? (duration - projected < (0.01 * duration) ? duration : projected) : start
|
||||
|
||||
guard projected.isFinite && projected >= 0 && projected <= duration else {
|
||||
return 0.0
|
||||
}
|
||||
|
||||
return projected.clamped(to: 0 ... duration)
|
||||
}
|
||||
|
||||
var thumbOffset: Double {
|
||||
let offset = dragging ? (draggedThumbHorizontalOffset + dragOffset) : thumbHorizontalOffset
|
||||
let offset = dragging ? draggedThumbHorizontalOffset : thumbHorizontalOffset
|
||||
return offset.isFinite ? offset : thumbLeadingOffset
|
||||
}
|
||||
|
||||
var thumbTooltipOffset: Double {
|
||||
let offset = (dragging ? ((current * oneUnitWidth) + dragOffset) : (current * oneUnitWidth)) - (thumbTooltipWidth / 2)
|
||||
let leadingOffset = size.width / 2 - (tooltipSize.width / 2)
|
||||
let offsetForThumb = thumbOffset - thumbLeadingOffset
|
||||
|
||||
return offset.clamped(to: minThumbTooltipOffset ... maxThumbTooltipOffset)
|
||||
guard offsetForThumb > tooltipSize.width / 2 else {
|
||||
return -leadingOffset
|
||||
}
|
||||
|
||||
return thumbOffset.clamped(to: -leadingOffset ... leadingOffset)
|
||||
}
|
||||
|
||||
var minThumbTooltipOffset: Double = -10
|
||||
var minThumbTooltipOffset: Double {
|
||||
60
|
||||
}
|
||||
|
||||
var maxThumbTooltipOffset: Double {
|
||||
max(minThumbTooltipOffset, (units * oneUnitWidth) - thumbTooltipWidth + 10)
|
||||
max(minThumbTooltipOffset, units * oneUnitWidth)
|
||||
}
|
||||
|
||||
var segments: [Segment] {
|
||||
// [.init(category: "outro", segment: [25,30], uuid: UUID().uuidString, videoDuration: 100)] ??
|
||||
player.sponsorBlock.segments
|
||||
}
|
||||
|
||||
var segmentsLayers: some View {
|
||||
ForEach(player.sponsorBlock.segments, id: \.uuid) { segment in
|
||||
RoundedRectangle(cornerRadius: cornerRadius)
|
||||
ForEach(segments, id: \.uuid) { segment in
|
||||
Rectangle()
|
||||
.offset(x: segmentLayerHorizontalOffset(segment))
|
||||
.foregroundColor(.red)
|
||||
.foregroundColor(Color("AppRedColor"))
|
||||
.frame(maxHeight: height)
|
||||
.frame(width: segmentLayerWidth(segment))
|
||||
}
|
||||
}
|
||||
|
||||
var projectedSegment: Segment? {
|
||||
segments.first { $0.timeInSegment(.secondsInDefaultTimescale(projectedValue)) }
|
||||
}
|
||||
|
||||
var projectedChapter: Chapter? {
|
||||
chapters.last { $0.start <= projectedValue }
|
||||
}
|
||||
|
||||
var chaptersLayers: some View {
|
||||
ForEach(chapters) { chapter in
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(Color("AppBlueColor"))
|
||||
.frame(maxWidth: 2, maxHeight: 12)
|
||||
.offset(x: (chapter.start * oneUnitWidth) - 1)
|
||||
}
|
||||
}
|
||||
|
||||
func segmentLayerHorizontalOffset(_ segment: Segment) -> Double {
|
||||
segment.start * oneUnitWidth
|
||||
}
|
||||
|
||||
func segmentLayerWidth(_ segment: Segment) -> Double {
|
||||
let width = segment.duration * oneUnitWidth
|
||||
return width.isFinite ? width : thumbLeadingOffset
|
||||
return width.isFinite ? width : 1
|
||||
}
|
||||
|
||||
var draggedThumbHorizontalOffset: Double {
|
||||
thumbLeadingOffset + (draggedFrom * oneUnitWidth)
|
||||
thumbLeadingOffset + (draggedFrom * oneUnitWidth) + dragOffset
|
||||
}
|
||||
|
||||
var thumbHorizontalOffset: Double {
|
||||
@@ -161,7 +314,7 @@ struct TimelineView: View {
|
||||
}
|
||||
|
||||
var thumbLeadingOffset: Double {
|
||||
-(size.width / 2)
|
||||
-size.width / 2
|
||||
}
|
||||
|
||||
var oneUnitWidth: Double {
|
||||
@@ -172,26 +325,33 @@ struct TimelineView: View {
|
||||
var units: Double {
|
||||
duration - start
|
||||
}
|
||||
|
||||
func setCurrent(_ current: Double) {
|
||||
withAnimation {
|
||||
self.current = current
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct TimelineView_Previews: PreviewProvider {
|
||||
static var duration = 100.0
|
||||
static var current = 0.0
|
||||
static var durationBinding: Binding<Double> = .init(
|
||||
get: { duration },
|
||||
set: { value in duration = value }
|
||||
)
|
||||
static var currentBinding = Binding<Double>(
|
||||
get: { current },
|
||||
set: { value in current = value }
|
||||
)
|
||||
|
||||
static var previews: some View {
|
||||
VStack(spacing: 40) {
|
||||
TimelineView(duration: .constant(100), current: .constant(0))
|
||||
TimelineView(duration: .constant(100), current: .constant(1))
|
||||
TimelineView(duration: .constant(100), current: .constant(30))
|
||||
TimelineView(duration: .constant(100), current: .constant(50))
|
||||
TimelineView(duration: .constant(100), current: .constant(66))
|
||||
TimelineView(duration: .constant(100), current: .constant(90))
|
||||
TimelineView(duration: .constant(100), current: .constant(100))
|
||||
let playerModel = PlayerModel()
|
||||
playerModel.currentItem = .init(Video.fixture)
|
||||
let playerTimeModel = PlayerTimeModel()
|
||||
playerTimeModel.player = playerModel
|
||||
playerTimeModel.currentTime = .secondsInDefaultTimescale(33)
|
||||
playerTimeModel.duration = .secondsInDefaultTimescale(100)
|
||||
return VStack(spacing: 40) {
|
||||
TimelineView()
|
||||
}
|
||||
.environmentObject(PlayerModel())
|
||||
.environmentObject(playerModel)
|
||||
.environmentObject(playerTimeModel)
|
||||
.environmentObject(PlayerControlsModel())
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
@@ -2,14 +2,30 @@ import Defaults
|
||||
import Foundation
|
||||
import SDWebImageSwiftUI
|
||||
import SwiftUI
|
||||
import SwiftUIPager
|
||||
|
||||
struct VideoDetails: View {
|
||||
enum Page {
|
||||
case info, comments, related, queue
|
||||
enum DetailsPage: CaseIterable {
|
||||
case info, chapters, comments, related, queue
|
||||
|
||||
var index: Int {
|
||||
switch self {
|
||||
case .info:
|
||||
return 0
|
||||
case .chapters:
|
||||
return 1
|
||||
case .comments:
|
||||
return 2
|
||||
case .related:
|
||||
return 3
|
||||
case .queue:
|
||||
return 4
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Binding var sidebarQueue: Bool
|
||||
@Binding var fullScreen: Bool
|
||||
var sidebarQueue: Bool
|
||||
var fullScreen: Bool
|
||||
|
||||
@State private var subscribed = false
|
||||
@State private var subscriptionToggleButtonDisabled = false
|
||||
@@ -18,89 +34,82 @@ struct VideoDetails: View {
|
||||
@State private var presentingShareSheet = false
|
||||
@State private var shareURL: URL?
|
||||
|
||||
@State private var currentPage = Page.info
|
||||
@StateObject private var page: Page = .first()
|
||||
|
||||
@Environment(\.presentationMode) private var presentationMode
|
||||
@Environment(\.navigationStyle) private var navigationStyle
|
||||
|
||||
@EnvironmentObject<AccountsModel> private var accounts
|
||||
@EnvironmentObject<CommentsModel> private var comments
|
||||
@EnvironmentObject<NavigationModel> private var navigation
|
||||
@EnvironmentObject<PlayerModel> private var player
|
||||
@EnvironmentObject<RecentsModel> private var recents
|
||||
@EnvironmentObject<SubscriptionsModel> private var subscriptions
|
||||
|
||||
@Default(.showKeywords) private var showKeywords
|
||||
@Default(.playerDetailsPageButtonLabelStyle) private var playerDetailsPageButtonLabelStyle
|
||||
@Default(.controlsBarInPlayer) private var controlsBarInPlayer
|
||||
|
||||
init(
|
||||
sidebarQueue: Binding<Bool>? = nil,
|
||||
fullScreen: Binding<Bool>? = nil
|
||||
) {
|
||||
_sidebarQueue = sidebarQueue ?? .constant(true)
|
||||
_fullScreen = fullScreen ?? .constant(false)
|
||||
var currentPage: DetailsPage {
|
||||
DetailsPage.allCases.first { $0.index == page.index } ?? .info
|
||||
}
|
||||
|
||||
var video: Video? {
|
||||
player.currentVideo
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
Group {
|
||||
Group {
|
||||
HStack(spacing: 0) {
|
||||
title
|
||||
func pageButton(
|
||||
_ label: String,
|
||||
_ symbolName: String,
|
||||
_ destination: DetailsPage,
|
||||
pageChangeAction: (() -> Void)? = nil
|
||||
) -> some View {
|
||||
Button(action: {
|
||||
page.update(.new(index: destination.index))
|
||||
pageChangeAction?()
|
||||
}) {
|
||||
HStack {
|
||||
Spacer()
|
||||
|
||||
toggleFullScreenDetailsButton
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: symbolName)
|
||||
|
||||
if playerDetailsPageButtonLabelStyle.text {
|
||||
Text(label)
|
||||
}
|
||||
#if os(macOS)
|
||||
.padding(.top, 10)
|
||||
#endif
|
||||
|
||||
if !video.isNil {
|
||||
Divider()
|
||||
}
|
||||
|
||||
subscriptionsSection
|
||||
.onChange(of: video) { video in
|
||||
if let video = video {
|
||||
subscribed = subscriptions.isSubscribing(video.channel.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.frame(minHeight: 15)
|
||||
.lineLimit(1)
|
||||
.padding(.vertical, 4)
|
||||
.foregroundColor(currentPage == destination ? .white : .accentColor)
|
||||
|
||||
if !sidebarQueue ||
|
||||
(CommentsModel.enabled && CommentsModel.placement == .separate)
|
||||
{
|
||||
pagePicker
|
||||
.padding(.horizontal)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onSwipeGesture(
|
||||
up: {
|
||||
withAnimation {
|
||||
fullScreen = true
|
||||
}
|
||||
},
|
||||
down: {
|
||||
withAnimation {
|
||||
if fullScreen {
|
||||
fullScreen = false
|
||||
} else {
|
||||
self.player.hide()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
.background(currentPage == destination ? Color.accentColor : .clear)
|
||||
.buttonStyle(.plain)
|
||||
.font(.system(size: 10).bold())
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 2)
|
||||
.stroke(Color.accentColor, lineWidth: 2)
|
||||
.foregroundColor(.clear)
|
||||
)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
switch currentPage {
|
||||
@ViewBuilder func detailsByPage(_ page: DetailsPage) -> some View {
|
||||
Group {
|
||||
switch page {
|
||||
case .info:
|
||||
ScrollView(.vertical, showsIndicators: false) {
|
||||
detailsPage
|
||||
}
|
||||
case .chapters:
|
||||
ChaptersView()
|
||||
.edgesIgnoringSafeArea(.horizontal)
|
||||
|
||||
case .queue:
|
||||
PlayerQueueView(sidebarQueue: $sidebarQueue, fullScreen: $fullScreen)
|
||||
PlayerQueueView(sidebarQueue: sidebarQueue, fullScreen: fullScreen)
|
||||
.edgesIgnoringSafeArea(.horizontal)
|
||||
|
||||
case .related:
|
||||
@@ -111,9 +120,54 @@ struct VideoDetails: View {
|
||||
.edgesIgnoringSafeArea(.horizontal)
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
Group {
|
||||
// Group {
|
||||
// subscriptionsSection
|
||||
// .border(.red, width: 4)
|
||||
//
|
||||
// .onChange(of: video) { video in
|
||||
// if let video = video {
|
||||
// subscribed = subscriptions.isSubscribing(video.channel.id)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// .padding(.top, 4)
|
||||
// .padding(.horizontal)
|
||||
|
||||
HStack(spacing: 4) {
|
||||
pageButton("Info", "info.circle", .info)
|
||||
pageButton("Chapters", "bookmark", .chapters)
|
||||
pageButton("Comments", "text.bubble", .comments) { comments.load() }
|
||||
pageButton("Related", "rectangle.stack.fill", .related)
|
||||
pageButton("Queue", "list.number", .queue)
|
||||
}
|
||||
.onChange(of: player.currentItem) { _ in
|
||||
page.update(.moveToFirst)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
|
||||
Pager(page: page, data: DetailsPage.allCases, id: \.self) {
|
||||
detailsByPage($0)
|
||||
}
|
||||
.onPageWillChange { pageIndex in
|
||||
if pageIndex == DetailsPage.comments.index {
|
||||
comments.load()
|
||||
} else {
|
||||
print("comments not loading")
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if video.isNil && !sidebarQueue {
|
||||
currentPage = .queue
|
||||
page.update(.new(index: DetailsPage.queue.index))
|
||||
}
|
||||
|
||||
guard video != nil, accounts.app.supportsSubscriptions else {
|
||||
@@ -124,91 +178,56 @@ struct VideoDetails: View {
|
||||
.onChange(of: sidebarQueue) { queue in
|
||||
if queue {
|
||||
if currentPage == .related || currentPage == .queue {
|
||||
currentPage = .info
|
||||
page.update(.moveToFirst)
|
||||
}
|
||||
} else if video.isNil {
|
||||
currentPage = .queue
|
||||
page.update(.moveToLast)
|
||||
}
|
||||
}
|
||||
.edgesIgnoringSafeArea(.horizontal)
|
||||
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity, alignment: .topLeading)
|
||||
}
|
||||
|
||||
var title: some View {
|
||||
Group {
|
||||
if video != nil {
|
||||
Text(video!.title)
|
||||
.onAppear {
|
||||
currentPage = .info
|
||||
}
|
||||
.contextMenu {
|
||||
Button {
|
||||
player.closeCurrentItem()
|
||||
if !sidebarQueue {
|
||||
currentPage = .queue
|
||||
} else {
|
||||
currentPage = .info
|
||||
}
|
||||
} label: {
|
||||
Label("Close Video", systemImage: "xmark.circle")
|
||||
}
|
||||
.disabled(player.currentItem.isNil)
|
||||
}
|
||||
|
||||
.font(.title2.bold())
|
||||
} else {
|
||||
Text("Not playing")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
var toggleFullScreenDetailsButton: some View {
|
||||
Button {
|
||||
withAnimation {
|
||||
fullScreen.toggle()
|
||||
}
|
||||
} label: {
|
||||
Label("Resize", systemImage: fullScreen ? "chevron.down" : "chevron.up")
|
||||
.labelStyle(.iconOnly)
|
||||
}
|
||||
.help("Toggle fullscreen details")
|
||||
.buttonStyle(.plain)
|
||||
.keyboardShortcut("t")
|
||||
var showAddToPlaylistButton: Bool {
|
||||
accounts.app.supportsUserPlaylists && accounts.signedIn
|
||||
}
|
||||
|
||||
var subscriptionsSection: some View {
|
||||
Group {
|
||||
if video != nil {
|
||||
if let video = video {
|
||||
HStack(alignment: .center) {
|
||||
HStack(spacing: 10) {
|
||||
Group {
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
authorAvatar
|
||||
// ZStack(alignment: .bottomTrailing) {
|
||||
// authorAvatar
|
||||
//
|
||||
// if subscribed {
|
||||
// Image(systemName: "star.circle.fill")
|
||||
// .background(Color.background)
|
||||
// .clipShape(Circle())
|
||||
// .foregroundColor(.secondary)
|
||||
// }
|
||||
// }
|
||||
|
||||
if subscribed {
|
||||
Image(systemName: "star.circle.fill")
|
||||
.background(Color.background)
|
||||
.clipShape(Circle())
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
Text(video!.channel.name)
|
||||
.font(.system(size: 14))
|
||||
.bold()
|
||||
|
||||
Group {
|
||||
if let subscribers = video!.channel.subscriptionsString {
|
||||
Text("\(subscribers) subscribers")
|
||||
}
|
||||
}
|
||||
.foregroundColor(.secondary)
|
||||
.font(.caption2)
|
||||
}
|
||||
// VStack(alignment: .leading, spacing: 4) {
|
||||
// Text(video.title)
|
||||
// .font(.system(size: 11))
|
||||
// .fontWeight(.bold)
|
||||
//
|
||||
// HStack(spacing: 4) {
|
||||
// Text(video.channel.name)
|
||||
//
|
||||
// if let subscribers = video.channel.subscriptionsString {
|
||||
// Text("•")
|
||||
// .foregroundColor(.secondary)
|
||||
// .opacity(0.3)
|
||||
//
|
||||
// Text("\(subscribers) subscribers")
|
||||
// }
|
||||
// }
|
||||
// .foregroundColor(.secondary)
|
||||
// .font(.caption2)
|
||||
// }
|
||||
}
|
||||
}
|
||||
.contentShape(RoundedRectangle(cornerRadius: 12))
|
||||
@@ -227,83 +246,11 @@ struct VideoDetails: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if accounts.app.supportsSubscriptions, accounts.signedIn {
|
||||
Spacer()
|
||||
|
||||
Section {
|
||||
if subscribed {
|
||||
Button("Unsubscribe") {
|
||||
presentingUnsubscribeAlert = true
|
||||
}
|
||||
#if os(iOS)
|
||||
.backport
|
||||
.tint(.gray)
|
||||
#endif
|
||||
.alert(isPresented: $presentingUnsubscribeAlert) {
|
||||
Alert(
|
||||
title: Text(
|
||||
"Are you sure you want to unsubscribe from \(video!.channel.name)?"
|
||||
),
|
||||
primaryButton: .destructive(Text("Unsubscribe")) {
|
||||
subscriptionToggleButtonDisabled = true
|
||||
|
||||
subscriptions.unsubscribe(video!.channel.id) {
|
||||
withAnimation {
|
||||
subscriptionToggleButtonDisabled = false
|
||||
subscribed.toggle()
|
||||
}
|
||||
}
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Button("Subscribe") {
|
||||
subscriptionToggleButtonDisabled = true
|
||||
|
||||
subscriptions.subscribe(video!.channel.id) {
|
||||
withAnimation {
|
||||
subscriptionToggleButtonDisabled = false
|
||||
subscribed.toggle()
|
||||
}
|
||||
}
|
||||
}
|
||||
.backport
|
||||
.tint(subscriptionToggleButtonDisabled ? .gray : .blue)
|
||||
}
|
||||
}
|
||||
.disabled(subscriptionToggleButtonDisabled)
|
||||
.font(.system(size: 13))
|
||||
.buttonStyle(.borderless)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var pagePicker: some View {
|
||||
Picker("Page", selection: $currentPage) {
|
||||
if !video.isNil {
|
||||
Text("Info").tag(Page.info)
|
||||
if CommentsModel.enabled, CommentsModel.placement == .separate {
|
||||
Text("Comments").tag(Page.comments)
|
||||
}
|
||||
if !sidebarQueue {
|
||||
Text("Related").tag(Page.related)
|
||||
}
|
||||
}
|
||||
if !sidebarQueue {
|
||||
Text("Queue").tag(Page.queue)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.segmented)
|
||||
.onDisappear {
|
||||
currentPage = .info
|
||||
}
|
||||
}
|
||||
|
||||
var publishedDateSection: some View {
|
||||
Group {
|
||||
if let video = player.currentVideo {
|
||||
@@ -311,32 +258,11 @@ struct VideoDetails: View {
|
||||
if let published = video.publishedDate {
|
||||
Text(published)
|
||||
}
|
||||
|
||||
if let date = video.publishedAt {
|
||||
if video.publishedDate != nil {
|
||||
Text("•")
|
||||
.foregroundColor(.secondary)
|
||||
.opacity(0.3)
|
||||
}
|
||||
Text(formattedPublishedAt(date))
|
||||
}
|
||||
}
|
||||
.font(.system(size: 12))
|
||||
.padding(.bottom, -1)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func formattedPublishedAt(_ date: Date) -> String {
|
||||
let dateFormatter = DateFormatter()
|
||||
|
||||
dateFormatter.dateStyle = .short
|
||||
dateFormatter.timeStyle = .none
|
||||
|
||||
return dateFormatter.string(from: date)
|
||||
}
|
||||
|
||||
var countsSection: some View {
|
||||
Group {
|
||||
if let video = player.currentVideo {
|
||||
@@ -386,13 +312,6 @@ struct VideoDetails: View {
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.background(
|
||||
EmptyView().sheet(isPresented: $presentingAddToPlaylist) {
|
||||
if let video = video {
|
||||
AddToPlaylistView(video: video)
|
||||
}
|
||||
}
|
||||
)
|
||||
#if os(iOS)
|
||||
.background(
|
||||
EmptyView().sheet(isPresented: $presentingShareSheet) {
|
||||
@@ -419,31 +338,60 @@ struct VideoDetails: View {
|
||||
.retryOnAppear(true)
|
||||
.indicator(.activity)
|
||||
.clipShape(Circle())
|
||||
.frame(width: 45, height: 45, alignment: .leading)
|
||||
.frame(width: 35, height: 35, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var videoProperties: some View {
|
||||
HStack(spacing: 2) {
|
||||
publishedDateSection
|
||||
Spacer()
|
||||
|
||||
HStack(spacing: 4) {
|
||||
if let views = video?.viewsCount {
|
||||
Image(systemName: "eye")
|
||||
|
||||
Text(views)
|
||||
}
|
||||
|
||||
if let likes = video?.likesCount {
|
||||
Image(systemName: "hand.thumbsup")
|
||||
|
||||
Text(likes)
|
||||
}
|
||||
|
||||
if let likes = video?.dislikesCount {
|
||||
Image(systemName: "hand.thumbsdown")
|
||||
|
||||
Text(likes)
|
||||
}
|
||||
}
|
||||
}
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
var detailsPage: some View {
|
||||
Group {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
if let video = player.currentVideo {
|
||||
VStack(spacing: 6) {
|
||||
HStack {
|
||||
publishedDateSection
|
||||
Spacer()
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
countsSection
|
||||
videoProperties
|
||||
|
||||
Divider()
|
||||
}
|
||||
.padding(.bottom, 6)
|
||||
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
if let description = video.description {
|
||||
if !player.videoBeingOpened.isNil && (video.description.isNil || video.description!.isEmpty) {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
ForEach(1 ... Int.random(in: 3 ... 5), id: \.self) { _ in
|
||||
Text(String(repeating: Video.fixture.description!, count: Int.random(in: 1 ... 4)))
|
||||
.redacted(reason: .placeholder)
|
||||
}
|
||||
}
|
||||
} else if let description = video.description {
|
||||
Group {
|
||||
if #available(iOS 15.0, macOS 12.0, tvOS 15.0, *) {
|
||||
Text(description)
|
||||
@@ -531,7 +479,7 @@ struct VideoDetails: View {
|
||||
|
||||
struct VideoDetails_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
VideoDetails(sidebarQueue: .constant(true))
|
||||
VideoDetails(sidebarQueue: true, fullScreen: false)
|
||||
.injectFixtureEnvironmentObjects()
|
||||
}
|
||||
}
|
||||
|
@@ -8,7 +8,7 @@ import SwiftUI
|
||||
|
||||
struct VideoPlayerView: View {
|
||||
#if os(iOS)
|
||||
static let hiddenOffset = max(UIScreen.main.bounds.height, UIScreen.main.bounds.width) + 100
|
||||
static let hiddenOffset = YatteeApp.isForPreviews ? 0 : max(UIScreen.main.bounds.height, UIScreen.main.bounds.width) + 100
|
||||
#endif
|
||||
|
||||
static let defaultAspectRatio = 16 / 9.0
|
||||
@@ -20,20 +20,22 @@ struct VideoPlayerView: View {
|
||||
#endif
|
||||
}
|
||||
|
||||
@State private var playerSize: CGSize = .zero
|
||||
@State private var playerSize: CGSize = .zero { didSet {
|
||||
if playerSize.width > 900 && Defaults[.playerSidebar] == .whenFits {
|
||||
sidebarQueue = true
|
||||
} else {
|
||||
sidebarQueue = false
|
||||
}
|
||||
}}
|
||||
@State private var hoveringPlayer = false
|
||||
@State private var fullScreenDetails = false
|
||||
@State private var sidebarQueue = false
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
#if os(iOS)
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
@Environment(\.verticalSizeClass) private var verticalSizeClass
|
||||
|
||||
@Default(.enterFullscreenInLandscape) private var enterFullscreenInLandscape
|
||||
@Default(.honorSystemOrientationLock) private var honorSystemOrientationLock
|
||||
@Default(.lockOrientationInFullScreen) private var lockOrientationInFullScreen
|
||||
|
||||
@State private var motionManager: CMMotionManager!
|
||||
@State private var orientation = UIInterfaceOrientation.portrait
|
||||
@State private var lastOrientation: UIInterfaceOrientation?
|
||||
@@ -46,19 +48,29 @@ struct VideoPlayerView: View {
|
||||
#endif
|
||||
|
||||
@EnvironmentObject<AccountsModel> private var accounts
|
||||
@EnvironmentObject<PlayerControlsModel> private var playerControls
|
||||
@EnvironmentObject<PlayerModel> private var player
|
||||
@EnvironmentObject<ThumbnailsModel> private var thumbnails
|
||||
|
||||
init() {
|
||||
if Defaults[.playerSidebar] == .always {
|
||||
sidebarQueue = true
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
// TODO: remove
|
||||
if #available(iOS 15.0, macOS 12.0, *) {
|
||||
_ = Self._printChanges()
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
HSplitView {
|
||||
return HSplitView {
|
||||
content
|
||||
}
|
||||
.onOpenURL { OpenURLHandler(accounts: accounts, player: player).handle($0) }
|
||||
.frame(minWidth: 950, minHeight: 700)
|
||||
#else
|
||||
GeometryReader { geometry in
|
||||
return GeometryReader { geometry in
|
||||
HStack(spacing: 0) {
|
||||
content
|
||||
.onAppear {
|
||||
@@ -79,6 +91,11 @@ struct VideoPlayerView: View {
|
||||
if newValue {
|
||||
viewVerticalOffset = 0
|
||||
configureOrientationUpdatesBasedOnAccelerometer()
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak player] in
|
||||
player?.onPresentPlayer?()
|
||||
player?.onPresentPlayer = nil
|
||||
}
|
||||
} else {
|
||||
if Defaults[.lockPortraitWhenBrowsing] {
|
||||
Orientation.lockOrientation(.portrait, andRotateTo: .portrait)
|
||||
@@ -95,7 +112,7 @@ struct VideoPlayerView: View {
|
||||
}
|
||||
#if os(iOS)
|
||||
.offset(y: viewVerticalOffset)
|
||||
.animation(.easeIn(duration: 0.2), value: viewVerticalOffset)
|
||||
.animation(.easeOut(duration: 0.3), value: viewVerticalOffset)
|
||||
.backport
|
||||
.persistentSystemOverlays(!fullScreenLayout)
|
||||
#endif
|
||||
@@ -104,7 +121,7 @@ struct VideoPlayerView: View {
|
||||
|
||||
var content: some View {
|
||||
Group {
|
||||
Group {
|
||||
ZStack(alignment: .bottomLeading) {
|
||||
#if os(tvOS)
|
||||
playerView
|
||||
.ignoresSafeArea(.all, edges: .all)
|
||||
@@ -138,17 +155,17 @@ struct VideoPlayerView: View {
|
||||
VideoPlayerSizeModifier(
|
||||
geometry: geometry,
|
||||
aspectRatio: player.avPlayerBackend.controller?.aspectRatio,
|
||||
fullScreen: playerControls.playingFullscreen
|
||||
fullScreen: player.playingFullScreen
|
||||
)
|
||||
)
|
||||
.overlay(playerPlaceholder(geometry: geometry))
|
||||
// .overlay(playerPlaceholder(geometry: geometry))
|
||||
#endif
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: fullScreenLayout ? .infinity : nil, maxHeight: fullScreenLayout ? .infinity : nil)
|
||||
.onHover { hovering in
|
||||
hoveringPlayer = hovering
|
||||
hovering ? playerControls.show() : playerControls.hide()
|
||||
// hovering ? playerControls.show() : playerControls.hide()
|
||||
}
|
||||
#if !os(macOS)
|
||||
.gesture(
|
||||
@@ -169,9 +186,7 @@ struct VideoPlayerView: View {
|
||||
return
|
||||
}
|
||||
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
viewVerticalOffset = drag
|
||||
}
|
||||
viewVerticalOffset = drag
|
||||
}
|
||||
.onEnded { _ in
|
||||
if viewVerticalOffset > 100 {
|
||||
@@ -185,29 +200,30 @@ struct VideoPlayerView: View {
|
||||
}
|
||||
)
|
||||
#else
|
||||
.onAppear(perform: {
|
||||
NSEvent.addLocalMonitorForEvents(matching: [.mouseMoved]) {
|
||||
if hoveringPlayer {
|
||||
playerControls.resetTimer()
|
||||
}
|
||||
|
||||
return $0
|
||||
}
|
||||
})
|
||||
// .onAppear(perform: {
|
||||
// NSEvent.addLocalMonitorForEvents(matching: [.mouseMoved]) {
|
||||
// if hoveringPlayer {
|
||||
// playerControls.resetTimer()
|
||||
// }
|
||||
//
|
||||
// return $0
|
||||
// }
|
||||
// })
|
||||
#endif
|
||||
|
||||
.background(Color.black)
|
||||
.background(Color.black)
|
||||
|
||||
#if !os(tvOS)
|
||||
if !playerControls.playingFullscreen {
|
||||
Group {
|
||||
if !player.playingFullScreen {
|
||||
VStack(spacing: 0) {
|
||||
#if os(iOS)
|
||||
if verticalSizeClass == .regular {
|
||||
VideoDetails(sidebarQueue: sidebarQueueBinding, fullScreen: $fullScreenDetails)
|
||||
VideoDetails(sidebarQueue: sidebarQueue, fullScreen: fullScreenDetails)
|
||||
}
|
||||
|
||||
#else
|
||||
VideoDetails(sidebarQueue: sidebarQueueBinding, fullScreen: $fullScreenDetails)
|
||||
VideoDetails(sidebarQueue: sidebarQueue, fullScreen: fullScreenDetails)
|
||||
|
||||
#endif
|
||||
}
|
||||
.background(colorScheme == .dark ? Color.black : Color.white)
|
||||
@@ -220,28 +236,35 @@ struct VideoPlayerView: View {
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
|
||||
#if !os(tvOS)
|
||||
if !fullScreenLayout {
|
||||
ControlsBar()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
.background(((colorScheme == .dark || fullScreenLayout) ? Color.black : Color.white).edgesIgnoringSafeArea(.all))
|
||||
#if os(macOS)
|
||||
.frame(minWidth: 650)
|
||||
#endif
|
||||
if !playerControls.playingFullscreen {
|
||||
if !player.playingFullScreen {
|
||||
#if os(iOS)
|
||||
if sidebarQueue {
|
||||
PlayerQueueView(sidebarQueue: .constant(true), fullScreen: $fullScreenDetails)
|
||||
PlayerQueueView(sidebarQueue: true, fullScreen: fullScreenDetails)
|
||||
.frame(maxWidth: 350)
|
||||
}
|
||||
#elseif os(macOS)
|
||||
if Defaults[.playerSidebar] != .never {
|
||||
PlayerQueueView(sidebarQueue: sidebarQueueBinding, fullScreen: $fullScreenDetails)
|
||||
PlayerQueueView(sidebarQueue: true, fullScreen: fullScreenDetails)
|
||||
.frame(minWidth: 300)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
.transition(.asymmetric(insertion: .slide, removal: .identity))
|
||||
.ignoresSafeArea(.all, edges: fullScreenLayout ? .vertical : Edge.Set())
|
||||
#if os(iOS)
|
||||
.statusBar(hidden: playerControls.playingFullscreen)
|
||||
.statusBar(hidden: player.playingFullScreen)
|
||||
.navigationBarHidden(true)
|
||||
#endif
|
||||
}
|
||||
@@ -285,9 +308,9 @@ struct VideoPlayerView: View {
|
||||
|
||||
var fullScreenLayout: Bool {
|
||||
#if os(iOS)
|
||||
playerControls.playingFullscreen || verticalSizeClass == .compact
|
||||
player.playingFullScreen || verticalSizeClass == .compact
|
||||
#else
|
||||
playerControls.playingFullscreen
|
||||
player.playingFullScreen
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -357,29 +380,11 @@ struct VideoPlayerView: View {
|
||||
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: geometry.size.width / Self.defaultAspectRatio)
|
||||
}
|
||||
|
||||
var sidebarQueue: Bool {
|
||||
switch Defaults[.playerSidebar] {
|
||||
case .never:
|
||||
return false
|
||||
case .always:
|
||||
return true
|
||||
case .whenFits:
|
||||
return playerSize.width > 900
|
||||
}
|
||||
}
|
||||
|
||||
var sidebarQueueBinding: Binding<Bool> {
|
||||
Binding(
|
||||
get: { sidebarQueue },
|
||||
set: { _ in }
|
||||
)
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
private func configureOrientationUpdatesBasedOnAccelerometer() {
|
||||
if UIDevice.current.orientation.isLandscape,
|
||||
enterFullscreenInLandscape,
|
||||
!playerControls.playingFullscreen,
|
||||
Defaults[.enterFullscreenInLandscape],
|
||||
!player.playingFullScreen,
|
||||
!player.playingInPictureInPicture
|
||||
{
|
||||
DispatchQueue.main.async {
|
||||
@@ -387,7 +392,7 @@ struct VideoPlayerView: View {
|
||||
}
|
||||
}
|
||||
|
||||
guard !honorSystemOrientationLock, motionManager.isNil else {
|
||||
guard !Defaults[.honorSystemOrientationLock], motionManager.isNil else {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -422,7 +427,7 @@ struct VideoPlayerView: View {
|
||||
|
||||
if orientation.isLandscape {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) {
|
||||
guard enterFullscreenInLandscape else {
|
||||
guard Defaults[.enterFullscreenInLandscape] else {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -433,7 +438,7 @@ struct VideoPlayerView: View {
|
||||
|
||||
Orientation.lockOrientation(orientationLockMask, andRotateTo: orientation)
|
||||
|
||||
guard lockOrientationInFullScreen else {
|
||||
guard Defaults[.lockOrientationInFullScreen] else {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -442,8 +447,8 @@ struct VideoPlayerView: View {
|
||||
} else {
|
||||
guard abs(acceleration.z) <= 0.74,
|
||||
player.lockedOrientation.isNil,
|
||||
enterFullscreenInLandscape,
|
||||
!lockOrientationInFullScreen
|
||||
Defaults[.enterFullscreenInLandscape],
|
||||
!Defaults[.lockOrientationInFullScreen]
|
||||
else {
|
||||
return
|
||||
}
|
||||
@@ -462,14 +467,14 @@ struct VideoPlayerView: View {
|
||||
let newOrientation = UIApplication.shared.windows.first?.windowScene?.interfaceOrientation
|
||||
if newOrientation?.isLandscape ?? false,
|
||||
player.presentingPlayer,
|
||||
lockOrientationInFullScreen,
|
||||
Defaults[.lockOrientationInFullScreen],
|
||||
!player.lockedOrientation.isNil
|
||||
{
|
||||
Orientation.lockOrientation(.landscape, andRotateTo: newOrientation)
|
||||
return
|
||||
}
|
||||
|
||||
guard player.presentingPlayer, enterFullscreenInLandscape, honorSystemOrientationLock else {
|
||||
guard player.presentingPlayer, Defaults[.enterFullscreenInLandscape], Defaults[.honorSystemOrientationLock] else {
|
||||
return
|
||||
}
|
||||
|
||||
|
@@ -9,11 +9,11 @@ struct AddToPlaylistView: View {
|
||||
|
||||
@State private var error = ""
|
||||
@State private var presentingErrorAlert = false
|
||||
@State private var submitButtonDisabled = false
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@Environment(\.presentationMode) private var presentationMode
|
||||
|
||||
@EnvironmentObject<NavigationModel> private var navigation
|
||||
@EnvironmentObject<PlaylistsModel> private var model
|
||||
|
||||
var body: some View {
|
||||
@@ -123,14 +123,8 @@ struct AddToPlaylistView: View {
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Add to Playlist", action: addToPlaylist)
|
||||
.disabled(submitButtonDisabled || selectedPlaylist.isNil)
|
||||
.disabled(selectedPlaylist.isNil)
|
||||
.padding(.top, 30)
|
||||
.alert(isPresented: $presentingErrorAlert) {
|
||||
Alert(
|
||||
title: Text("Error when accessing playlist"),
|
||||
message: Text(error)
|
||||
)
|
||||
}
|
||||
#if !os(tvOS)
|
||||
.keyboardShortcut(.defaultAction)
|
||||
#endif
|
||||
@@ -166,20 +160,9 @@ struct AddToPlaylistView: View {
|
||||
|
||||
Defaults[.lastUsedPlaylistID] = id
|
||||
|
||||
submitButtonDisabled = true
|
||||
model.addVideo(playlistID: id, videoID: video.videoID, navigation: navigation)
|
||||
|
||||
model.addVideo(
|
||||
playlistID: id,
|
||||
videoID: video.videoID,
|
||||
onSuccess: {
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
},
|
||||
onFailure: { requestError in
|
||||
error = "(\(requestError.httpStatusCode ?? -1)) \(requestError.userMessage)"
|
||||
presentingErrorAlert = true
|
||||
submitButtonDisabled = false
|
||||
}
|
||||
)
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
}
|
||||
|
||||
private var selectedPlaylist: Playlist? {
|
||||
|
@@ -66,8 +66,9 @@ struct SearchSuggestions: View {
|
||||
#endif
|
||||
}
|
||||
}
|
||||
.id(UUID())
|
||||
#if os(macOS)
|
||||
.buttonStyle(.link)
|
||||
.buttonStyle(.link)
|
||||
#endif
|
||||
}
|
||||
|
||||
|
@@ -297,6 +297,7 @@ struct SearchView: View {
|
||||
}
|
||||
.redrawOn(change: recentsChanged)
|
||||
}
|
||||
.id(UUID())
|
||||
}
|
||||
#if os(iOS)
|
||||
.listStyle(.insetGrouped)
|
||||
|
@@ -1,146 +1,230 @@
|
||||
import Foundation
|
||||
import SDWebImageSwiftUI
|
||||
import SwiftUI
|
||||
|
||||
struct BrowserPlayerControls<Content: View, Toolbar: View>: View {
|
||||
let content: Content
|
||||
let toolbar: Toolbar?
|
||||
|
||||
@Environment(\.navigationStyle) private var navigationStyle
|
||||
@EnvironmentObject<PlayerControlsModel> private var playerControls
|
||||
@EnvironmentObject<PlayerModel> private var model
|
||||
|
||||
init(@ViewBuilder toolbar: @escaping () -> Toolbar? = { nil }, @ViewBuilder content: @escaping () -> Content) {
|
||||
self.content = content()
|
||||
self.toolbar = toolbar()
|
||||
enum Context {
|
||||
case browser, player
|
||||
}
|
||||
|
||||
init(@ViewBuilder content: @escaping () -> Content) where Toolbar == EmptyView {
|
||||
self.init(toolbar: { EmptyView() }, content: content)
|
||||
let content: Content
|
||||
|
||||
init(
|
||||
context _: Context? = nil,
|
||||
@ViewBuilder toolbar: @escaping () -> Toolbar? = { nil },
|
||||
@ViewBuilder content: @escaping () -> Content
|
||||
) {
|
||||
self.content = content()
|
||||
}
|
||||
|
||||
init(
|
||||
context: Context? = nil,
|
||||
@ViewBuilder content: @escaping () -> Content
|
||||
) where Toolbar == EmptyView {
|
||||
self.init(context: context, toolbar: { EmptyView() }, content: content)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .bottomLeading) {
|
||||
if #available(iOS 15.0, macOS 12.0, *) {
|
||||
_ = Self._printChanges()
|
||||
}
|
||||
|
||||
return VStack(spacing: 0) {
|
||||
content
|
||||
|
||||
#if !os(tvOS)
|
||||
.frame(minHeight: 0, maxHeight: .infinity)
|
||||
#endif
|
||||
|
||||
Group {
|
||||
#if !os(tvOS)
|
||||
#if !os(macOS)
|
||||
toolbar
|
||||
.frame(height: 100)
|
||||
.offset(x: 0, y: -28)
|
||||
#endif
|
||||
controls
|
||||
|
||||
#endif
|
||||
}
|
||||
.borderTop(height: 0.4, color: Color("ControlsBorderColor"))
|
||||
#if os(macOS)
|
||||
.background(VisualEffectBlur(material: .sidebar))
|
||||
#elseif os(iOS)
|
||||
.background(VisualEffectBlur(blurStyle: .systemThinMaterial).edgesIgnoringSafeArea(.all))
|
||||
ControlsBar()
|
||||
.edgesIgnoringSafeArea(.bottom)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
private var controls: some View {
|
||||
HStack {
|
||||
Button(action: {
|
||||
model.togglePlayer()
|
||||
}) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(model.currentVideo?.title ?? "Not playing")
|
||||
.font(.system(size: 14).bold())
|
||||
.foregroundColor(model.currentItem.isNil ? .secondary : .accentColor)
|
||||
.lineLimit(1)
|
||||
|
||||
if let video = model.currentVideo {
|
||||
Text(video.author)
|
||||
.fontWeight(.bold)
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.vertical)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.padding(.vertical, 20)
|
||||
|
||||
HStack {
|
||||
Group {
|
||||
if !model.currentItem.isNil {
|
||||
Button {
|
||||
model.closeCurrentItem()
|
||||
model.closePiP()
|
||||
} label: {
|
||||
Label("Close Video", systemImage: "xmark")
|
||||
}
|
||||
}
|
||||
|
||||
if playerControls.isPlaying {
|
||||
Button(action: {
|
||||
model.pause()
|
||||
}) {
|
||||
Label("Pause", systemImage: "pause.fill")
|
||||
}
|
||||
} else {
|
||||
Button(action: {
|
||||
model.play()
|
||||
}) {
|
||||
Label("Play", systemImage: "play.fill")
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(playerControls.isLoadingVideo || model.currentItem.isNil)
|
||||
.font(.system(size: 30))
|
||||
.frame(minWidth: 30)
|
||||
|
||||
Button(action: { model.advanceToNextItem() }) {
|
||||
Label("Next", systemImage: "forward.fill")
|
||||
.padding(.vertical)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.disabled(model.queue.isEmpty)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.labelStyle(.iconOnly)
|
||||
.padding(.horizontal)
|
||||
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: 55)
|
||||
.padding(.vertical, 0)
|
||||
.borderTop(height: 0.4, color: Color("ControlsBorderColor"))
|
||||
.borderBottom(height: navigationStyle == .sidebar ? 0 : 0.4, color: Color("ControlsBorderColor"))
|
||||
#if !os(tvOS)
|
||||
.onSwipeGesture(up: {
|
||||
model.show()
|
||||
})
|
||||
#endif
|
||||
}
|
||||
|
||||
private var progressViewValue: Double {
|
||||
[model.time?.seconds, model.videoDuration].compactMap { $0 }.min() ?? 0
|
||||
}
|
||||
|
||||
private var progressViewTotal: Double {
|
||||
model.videoDuration ?? 100
|
||||
}
|
||||
}
|
||||
|
||||
// struct BrowserPlayerControls<Content: View, Toolbar: View>: View {
|
||||
// enum Context {
|
||||
// case browser, player
|
||||
// }
|
||||
//
|
||||
// let context: Context
|
||||
// let content: Content
|
||||
// let toolbar: Toolbar?
|
||||
//
|
||||
// @Environment(\.navigationStyle) private var navigationStyle
|
||||
// @EnvironmentObject<PlayerControlsModel> private var playerControls
|
||||
// @EnvironmentObject<PlayerModel> private var model
|
||||
//
|
||||
// var barHeight: Double {
|
||||
// 75
|
||||
// }
|
||||
//
|
||||
// init(
|
||||
// context: Context? = nil,
|
||||
// @ViewBuilder toolbar: @escaping () -> Toolbar? = { nil },
|
||||
// @ViewBuilder content: @escaping () -> Content
|
||||
// ) {
|
||||
// self.context = context ?? .browser
|
||||
// self.content = content()
|
||||
// self.toolbar = toolbar()
|
||||
// }
|
||||
//
|
||||
// init(
|
||||
// context: Context? = nil,
|
||||
// @ViewBuilder content: @escaping () -> Content
|
||||
// ) where Toolbar == EmptyView {
|
||||
// self.init(context: context, toolbar: { EmptyView() }, content: content)
|
||||
// }
|
||||
//
|
||||
// var body: some View {
|
||||
// ZStack(alignment: .bottomLeading) {
|
||||
// VStack(spacing: 0) {
|
||||
// content
|
||||
//
|
||||
// Color.clear.frame(height: barHeight)
|
||||
// }
|
||||
// #if !os(tvOS)
|
||||
// .frame(minHeight: 0, maxHeight: .infinity)
|
||||
// #endif
|
||||
//
|
||||
//
|
||||
// VStack {
|
||||
// #if !os(tvOS)
|
||||
// #if !os(macOS)
|
||||
// toolbar
|
||||
// .frame(height: 100)
|
||||
// .offset(x: 0, y: -28)
|
||||
// #endif
|
||||
//
|
||||
// if context != .player || !playerControls.playingFullscreen {
|
||||
// controls
|
||||
// }
|
||||
// #endif
|
||||
// }
|
||||
// .borderTop(height: 0.4, color: Color("ControlsBorderColor"))
|
||||
// #if os(macOS)
|
||||
// .background(VisualEffectBlur(material: .sidebar))
|
||||
// #elseif os(iOS)
|
||||
// .background(VisualEffectBlur(blurStyle: .systemThinMaterial).edgesIgnoringSafeArea(.all))
|
||||
// #endif
|
||||
// }
|
||||
// .background(Color.debug)
|
||||
// }
|
||||
//
|
||||
// private var controls: some View {
|
||||
// VStack(spacing: 0) {
|
||||
// TimelineView(duration: playerControls.durationBinding, current: playerControls.currentTimeBinding)
|
||||
// .foregroundColor(.secondary)
|
||||
//
|
||||
// Button(action: {
|
||||
// model.togglePlayer()
|
||||
// }) {
|
||||
// HStack(spacing: 8) {
|
||||
// authorAvatar
|
||||
//
|
||||
// VStack(alignment: .leading, spacing: 5) {
|
||||
// Text(model.currentVideo?.title ?? "Not playing")
|
||||
// .font(.headline)
|
||||
// .foregroundColor(model.currentVideo.isNil ? .secondary : .accentColor)
|
||||
// .lineLimit(1)
|
||||
//
|
||||
// Text(model.currentVideo?.author ?? "")
|
||||
// .font(.subheadline)
|
||||
// .foregroundColor(.secondary)
|
||||
// .lineLimit(1)
|
||||
// }
|
||||
//
|
||||
// Spacer()
|
||||
//
|
||||
// HStack {
|
||||
// Group {
|
||||
// if !model.currentItem.isNil {
|
||||
// Button {
|
||||
// model.closeCurrentItem()
|
||||
// model.closePiP()
|
||||
// } label: {
|
||||
// Label("Close Video", systemImage: "xmark")
|
||||
// .padding(.horizontal, 4)
|
||||
// .contentShape(Rectangle())
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// if playerControls.isPlaying {
|
||||
// Button(action: {
|
||||
// model.pause()
|
||||
// }) {
|
||||
// Label("Pause", systemImage: "pause.fill")
|
||||
// .padding(.horizontal, 4)
|
||||
// .contentShape(Rectangle())
|
||||
// }
|
||||
// } else {
|
||||
// Button(action: {
|
||||
// model.play()
|
||||
// }) {
|
||||
// Label("Play", systemImage: "play.fill")
|
||||
// .padding(.horizontal, 4)
|
||||
// .contentShape(Rectangle())
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// .disabled(playerControls.isLoadingVideo || model.currentItem.isNil)
|
||||
// .font(.system(size: 30))
|
||||
// .frame(minWidth: 30)
|
||||
//
|
||||
// Button(action: { model.advanceToNextItem() }) {
|
||||
// Label("Next", systemImage: "forward.fill")
|
||||
// .padding(.vertical)
|
||||
// .contentShape(Rectangle())
|
||||
// }
|
||||
// .disabled(model.queue.isEmpty)
|
||||
// }
|
||||
// }
|
||||
// .buttonStyle(.plain)
|
||||
// .contentShape(Rectangle())
|
||||
// }
|
||||
// }
|
||||
// .buttonStyle(.plain)
|
||||
// .labelStyle(.iconOnly)
|
||||
// .padding(.horizontal)
|
||||
// .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: barHeight)
|
||||
// .borderTop(height: 0.4, color: Color("ControlsBorderColor"))
|
||||
// .borderBottom(height: navigationStyle == .sidebar ? 0 : 0.4, color: Color("ControlsBorderColor"))
|
||||
// }
|
||||
//
|
||||
// private var authorAvatar: some View {
|
||||
// Group {
|
||||
// if let video = model.currentItem?.video, let url = video.channel.thumbnailURL {
|
||||
// WebImage(url: url)
|
||||
// .resizable()
|
||||
// .placeholder {
|
||||
// Rectangle().fill(Color("PlaceholderColor"))
|
||||
// }
|
||||
// .retryOnAppear(true)
|
||||
// .indicator(.activity)
|
||||
// .clipShape(Circle())
|
||||
// .frame(width: 44, height: 44, alignment: .leading)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// private var progressViewValue: Double {
|
||||
// [model.time?.seconds, model.videoDuration].compactMap { $0 }.min() ?? 0
|
||||
// }
|
||||
//
|
||||
// private var progressViewTotal: Double {
|
||||
// model.videoDuration ?? 100
|
||||
// }
|
||||
// }
|
||||
//
|
||||
struct PlayerControlsView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
BrowserPlayerControls {
|
||||
VStack {
|
||||
Spacer()
|
||||
Text("Hello")
|
||||
Spacer()
|
||||
BrowserPlayerControls(context: .player) {
|
||||
BrowserPlayerControls {
|
||||
VStack {
|
||||
Spacer()
|
||||
Text("Hello")
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.offset(y: -100)
|
||||
}
|
||||
.injectFixtureEnvironmentObjects()
|
||||
}
|
||||
|
221
Shared/Views/ControlsBar.swift
Normal file
221
Shared/Views/ControlsBar.swift
Normal file
@@ -0,0 +1,221 @@
|
||||
import Defaults
|
||||
import SDWebImageSwiftUI
|
||||
import SwiftUI
|
||||
import SwiftUIPager
|
||||
|
||||
struct ControlsBar: View {
|
||||
enum Pages: CaseIterable {
|
||||
case details, controls
|
||||
}
|
||||
|
||||
@Environment(\.navigationStyle) private var navigationStyle
|
||||
|
||||
@EnvironmentObject<AccountsModel> private var accounts
|
||||
@EnvironmentObject<NavigationModel> private var navigation
|
||||
@EnvironmentObject<PlayerControlsModel> private var playerControls
|
||||
@EnvironmentObject<PlayerModel> private var model
|
||||
@EnvironmentObject<PlaylistsModel> private var playlists
|
||||
@EnvironmentObject<RecentsModel> private var recents
|
||||
|
||||
@StateObject private var controlsPage = Page.first()
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
Pager(page: controlsPage, data: Pages.allCases, id: \.self) { index in
|
||||
switch index {
|
||||
case .details:
|
||||
details
|
||||
default:
|
||||
controls
|
||||
}
|
||||
}
|
||||
.pagingPriority(.simultaneous)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.labelStyle(.iconOnly)
|
||||
.padding(.horizontal)
|
||||
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: barHeight)
|
||||
.borderTop(height: 0.4, color: Color("ControlsBorderColor"))
|
||||
.borderBottom(height: navigationStyle == .sidebar ? 0 : 0.4, color: Color("ControlsBorderColor"))
|
||||
.modifier(ControlBackgroundModifier(edgesIgnoringSafeArea: .bottom))
|
||||
}
|
||||
|
||||
var controls: some View {
|
||||
HStack(spacing: 4) {
|
||||
Group {
|
||||
Button {
|
||||
model.closeCurrentItem()
|
||||
model.closePiP()
|
||||
} label: {
|
||||
Label("Close Video", systemImage: "xmark")
|
||||
.padding(.horizontal, 4)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(action: { model.backend.seek(to: 0) }) {
|
||||
Label("Restart", systemImage: "backward.end.fill")
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
model.backend.seek(relative: .secondsInDefaultTimescale(-10))
|
||||
} label: {
|
||||
Label("Backward", systemImage: "gobackward.10")
|
||||
}
|
||||
Spacer()
|
||||
|
||||
if playerControls.isPlaying {
|
||||
Button(action: {
|
||||
model.pause()
|
||||
}) {
|
||||
Label("Pause", systemImage: "pause.fill")
|
||||
.padding(.horizontal, 4)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
} else {
|
||||
Button(action: {
|
||||
model.play()
|
||||
}) {
|
||||
Label("Play", systemImage: "play.fill")
|
||||
.padding(.horizontal, 4)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
model.backend.seek(relative: .secondsInDefaultTimescale(10))
|
||||
} label: {
|
||||
Label("Forward", systemImage: "goforward.10")
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.disabled(playerControls.isLoadingVideo || model.currentItem.isNil)
|
||||
|
||||
Button(action: { model.advanceToNextItem() }) {
|
||||
Label("Next", systemImage: "forward.fill")
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.disabled(model.queue.isEmpty)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.vertical)
|
||||
|
||||
.font(.system(size: 24))
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
var barHeight: Double {
|
||||
75
|
||||
}
|
||||
|
||||
var details: some View {
|
||||
HStack {
|
||||
HStack(spacing: 8) {
|
||||
authorAvatar
|
||||
.contextMenu {
|
||||
if let video = model.currentVideo {
|
||||
Group {
|
||||
Section {
|
||||
Text(video.title)
|
||||
|
||||
if accounts.app.supportsUserPlaylists && accounts.signedIn {
|
||||
Section {
|
||||
Button {
|
||||
navigation.presentAddToPlaylist(video)
|
||||
} label: {
|
||||
Label("Add to Playlist...", systemImage: "text.badge.plus")
|
||||
}
|
||||
|
||||
if let playlist = playlists.lastUsed, let video = model.currentVideo {
|
||||
Button {
|
||||
playlists.addVideo(playlistID: playlist.id, videoID: video.videoID, navigation: navigation)
|
||||
} label: {
|
||||
Label("Add to \(playlist.title)", systemImage: "text.badge.star")
|
||||
}
|
||||
}
|
||||
|
||||
Button {} label: {
|
||||
Label("Share", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Button {
|
||||
NavigationModel.openChannel(
|
||||
video.channel,
|
||||
player: model,
|
||||
recents: recents,
|
||||
navigation: navigation,
|
||||
navigationStyle: navigationStyle
|
||||
)
|
||||
} label: {
|
||||
Label("\(video.author) Channel", systemImage: "rectangle.stack.fill.badge.person.crop")
|
||||
}
|
||||
|
||||
Button {} label: {
|
||||
Label("Unsubscribe", systemImage: "xmark.circle")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.labelStyle(.automatic)
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Text(model.currentVideo?.title ?? "Not playing")
|
||||
.font(.system(size: 14))
|
||||
.fontWeight(.semibold)
|
||||
.foregroundColor(model.currentVideo.isNil ? .secondary : .accentColor)
|
||||
.lineLimit(1)
|
||||
|
||||
Text(model.currentVideo?.author ?? "")
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.vertical)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
private var authorAvatar: some View {
|
||||
Button {
|
||||
model.togglePlayer()
|
||||
} label: {
|
||||
if let video = model.currentItem?.video, let url = video.channel.thumbnailURL {
|
||||
WebImage(url: url)
|
||||
.resizable()
|
||||
.placeholder {
|
||||
Rectangle().fill(Color("PlaceholderColor"))
|
||||
}
|
||||
.retryOnAppear(true)
|
||||
.indicator(.activity)
|
||||
} else {
|
||||
Image(systemName: "play.rectangle")
|
||||
.foregroundColor(.accentColor)
|
||||
.font(.system(size: 30))
|
||||
}
|
||||
}
|
||||
.frame(width: 44, height: 44, alignment: .leading)
|
||||
.clipShape(Circle())
|
||||
}
|
||||
}
|
||||
|
||||
struct ControlsBar_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
ControlsBar()
|
||||
.injectFixtureEnvironmentObjects()
|
||||
}
|
||||
}
|
@@ -77,9 +77,10 @@ struct VideoContextMenuView: View {
|
||||
}
|
||||
}
|
||||
|
||||
if accounts.app.supportsUserPlaylists {
|
||||
if accounts.app.supportsUserPlaylists, accounts.signedIn {
|
||||
Section {
|
||||
addToPlaylistButton
|
||||
addToLastPlaylistButton
|
||||
|
||||
if let id = navigation.tabSelection?.playlistID ?? playlistID {
|
||||
removeFromPlaylistButton(playlistID: id)
|
||||
@@ -116,7 +117,7 @@ struct VideoContextMenuView: View {
|
||||
Button {
|
||||
player.play(video, at: .secondsInDefaultTimescale(watch!.stoppedAt))
|
||||
} label: {
|
||||
Label("Continue from \(watch!.stoppedAt.formattedAsPlaybackTime() ?? "where I left off")", systemImage: "playpause")
|
||||
Label("Continue from \(watch!.stoppedAt.formattedAsPlaybackTime(allowZero: true) ?? "where I left off")", systemImage: "playpause")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,6 +231,16 @@ struct VideoContextMenuView: View {
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private var addToLastPlaylistButton: some View {
|
||||
if let playlist = playlists.lastUsed {
|
||||
Button {
|
||||
playlists.addVideo(playlistID: playlist.id, videoID: video.videoID, navigation: navigation)
|
||||
} label: {
|
||||
Label("Add to \(playlist.title)", systemImage: "text.badge.star")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func removeFromPlaylistButton(playlistID: String) -> some View {
|
||||
Button {
|
||||
playlists.removeVideo(index: video.indexID!, playlistID: playlistID)
|
||||
|
@@ -1,4 +1,9 @@
|
||||
import Defaults
|
||||
import MediaPlayer
|
||||
import PINCache
|
||||
import SDWebImage
|
||||
import SDWebImageWebPCoder
|
||||
import Siesta
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
@@ -11,19 +16,27 @@ struct YatteeApp: App {
|
||||
Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "unknown"
|
||||
}
|
||||
|
||||
static var isForPreviews: Bool {
|
||||
ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1"
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
||||
#elseif os(iOS)
|
||||
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
||||
#endif
|
||||
|
||||
@State private var configured = false
|
||||
|
||||
@StateObject private var accounts = AccountsModel()
|
||||
@StateObject private var comments = CommentsModel()
|
||||
@StateObject private var instances = InstancesModel()
|
||||
@StateObject private var menu = MenuModel()
|
||||
@StateObject private var navigation = NavigationModel()
|
||||
@StateObject private var networkState = NetworkStateModel()
|
||||
@StateObject private var player = PlayerModel()
|
||||
@StateObject private var playerControls = PlayerControlsModel()
|
||||
@StateObject private var playerTime = PlayerTimeModel()
|
||||
@StateObject private var playlists = PlaylistsModel()
|
||||
@StateObject private var recents = RecentsModel()
|
||||
@StateObject private var search = SearchModel()
|
||||
@@ -35,13 +48,16 @@ struct YatteeApp: App {
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
.onAppear(perform: configure)
|
||||
.environment(\.managedObjectContext, persistenceController.container.viewContext)
|
||||
.environmentObject(accounts)
|
||||
.environmentObject(comments)
|
||||
.environmentObject(instances)
|
||||
.environmentObject(navigation)
|
||||
.environmentObject(networkState)
|
||||
.environmentObject(player)
|
||||
.environmentObject(playerControls)
|
||||
.environmentObject(playerTime)
|
||||
.environmentObject(playlists)
|
||||
.environmentObject(recents)
|
||||
.environmentObject(subscriptions)
|
||||
@@ -86,6 +102,7 @@ struct YatteeApp: App {
|
||||
#if os(macOS)
|
||||
WindowGroup(player.windowTitle) {
|
||||
VideoPlayerView()
|
||||
.onAppear(perform: configure)
|
||||
.background(
|
||||
HostingWindowFinder { window in
|
||||
Windows.playerWindow = window
|
||||
@@ -96,7 +113,7 @@ struct YatteeApp: App {
|
||||
queue: OperationQueue.main
|
||||
) { _ in
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
self.player.controls.playingFullscreen = false
|
||||
self.player.playingFullScreen = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -109,8 +126,10 @@ struct YatteeApp: App {
|
||||
.environmentObject(comments)
|
||||
.environmentObject(instances)
|
||||
.environmentObject(navigation)
|
||||
.environmentObject(networkState)
|
||||
.environmentObject(player)
|
||||
.environmentObject(playerControls)
|
||||
.environmentObject(playerTime)
|
||||
.environmentObject(playlists)
|
||||
.environmentObject(recents)
|
||||
.environmentObject(subscriptions)
|
||||
@@ -129,4 +148,132 @@ struct YatteeApp: App {
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
func configure() {
|
||||
guard !Self.isForPreviews, !configured else {
|
||||
return
|
||||
}
|
||||
configured = true
|
||||
|
||||
SiestaLog.Category.enabled = .common
|
||||
SDImageCodersManager.shared.addCoder(SDImageWebPCoder.shared)
|
||||
SDWebImageManager.defaultImageCache = PINCache(name: "stream.yattee.app")
|
||||
|
||||
#if !os(macOS)
|
||||
configureNowPlayingInfoCenter()
|
||||
#endif
|
||||
|
||||
#if os(iOS)
|
||||
if Defaults[.lockPortraitWhenBrowsing] {
|
||||
Orientation.lockOrientation(.portrait, andRotateTo: .portrait)
|
||||
}
|
||||
#endif
|
||||
|
||||
if let account = accounts.lastUsed ??
|
||||
instances.lastUsed?.anonymousAccount ??
|
||||
InstancesModel.all.first?.anonymousAccount
|
||||
{
|
||||
accounts.setCurrent(account)
|
||||
}
|
||||
|
||||
if accounts.current.isNil {
|
||||
navigation.presentingWelcomeScreen = true
|
||||
}
|
||||
|
||||
playlists.accounts = accounts
|
||||
search.accounts = accounts
|
||||
subscriptions.accounts = accounts
|
||||
|
||||
comments.player = player
|
||||
|
||||
menu.accounts = accounts
|
||||
menu.navigation = navigation
|
||||
menu.player = player
|
||||
|
||||
playerControls.player = player
|
||||
|
||||
player.accounts = accounts
|
||||
player.comments = comments
|
||||
player.controls = playerControls
|
||||
player.networkState = networkState
|
||||
player.playerTime = playerTime
|
||||
|
||||
if !accounts.current.isNil {
|
||||
player.restoreQueue()
|
||||
}
|
||||
|
||||
if !Defaults[.saveRecents] {
|
||||
recents.clear()
|
||||
}
|
||||
|
||||
var section = Defaults[.visibleSections].min()?.tabSelection
|
||||
|
||||
#if os(macOS)
|
||||
if section == .playlists {
|
||||
section = .search
|
||||
}
|
||||
#endif
|
||||
|
||||
navigation.tabSelection = section ?? .search
|
||||
|
||||
subscriptions.load()
|
||||
playlists.load()
|
||||
|
||||
#if os(macOS)
|
||||
Windows.player.open()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
Windows.main.focus()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
func configureNowPlayingInfoCenter() {
|
||||
#if !os(macOS)
|
||||
try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .moviePlayback)
|
||||
|
||||
UIApplication.shared.beginReceivingRemoteControlEvents()
|
||||
#endif
|
||||
|
||||
MPRemoteCommandCenter.shared().playCommand.addTarget { _ in
|
||||
player.play()
|
||||
return .success
|
||||
}
|
||||
|
||||
MPRemoteCommandCenter.shared().pauseCommand.addTarget { _ in
|
||||
player.pause()
|
||||
return .success
|
||||
}
|
||||
|
||||
MPRemoteCommandCenter.shared().previousTrackCommand.isEnabled = false
|
||||
MPRemoteCommandCenter.shared().nextTrackCommand.isEnabled = false
|
||||
|
||||
MPRemoteCommandCenter.shared().changePlaybackPositionCommand.addTarget { remoteEvent in
|
||||
guard let event = remoteEvent as? MPChangePlaybackPositionCommandEvent
|
||||
else {
|
||||
return .commandFailed
|
||||
}
|
||||
|
||||
player.backend.seek(to: event.positionTime)
|
||||
|
||||
return .success
|
||||
}
|
||||
|
||||
let skipForwardCommand = MPRemoteCommandCenter.shared().skipForwardCommand
|
||||
skipForwardCommand.isEnabled = true
|
||||
skipForwardCommand.preferredIntervals = [10]
|
||||
|
||||
skipForwardCommand.addTarget { _ in
|
||||
player.backend.seek(relative: .secondsInDefaultTimescale(10))
|
||||
return .success
|
||||
}
|
||||
|
||||
let skipBackwardCommand = MPRemoteCommandCenter.shared().skipBackwardCommand
|
||||
skipBackwardCommand.isEnabled = true
|
||||
skipBackwardCommand.preferredIntervals = [10]
|
||||
|
||||
skipBackwardCommand.addTarget { _ in
|
||||
player.backend.seek(relative: .secondsInDefaultTimescale(-10))
|
||||
return .success
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user