mirror of
https://github.com/jellyfin/Swiftfin.git
synced 2024-12-02 19:16:27 +00:00
general VLCPlayer implementation
This commit is contained in:
parent
4c7490b5fa
commit
86e41c4f81
@ -18,9 +18,6 @@ struct MediaPlayButtonRowView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
HStack {
|
HStack {
|
||||||
VStack {
|
VStack {
|
||||||
// NavigationLink(destination: VideoPlayerView(item: viewModel.item).ignoresSafeArea()) {
|
|
||||||
// MediaViewActionButton(icon: "play.fill", scrollView: $wrappedScrollView)
|
|
||||||
// }
|
|
||||||
Button {
|
Button {
|
||||||
itemRouter.route(to: \.videoPlayer, viewModel.itemVideoPlayerViewModel!)
|
itemRouter.route(to: \.videoPlayer, viewModel.itemVideoPlayerViewModel!)
|
||||||
} label: {
|
} label: {
|
||||||
|
56
JellyfinPlayer tvOS/Components/SFSymbolButton.swift
Normal file
56
JellyfinPlayer tvOS/Components/SFSymbolButton.swift
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
//
|
||||||
|
/*
|
||||||
|
* 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 SwiftUI
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
struct SFSymbolButton: UIViewRepresentable {
|
||||||
|
|
||||||
|
let systemName: String
|
||||||
|
let action: () -> Void
|
||||||
|
private let pointSize: CGFloat
|
||||||
|
|
||||||
|
init(systemName: String, pointSize: CGFloat = 24, action: @escaping () -> Void) {
|
||||||
|
self.systemName = systemName
|
||||||
|
self.action = action
|
||||||
|
self.pointSize = pointSize
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeUIView(context: Context) -> some UIButton {
|
||||||
|
var configuration = UIButton.Configuration.plain()
|
||||||
|
configuration.cornerStyle = .capsule
|
||||||
|
|
||||||
|
let buttonAction = UIAction(title: "") { action in
|
||||||
|
self.action()
|
||||||
|
}
|
||||||
|
|
||||||
|
let button = UIButton(configuration: configuration, primaryAction: buttonAction)
|
||||||
|
|
||||||
|
let symbolImageConfig = UIImage.SymbolConfiguration(pointSize: pointSize)
|
||||||
|
let symbolImage = UIImage(systemName: systemName, withConfiguration: symbolImageConfig)
|
||||||
|
|
||||||
|
button.setImage(symbolImage, for: .normal)
|
||||||
|
|
||||||
|
return button
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIView(_ uiView: UIViewType, context: Context) {
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension SFSymbolButton: Hashable {
|
||||||
|
static func == (lhs: SFSymbolButton, rhs: SFSymbolButton) -> Bool {
|
||||||
|
return lhs.systemName == rhs.systemName
|
||||||
|
}
|
||||||
|
|
||||||
|
func hash(into hasher: inout Hasher) {
|
||||||
|
hasher.combine(systemName)
|
||||||
|
}
|
||||||
|
}
|
@ -17,38 +17,43 @@ struct HomeView: View {
|
|||||||
@State var showingSettings = false
|
@State var showingSettings = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ZStack {
|
||||||
if viewModel.isLoading {
|
Color.black
|
||||||
ProgressView()
|
.ignoresSafeArea()
|
||||||
} else {
|
|
||||||
LazyVStack(alignment: .leading) {
|
ScrollView {
|
||||||
if !viewModel.resumeItems.isEmpty {
|
if viewModel.isLoading {
|
||||||
ContinueWatchingView(items: viewModel.resumeItems)
|
ProgressView()
|
||||||
}
|
} else {
|
||||||
if !viewModel.nextUpItems.isEmpty {
|
LazyVStack(alignment: .leading) {
|
||||||
NextUpView(items: viewModel.nextUpItems)
|
if !viewModel.resumeItems.isEmpty {
|
||||||
}
|
ContinueWatchingView(items: viewModel.resumeItems)
|
||||||
|
}
|
||||||
|
if !viewModel.nextUpItems.isEmpty {
|
||||||
|
NextUpView(items: viewModel.nextUpItems)
|
||||||
|
}
|
||||||
|
|
||||||
if !viewModel.librariesShowRecentlyAddedIDs.isEmpty {
|
if !viewModel.librariesShowRecentlyAddedIDs.isEmpty {
|
||||||
ForEach(viewModel.librariesShowRecentlyAddedIDs, id: \.self) { libraryID in
|
ForEach(viewModel.librariesShowRecentlyAddedIDs, id: \.self) { libraryID in
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
let library = viewModel.libraries.first(where: { $0.id == libraryID })
|
let library = viewModel.libraries.first(where: { $0.id == libraryID })
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
self.homeRouter.route(to: \.modalLibrary, (.init(parentID: libraryID, filters: viewModel.recentFilterSet), title: library?.name ?? ""))
|
self.homeRouter.route(to: \.modalLibrary, (.init(parentID: libraryID, filters: viewModel.recentFilterSet), title: library?.name ?? ""))
|
||||||
} label: {
|
} label: {
|
||||||
HStack {
|
HStack {
|
||||||
Text(L10n.latestWithString(library?.name ?? ""))
|
Text(L10n.latestWithString(library?.name ?? ""))
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
.fontWeight(.semibold)
|
.fontWeight(.semibold)
|
||||||
Image(systemName: "chevron.forward.circle.fill")
|
Image(systemName: "chevron.forward.circle.fill")
|
||||||
}
|
}
|
||||||
}.padding(EdgeInsets(top: 0, leading: 90, bottom: 0, trailing: 0))
|
}.padding(EdgeInsets(top: 0, leading: 90, bottom: 0, trailing: 0))
|
||||||
LatestMediaView(usingParentID: libraryID)
|
LatestMediaView(usingParentID: libraryID)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Spacer().frame(height: 30)
|
||||||
}
|
}
|
||||||
Spacer().frame(height: 30)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -120,42 +120,8 @@ struct EpisodeItemView: View {
|
|||||||
.font(.body)
|
.font(.body)
|
||||||
.fontWeight(.medium)
|
.fontWeight(.medium)
|
||||||
.foregroundColor(.primary)
|
.foregroundColor(.primary)
|
||||||
|
|
||||||
HStack {
|
MediaPlayButtonRowView(viewModel: viewModel)
|
||||||
VStack {
|
|
||||||
Button {
|
|
||||||
viewModel.updateFavoriteState()
|
|
||||||
} label: {
|
|
||||||
MediaViewActionButton(icon: "heart.fill", iconColor: viewModel.isFavorited ? .red : .white)
|
|
||||||
}
|
|
||||||
Text(viewModel.isFavorited ? "Unfavorite" : "Favorite")
|
|
||||||
.font(.caption)
|
|
||||||
}
|
|
||||||
VStack {
|
|
||||||
// NavigationLink(destination: VideoPlayerView(item: viewModel.item).ignoresSafeArea()) {
|
|
||||||
// MediaViewActionButton(icon: "play.fill")
|
|
||||||
// }
|
|
||||||
Button {
|
|
||||||
itemRouter.route(to: \.videoPlayer, viewModel.itemVideoPlayerViewModel!)
|
|
||||||
} label: {
|
|
||||||
// MediaViewActionButton(icon: "play.fill", scrollView: $wrappedScrollView)
|
|
||||||
MediaViewActionButton(icon: "play.fill")
|
|
||||||
}
|
|
||||||
Text(viewModel.item.getItemProgressString() != "" ? "\(viewModel.item.getItemProgressString()) left" : L10n.play)
|
|
||||||
.font(.caption)
|
|
||||||
}
|
|
||||||
VStack {
|
|
||||||
Button {
|
|
||||||
viewModel.updateWatchState()
|
|
||||||
} label: {
|
|
||||||
MediaViewActionButton(icon: "eye.fill", iconColor: viewModel.isWatched ? .red : .white)
|
|
||||||
}
|
|
||||||
Text(viewModel.isWatched ? "Unwatch" : "Mark Watched")
|
|
||||||
.font(.caption)
|
|
||||||
}
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
.padding(.top, 15)
|
|
||||||
}
|
}
|
||||||
}.padding(.top, 50)
|
}.padding(.top, 50)
|
||||||
|
|
||||||
|
@ -21,59 +21,78 @@ struct SettingsView: View {
|
|||||||
@Default(.autoSelectAudioLangCode) var autoSelectAudioLangcode
|
@Default(.autoSelectAudioLangCode) var autoSelectAudioLangcode
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Form {
|
ZStack {
|
||||||
Section(header: L10n.playbackSettings.text) {
|
Color.black
|
||||||
Picker("Default local quality", selection: $inNetworkStreamBitrate) {
|
.ignoresSafeArea()
|
||||||
ForEach(self.viewModel.bitrates, id: \.self) { bitrate in
|
|
||||||
Text(bitrate.name).tag(bitrate.value)
|
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)
|
||||||
Picker("Default remote quality", selection: $outOfNetworkStreamBitrate) {
|
.padding(.trailing, 90)
|
||||||
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())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -27,4 +27,6 @@ protocol PlayerOverlayDelegate {
|
|||||||
|
|
||||||
func didSelectAudioStream(index: Int)
|
func didSelectAudioStream(index: Int)
|
||||||
func didSelectSubtitleStream(index: Int)
|
func didSelectSubtitleStream(index: Int)
|
||||||
|
|
||||||
|
func didFocusOnButton()
|
||||||
}
|
}
|
||||||
|
@ -37,8 +37,11 @@ class VLCPlayerViewController: UIViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var displayingOverlay: Bool {
|
private var displayingOverlay: Bool {
|
||||||
// return currentOverlayHostingController?.view.alpha ?? 0 > 0
|
return currentOverlayHostingController?.view.alpha ?? 0 > 0
|
||||||
return false
|
}
|
||||||
|
|
||||||
|
private var displayingContentOverlay: Bool {
|
||||||
|
return currentOverlayContentHostingController?.view.alpha ?? 0 > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
private var jumpForwardLength: VideoPlayerJumpLength {
|
private var jumpForwardLength: VideoPlayerJumpLength {
|
||||||
@ -50,8 +53,8 @@ class VLCPlayerViewController: UIViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private lazy var videoContentView = makeVideoContentView()
|
private lazy var videoContentView = makeVideoContentView()
|
||||||
private lazy var tapGestureView = makeTapGestureView()
|
private var currentOverlayHostingController: UIHostingController<tvOSVLCOverlay>?
|
||||||
// private var currentOverlayHostingController: UIHostingController<VLCPlayerCompactOverlayView>?
|
private var currentOverlayContentHostingController: UIHostingController<tvOSOverlayContentView>?
|
||||||
|
|
||||||
// MARK: init
|
// MARK: init
|
||||||
|
|
||||||
@ -70,7 +73,6 @@ class VLCPlayerViewController: UIViewController {
|
|||||||
|
|
||||||
private func setupSubviews() {
|
private func setupSubviews() {
|
||||||
view.addSubview(videoContentView)
|
view.addSubview(videoContentView)
|
||||||
view.addSubview(tapGestureView)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setupConstraints() {
|
private func setupConstraints() {
|
||||||
@ -80,12 +82,6 @@ class VLCPlayerViewController: UIViewController {
|
|||||||
videoContentView.leftAnchor.constraint(equalTo: view.leftAnchor),
|
videoContentView.leftAnchor.constraint(equalTo: view.leftAnchor),
|
||||||
videoContentView.rightAnchor.constraint(equalTo: view.rightAnchor)
|
videoContentView.rightAnchor.constraint(equalTo: view.rightAnchor)
|
||||||
])
|
])
|
||||||
NSLayoutConstraint.activate([
|
|
||||||
tapGestureView.topAnchor.constraint(equalTo: videoContentView.topAnchor),
|
|
||||||
tapGestureView.bottomAnchor.constraint(equalTo: videoContentView.bottomAnchor),
|
|
||||||
tapGestureView.leftAnchor.constraint(equalTo: videoContentView.leftAnchor),
|
|
||||||
tapGestureView.rightAnchor.constraint(equalTo: videoContentView.rightAnchor)
|
|
||||||
])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: viewWillDisappear
|
// MARK: viewWillDisappear
|
||||||
@ -115,12 +111,13 @@ class VLCPlayerViewController: UIViewController {
|
|||||||
// they aren't unnecessarily set more than once
|
// they aren't unnecessarily set more than once
|
||||||
vlcMediaPlayer.delegate = self
|
vlcMediaPlayer.delegate = self
|
||||||
vlcMediaPlayer.drawable = videoContentView
|
vlcMediaPlayer.drawable = videoContentView
|
||||||
vlcMediaPlayer.perform(Selector(("setTextRendererFontSize:")), with: 14)
|
vlcMediaPlayer.perform(Selector(("setTextRendererFontSize:")), with: 16)
|
||||||
|
|
||||||
setupMediaPlayer(newViewModel: viewModel)
|
setupMediaPlayer(newViewModel: viewModel)
|
||||||
|
|
||||||
setupRightSwipedGestureRecognizer()
|
setupRightSwipedGestureRecognizer()
|
||||||
setupLeftSwipedGestureRecognizer()
|
setupLeftSwipedGestureRecognizer()
|
||||||
|
setupPanGestureRecognizer()
|
||||||
|
|
||||||
let defaultNotificationCenter = NotificationCenter.default
|
let defaultNotificationCenter = NotificationCenter.default
|
||||||
defaultNotificationCenter.addObserver(self, selector: #selector(appWillTerminate), name: UIApplication.willTerminateNotification, object: nil)
|
defaultNotificationCenter.addObserver(self, selector: #selector(appWillTerminate), name: UIApplication.willTerminateNotification, object: nil)
|
||||||
@ -158,35 +155,6 @@ class VLCPlayerViewController: UIViewController {
|
|||||||
return view
|
return view
|
||||||
}
|
}
|
||||||
|
|
||||||
private func makeTapGestureView() -> UIView {
|
|
||||||
let view = UIView()
|
|
||||||
view.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
|
|
||||||
let singleTapGesture = UITapGestureRecognizer(target: self, action: #selector(didTap))
|
|
||||||
let rightSwipeGesture = UISwipeGestureRecognizer(target: self, action: #selector(didRightSwipe))
|
|
||||||
rightSwipeGesture.direction = .right
|
|
||||||
let leftSwipeGesture = UISwipeGestureRecognizer(target: self, action: #selector(didLeftSwipe))
|
|
||||||
leftSwipeGesture.direction = .left
|
|
||||||
|
|
||||||
view.addGestureRecognizer(singleTapGesture)
|
|
||||||
view.addGestureRecognizer(rightSwipeGesture)
|
|
||||||
view.addGestureRecognizer(leftSwipeGesture)
|
|
||||||
|
|
||||||
return view
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc private func didTap() {
|
|
||||||
self.didGenerallyTap()
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc private func didRightSwipe() {
|
|
||||||
self.didSelectForward()
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc private func didLeftSwipe() {
|
|
||||||
self.didSelectBackward()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: pressesBegan
|
// MARK: pressesBegan
|
||||||
override func pressesBegan(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
|
override func pressesBegan(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
|
||||||
guard let buttonPress = presses.first?.type else { return }
|
guard let buttonPress = presses.first?.type else { return }
|
||||||
@ -196,17 +164,17 @@ class VLCPlayerViewController: UIViewController {
|
|||||||
print("Menu")
|
print("Menu")
|
||||||
case .playPause:
|
case .playPause:
|
||||||
didSelectMain()
|
didSelectMain()
|
||||||
print("Play/Pause")
|
|
||||||
case .select:
|
case .select:
|
||||||
print("select")
|
didGenerallyTap()
|
||||||
case .upArrow:
|
case .upArrow:
|
||||||
print("Up arrow")
|
print("Up arrow")
|
||||||
case .downArrow:
|
case .downArrow:
|
||||||
print("Down arrow")
|
print("Down arrow")
|
||||||
case .leftArrow:
|
case .leftArrow:
|
||||||
|
didSelectBackward()
|
||||||
print("Left arrow")
|
print("Left arrow")
|
||||||
case .rightArrow:
|
case .rightArrow:
|
||||||
print("right arrow")
|
didSelectForward()
|
||||||
case .pageUp:
|
case .pageUp:
|
||||||
print("page up")
|
print("page up")
|
||||||
case .pageDown:
|
case .pageDown:
|
||||||
@ -215,73 +183,115 @@ class VLCPlayerViewController: UIViewController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func setupRightSwipedGestureRecognizer() {
|
private func setupRightSwipedGestureRecognizer() {
|
||||||
let swipeRecognizer = UISwipeGestureRecognizer(target: self, action: #selector(swipedRight))
|
let swipeRecognizer = UISwipeGestureRecognizer(target: self, action: #selector(swipedRight))
|
||||||
swipeRecognizer.direction = .right
|
swipeRecognizer.direction = .right
|
||||||
view.addGestureRecognizer(swipeRecognizer)
|
view.addGestureRecognizer(swipeRecognizer)
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func swipedRight() {
|
@objc private func swipedRight() {
|
||||||
didSelectForward()
|
didSelectForward()
|
||||||
}
|
}
|
||||||
|
|
||||||
func setupLeftSwipedGestureRecognizer() {
|
private func setupLeftSwipedGestureRecognizer() {
|
||||||
let swipeRecognizer = UISwipeGestureRecognizer(target: self, action: #selector(swipedLeft))
|
let swipeRecognizer = UISwipeGestureRecognizer(target: self, action: #selector(swipedLeft))
|
||||||
swipeRecognizer.direction = .left
|
swipeRecognizer.direction = .left
|
||||||
view.addGestureRecognizer(swipeRecognizer)
|
view.addGestureRecognizer(swipeRecognizer)
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func swipedLeft() {
|
@objc private func swipedLeft() {
|
||||||
didSelectBackward()
|
didSelectBackward()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func setupPanGestureRecognizer() {
|
||||||
|
let panGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(userPanned(panGestureRecognizer:)))
|
||||||
|
view.addGestureRecognizer(panGestureRecognizer)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func userPanned(panGestureRecognizer: UIPanGestureRecognizer) {
|
||||||
|
if displayingOverlay {
|
||||||
|
restartOverlayDismissTimer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: setupOverlayHostingController
|
// MARK: setupOverlayHostingController
|
||||||
private func setupOverlayHostingController(viewModel: VideoPlayerViewModel) {
|
private func setupOverlayHostingController(viewModel: VideoPlayerViewModel) {
|
||||||
|
|
||||||
// // TODO: Look at injecting viewModel into the environment so it updates the current overlay
|
// TODO: Look at injecting viewModel into the environment so it updates the current overlay
|
||||||
// if let currentOverlayHostingController = currentOverlayHostingController {
|
|
||||||
// // UX fade-out
|
// Overlay
|
||||||
// UIView.animate(withDuration: 0.5) {
|
if let currentOverlayHostingController = currentOverlayHostingController {
|
||||||
// currentOverlayHostingController.view.alpha = 0
|
// UX fade-out
|
||||||
// } completion: { _ in
|
UIView.animate(withDuration: 0.5) {
|
||||||
// currentOverlayHostingController.view.isHidden = true
|
currentOverlayHostingController.view.alpha = 0
|
||||||
//
|
} completion: { _ in
|
||||||
// currentOverlayHostingController.view.removeFromSuperview()
|
currentOverlayHostingController.view.isHidden = true
|
||||||
// currentOverlayHostingController.removeFromParent()
|
|
||||||
//// self.currentOverlayHostingController = nil
|
currentOverlayHostingController.view.removeFromSuperview()
|
||||||
// }
|
currentOverlayHostingController.removeFromParent()
|
||||||
// }
|
// self.currentOverlayHostingController = nil
|
||||||
//
|
}
|
||||||
// let newOverlayView = VLCPlayerCompactOverlayView(viewModel: viewModel)
|
}
|
||||||
// let newOverlayHostingController = UIHostingController(rootView: newOverlayView)
|
|
||||||
//
|
let newOverlayView = tvOSVLCOverlay(viewModel: viewModel)
|
||||||
// newOverlayHostingController.view.translatesAutoresizingMaskIntoConstraints = false
|
let newOverlayHostingController = UIHostingController(rootView: newOverlayView)
|
||||||
// newOverlayHostingController.view.backgroundColor = UIColor.clear
|
|
||||||
//
|
newOverlayHostingController.view.translatesAutoresizingMaskIntoConstraints = false
|
||||||
// // UX fade-in
|
newOverlayHostingController.view.backgroundColor = UIColor.clear
|
||||||
// newOverlayHostingController.view.alpha = 0
|
|
||||||
//
|
// UX fade-in
|
||||||
// addChild(newOverlayHostingController)
|
newOverlayHostingController.view.alpha = 0
|
||||||
// view.addSubview(newOverlayHostingController.view)
|
|
||||||
// newOverlayHostingController.didMove(toParent: self)
|
addChild(newOverlayHostingController)
|
||||||
//
|
view.addSubview(newOverlayHostingController.view)
|
||||||
// NSLayoutConstraint.activate([
|
newOverlayHostingController.didMove(toParent: self)
|
||||||
// newOverlayHostingController.view.topAnchor.constraint(equalTo: videoContentView.topAnchor),
|
|
||||||
// newOverlayHostingController.view.bottomAnchor.constraint(equalTo: videoContentView.bottomAnchor),
|
NSLayoutConstraint.activate([
|
||||||
// newOverlayHostingController.view.leftAnchor.constraint(equalTo: videoContentView.leftAnchor),
|
newOverlayHostingController.view.topAnchor.constraint(equalTo: videoContentView.topAnchor),
|
||||||
// newOverlayHostingController.view.rightAnchor.constraint(equalTo: videoContentView.rightAnchor)
|
newOverlayHostingController.view.bottomAnchor.constraint(equalTo: videoContentView.bottomAnchor),
|
||||||
// ])
|
newOverlayHostingController.view.leftAnchor.constraint(equalTo: videoContentView.leftAnchor),
|
||||||
//
|
newOverlayHostingController.view.rightAnchor.constraint(equalTo: videoContentView.rightAnchor)
|
||||||
// // UX fade-in
|
])
|
||||||
// UIView.animate(withDuration: 0.5) {
|
|
||||||
// newOverlayHostingController.view.alpha = 1
|
// UX fade-in
|
||||||
// }
|
UIView.animate(withDuration: 0.5) {
|
||||||
//
|
newOverlayHostingController.view.alpha = 1
|
||||||
// self.currentOverlayHostingController = newOverlayHostingController
|
}
|
||||||
//
|
|
||||||
// // There is a behavior when setting this that the navigation bar
|
self.currentOverlayHostingController = newOverlayHostingController
|
||||||
// // on the current navigation controller pops up, re-hide it
|
|
||||||
// self.navigationController?.isNavigationBarHidden = true
|
// OverlayContent
|
||||||
|
if let currentOverlayContentHostingController = currentOverlayContentHostingController {
|
||||||
|
currentOverlayContentHostingController.view.isHidden = true
|
||||||
|
|
||||||
|
currentOverlayContentHostingController.view.removeFromSuperview()
|
||||||
|
currentOverlayContentHostingController.removeFromParent()
|
||||||
|
}
|
||||||
|
|
||||||
|
let newOverlayContentView = tvOSOverlayContentView(viewModel: viewModel)
|
||||||
|
let newOverlayContentHostingController = UIHostingController(rootView: newOverlayContentView)
|
||||||
|
|
||||||
|
newOverlayContentHostingController.view.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
newOverlayContentHostingController.view.backgroundColor = UIColor.clear
|
||||||
|
|
||||||
|
newOverlayContentHostingController.view.alpha = 0
|
||||||
|
|
||||||
|
addChild(newOverlayContentHostingController)
|
||||||
|
view.addSubview(newOverlayContentHostingController.view)
|
||||||
|
newOverlayContentHostingController.didMove(toParent: self)
|
||||||
|
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
newOverlayContentHostingController.view.topAnchor.constraint(equalTo: videoContentView.topAnchor),
|
||||||
|
newOverlayContentHostingController.view.bottomAnchor.constraint(equalTo: videoContentView.bottomAnchor),
|
||||||
|
newOverlayContentHostingController.view.leftAnchor.constraint(equalTo: videoContentView.leftAnchor),
|
||||||
|
newOverlayContentHostingController.view.rightAnchor.constraint(equalTo: videoContentView.rightAnchor)
|
||||||
|
])
|
||||||
|
|
||||||
|
self.currentOverlayContentHostingController = newOverlayContentHostingController
|
||||||
|
|
||||||
|
// There is a behavior when setting this that the navigation bar
|
||||||
|
// on the current navigation controller pops up, re-hide it
|
||||||
|
self.navigationController?.isNavigationBarHidden = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -337,7 +347,7 @@ extension VLCPlayerViewController {
|
|||||||
|
|
||||||
viewModel.sendPlayReport()
|
viewModel.sendPlayReport()
|
||||||
|
|
||||||
restartOverlayDismissTimer()
|
restartOverlayDismissTimer(interval: 5)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: setupViewModelListeners
|
// MARK: setupViewModelListeners
|
||||||
@ -384,40 +394,58 @@ extension VLCPlayerViewController {
|
|||||||
extension VLCPlayerViewController {
|
extension VLCPlayerViewController {
|
||||||
|
|
||||||
private func showOverlay() {
|
private func showOverlay() {
|
||||||
// guard let overlayHostingController = currentOverlayHostingController else { return }
|
guard let overlayHostingController = currentOverlayHostingController else { return }
|
||||||
//
|
|
||||||
// guard overlayHostingController.view.alpha != 1 else { return }
|
guard overlayHostingController.view.alpha != 1 else { return }
|
||||||
//
|
|
||||||
// UIView.animate(withDuration: 0.2) {
|
UIView.animate(withDuration: 0.2) {
|
||||||
// overlayHostingController.view.alpha = 1
|
overlayHostingController.view.alpha = 1
|
||||||
// }
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func hideOverlay() {
|
private func hideOverlay() {
|
||||||
// guard let overlayHostingController = currentOverlayHostingController else { return }
|
guard let overlayHostingController = currentOverlayHostingController else { return }
|
||||||
//
|
|
||||||
// guard overlayHostingController.view.alpha != 0 else { return }
|
guard overlayHostingController.view.alpha != 0 else { return }
|
||||||
//
|
|
||||||
// UIView.animate(withDuration: 0.2) {
|
UIView.animate(withDuration: 0.2) {
|
||||||
// overlayHostingController.view.alpha = 0
|
overlayHostingController.view.alpha = 0
|
||||||
// }
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func toggleOverlay() {
|
private func toggleOverlay() {
|
||||||
// guard let overlayHostingController = currentOverlayHostingController else { return }
|
if displayingOverlay {
|
||||||
//
|
hideOverlay()
|
||||||
// if overlayHostingController.view.alpha < 1 {
|
} else {
|
||||||
// showOverlay()
|
showOverlay()
|
||||||
// } else {
|
}
|
||||||
// hideOverlay()
|
}
|
||||||
// }
|
|
||||||
|
private func showOverlayContent() {
|
||||||
|
guard let currentOverlayContentHostingController = currentOverlayContentHostingController else { return }
|
||||||
|
|
||||||
|
guard currentOverlayContentHostingController.view.alpha != 1 else { return }
|
||||||
|
|
||||||
|
UIView.animate(withDuration: 0.2) {
|
||||||
|
currentOverlayContentHostingController.view.alpha = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func hideOverlayContent() {
|
||||||
|
guard let currentOverlayContentHostingController = currentOverlayContentHostingController else { return }
|
||||||
|
|
||||||
|
guard currentOverlayContentHostingController.view.alpha != 0 else { return }
|
||||||
|
|
||||||
|
UIView.animate(withDuration: 0.2) {
|
||||||
|
currentOverlayContentHostingController.view.alpha = 0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: OverlayTimer
|
// MARK: OverlayTimer
|
||||||
extension VLCPlayerViewController {
|
extension VLCPlayerViewController {
|
||||||
|
|
||||||
private func restartOverlayDismissTimer(interval: Double = 3) {
|
private func restartOverlayDismissTimer(interval: Double = 5) {
|
||||||
self.overlayDismissTimer?.invalidate()
|
self.overlayDismissTimer?.invalidate()
|
||||||
self.overlayDismissTimer = Timer.scheduledTimer(timeInterval: interval, target: self, selector: #selector(dismissTimerFired), userInfo: nil, repeats: false)
|
self.overlayDismissTimer = Timer.scheduledTimer(timeInterval: interval, target: self, selector: #selector(dismissTimerFired), userInfo: nil, repeats: false)
|
||||||
}
|
}
|
||||||
@ -534,12 +562,15 @@ extension VLCPlayerViewController: PlayerOverlayDelegate {
|
|||||||
|
|
||||||
// TODO: Implement properly in overlays
|
// TODO: Implement properly in overlays
|
||||||
func didSelectMenu() {
|
func didSelectMenu() {
|
||||||
stopOverlayDismissTimer()
|
// stopOverlayDismissTimer()
|
||||||
|
//
|
||||||
|
// hideOverlay()
|
||||||
|
// showOverlayContent()
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Implement properly in overlays
|
// TODO: Implement properly in overlays
|
||||||
func didDeselectMenu() {
|
func didDeselectMenu() {
|
||||||
restartOverlayDismissTimer()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func didSelectBackward() {
|
func didSelectBackward() {
|
||||||
@ -571,7 +602,8 @@ extension VLCPlayerViewController: PlayerOverlayDelegate {
|
|||||||
case .playing:
|
case .playing:
|
||||||
viewModel.sendPauseReport(paused: true)
|
viewModel.sendPauseReport(paused: true)
|
||||||
vlcMediaPlayer.pause()
|
vlcMediaPlayer.pause()
|
||||||
restartOverlayDismissTimer(interval: 5)
|
showOverlay()
|
||||||
|
restartOverlayDismissTimer(interval: 10)
|
||||||
case .paused:
|
case .paused:
|
||||||
viewModel.sendPauseReport(paused: false)
|
viewModel.sendPauseReport(paused: false)
|
||||||
vlcMediaPlayer.play()
|
vlcMediaPlayer.play()
|
||||||
@ -609,4 +641,8 @@ extension VLCPlayerViewController: PlayerOverlayDelegate {
|
|||||||
setupMediaPlayer(newViewModel: viewModel.nextItemVideoPlayerViewModel!)
|
setupMediaPlayer(newViewModel: viewModel.nextItemVideoPlayerViewModel!)
|
||||||
startPlayback()
|
startPlayback()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func didFocusOnButton() {
|
||||||
|
restartOverlayDismissTimer(interval: 8)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,89 @@
|
|||||||
|
//
|
||||||
|
/*
|
||||||
|
* 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")
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
print("here")
|
||||||
|
} label: {
|
||||||
|
Text("Chapters")
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
print("here")
|
||||||
|
} label: {
|
||||||
|
Text("Subtitles")
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
print("here")
|
||||||
|
} label: {
|
||||||
|
Text("Audio")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: 50)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.bottom)
|
||||||
|
|
||||||
|
Color.gray
|
||||||
|
.frame(height: 300)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct tvOSOverlayContentView_Previews: PreviewProvider {
|
||||||
|
static var previews: some View {
|
||||||
|
ZStack {
|
||||||
|
Color.red
|
||||||
|
.ignoresSafeArea()
|
||||||
|
|
||||||
|
tvOSOverlayContentView(viewModel: VideoPlayerViewModel(item: BaseItemDto(runTimeTicks: 720 * 10_000_000),
|
||||||
|
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)],
|
||||||
|
defaultAudioStreamIndex: -1,
|
||||||
|
defaultSubtitleStreamIndex: -1,
|
||||||
|
playerState: .error,
|
||||||
|
shouldShowGoogleCast: false,
|
||||||
|
shouldShowAirplay: false,
|
||||||
|
subtitlesEnabled: true,
|
||||||
|
sliderPercentage: 0.432,
|
||||||
|
selectedAudioStreamIndex: -1,
|
||||||
|
selectedSubtitleStreamIndex: -1,
|
||||||
|
showAdjacentItems: true,
|
||||||
|
shouldShowAutoPlayNextItem: true,
|
||||||
|
autoPlayNextItem: true))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,134 @@
|
|||||||
|
//
|
||||||
|
/*
|
||||||
|
* 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 tvOSVLCOverlay: View {
|
||||||
|
|
||||||
|
@ObservedObject var viewModel: VideoPlayerViewModel
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var mainButtonView: some View {
|
||||||
|
switch viewModel.playerState {
|
||||||
|
case .stopped, .paused:
|
||||||
|
Image(systemName: "play.circle")
|
||||||
|
case .playing:
|
||||||
|
Image(systemName: "pause.circle")
|
||||||
|
default:
|
||||||
|
ProgressView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack(alignment: .bottom) {
|
||||||
|
|
||||||
|
LinearGradient(gradient: Gradient(colors: [.clear, .black.opacity(0.7), .black]),
|
||||||
|
startPoint: .top,
|
||||||
|
endPoint: .bottom)
|
||||||
|
.ignoresSafeArea()
|
||||||
|
.frame(height: viewModel.subtitle == nil ? 180 : 210)
|
||||||
|
|
||||||
|
VStack {
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
HStack(alignment: .bottom) {
|
||||||
|
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
if let subtitle = viewModel.subtitle {
|
||||||
|
Text(subtitle)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.secondarySystemFill)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(viewModel.title)
|
||||||
|
.font(.title3)
|
||||||
|
.fontWeight(.bold)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if viewModel.subtitlesEnabled {
|
||||||
|
SFSymbolButton(systemName: "captions.bubble.fill") {
|
||||||
|
viewModel.playerOverlayDelegate?.didSelectCaptions()
|
||||||
|
}
|
||||||
|
.frame(maxWidth: 30, maxHeight: 30)
|
||||||
|
} else {
|
||||||
|
SFSymbolButton(systemName: "captions.bubble") {
|
||||||
|
viewModel.playerOverlayDelegate?.didSelectCaptions()
|
||||||
|
}
|
||||||
|
.frame(maxWidth: 30, maxHeight: 30)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
.frame(maxHeight: 40)
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
mainButtonView
|
||||||
|
.frame(maxWidth: 40, maxHeight: 40)
|
||||||
|
|
||||||
|
Text(viewModel.leftLabelText)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Text(viewModel.rightLabelText)
|
||||||
|
}
|
||||||
|
.offset(x: 0, y: -10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.foregroundColor(.white)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct tvOSVLCOverlay_Previews: PreviewProvider {
|
||||||
|
static var previews: some View {
|
||||||
|
ZStack {
|
||||||
|
Color.red
|
||||||
|
.ignoresSafeArea()
|
||||||
|
|
||||||
|
tvOSVLCOverlay(viewModel: VideoPlayerViewModel(item: BaseItemDto(runTimeTicks: 720 * 10_000_000),
|
||||||
|
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)],
|
||||||
|
defaultAudioStreamIndex: -1,
|
||||||
|
defaultSubtitleStreamIndex: -1,
|
||||||
|
playerState: .error,
|
||||||
|
shouldShowGoogleCast: false,
|
||||||
|
shouldShowAirplay: false,
|
||||||
|
subtitlesEnabled: true,
|
||||||
|
sliderPercentage: 0.432,
|
||||||
|
selectedAudioStreamIndex: -1,
|
||||||
|
selectedSubtitleStreamIndex: -1,
|
||||||
|
showAdjacentItems: true,
|
||||||
|
shouldShowAutoPlayNextItem: true,
|
||||||
|
autoPlayNextItem: true))
|
||||||
|
}
|
||||||
|
.previewInterfaceOrientation(.landscapeLeft)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,36 @@
|
|||||||
|
//
|
||||||
|
/*
|
||||||
|
* 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 SwiftUI
|
||||||
|
|
||||||
|
struct SliderView: UIViewRepresentable {
|
||||||
|
|
||||||
|
@ObservedObject var viewModel: VideoPlayerViewModel
|
||||||
|
|
||||||
|
private let maxValue: Double = 1000
|
||||||
|
|
||||||
|
func updateUIView(_ uiView: TvOSSlider, context: Context) {
|
||||||
|
uiView.value = Float(maxValue * viewModel.sliderPercentage)
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeUIView(context: Context) -> TvOSSlider {
|
||||||
|
let slider = TvOSSlider(viewModel: viewModel)
|
||||||
|
|
||||||
|
slider.minimumValue = 0
|
||||||
|
slider.maximumValue = Float(maxValue)
|
||||||
|
slider.value = Float(maxValue * viewModel.sliderPercentage)
|
||||||
|
slider.thumbSize = 25
|
||||||
|
slider.thumbTintColor = .white
|
||||||
|
slider.minimumTrackTintColor = .white
|
||||||
|
slider.focusScaleFactor = 1.4
|
||||||
|
slider.panDampingValue = 50
|
||||||
|
|
||||||
|
return slider
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,552 @@
|
|||||||
|
//
|
||||||
|
/*
|
||||||
|
* 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
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Modification of https://github.com/zattoo/TvOSSlider
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import GameController
|
||||||
|
|
||||||
|
enum DPadState {
|
||||||
|
case select
|
||||||
|
case right
|
||||||
|
case left
|
||||||
|
case up
|
||||||
|
case down
|
||||||
|
}
|
||||||
|
|
||||||
|
private let trackViewHeight: CGFloat = 5
|
||||||
|
private let animationDuration: TimeInterval = 0.3
|
||||||
|
private let defaultValue: Float = 0
|
||||||
|
private let defaultMinimumValue: Float = 0
|
||||||
|
private let defaultMaximumValue: Float = 1
|
||||||
|
private let defaultIsContinuous: Bool = true
|
||||||
|
private let defaultThumbTintColor: UIColor = .white
|
||||||
|
private let defaultTrackColor: UIColor = .gray
|
||||||
|
private let defaultMininumTrackTintColor: UIColor = .blue
|
||||||
|
private let defaultFocusScaleFactor: CGFloat = 1.05
|
||||||
|
private let defaultStepValue: Float = 0.1
|
||||||
|
private let decelerationRate: Float = 0.92
|
||||||
|
private let decelerationMaxVelocity: Float = 1000
|
||||||
|
private let fineTunningVelocityThreshold: Float = 600
|
||||||
|
|
||||||
|
/// A control used to select a single value from a continuous range of values.
|
||||||
|
public final class TvOSSlider: UIControl {
|
||||||
|
|
||||||
|
// MARK: - Public
|
||||||
|
|
||||||
|
/// The slider’s current value.
|
||||||
|
@IBInspectable
|
||||||
|
public var value: Float {
|
||||||
|
get {
|
||||||
|
return storedValue
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
storedValue = min(maximumValue, newValue)
|
||||||
|
storedValue = max(minimumValue, storedValue)
|
||||||
|
|
||||||
|
var offset = trackView.bounds.width * CGFloat((storedValue - minimumValue) / (maximumValue - minimumValue))
|
||||||
|
offset = min(trackView.bounds.width, offset)
|
||||||
|
thumbViewCenterXConstraint.constant = offset
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The minimum value of the slider.
|
||||||
|
@IBInspectable
|
||||||
|
public var minimumValue: Float = defaultMinimumValue {
|
||||||
|
didSet {
|
||||||
|
value = max(value, minimumValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The maximum value of the slider.
|
||||||
|
@IBInspectable
|
||||||
|
public var maximumValue: Float = defaultMaximumValue {
|
||||||
|
didSet {
|
||||||
|
value = min(value, maximumValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A Boolean value indicating whether changes in the slider’s value generate continuous update events.
|
||||||
|
@IBInspectable
|
||||||
|
public var isContinuous: Bool = defaultIsContinuous
|
||||||
|
|
||||||
|
/// The color used to tint the default minimum track images.
|
||||||
|
@IBInspectable
|
||||||
|
public var minimumTrackTintColor: UIColor? = defaultMininumTrackTintColor {
|
||||||
|
didSet {
|
||||||
|
minimumTrackView.backgroundColor = minimumTrackTintColor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The color used to tint the default maximum track images.
|
||||||
|
@IBInspectable
|
||||||
|
public var maximumTrackTintColor: UIColor? {
|
||||||
|
didSet {
|
||||||
|
maximumTrackView.backgroundColor = maximumTrackTintColor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The color used to tint the default thumb images.
|
||||||
|
@IBInspectable
|
||||||
|
public var thumbTintColor: UIColor = defaultThumbTintColor {
|
||||||
|
didSet {
|
||||||
|
thumbView.backgroundColor = thumbTintColor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scale factor applied to the slider when receiving the focus
|
||||||
|
@IBInspectable
|
||||||
|
public var focusScaleFactor: CGFloat = defaultFocusScaleFactor {
|
||||||
|
didSet {
|
||||||
|
updateStateDependantViews()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Value added or subtracted from the current value on steps left or right updates
|
||||||
|
public var stepValue: Float = defaultStepValue
|
||||||
|
|
||||||
|
/// Damping value for panning gestures
|
||||||
|
public var panDampingValue: Float = 5
|
||||||
|
|
||||||
|
// Size for thumb view
|
||||||
|
public var thumbSize: CGFloat = 30
|
||||||
|
|
||||||
|
/**
|
||||||
|
Sets the slider’s current value, allowing you to animate the change visually.
|
||||||
|
|
||||||
|
- Parameters:
|
||||||
|
- value: The new value to assign to the value property
|
||||||
|
- animated: Specify true to animate the change in value; otherwise, specify false to update the slider’s appearance immediately. Animations are performed asynchronously and do not block the calling thread.
|
||||||
|
*/
|
||||||
|
public func setValue(_ value: Float, animated: Bool) {
|
||||||
|
self.value = value
|
||||||
|
stopDeceleratingTimer()
|
||||||
|
|
||||||
|
if animated {
|
||||||
|
UIView.animate(withDuration: animationDuration) {
|
||||||
|
self.setNeedsLayout()
|
||||||
|
self.layoutIfNeeded()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
Assigns a minimum track image to the specified control states.
|
||||||
|
|
||||||
|
- Parameters:
|
||||||
|
- image: The minimum track image to associate with the specified states.
|
||||||
|
- state: The control state with which to associate the image.
|
||||||
|
*/
|
||||||
|
public func setMinimumTrackImage(_ image: UIImage?, for state: UIControl.State) {
|
||||||
|
minimumTrackViewImages[state.rawValue] = image
|
||||||
|
updateStateDependantViews()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
Assigns a maximum track image to the specified control states.
|
||||||
|
|
||||||
|
- Parameters:
|
||||||
|
- image: The maximum track image to associate with the specified states.
|
||||||
|
- state: The control state with which to associate the image.
|
||||||
|
*/
|
||||||
|
public func setMaximumTrackImage(_ image: UIImage?, for state: UIControl.State) {
|
||||||
|
maximumTrackViewImages[state.rawValue] = image
|
||||||
|
updateStateDependantViews()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
Assigns a thumb image to the specified control states.
|
||||||
|
|
||||||
|
- Parameters:
|
||||||
|
- image: The thumb image to associate with the specified states.
|
||||||
|
- state: The control state with which to associate the image.
|
||||||
|
*/
|
||||||
|
public func setThumbImage(_ image: UIImage?, for state: UIControl.State) {
|
||||||
|
thumbViewImages[state.rawValue] = image
|
||||||
|
updateStateDependantViews()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The minimum track image currently being used to render the slider.
|
||||||
|
public var currentMinimumTrackImage: UIImage? {
|
||||||
|
return minimumTrackView.image
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Contains the maximum track image currently being used to render the slider.
|
||||||
|
public var currentMaximumTrackImage: UIImage? {
|
||||||
|
return maximumTrackView.image
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The thumb image currently being used to render the slider.
|
||||||
|
public var currentThumbImage: UIImage? {
|
||||||
|
return thumbView.image
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
Returns the minimum track image associated with the specified control state.
|
||||||
|
|
||||||
|
- Parameters:
|
||||||
|
- state: The control state whose minimum track image you want to use. Specify a single control state value for this parameter.
|
||||||
|
|
||||||
|
- Returns: The minimum track image associated with the specified state, or nil if no image has been set. This method might also return nil if you specify multiple control states in the state parameter. For a description of track images, see Customizing the Slider’s Appearance.
|
||||||
|
*/
|
||||||
|
public func minimumTrackImage(for state: UIControl.State) -> UIImage? {
|
||||||
|
return minimumTrackViewImages[state.rawValue]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
Returns the maximum track image associated with the specified control state.
|
||||||
|
|
||||||
|
- Parameters:
|
||||||
|
- state: The control state whose maximum track image you want to use. Specify a single control state value for this parameter.
|
||||||
|
|
||||||
|
- Returns: The maximum track image associated with the specified state, or nil if an appropriate image could not be retrieved. This method might return nil if you specify multiple control states in the state parameter. For a description of track images, see Customizing the Slider’s Appearance.
|
||||||
|
*/
|
||||||
|
public func maximumTrackImage(for state: UIControl.State) -> UIImage? {
|
||||||
|
return maximumTrackViewImages[state.rawValue]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
Returns the thumb image associated with the specified control state.
|
||||||
|
|
||||||
|
- Parameters:
|
||||||
|
- state: The control state whose thumb image you want to use. Specify a single control state value for this parameter.
|
||||||
|
|
||||||
|
- Returns: The thumb image associated with the specified state, or nil if an appropriate image could not be retrieved. This method might return nil if you specify multiple control states in the state parameter. For a description of track and thumb images, see Customizing the Slider’s Appearance.
|
||||||
|
*/
|
||||||
|
public func thumbImage(for state: UIControl.State) -> UIImage? {
|
||||||
|
return thumbViewImages[state.rawValue]
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Initializers
|
||||||
|
|
||||||
|
/// :nodoc:
|
||||||
|
// public override init(frame: CGRect) {
|
||||||
|
// super.init(frame: frame)
|
||||||
|
// setUpView()
|
||||||
|
// }
|
||||||
|
|
||||||
|
/// :nodoc:
|
||||||
|
// public required init?(coder aDecoder: NSCoder) {
|
||||||
|
// super.init(coder: aDecoder)
|
||||||
|
// setUpView()
|
||||||
|
// }
|
||||||
|
|
||||||
|
// MARK: VideoPlayerVieModel init
|
||||||
|
init(viewModel: VideoPlayerViewModel) {
|
||||||
|
self.viewModel = viewModel
|
||||||
|
super.init(frame: .zero)
|
||||||
|
setUpView()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
NotificationCenter.default.removeObserver(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - UIControlStates
|
||||||
|
|
||||||
|
/// :nodoc:
|
||||||
|
public override var isEnabled: Bool {
|
||||||
|
didSet {
|
||||||
|
panGestureRecognizer.isEnabled = isEnabled
|
||||||
|
updateStateDependantViews()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// :nodoc:
|
||||||
|
public override var isSelected: Bool {
|
||||||
|
didSet {
|
||||||
|
updateStateDependantViews()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// :nodoc:
|
||||||
|
public override var isHighlighted: Bool {
|
||||||
|
didSet {
|
||||||
|
updateStateDependantViews()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// :nodoc:
|
||||||
|
public override func didUpdateFocus(in context: UIFocusUpdateContext, with coordinator: UIFocusAnimationCoordinator) {
|
||||||
|
coordinator.addCoordinatedAnimations({
|
||||||
|
self.updateStateDependantViews()
|
||||||
|
}, completion: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private
|
||||||
|
|
||||||
|
private let viewModel: VideoPlayerViewModel!
|
||||||
|
|
||||||
|
private typealias ControlState = UInt
|
||||||
|
|
||||||
|
public var storedValue: Float = defaultValue
|
||||||
|
|
||||||
|
private var thumbViewImages: [ControlState: UIImage] = [:]
|
||||||
|
private var thumbView: UIImageView!
|
||||||
|
|
||||||
|
private var trackViewImages: [ControlState: UIImage] = [:]
|
||||||
|
private var trackView: UIImageView!
|
||||||
|
|
||||||
|
private var minimumTrackViewImages: [ControlState: UIImage] = [:]
|
||||||
|
private var minimumTrackView: UIImageView!
|
||||||
|
|
||||||
|
private var maximumTrackViewImages: [ControlState: UIImage] = [:]
|
||||||
|
private var maximumTrackView: UIImageView!
|
||||||
|
|
||||||
|
private var panGestureRecognizer: UIPanGestureRecognizer!
|
||||||
|
private var leftTapGestureRecognizer: UITapGestureRecognizer!
|
||||||
|
private var rightTapGestureRecognizer: UITapGestureRecognizer!
|
||||||
|
|
||||||
|
private var thumbViewCenterXConstraint: NSLayoutConstraint!
|
||||||
|
|
||||||
|
private var dPadState: DPadState = .select
|
||||||
|
|
||||||
|
private weak var deceleratingTimer: Timer?
|
||||||
|
private var deceleratingVelocity: Float = 0
|
||||||
|
|
||||||
|
private var thumbViewCenterXConstraintConstant: Float = 0
|
||||||
|
|
||||||
|
private func setUpView() {
|
||||||
|
setUpTrackView()
|
||||||
|
setUpMinimumTrackView()
|
||||||
|
setUpMaximumTrackView()
|
||||||
|
setUpThumbView()
|
||||||
|
|
||||||
|
setUpTrackViewConstraints()
|
||||||
|
setUpMinimumTrackViewConstraints()
|
||||||
|
setUpMaximumTrackViewConstraints()
|
||||||
|
setUpThumbViewConstraints()
|
||||||
|
|
||||||
|
setUpGestures()
|
||||||
|
|
||||||
|
NotificationCenter.default.addObserver(self, selector: #selector(controllerConnected(note:)), name: .GCControllerDidConnect, object: nil)
|
||||||
|
updateStateDependantViews()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setUpThumbView() {
|
||||||
|
thumbView = UIImageView()
|
||||||
|
thumbView.layer.cornerRadius = thumbSize / 6
|
||||||
|
thumbView.backgroundColor = thumbTintColor
|
||||||
|
addSubview(thumbView)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setUpTrackView() {
|
||||||
|
trackView = UIImageView()
|
||||||
|
trackView.layer.cornerRadius = trackViewHeight/2
|
||||||
|
trackView.backgroundColor = defaultTrackColor.withAlphaComponent(0.3)
|
||||||
|
addSubview(trackView)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setUpMinimumTrackView() {
|
||||||
|
minimumTrackView = UIImageView()
|
||||||
|
minimumTrackView.layer.cornerRadius = trackViewHeight / 2
|
||||||
|
minimumTrackView.backgroundColor = minimumTrackTintColor
|
||||||
|
addSubview(minimumTrackView)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setUpMaximumTrackView() {
|
||||||
|
maximumTrackView = UIImageView()
|
||||||
|
maximumTrackView.layer.cornerRadius = trackViewHeight / 2
|
||||||
|
maximumTrackView.backgroundColor = maximumTrackTintColor
|
||||||
|
addSubview(maximumTrackView)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setUpTrackViewConstraints() {
|
||||||
|
trackView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
trackView.leadingAnchor.constraint(equalTo: leadingAnchor).isActive = true
|
||||||
|
trackView.trailingAnchor.constraint(equalTo: trailingAnchor).isActive = true
|
||||||
|
trackView.centerYAnchor.constraint(equalTo: centerYAnchor).isActive = true
|
||||||
|
trackView.heightAnchor.constraint(equalToConstant: trackViewHeight).isActive = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setUpMinimumTrackViewConstraints() {
|
||||||
|
minimumTrackView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
minimumTrackView.leadingAnchor.constraint(equalTo: trackView.leadingAnchor).isActive = true
|
||||||
|
minimumTrackView.trailingAnchor.constraint(equalTo: thumbView.centerXAnchor).isActive = true
|
||||||
|
minimumTrackView.centerYAnchor.constraint(equalTo:trackView.centerYAnchor).isActive = true
|
||||||
|
minimumTrackView.heightAnchor.constraint(equalToConstant: trackViewHeight).isActive = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setUpMaximumTrackViewConstraints() {
|
||||||
|
maximumTrackView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
maximumTrackView.leadingAnchor.constraint(equalTo: thumbView.centerXAnchor).isActive = true
|
||||||
|
maximumTrackView.trailingAnchor.constraint(equalTo: trackView.trailingAnchor).isActive = true
|
||||||
|
maximumTrackView.centerYAnchor.constraint(equalTo:trackView.centerYAnchor).isActive = true
|
||||||
|
maximumTrackView.heightAnchor.constraint(equalToConstant: trackViewHeight).isActive = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setUpThumbViewConstraints() {
|
||||||
|
thumbView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
thumbView.centerYAnchor.constraint(equalTo: centerYAnchor).isActive = true
|
||||||
|
thumbView.widthAnchor.constraint(equalToConstant: thumbSize / 3).isActive = true
|
||||||
|
thumbView.heightAnchor.constraint(equalToConstant: thumbSize).isActive = true
|
||||||
|
thumbViewCenterXConstraint = thumbView.centerXAnchor.constraint(equalTo: trackView.leadingAnchor, constant: CGFloat(value))
|
||||||
|
thumbViewCenterXConstraint.isActive = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setUpGestures() {
|
||||||
|
panGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(panGestureWasTriggered(panGestureRecognizer:)))
|
||||||
|
addGestureRecognizer(panGestureRecognizer)
|
||||||
|
|
||||||
|
leftTapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(leftTapWasTriggered))
|
||||||
|
leftTapGestureRecognizer.allowedPressTypes = [NSNumber(value: UIPress.PressType.leftArrow.rawValue)]
|
||||||
|
leftTapGestureRecognizer.allowedTouchTypes = [NSNumber(value: UITouch.TouchType.indirect.rawValue)]
|
||||||
|
addGestureRecognizer(leftTapGestureRecognizer)
|
||||||
|
|
||||||
|
rightTapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(rightTapWasTriggered))
|
||||||
|
rightTapGestureRecognizer.allowedPressTypes = [NSNumber(value: UIPress.PressType.rightArrow.rawValue)]
|
||||||
|
rightTapGestureRecognizer.allowedTouchTypes = [NSNumber(value: UITouch.TouchType.indirect.rawValue)]
|
||||||
|
addGestureRecognizer(rightTapGestureRecognizer)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateStateDependantViews() {
|
||||||
|
thumbView.image = thumbViewImages[state.rawValue] ?? thumbViewImages[UIControl.State.normal.rawValue]
|
||||||
|
|
||||||
|
if isFocused {
|
||||||
|
thumbView.transform = CGAffineTransform(scaleX: focusScaleFactor, y: focusScaleFactor)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
thumbView.transform = CGAffineTransform.identity
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func controllerConnected(note: NSNotification) {
|
||||||
|
guard let controller = note.object as? GCController else { return }
|
||||||
|
guard let micro = controller.microGamepad else { return }
|
||||||
|
|
||||||
|
let threshold: Float = 0.7
|
||||||
|
micro.reportsAbsoluteDpadValues = true
|
||||||
|
micro.dpad.valueChangedHandler = {
|
||||||
|
[weak self] (pad, x, y) in
|
||||||
|
|
||||||
|
if x < -threshold {
|
||||||
|
self?.dPadState = .left
|
||||||
|
}
|
||||||
|
else if x > threshold {
|
||||||
|
self?.dPadState = .right
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
self?.dPadState = .select
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc
|
||||||
|
private func handleDeceleratingTimer(timer: Timer) {
|
||||||
|
let centerX = thumbViewCenterXConstraintConstant + deceleratingVelocity * 0.01
|
||||||
|
let percent = centerX / Float(trackView.frame.width)
|
||||||
|
value = minimumValue + ((maximumValue - minimumValue) * percent)
|
||||||
|
|
||||||
|
if isContinuous {
|
||||||
|
sendActions(for: .valueChanged)
|
||||||
|
}
|
||||||
|
|
||||||
|
thumbViewCenterXConstraintConstant = Float(thumbViewCenterXConstraint.constant)
|
||||||
|
|
||||||
|
deceleratingVelocity *= decelerationRate
|
||||||
|
if !isFocused || abs(deceleratingVelocity) < 1 {
|
||||||
|
stopDeceleratingTimer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stopDeceleratingTimer() {
|
||||||
|
deceleratingTimer?.invalidate()
|
||||||
|
deceleratingTimer = nil
|
||||||
|
deceleratingVelocity = 0
|
||||||
|
sendActions(for: .valueChanged)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isVerticalGesture(_ recognizer: UIPanGestureRecognizer) -> Bool {
|
||||||
|
let translation = recognizer.translation(in: self)
|
||||||
|
if abs(translation.y) > abs(translation.x) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Actions
|
||||||
|
|
||||||
|
@objc
|
||||||
|
private func panGestureWasTriggered(panGestureRecognizer: UIPanGestureRecognizer) {
|
||||||
|
|
||||||
|
if self.isVerticalGesture(panGestureRecognizer) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let translation = Float(panGestureRecognizer.translation(in: self).x)
|
||||||
|
let velocity = Float(panGestureRecognizer.velocity(in: self).x)
|
||||||
|
|
||||||
|
switch panGestureRecognizer.state {
|
||||||
|
case .began:
|
||||||
|
viewModel.sliderIsScrubbing = true
|
||||||
|
|
||||||
|
stopDeceleratingTimer()
|
||||||
|
thumbViewCenterXConstraintConstant = Float(thumbViewCenterXConstraint.constant)
|
||||||
|
case .changed:
|
||||||
|
viewModel.sliderIsScrubbing = true
|
||||||
|
|
||||||
|
let centerX = thumbViewCenterXConstraintConstant + translation / panDampingValue
|
||||||
|
let percent = centerX / Float(trackView.frame.width)
|
||||||
|
value = minimumValue + ((maximumValue - minimumValue) * percent)
|
||||||
|
if isContinuous {
|
||||||
|
sendActions(for: .valueChanged)
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModel.sliderPercentage = Double(percent)
|
||||||
|
case .ended, .cancelled:
|
||||||
|
viewModel.sliderIsScrubbing = false
|
||||||
|
|
||||||
|
thumbViewCenterXConstraintConstant = Float(thumbViewCenterXConstraint.constant)
|
||||||
|
|
||||||
|
if abs(velocity) > fineTunningVelocityThreshold {
|
||||||
|
let direction: Float = velocity > 0 ? 1 : -1
|
||||||
|
deceleratingVelocity = abs(velocity) > decelerationMaxVelocity ? decelerationMaxVelocity * direction : velocity
|
||||||
|
deceleratingTimer = Timer.scheduledTimer(timeInterval: 0.01, target: self, selector: #selector(handleDeceleratingTimer(timer:)), userInfo: nil, repeats: true)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
stopDeceleratingTimer()
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc
|
||||||
|
private func leftTapWasTriggered() {
|
||||||
|
setValue(value-stepValue, animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc
|
||||||
|
private func rightTapWasTriggered() {
|
||||||
|
setValue(value+stepValue, animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
public override func pressesBegan(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
|
||||||
|
for press in presses {
|
||||||
|
switch press.type {
|
||||||
|
case .select where dPadState == .left:
|
||||||
|
panGestureRecognizer.isEnabled = false
|
||||||
|
leftTapWasTriggered()
|
||||||
|
case .select where dPadState == .right:
|
||||||
|
panGestureRecognizer.isEnabled = false
|
||||||
|
rightTapWasTriggered()
|
||||||
|
case .select:
|
||||||
|
panGestureRecognizer.isEnabled = false
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
panGestureRecognizer.isEnabled = true
|
||||||
|
super.pressesBegan(presses, with: event)
|
||||||
|
}
|
||||||
|
}
|
@ -291,6 +291,11 @@
|
|||||||
E173DA5226D04AAF00CC4EB7 /* ColorExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = E173DA5126D04AAF00CC4EB7 /* ColorExtension.swift */; };
|
E173DA5226D04AAF00CC4EB7 /* ColorExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = E173DA5126D04AAF00CC4EB7 /* ColorExtension.swift */; };
|
||||||
E173DA5426D050F500CC4EB7 /* ServerDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E173DA5326D050F500CC4EB7 /* ServerDetailViewModel.swift */; };
|
E173DA5426D050F500CC4EB7 /* ServerDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E173DA5326D050F500CC4EB7 /* ServerDetailViewModel.swift */; };
|
||||||
E178857D278037FD0094FBCF /* JellyfinAPI in Frameworks */ = {isa = PBXBuildFile; productRef = E178857C278037FD0094FBCF /* JellyfinAPI */; };
|
E178857D278037FD0094FBCF /* JellyfinAPI in Frameworks */ = {isa = PBXBuildFile; productRef = E178857C278037FD0094FBCF /* JellyfinAPI */; };
|
||||||
|
E178859B2780F1F40094FBCF /* tvOSSlider.swift in Sources */ = {isa = PBXBuildFile; fileRef = E178859A2780F1F40094FBCF /* tvOSSlider.swift */; };
|
||||||
|
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 */; };
|
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 */; };
|
E18845F626DD631E00B0C5B7 /* BaseItemDto+Stackable.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18845F426DD631E00B0C5B7 /* BaseItemDto+Stackable.swift */; };
|
||||||
E18845F826DEA9C900B0C5B7 /* ItemViewBody.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18845F726DEA9C900B0C5B7 /* ItemViewBody.swift */; };
|
E18845F826DEA9C900B0C5B7 /* ItemViewBody.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18845F726DEA9C900B0C5B7 /* ItemViewBody.swift */; };
|
||||||
@ -593,6 +598,11 @@
|
|||||||
E173DA4F26D048D600CC4EB7 /* ServerDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerDetailView.swift; sourceTree = "<group>"; };
|
E173DA4F26D048D600CC4EB7 /* ServerDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerDetailView.swift; sourceTree = "<group>"; };
|
||||||
E173DA5126D04AAF00CC4EB7 /* ColorExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorExtension.swift; sourceTree = "<group>"; };
|
E173DA5126D04AAF00CC4EB7 /* ColorExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorExtension.swift; sourceTree = "<group>"; };
|
||||||
E173DA5326D050F500CC4EB7 /* ServerDetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerDetailViewModel.swift; sourceTree = "<group>"; };
|
E173DA5326D050F500CC4EB7 /* ServerDetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerDetailViewModel.swift; sourceTree = "<group>"; };
|
||||||
|
E178859A2780F1F40094FBCF /* tvOSSlider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = tvOSSlider.swift; sourceTree = "<group>"; };
|
||||||
|
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>"; };
|
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>"; };
|
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>"; };
|
E18845FF26DECB9E00B0C5B7 /* ItemLandscapeTopBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemLandscapeTopBarView.swift; sourceTree = "<group>"; };
|
||||||
@ -714,6 +724,8 @@
|
|||||||
children = (
|
children = (
|
||||||
E1C812C7277AE40900918266 /* NativePlayerViewController.swift */,
|
E1C812C7277AE40900918266 /* NativePlayerViewController.swift */,
|
||||||
E1C812C6277AE40900918266 /* PlayerOverlayDelegate.swift */,
|
E1C812C6277AE40900918266 /* PlayerOverlayDelegate.swift */,
|
||||||
|
E178859C2780F5300094FBCF /* tvOSSLider */,
|
||||||
|
E17885A7278130690094FBCF /* tvOSOverlay */,
|
||||||
E1C812C8277AE40900918266 /* VideoPlayerView.swift */,
|
E1C812C8277AE40900918266 /* VideoPlayerView.swift */,
|
||||||
E1384943278036C70024FB48 /* VLCPlayerViewController.swift */,
|
E1384943278036C70024FB48 /* VLCPlayerViewController.swift */,
|
||||||
);
|
);
|
||||||
@ -864,6 +876,7 @@
|
|||||||
53116A18268B947A003024C9 /* PlainLinkButton.swift */,
|
53116A18268B947A003024C9 /* PlainLinkButton.swift */,
|
||||||
536D3D80267BDFC60004248C /* PortraitItemElement.swift */,
|
536D3D80267BDFC60004248C /* PortraitItemElement.swift */,
|
||||||
536D3D87267C17350004248C /* PublicUserButton.swift */,
|
536D3D87267C17350004248C /* PublicUserButton.swift */,
|
||||||
|
E17885A3278105170094FBCF /* SFSymbolButton.swift */,
|
||||||
);
|
);
|
||||||
path = Components;
|
path = Components;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@ -1287,6 +1300,24 @@
|
|||||||
path = ItemView;
|
path = ItemView;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
E178859C2780F5300094FBCF /* tvOSSLider */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
E178859D2780F53B0094FBCF /* SliderView.swift */,
|
||||||
|
E178859A2780F1F40094FBCF /* tvOSSlider.swift */,
|
||||||
|
);
|
||||||
|
path = tvOSSLider;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
E17885A7278130690094FBCF /* tvOSOverlay */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
E17885A5278130610094FBCF /* tvOSOverlayContent.swift */,
|
||||||
|
E178859F2780F55C0094FBCF /* tvOSVLCOverlay.swift */,
|
||||||
|
);
|
||||||
|
path = tvOSOverlay;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
E18845FA26DEACBE00B0C5B7 /* Portrait */ = {
|
E18845FA26DEACBE00B0C5B7 /* Portrait */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@ -1832,18 +1863,22 @@
|
|||||||
E1C812CC277AE40A00918266 /* VideoPlayerView.swift in Sources */,
|
E1C812CC277AE40A00918266 /* VideoPlayerView.swift in Sources */,
|
||||||
53CD2A40268A49C2002ABD4E /* ItemView.swift in Sources */,
|
53CD2A40268A49C2002ABD4E /* ItemView.swift in Sources */,
|
||||||
53CD2A42268A4B38002ABD4E /* MovieItemView.swift in Sources */,
|
53CD2A42268A4B38002ABD4E /* MovieItemView.swift in Sources */,
|
||||||
|
E178859E2780F53B0094FBCF /* SliderView.swift in Sources */,
|
||||||
536D3D7F267BDF100004248C /* LatestMediaView.swift in Sources */,
|
536D3D7F267BDF100004248C /* LatestMediaView.swift in Sources */,
|
||||||
E1384944278036C70024FB48 /* VLCPlayerViewController.swift in Sources */,
|
E1384944278036C70024FB48 /* VLCPlayerViewController.swift in Sources */,
|
||||||
091B5A8E268315D400D78B61 /* UDPBroadCastConnection.swift in Sources */,
|
091B5A8E268315D400D78B61 /* UDPBroadCastConnection.swift in Sources */,
|
||||||
E10EAA50277BBCC4000269ED /* CGSizeExtensions.swift in Sources */,
|
E10EAA50277BBCC4000269ED /* CGSizeExtensions.swift in Sources */,
|
||||||
|
E17885A6278130610094FBCF /* tvOSOverlayContent.swift in Sources */,
|
||||||
E1FCD08926C35A0D007C8DCF /* NetworkError.swift in Sources */,
|
E1FCD08926C35A0D007C8DCF /* NetworkError.swift in Sources */,
|
||||||
531690ED267ABF46005D8AB9 /* ContinueWatchingView.swift in Sources */,
|
531690ED267ABF46005D8AB9 /* ContinueWatchingView.swift in Sources */,
|
||||||
|
E17885A4278105170094FBCF /* SFSymbolButton.swift in Sources */,
|
||||||
E13DD3ED27178A54009D4DAF /* UserSignInViewModel.swift in Sources */,
|
E13DD3ED27178A54009D4DAF /* UserSignInViewModel.swift in Sources */,
|
||||||
62EC3530267666A5000E9F2D /* SessionManager.swift in Sources */,
|
62EC3530267666A5000E9F2D /* SessionManager.swift in Sources */,
|
||||||
62671DB327159C1800199D95 /* ItemCoordinator.swift in Sources */,
|
62671DB327159C1800199D95 /* ItemCoordinator.swift in Sources */,
|
||||||
E1AD104B26D94822003E4A08 /* DetailItem.swift in Sources */,
|
E1AD104B26D94822003E4A08 /* DetailItem.swift in Sources */,
|
||||||
E13DD3E227176BD3009D4DAF /* ServerListViewModel.swift in Sources */,
|
E13DD3E227176BD3009D4DAF /* ServerListViewModel.swift in Sources */,
|
||||||
53272539268C20100035FBF1 /* EpisodeItemView.swift in Sources */,
|
53272539268C20100035FBF1 /* EpisodeItemView.swift in Sources */,
|
||||||
|
E178859B2780F1F40094FBCF /* tvOSSlider.swift in Sources */,
|
||||||
531690F7267ACC00005D8AB9 /* LandscapeItemElement.swift in Sources */,
|
531690F7267ACC00005D8AB9 /* LandscapeItemElement.swift in Sources */,
|
||||||
62E632E1267D30CA0063E547 /* LibraryViewModel.swift in Sources */,
|
62E632E1267D30CA0063E547 /* LibraryViewModel.swift in Sources */,
|
||||||
535870A82669D8AE00D05A09 /* StringExtensions.swift in Sources */,
|
535870A82669D8AE00D05A09 /* StringExtensions.swift in Sources */,
|
||||||
@ -1859,6 +1894,7 @@
|
|||||||
C4BE0767271FC109003F4AD1 /* TVLibrariesViewModel.swift in Sources */,
|
C4BE0767271FC109003F4AD1 /* TVLibrariesViewModel.swift in Sources */,
|
||||||
E193D53727193F8700900D82 /* LibraryListCoordinator.swift in Sources */,
|
E193D53727193F8700900D82 /* LibraryListCoordinator.swift in Sources */,
|
||||||
E100720726BDABC100CE3E31 /* MediaPlayButtonRowView.swift in Sources */,
|
E100720726BDABC100CE3E31 /* MediaPlayButtonRowView.swift in Sources */,
|
||||||
|
E17885A02780F55C0094FBCF /* tvOSVLCOverlay.swift in Sources */,
|
||||||
E193D54D2719426600900D82 /* LibraryFilterView.swift in Sources */,
|
E193D54D2719426600900D82 /* LibraryFilterView.swift in Sources */,
|
||||||
E193D53927193F8E00900D82 /* SearchCoordinator.swift in Sources */,
|
E193D53927193F8E00900D82 /* SearchCoordinator.swift in Sources */,
|
||||||
E193D4D927193CAC00900D82 /* PortraitImageStackable.swift in Sources */,
|
E193D4D927193CAC00900D82 /* PortraitImageStackable.swift in Sources */,
|
||||||
|
@ -75,9 +75,6 @@ final class MainTabCoordinator: TabCoordinatable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder func makeSettingsTab(isActive: Bool) -> some View {
|
@ViewBuilder func makeSettingsTab(isActive: Bool) -> some View {
|
||||||
HStack {
|
Image(systemName: "gearshape.fill")
|
||||||
Image(systemName: "gearshape.fill")
|
|
||||||
Text("Settings")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -11,7 +11,7 @@ import SwiftUI
|
|||||||
|
|
||||||
extension Color {
|
extension Color {
|
||||||
|
|
||||||
static let jellyfinPurple = Color(red: 172 / 255, green: 92 / 255, blue: 195 / 255)
|
static let jellyfinPurple = Color(uiColor: .jellyfinPurple)
|
||||||
|
|
||||||
#if os(tvOS) // tvOS doesn't have these
|
#if os(tvOS) // tvOS doesn't have these
|
||||||
public static let systemFill = Color(UIColor.white)
|
public static let systemFill = Color(UIColor.white)
|
||||||
@ -23,3 +23,7 @@ extension Color {
|
|||||||
public static let tertiarySystemFill = Color(UIColor.tertiarySystemBackground)
|
public static let tertiarySystemFill = Color(UIColor.tertiarySystemBackground)
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension UIColor {
|
||||||
|
static let jellyfinPurple = UIColor(red: 172 / 255, green: 92 / 255, blue: 195 / 255, alpha: 1)
|
||||||
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user