lots of final tvos work

This commit is contained in:
Ethan Pippin 2022-01-03 22:55:39 -07:00
parent 7ab85e453d
commit 2d7cad8cec
25 changed files with 596 additions and 350 deletions

View File

@ -14,6 +14,15 @@ struct JellyfinPlayer_tvOSApp: App {
var body: some Scene {
WindowGroup {
MainCoordinator().view()
.onAppear {
JellyfinPlayer_tvOSApp.setupAppearance()
}
}
}
static func setupAppearance() {
let scenes = UIApplication.shared.connectedScenes
let windowScene = scenes.first as? UIWindowScene
windowScene?.windows.first?.overrideUserInterfaceStyle = .dark
}
}

View File

@ -28,12 +28,15 @@
<true/>
</dict>
<key>UILaunchScreen</key>
<dict/>
<dict>
<key>UIColorName</key>
<string>LaunchScreenBackground</string>
</dict>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>arm64</string>
</array>
<key>UIUserInterfaceStyle</key>
<string>Automatic</string>
<string>Dark</string>
</dict>
</plist>

View File

@ -39,7 +39,7 @@ struct BasicAppSettingsView: View {
}
.alert(L10n.reset, isPresented: $resetTapped, actions: {
Button(role: .destructive) {
viewModel.reset()
viewModel.resetAppSettings()
basicAppSettingsRouter.dismissCoordinator()
} label: {
L10n.reset.text

View File

@ -13,24 +13,11 @@ import SwiftUI
struct CinematicEpisodeItemView: View {
@ObservedObject var viewModel: EpisodeItemViewModel
@State var verticalScrollViewOffset: CGFloat = 0
@State var wrappedScrollView: UIScrollView?
var body: some View {
ZStack {
VStack {
Spacer()
GeometryReader { overlayGeoReader in
Text("")
.onAppear {
self.verticalScrollViewOffset = overlayGeoReader.frame(in: .global).origin.y + overlayGeoReader.frame(in: .global).height - 200
}
}
.frame(height: 50)
}
ImageView(src: viewModel.item.getBackdropImage(maxWidth: 1920),
bh: viewModel.item.getBackdropImageBlurHash())
.ignoresSafeArea()
@ -38,10 +25,9 @@ struct CinematicEpisodeItemView: View {
ScrollView {
VStack(spacing: 0) {
Spacer(minLength: verticalScrollViewOffset)
CinematicItemViewTopRow(viewModel: viewModel, wrappedScrollView: wrappedScrollView)
.focusSection()
.frame(height: UIScreen.main.bounds.height - 10)
ZStack(alignment: .topLeading) {

View File

@ -16,8 +16,8 @@ struct CinematicItemAboutView: View {
var body: some View {
HStack(alignment: .top, spacing: 10) {
ImageView(src: viewModel.item.portraitHeaderViewURL(maxWidth: 230))
.frame(width: 230, height: 380)
ImageView(src: viewModel.item.portraitHeaderViewURL(maxWidth: 257))
.frame(width: 257, height: 380)
.cornerRadius(10)
ZStack(alignment: .topLeading) {

View File

@ -25,26 +25,42 @@ struct CinematicItemViewTopRow: View {
.ignoresSafeArea()
.frame(height: 210)
VStack {
Spacer()
HStack(alignment: .bottom) {
VStack(alignment: .leading) {
HStack(alignment: .PlayInformationAlignmentGuide) {
CinematicItemViewTopRowButton(wrappedScrollView: wrappedScrollView) {
// MARK: Play
Button {
itemRouter.route(to: \.videoPlayer, viewModel.itemVideoPlayerViewModel!)
} label: {
ZStack {
Color.white.frame(width: 230, height: 100)
Text("Play")
HStack(spacing: 15) {
Image(systemName: "play.fill")
.foregroundColor(viewModel.playButtonItem == nil ? Color(UIColor.secondaryLabel) : Color.black)
.font(.title3)
.foregroundColor(.black)
Text(viewModel.playButtonText())
.foregroundColor(viewModel.playButtonItem == nil ? Color(UIColor.secondaryLabel) : Color.black)
// .font(.title3)
.fontWeight(.semibold)
}
.frame(width: 230, height: 100)
.background(viewModel.playButtonItem == nil ? Color.secondarySystemFill : Color.white)
.cornerRadius(10)
// ZStack {
// Color.white.frame(width: 230, height: 100)
//
// Text("Play")
// .font(.title3)
// .foregroundColor(.black)
// }
}
.buttonStyle(CardButtonStyle())
.disabled(viewModel.itemVideoPlayerViewModel == nil)
}
}
}
VStack(alignment: .leading, spacing: 5) {
Text(viewModel.item.name ?? "")
@ -88,6 +104,8 @@ struct CinematicItemViewTopRow: View {
.padding(.horizontal, 50)
.padding(.bottom, 50)
}
}
.onChange(of: envFocused) { envFocus in
if envFocus == true {
wrappedScrollView?.scrollToTop()

View File

@ -38,7 +38,10 @@ struct CinematicItemViewTopRowButton<Content: View>: View {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) {
wrappedScrollView?.scrollToTop()
}
print("Scroll to top")
withAnimation(.linear(duration: 0.15)) {
self.focused = newValue
}
}
}
}

View File

@ -13,24 +13,11 @@ import SwiftUI
struct CinematicMovieItemView: View {
@ObservedObject var viewModel: MovieItemViewModel
@State var verticalScrollViewOffset: CGFloat = 0
@State var wrappedScrollView: UIScrollView?
var body: some View {
ZStack {
VStack {
Spacer()
GeometryReader { overlayGeoReader in
Text("")
.onAppear {
self.verticalScrollViewOffset = overlayGeoReader.frame(in: .global).origin.y + overlayGeoReader.frame(in: .global).height - 200
}
}
.frame(height: 50)
}
ImageView(src: viewModel.item.getBackdropImage(maxWidth: 1920),
bh: viewModel.item.getBackdropImageBlurHash())
.ignoresSafeArea()
@ -38,10 +25,9 @@ struct CinematicMovieItemView: View {
ScrollView {
VStack(spacing: 0) {
Spacer(minLength: verticalScrollViewOffset)
CinematicItemViewTopRow(viewModel: viewModel, wrappedScrollView: wrappedScrollView)
.focusSection()
.frame(height: UIScreen.main.bounds.height - 10)
ZStack(alignment: .topLeading) {

View File

@ -22,6 +22,7 @@ struct ServerDetailView: View {
Text(SessionManager.main.currentLogin.server.name)
.foregroundColor(.secondary)
}
.focusable()
HStack {
Text("URI")

View File

@ -67,7 +67,7 @@ struct ServerListView: View {
Text("Connect to a Jellyfin server to get started")
.frame(minWidth: 50, maxWidth: 500)
.multilineTextAlignment(.center)
.font(.callout)
.font(.body)
Button {
serverListRouter.route(to: \.connectToServer)
@ -75,8 +75,12 @@ struct ServerListView: View {
L10n.connect.text
.bold()
.font(.callout)
.padding(.vertical)
.padding(.horizontal, 30)
.background(Color.jellyfinPurple)
}
.padding(.top, 40)
.buttonStyle(CardButtonStyle())
}
}

View File

@ -1,98 +0,0 @@
/* JellyfinPlayer/Swiftfin is subject to the terms of the Mozilla Public
* License, v2.0. If a copy of the MPL was not distributed with this
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
*
* Copyright 2021 Aiden Vigue & Jellyfin Contributors
*/
import CoreData
import SwiftUI
import Defaults
import JellyfinAPI
struct SettingsView: View {
@ObservedObject var viewModel: SettingsViewModel
@Default(.inNetworkBandwidth) var inNetworkStreamBitrate
@Default(.outOfNetworkBandwidth) var outOfNetworkStreamBitrate
@Default(.isAutoSelectSubtitles) var isAutoSelectSubtitles
@Default(.autoSelectSubtitlesLangCode) var autoSelectSubtitlesLangcode
@Default(.autoSelectAudioLangCode) var autoSelectAudioLangcode
var body: some View {
ZStack {
Color.black
.ignoresSafeArea()
GeometryReader { reader in
HStack {
Image(uiImage: UIImage(named: "App Icon")!)
.frame(width: reader.size.width / 2)
Form {
Section(header: L10n.playbackSettings.text) {
Picker("Default local quality", selection: $inNetworkStreamBitrate) {
ForEach(self.viewModel.bitrates, id: \.self) { bitrate in
Text(bitrate.name).tag(bitrate.value)
}
}
Picker("Default remote quality", selection: $outOfNetworkStreamBitrate) {
ForEach(self.viewModel.bitrates, id: \.self) { bitrate in
Text(bitrate.name).tag(bitrate.value)
}
}
}
Section(header: L10n.accessibility.text) {
Toggle("Automatically show subtitles", isOn: $isAutoSelectSubtitles)
SearchablePicker(label: "Preferred subtitle language",
options: viewModel.langs,
optionToString: { $0.name },
selected: Binding<TrackLanguage>(
get: { viewModel.langs.first(where: { $0.isoCode == autoSelectSubtitlesLangcode }) ?? .auto },
set: {autoSelectSubtitlesLangcode = $0.isoCode}
)
)
SearchablePicker(label: "Preferred audio language",
options: viewModel.langs,
optionToString: { $0.name },
selected: Binding<TrackLanguage>(
get: { viewModel.langs.first(where: { $0.isoCode == autoSelectAudioLangcode }) ?? .auto },
set: { autoSelectAudioLangcode = $0.isoCode}
)
)
}
Section(header: Text(SessionManager.main.currentLogin.server.name)) {
HStack {
Text(L10n.signedInAsWithString(SessionManager.main.currentLogin.user.username)).foregroundColor(.primary)
Spacer()
Button {
SwiftfinNotificationCenter.main.post(name: SwiftfinNotificationCenter.Keys.didSignOut, object: nil)
} label: {
L10n.switchUser.text.font(.callout)
}
}
Button {
SessionManager.main.logout()
} label: {
Text("Sign out").font(.callout)
}
}
}
.padding(.leading, 90)
.padding(.trailing, 90)
}
}
}
}
}
struct SettingsView_Previews: PreviewProvider {
static var previews: some View {
SettingsView(viewModel: SettingsViewModel())
}
}

View File

@ -0,0 +1,28 @@
//
/*
* SwiftFin is subject to the terms of the Mozilla Public
* License, v2.0. If a copy of the MPL was not distributed with this
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
*
* Copyright 2021 Aiden Vigue & Jellyfin Contributors
*/
import Defaults
import SwiftUI
struct ExperimentalSettingsView: View {
@Default(.Experimental.syncSubtitleStateWithAdjacent) var syncSubtitleStateWithAdjacent
var body: some View {
Form {
Section {
Toggle("Sync Subtitles with Adjacent Episodes", isOn: $syncSubtitleStateWithAdjacent)
} header: {
Text("Experimental")
}
}
}
}

View File

@ -0,0 +1,29 @@
//
/*
* SwiftFin is subject to the terms of the Mozilla Public
* License, v2.0. If a copy of the MPL was not distributed with this
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
*
* Copyright 2021 Aiden Vigue & Jellyfin Contributors
*/
import Defaults
import SwiftUI
struct OverlaySettingsView: View {
@Default(.shouldShowPlayPreviousItem) var shouldShowPlayPreviousItem
@Default(.shouldShowPlayNextItem) var shouldShowPlayNextItem
@Default(.shouldShowAutoPlay) var shouldShowAutoPlay
var body: some View {
Form {
Section(header: Text("Overlay")) {
Toggle("\(Image(systemName: "chevron.left.circle")) Play Previous Item", isOn: $shouldShowPlayPreviousItem)
Toggle("\(Image(systemName: "chevron.right.circle")) Play Next Item", isOn: $shouldShowPlayNextItem)
Toggle("\(Image(systemName: "play.circle.fill")) Auto Play", isOn: $shouldShowAutoPlay)
}
}
}
}

View File

@ -0,0 +1,111 @@
/* JellyfinPlayer/Swiftfin is subject to the terms of the Mozilla Public
* License, v2.0. If a copy of the MPL was not distributed with this
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
*
* Copyright 2021 Aiden Vigue & Jellyfin Contributors
*/
import CoreData
import SwiftUI
import Defaults
import JellyfinAPI
struct SettingsView: View {
@EnvironmentObject var settingsRouter: SettingsCoordinator.Router
@ObservedObject var viewModel: SettingsViewModel
@Default(.autoSelectAudioLangCode) var autoSelectAudioLangcode
@Default(.videoPlayerJumpForward) var jumpForwardLength
@Default(.videoPlayerJumpBackward) var jumpBackwardLength
@Default(.downActionShowsMenu) var downActionShowsMenu
var body: some View {
GeometryReader { reader in
HStack {
Image(uiImage: UIImage(named: "App Icon")!)
.scaleEffect(2)
.frame(width: reader.size.width / 2)
Form {
Section(header: EmptyView()) {
HStack {
Text("User")
Spacer()
Text(viewModel.user.username)
.foregroundColor(.jellyfinPurple)
}
Button {
settingsRouter.route(to: \.serverDetail)
} label: {
HStack {
Text("Server")
.foregroundColor(.primary)
Spacer()
Text(viewModel.server.name)
.foregroundColor(.jellyfinPurple)
Image(systemName: "chevron.right")
.foregroundColor(.jellyfinPurple)
}
}
Button {
SessionManager.main.logout()
} label: {
Text("Switch User")
.foregroundColor(Color.jellyfinPurple)
.font(.callout)
}
}
Section(header: Text("Video Player")) {
Picker("Jump Forward Length", selection: $jumpForwardLength) {
ForEach(VideoPlayerJumpLength.allCases, id: \.self) { length in
Text(length.label).tag(length.rawValue)
}
}
Picker("Jump Backward Length", selection: $jumpBackwardLength) {
ForEach(VideoPlayerJumpLength.allCases, id: \.self) { length in
Text(length.label).tag(length.rawValue)
}
}
Toggle("Press Down for Menu", isOn: $downActionShowsMenu)
Button {
settingsRouter.route(to: \.overlaySettings)
} label: {
HStack {
Text("Overlay")
.foregroundColor(.primary)
Spacer()
Image(systemName: "chevron.right")
}
}
Button {
settingsRouter.route(to: \.experimentalSettings)
} label: {
HStack {
Text("Experimental")
.foregroundColor(.primary)
Spacer()
Image(systemName: "chevron.right")
}
}
}
}
}
}
}
}
struct SettingsView_Previews: PreviewProvider {
static var previews: some View {
SettingsView(viewModel: SettingsViewModel(server: .sample, user: .sample))
}
}

View File

@ -13,7 +13,6 @@ protocol PlayerOverlayDelegate {
func didSelectClose()
func didSelectMenu()
func didDeselectMenu()
func didSelectBackward()
func didSelectForward()

View File

@ -116,6 +116,8 @@ class VLCPlayerViewController: UIViewController {
// aren't unnecessarily set more than once
vlcMediaPlayer.delegate = self
vlcMediaPlayer.drawable = videoContentView
// TODO: custom font sizes
vlcMediaPlayer.perform(Selector(("setTextRendererFontSize:")), with: 16)
setupMediaPlayer(newViewModel: viewModel)
@ -188,8 +190,7 @@ class VLCPlayerViewController: UIViewController {
guard let buttonPress = presses.first?.type else { return }
switch(buttonPress) {
case .menu:
print("Menu")
case .menu: () // Captured by other gesture
case .playPause:
didSelectMain()
case .select:
@ -201,24 +202,22 @@ class VLCPlayerViewController: UIViewController {
showOverlay()
restartOverlayDismissTimer()
}
print("Up arrow")
case .downArrow:
if Defaults[.downActionShowsMenu] {
if !displayingContentOverlay {
stopOverlayDismissTimer()
hideOverlay()
showOverlayContent()
didSelectMenu()
}
}
case .leftArrow:
if !displayingContentOverlay {
didSelectBackward()
print("Left arrow")
}
case .rightArrow:
if !displayingContentOverlay {
didSelectForward()
case .pageUp:
print("page up")
case .pageDown:
print("page down")
}
case .pageUp: ()
case .pageDown: ()
@unknown default: ()
}
}
@ -235,6 +234,9 @@ class VLCPlayerViewController: UIViewController {
hideOverlay()
} else if displayingContentOverlay {
hideOverlayContent()
showOverlay()
restartOverlayDismissTimer()
} else {
vlcMediaPlayer.pause()
@ -301,11 +303,23 @@ class VLCPlayerViewController: UIViewController {
currentOverlayContentHostingController.removeFromParent()
}
let newSmallMenuOverlayView = SmallMediaStreamSelectionView(viewModel: viewModel,
title: "Subtitles",
items: viewModel.subtitleStreams) { selectedMediaStream in
self.didSelectSubtitleStream(index: selectedMediaStream.index ?? -1)
}
// let newSmallMenuOverlayView = SmallMediaStreamSelectionView(viewModel: viewModel,
// title: "Subtitles",
// items: viewModel.subtitleStreams) { selectedMediaStream in
// self.didSelectSubtitleStream(index: selectedMediaStream.index ?? -1)
// }
// let newSmallMenuOverlayView = SmallMediaStreamSelectionView(viewModel: viewModel,
// items: viewModel.subtitleStreams,
// selectedIndex: viewModel.selectedSubtitleStreamIndex,
// title: "Subtitles") { selectedMediaStream in
// DispatchQueue.main.async {
// self.viewModel.selectedSubtitleStreamIndex = selectedMediaStream.index ?? -1
// self.didSelectSubtitleStream(index: selectedMediaStream.index ?? -1)
// }
// }
let newSmallMenuOverlayView = SmallMediaStreamSelectionView(viewModel: viewModel)
let newOverlayContentHostingController = UIHostingController(rootView: newSmallMenuOverlayView)
// let newOverlayContentView = tvOSOverlayContentView(viewModel: viewModel)
// let newOverlayContentHostingController = UIHostingController(rootView: newOverlayContentView)
@ -357,6 +371,8 @@ extension VLCPlayerViewController {
lastPlayerTicks = newViewModel.item.userData?.playbackPositionTicks ?? 0
lastProgressReportTicks = newViewModel.item.userData?.playbackPositionTicks ?? 0
// TODO: Custom buffer/cache amounts
let media = VLCMedia(url: newViewModel.streamURL)
media.addOption("--prefetch-buffer-size=1048576")
media.addOption("--network-caching=5000")
@ -640,14 +656,11 @@ extension VLCPlayerViewController: PlayerOverlayDelegate {
}
}
// TODO: Implement properly in overlays
func didSelectMenu() {
stopOverlayDismissTimer()
}
// TODO: Implement properly in overlays
func didDeselectMenu() {
restartOverlayDismissTimer()
hideOverlay()
showOverlayContent()
}
func didSelectBackward() {

View File

@ -10,47 +10,244 @@
import JellyfinAPI
import SwiftUI
// TODO: Needs replacement/reworking
struct SmallMediaStreamSelectionView: View {
enum Layer: Hashable {
case subtitles
case audio
case playbackSpeed
}
enum MediaSection: Hashable {
case titles
case items
}
@ObservedObject var viewModel: VideoPlayerViewModel
let title: String
var items: [MediaStream]
var selectedAction: (MediaStream) -> Void
@State private var updateFocusedLayer: Layer = .subtitles
@FocusState private var subtitlesFocused: Bool
@FocusState private var audioFocused: Bool
@FocusState private var playbackSpeedFocused: Bool
@FocusState private var focusedSection: MediaSection?
@FocusState private var focusedLayer: Layer? {
willSet {
updateFocusedLayer = newValue!
if focusedSection == .titles {
lastFocusedLayer = newValue!
}
}
}
@State private var lastFocusedLayer: Layer = .subtitles
var body: some View {
ZStack(alignment: .bottom) {
LinearGradient(gradient: Gradient(colors: [.clear, .black.opacity(0.9)]),
LinearGradient(gradient: Gradient(colors: [.clear, .black.opacity(0.8), .black]),
startPoint: .top,
endPoint: .bottom)
.ignoresSafeArea()
.frame(height: 150)
.frame(height: 300)
VStack {
Spacer()
HStack {
// MARK: Subtitle Header
Button {
updateFocusedLayer = .subtitles
focusedLayer = .subtitles
} label: {
if updateFocusedLayer == .subtitles {
HStack(spacing: 15) {
Image(systemName: "captions.bubble")
Text("Subtitles")
}
.padding()
.background(Color.white)
.foregroundColor(.black)
} else {
HStack(spacing: 15) {
Image(systemName: "captions.bubble")
Text("Subtitles")
}
.padding()
}
}
.buttonStyle(PlainButtonStyle())
.background(Color.clear)
.focused($focusedLayer, equals: .subtitles)
.focused($subtitlesFocused)
.onChange(of: subtitlesFocused) { isFocused in
if isFocused {
focusedLayer = .subtitles
}
}
// MARK: Audio Header
Button {
updateFocusedLayer = .audio
focusedLayer = .audio
} label: {
if updateFocusedLayer == .audio {
HStack(spacing: 15) {
Image(systemName: "speaker.wave.3")
Text("Audio")
}
.padding()
.background(Color.white)
.foregroundColor(.black)
} else {
HStack(spacing: 15) {
Image(systemName: "speaker.wave.3")
Text("Audio")
}
.padding()
}
}
.buttonStyle(PlainButtonStyle())
.background(Color.clear)
.focused($focusedLayer, equals: .audio)
.focused($audioFocused)
.onChange(of: audioFocused) { isFocused in
if isFocused {
focusedLayer = .audio
}
}
// MARK: Playback Speed Header
Button {
updateFocusedLayer = .playbackSpeed
focusedLayer = .playbackSpeed
} label: {
if updateFocusedLayer == .playbackSpeed {
HStack(spacing: 15) {
Image(systemName: "speedometer")
Text("Playback Speed")
}
.padding()
.background(Color.white)
.foregroundColor(.black)
} else {
HStack(spacing: 15) {
Image(systemName: "speedometer")
Text("Playback Speed")
}
.padding()
}
}
.buttonStyle(PlainButtonStyle())
.background(Color.clear)
.focused($focusedLayer, equals: .playbackSpeed)
.focused($playbackSpeedFocused)
.onChange(of: playbackSpeedFocused) { isFocused in
if isFocused {
focusedLayer = .playbackSpeed
}
}
Spacer()
}
.padding()
.focusSection()
.focused($focusedSection, equals: .titles)
.onChange(of: focusedSection) { newSection in
if focusedSection == .titles {
if lastFocusedLayer == .subtitles {
subtitlesFocused = true
} else if lastFocusedLayer == .audio {
audioFocused = true
} else if lastFocusedLayer == .playbackSpeed {
playbackSpeedFocused = true
}
}
}
if updateFocusedLayer == .subtitles && lastFocusedLayer == .subtitles {
// MARK: Subtitles
ScrollView(.horizontal) {
HStack {
ForEach(items, id: \.self) { item in
if viewModel.subtitleStreams.isEmpty {
Button {
viewModel.playerOverlayDelegate?.didSelectSubtitleStream(index: item.index ?? -1)
} label: {
if item.index ?? -1 == viewModel.selectedSubtitleStreamIndex {
Label(item.displayTitle ?? "No Title", systemImage: "checkmark")
Text("None")
}
} else {
Text(item.displayTitle ?? "No Title")
ForEach(viewModel.subtitleStreams, id: \.self) { subtitleStream in
Button {
viewModel.selectedSubtitleStreamIndex = subtitleStream.index ?? -1
} label: {
if subtitleStream.index == viewModel.selectedSubtitleStreamIndex {
Label(subtitleStream.displayTitle ?? "No Title", systemImage: "checkmark")
} else {
Text(subtitleStream.displayTitle ?? "No Title")
}
}
}
}
}
.frame(maxHeight: 100)
.padding(.vertical)
.focusSection()
.focused($focusedSection, equals: .items)
}
} else if updateFocusedLayer == .audio && lastFocusedLayer == .audio {
// MARK: Audio
ScrollView(.horizontal) {
HStack {
if viewModel.audioStreams.isEmpty {
Button {
} label: {
Text("None")
}
} else {
ForEach(viewModel.audioStreams, id: \.self) { audioStream in
Button {
viewModel.selectedAudioStreamIndex = audioStream.index ?? -1
} label: {
if audioStream.index == viewModel.selectedAudioStreamIndex {
Label(audioStream.displayTitle ?? "No Title", systemImage: "checkmark")
} else {
Text(audioStream.displayTitle ?? "No Title")
}
}
}
}
}
.padding(.vertical)
.focusSection()
.focused($focusedSection, equals: .items)
}
} else if updateFocusedLayer == .playbackSpeed && lastFocusedLayer == .playbackSpeed {
// MARK: Rates
ScrollView(.horizontal) {
HStack {
ForEach(PlaybackSpeed.allCases, id: \.self) { playbackSpeed in
Button {
viewModel.playbackSpeed = playbackSpeed
} label: {
if playbackSpeed == viewModel.playbackSpeed {
Label(playbackSpeed.displayTitle, systemImage: "checkmark")
} else {
Text(playbackSpeed.displayTitle)
}
}
}
}
.padding(.vertical)
.focusSection()
.focused($focusedSection, equals: .items)
}
}
}
}
}

View File

@ -1,96 +0,0 @@
//
/*
* SwiftFin is subject to the terms of the Mozilla Public
* License, v2.0. If a copy of the MPL was not distributed with this
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
*
* Copyright 2021 Aiden Vigue & Jellyfin Contributors
*/
import JellyfinAPI
import SwiftUI
struct tvOSOverlayContentView: View {
@ObservedObject var viewModel: VideoPlayerViewModel
@FocusState private var focused: Bool
var body: some View {
VStack {
Spacer()
HStack {
HStack {
Button {
print("here")
} label: {
Text("About")
}
.buttonStyle(PlainButtonStyle())
.background(Color.clear)
Button {
print("here")
} label: {
Text("Chapters")
}
.buttonStyle(PlainButtonStyle())
.background(Color.clear)
Button {
print("here")
} label: {
Text("Subtitles")
}
.buttonStyle(PlainButtonStyle())
.background(Color.clear)
Button {
print("here")
} label: {
Text("Audio")
}
.buttonStyle(PlainButtonStyle())
.background(Color.clear)
}
.frame(height: 50)
Spacer()
}
.padding(.bottom)
Color.gray
.frame(height: 300)
}
}
}
struct tvOSOverlayContentView_Previews: PreviewProvider {
static let videoPlayerViewModel = VideoPlayerViewModel(item: BaseItemDto(),
title: "Glorious Purpose",
subtitle: "Loki - S1E1",
streamURL: URL(string: "www.apple.com")!,
hlsURL: URL(string: "www.apple.com")!,
response: PlaybackInfoResponse(),
audioStreams: [MediaStream(displayTitle: "English", index: -1)],
subtitleStreams: [MediaStream(displayTitle: "None", index: -1)],
selectedAudioStreamIndex: -1,
selectedSubtitleStreamIndex: -1,
subtitlesEnabled: true,
autoplayEnabled: false,
overlayType: .compact,
shouldShowPlayPreviousItem: true,
shouldShowPlayNextItem: true,
shouldShowAutoPlay: true)
static var previews: some View {
ZStack {
Color.red
.ignoresSafeArea()
tvOSOverlayContentView(viewModel: videoPlayerViewModel)
}
}
}

View File

@ -7,12 +7,14 @@
* Copyright 2021 Aiden Vigue & Jellyfin Contributors
*/
import Defaults
import JellyfinAPI
import SwiftUI
struct tvOSVLCOverlay: View {
@ObservedObject var viewModel: VideoPlayerViewModel
@Default(.downActionShowsMenu) var downActionShowsMenu
@ViewBuilder
private var mainButtonView: some View {
@ -29,7 +31,7 @@ struct tvOSVLCOverlay: View {
var body: some View {
ZStack(alignment: .bottom) {
LinearGradient(gradient: Gradient(colors: [.clear, .black.opacity(0.7), .black]),
LinearGradient(gradient: Gradient(colors: [.clear, .black.opacity(0.8), .black]),
startPoint: .top,
endPoint: .bottom)
.ignoresSafeArea()
@ -101,16 +103,12 @@ struct tvOSVLCOverlay: View {
}
}
if !downActionShowsMenu {
SFSymbolButton(systemName: "ellipsis.circle") {
viewModel.playerOverlayDelegate?.didSelectMenu()
}
.frame(maxWidth: 30, maxHeight: 30)
.contextMenu {
SFSymbolButton(systemName: "speedometer") {
print("here")
}
}
}
} }
.offset(x: 0, y: 10)
SliderView(viewModel: viewModel)

View File

@ -13,9 +13,11 @@ struct SliderView: UIViewRepresentable {
@ObservedObject var viewModel: VideoPlayerViewModel
// TODO: look at adjusting value dependent on item runtime
private let maxValue: Double = 1000
func updateUIView(_ uiView: TvOSSlider, context: Context) {
guard !viewModel.sliderIsScrubbing else { return }
uiView.value = Float(maxValue * viewModel.sliderPercentage)
}

View File

@ -457,6 +457,9 @@ public final class TvOSSlider: UIControl {
if !isFocused || abs(deceleratingVelocity) < 1 {
stopDeceleratingTimer()
}
viewModel.sliderPercentage = Double(percent)
viewModel.sliderIsScrubbing = false
}
private func stopDeceleratingTimer() {
@ -504,7 +507,6 @@ public final class TvOSSlider: UIControl {
viewModel.sliderPercentage = Double(percent)
case .ended, .cancelled:
viewModel.sliderIsScrubbing = false
thumbViewCenterXConstraintConstant = Float(thumbViewCenterXConstraint.constant)
@ -514,6 +516,7 @@ public final class TvOSSlider: UIControl {
deceleratingTimer = Timer.scheduledTimer(timeInterval: 0.01, target: self, selector: #selector(handleDeceleratingTimer(timer:)), userInfo: nil, repeats: true)
}
else {
viewModel.sliderIsScrubbing = false
stopDeceleratingTimer()
}
default:

View File

@ -294,7 +294,6 @@
E178859E2780F53B0094FBCF /* SliderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E178859D2780F53B0094FBCF /* SliderView.swift */; };
E17885A02780F55C0094FBCF /* tvOSVLCOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = E178859F2780F55C0094FBCF /* tvOSVLCOverlay.swift */; };
E17885A4278105170094FBCF /* SFSymbolButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E17885A3278105170094FBCF /* SFSymbolButton.swift */; };
E17885A6278130610094FBCF /* tvOSOverlayContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = E17885A5278130610094FBCF /* tvOSOverlayContent.swift */; };
E18845F526DD631E00B0C5B7 /* BaseItemDto+Stackable.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18845F426DD631E00B0C5B7 /* BaseItemDto+Stackable.swift */; };
E18845F626DD631E00B0C5B7 /* BaseItemDto+Stackable.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18845F426DD631E00B0C5B7 /* BaseItemDto+Stackable.swift */; };
E18845F826DEA9C900B0C5B7 /* ItemViewBody.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18845F726DEA9C900B0C5B7 /* ItemViewBody.swift */; };
@ -384,6 +383,8 @@
E1E5D5462783C28100692DFE /* CinematicItemAboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E5D5452783C28100692DFE /* CinematicItemAboutView.swift */; };
E1E5D5492783CDD700692DFE /* OverlaySettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E5D5472783CCF900692DFE /* OverlaySettingsView.swift */; };
E1E5D54C2783E27200692DFE /* ExperimentalSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E5D54B2783E27200692DFE /* ExperimentalSettingsView.swift */; };
E1E5D54F2783E67100692DFE /* OverlaySettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E5D54E2783E67100692DFE /* OverlaySettingsView.swift */; };
E1E5D5512783E67700692DFE /* ExperimentalSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E5D5502783E67700692DFE /* ExperimentalSettingsView.swift */; };
E1F0204E26CCCA74001C1C3B /* VideoPlayerJumpLength.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1F0204D26CCCA74001C1C3B /* VideoPlayerJumpLength.swift */; };
E1F0204F26CCCA74001C1C3B /* VideoPlayerJumpLength.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1F0204D26CCCA74001C1C3B /* VideoPlayerJumpLength.swift */; };
E1FA2F7427818A8800B4C270 /* SmallMenuOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1FA2F7327818A8800B4C270 /* SmallMenuOverlay.swift */; };
@ -615,7 +616,6 @@
E178859D2780F53B0094FBCF /* SliderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SliderView.swift; sourceTree = "<group>"; };
E178859F2780F55C0094FBCF /* tvOSVLCOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = tvOSVLCOverlay.swift; sourceTree = "<group>"; };
E17885A3278105170094FBCF /* SFSymbolButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SFSymbolButton.swift; sourceTree = "<group>"; };
E17885A5278130610094FBCF /* tvOSOverlayContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = tvOSOverlayContent.swift; sourceTree = "<group>"; };
E18845F426DD631E00B0C5B7 /* BaseItemDto+Stackable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BaseItemDto+Stackable.swift"; sourceTree = "<group>"; };
E18845F726DEA9C900B0C5B7 /* ItemViewBody.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemViewBody.swift; sourceTree = "<group>"; };
E18845FF26DECB9E00B0C5B7 /* ItemLandscapeTopBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemLandscapeTopBarView.swift; sourceTree = "<group>"; };
@ -668,6 +668,8 @@
E1E5D5452783C28100692DFE /* CinematicItemAboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CinematicItemAboutView.swift; sourceTree = "<group>"; };
E1E5D5472783CCF900692DFE /* OverlaySettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverlaySettingsView.swift; sourceTree = "<group>"; };
E1E5D54B2783E27200692DFE /* ExperimentalSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExperimentalSettingsView.swift; sourceTree = "<group>"; };
E1E5D54E2783E67100692DFE /* OverlaySettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverlaySettingsView.swift; sourceTree = "<group>"; };
E1E5D5502783E67700692DFE /* ExperimentalSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExperimentalSettingsView.swift; sourceTree = "<group>"; };
E1F0204D26CCCA74001C1C3B /* VideoPlayerJumpLength.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerJumpLength.swift; sourceTree = "<group>"; };
E1FA2F7327818A8800B4C270 /* SmallMenuOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SmallMenuOverlay.swift; sourceTree = "<group>"; };
E1FCD08726C35A0D007C8DCF /* NetworkError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkError.swift; sourceTree = "<group>"; };
@ -1255,7 +1257,7 @@
531690EE267ABF72005D8AB9 /* NextUpView.swift */,
E193D54F2719430400900D82 /* ServerDetailView.swift */,
E193D54A271941D300900D82 /* ServerListView.swift */,
5398514426B64DA100101B49 /* SettingsView.swift */,
E1E5D54D2783E66600692DFE /* SettingsView */,
E193D546271941C500900D82 /* UserListView.swift */,
E193D548271941CC00900D82 /* UserSignInView.swift */,
5310694F2684E7EE00CFFDBA /* VideoPlayer */,
@ -1331,7 +1333,6 @@
isa = PBXGroup;
children = (
E1FA2F7327818A8800B4C270 /* SmallMenuOverlay.swift */,
E17885A5278130610094FBCF /* tvOSOverlayContent.swift */,
E178859F2780F55C0094FBCF /* tvOSVLCOverlay.swift */,
);
path = tvOSOverlay;
@ -1460,6 +1461,16 @@
path = SettingsView;
sourceTree = "<group>";
};
E1E5D54D2783E66600692DFE /* SettingsView */ = {
isa = PBXGroup;
children = (
E1E5D5502783E67700692DFE /* ExperimentalSettingsView.swift */,
E1E5D54E2783E67100692DFE /* OverlaySettingsView.swift */,
5398514426B64DA100101B49 /* SettingsView.swift */,
);
path = SettingsView;
sourceTree = "<group>";
};
E1FCD08E26C466F3007C8DCF /* Errors */ = {
isa = PBXGroup;
children = (
@ -1916,7 +1927,6 @@
091B5A8E268315D400D78B61 /* UDPBroadCastConnection.swift in Sources */,
E10EAA50277BBCC4000269ED /* CGSizeExtensions.swift in Sources */,
E1E5D5422783B33900692DFE /* PortraitItemsRowView.swift in Sources */,
E17885A6278130610094FBCF /* tvOSOverlayContent.swift in Sources */,
E1FCD08926C35A0D007C8DCF /* NetworkError.swift in Sources */,
531690ED267ABF46005D8AB9 /* ContinueWatchingView.swift in Sources */,
E17885A4278105170094FBCF /* SFSymbolButton.swift in Sources */,
@ -1983,6 +1993,7 @@
C45B29BB26FAC5B600CEF5E0 /* ColorExtension.swift in Sources */,
E1D4BF822719D22800A11E64 /* AppAppearance.swift in Sources */,
C40CD923271F8CD8000FB198 /* MoviesLibrariesCoordinator.swift in Sources */,
E1E5D54F2783E67100692DFE /* OverlaySettingsView.swift in Sources */,
53272537268C1DBB0035FBF1 /* SeasonItemView.swift in Sources */,
09389CC526814E4500AE350E /* DeviceProfileBuilder.swift in Sources */,
E1C812D2277AE50A00918266 /* URLComponentsExtensions.swift in Sources */,
@ -2016,6 +2027,7 @@
531690FA267AD6EC005D8AB9 /* PlainNavigationLinkButton.swift in Sources */,
C4BE0764271FC0BB003F4AD1 /* TVLibrariesCoordinator.swift in Sources */,
E131691826C583BC0074BFEE /* LogConstructor.swift in Sources */,
E1E5D5512783E67700692DFE /* ExperimentalSettingsView.swift in Sources */,
C4BE076A271FC164003F4AD1 /* TVLibrariesView.swift in Sources */,
E13DD3C327164941009D4DAF /* SwiftfinStore.swift in Sources */,
09389CC826819B4600AE350E /* VideoPlayerModel.swift in Sources */,

View File

@ -56,4 +56,7 @@ extension Defaults.Keys {
struct Experimental {
static let syncSubtitleStateWithAdjacent = Key<Bool>("experimental.syncSubtitleState", default: false, suite: SwiftfinStore.Defaults.generalSuite)
}
// tvos specific
static let downActionShowsMenu = Key<Bool>("downActionShowsMenu", default: true, suite: SwiftfinStore.Defaults.generalSuite)
}

View File

@ -114,6 +114,11 @@ final class VideoPlayerViewModel: ViewModel {
// Necessary PassthroughSubject to capture manual scrubbing from sliders
let sliderScrubbingSubject = PassthroughSubject<VideoPlayerViewModel, Never>()
// During scrubbing, many progress reports were spammed
// Send only the current report after a delay
private var progressReportTimer: Timer?
private var lastProgressReport: PlaybackProgressInfo?
// MARK: init
init(item: BaseItemDto,
@ -304,6 +309,15 @@ extension VideoPlayerViewModel {
}
}
// MARK: Progress Report Timer
extension VideoPlayerViewModel {
private func sendNewProgressReportWithTimer() {
self.progressReportTimer?.invalidate()
self.progressReportTimer = Timer.scheduledTimer(timeInterval: 0.7, target: self, selector: #selector(_sendProgressReport), userInfo: nil, repeats: false)
}
}
// MARK: Updates
extension VideoPlayerViewModel {
@ -408,7 +422,15 @@ extension VideoPlayerViewModel {
nowPlayingQueue: nil,
playlistItemId: "playlistItem0")
PlaystateAPI.reportPlaybackProgress(playbackProgressInfo: progressInfo)
self.lastProgressReport = progressInfo
self.sendNewProgressReportWithTimer()
}
@objc private func _sendProgressReport() {
guard let lastProgressReport = lastProgressReport else { return }
PlaystateAPI.reportPlaybackProgress(playbackProgressInfo: lastProgressReport)
.sink { completion in
self.handleAPIRequestError(completion: completion)
} receiveValue: { _ in
@ -441,3 +463,10 @@ extension VideoPlayerViewModel {
.store(in: &cancellables)
}
}
// MARK: Embedded SubtitleStreamViewModel
extension VideoPlayerViewModel {
}

View File

@ -20,6 +20,7 @@ struct ImageView: View {
self.failureInitials = failureInitials
}
// TODO: fix placeholder hash image
@ViewBuilder
private var placeholderImage: some View {
Image(uiImage: UIImage(blurHash: blurhash, size: CGSize(width: 8, height: 8)) ?? UIImage(blurHash: "001fC^", size: CGSize(width: 8, height: 8))!)
@ -47,7 +48,12 @@ struct ImageView: View {
} else if phase.error != nil {
failureImage
} else {
placeholderImage
// TODO: remove once placeholder hash image fixed
ZStack {
Color.gray.ignoresSafeArea()
ProgressView()
}
}
}
}