general VLCPlayer implementation

This commit is contained in:
Ethan Pippin 2022-01-01 19:14:57 -07:00
parent 4c7490b5fa
commit 86e41c4f81
14 changed files with 1168 additions and 239 deletions

View File

@ -18,9 +18,6 @@ struct MediaPlayButtonRowView: View {
var body: some View {
HStack {
VStack {
// NavigationLink(destination: VideoPlayerView(item: viewModel.item).ignoresSafeArea()) {
// MediaViewActionButton(icon: "play.fill", scrollView: $wrappedScrollView)
// }
Button {
itemRouter.route(to: \.videoPlayer, viewModel.itemVideoPlayerViewModel!)
} label: {

View 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)
}
}

View File

@ -17,38 +17,43 @@ struct HomeView: View {
@State var showingSettings = false
var body: some View {
ScrollView {
if viewModel.isLoading {
ProgressView()
} else {
LazyVStack(alignment: .leading) {
if !viewModel.resumeItems.isEmpty {
ContinueWatchingView(items: viewModel.resumeItems)
}
if !viewModel.nextUpItems.isEmpty {
NextUpView(items: viewModel.nextUpItems)
}
ZStack {
Color.black
.ignoresSafeArea()
ScrollView {
if viewModel.isLoading {
ProgressView()
} else {
LazyVStack(alignment: .leading) {
if !viewModel.resumeItems.isEmpty {
ContinueWatchingView(items: viewModel.resumeItems)
}
if !viewModel.nextUpItems.isEmpty {
NextUpView(items: viewModel.nextUpItems)
}
if !viewModel.librariesShowRecentlyAddedIDs.isEmpty {
ForEach(viewModel.librariesShowRecentlyAddedIDs, id: \.self) { libraryID in
VStack(alignment: .leading) {
let library = viewModel.libraries.first(where: { $0.id == libraryID })
if !viewModel.librariesShowRecentlyAddedIDs.isEmpty {
ForEach(viewModel.librariesShowRecentlyAddedIDs, id: \.self) { libraryID in
VStack(alignment: .leading) {
let library = viewModel.libraries.first(where: { $0.id == libraryID })
Button {
self.homeRouter.route(to: \.modalLibrary, (.init(parentID: libraryID, filters: viewModel.recentFilterSet), title: library?.name ?? ""))
} label: {
HStack {
Text(L10n.latestWithString(library?.name ?? ""))
.font(.headline)
.fontWeight(.semibold)
Image(systemName: "chevron.forward.circle.fill")
}
}.padding(EdgeInsets(top: 0, leading: 90, bottom: 0, trailing: 0))
LatestMediaView(usingParentID: libraryID)
Button {
self.homeRouter.route(to: \.modalLibrary, (.init(parentID: libraryID, filters: viewModel.recentFilterSet), title: library?.name ?? ""))
} label: {
HStack {
Text(L10n.latestWithString(library?.name ?? ""))
.font(.headline)
.fontWeight(.semibold)
Image(systemName: "chevron.forward.circle.fill")
}
}.padding(EdgeInsets(top: 0, leading: 90, bottom: 0, trailing: 0))
LatestMediaView(usingParentID: libraryID)
}
}
}
Spacer().frame(height: 30)
}
Spacer().frame(height: 30)
}
}
}

View File

@ -120,42 +120,8 @@ struct EpisodeItemView: View {
.font(.body)
.fontWeight(.medium)
.foregroundColor(.primary)
HStack {
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)
MediaPlayButtonRowView(viewModel: viewModel)
}
}.padding(.top, 50)

View File

@ -21,59 +21,78 @@ struct SettingsView: View {
@Default(.autoSelectAudioLangCode) var autoSelectAudioLangcode
var body: some View {
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)
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)
}
}
}
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)
}
}
.padding(.leading, 90)
.padding(.trailing, 90)
}
}
}
struct SettingsView_Previews: PreviewProvider {
static var previews: some View {
SettingsView(viewModel: SettingsViewModel())
}
}

View File

@ -27,4 +27,6 @@ protocol PlayerOverlayDelegate {
func didSelectAudioStream(index: Int)
func didSelectSubtitleStream(index: Int)
func didFocusOnButton()
}

View File

@ -37,8 +37,11 @@ class VLCPlayerViewController: UIViewController {
}
private var displayingOverlay: Bool {
// return currentOverlayHostingController?.view.alpha ?? 0 > 0
return false
return currentOverlayHostingController?.view.alpha ?? 0 > 0
}
private var displayingContentOverlay: Bool {
return currentOverlayContentHostingController?.view.alpha ?? 0 > 0
}
private var jumpForwardLength: VideoPlayerJumpLength {
@ -50,8 +53,8 @@ class VLCPlayerViewController: UIViewController {
}
private lazy var videoContentView = makeVideoContentView()
private lazy var tapGestureView = makeTapGestureView()
// private var currentOverlayHostingController: UIHostingController<VLCPlayerCompactOverlayView>?
private var currentOverlayHostingController: UIHostingController<tvOSVLCOverlay>?
private var currentOverlayContentHostingController: UIHostingController<tvOSOverlayContentView>?
// MARK: init
@ -70,7 +73,6 @@ class VLCPlayerViewController: UIViewController {
private func setupSubviews() {
view.addSubview(videoContentView)
view.addSubview(tapGestureView)
}
private func setupConstraints() {
@ -80,12 +82,6 @@ class VLCPlayerViewController: UIViewController {
videoContentView.leftAnchor.constraint(equalTo: view.leftAnchor),
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
@ -115,12 +111,13 @@ class VLCPlayerViewController: UIViewController {
// they aren't unnecessarily set more than once
vlcMediaPlayer.delegate = self
vlcMediaPlayer.drawable = videoContentView
vlcMediaPlayer.perform(Selector(("setTextRendererFontSize:")), with: 14)
vlcMediaPlayer.perform(Selector(("setTextRendererFontSize:")), with: 16)
setupMediaPlayer(newViewModel: viewModel)
setupRightSwipedGestureRecognizer()
setupLeftSwipedGestureRecognizer()
setupPanGestureRecognizer()
let defaultNotificationCenter = NotificationCenter.default
defaultNotificationCenter.addObserver(self, selector: #selector(appWillTerminate), name: UIApplication.willTerminateNotification, object: nil)
@ -158,35 +155,6 @@ class VLCPlayerViewController: UIViewController {
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
override func pressesBegan(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
guard let buttonPress = presses.first?.type else { return }
@ -196,17 +164,17 @@ class VLCPlayerViewController: UIViewController {
print("Menu")
case .playPause:
didSelectMain()
print("Play/Pause")
case .select:
print("select")
didGenerallyTap()
case .upArrow:
print("Up arrow")
case .downArrow:
print("Down arrow")
case .leftArrow:
didSelectBackward()
print("Left arrow")
case .rightArrow:
print("right arrow")
didSelectForward()
case .pageUp:
print("page up")
case .pageDown:
@ -215,73 +183,115 @@ class VLCPlayerViewController: UIViewController {
}
}
func setupRightSwipedGestureRecognizer() {
private func setupRightSwipedGestureRecognizer() {
let swipeRecognizer = UISwipeGestureRecognizer(target: self, action: #selector(swipedRight))
swipeRecognizer.direction = .right
view.addGestureRecognizer(swipeRecognizer)
}
@objc func swipedRight() {
@objc private func swipedRight() {
didSelectForward()
}
func setupLeftSwipedGestureRecognizer() {
private func setupLeftSwipedGestureRecognizer() {
let swipeRecognizer = UISwipeGestureRecognizer(target: self, action: #selector(swipedLeft))
swipeRecognizer.direction = .left
view.addGestureRecognizer(swipeRecognizer)
}
@objc func swipedLeft() {
@objc private func swipedLeft() {
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
private func setupOverlayHostingController(viewModel: VideoPlayerViewModel) {
// // TODO: Look at injecting viewModel into the environment so it updates the current overlay
// if let currentOverlayHostingController = currentOverlayHostingController {
// // UX fade-out
// UIView.animate(withDuration: 0.5) {
// currentOverlayHostingController.view.alpha = 0
// } completion: { _ in
// currentOverlayHostingController.view.isHidden = true
//
// currentOverlayHostingController.view.removeFromSuperview()
// currentOverlayHostingController.removeFromParent()
//// self.currentOverlayHostingController = nil
// }
// }
//
// let newOverlayView = VLCPlayerCompactOverlayView(viewModel: viewModel)
// let newOverlayHostingController = UIHostingController(rootView: newOverlayView)
//
// newOverlayHostingController.view.translatesAutoresizingMaskIntoConstraints = false
// newOverlayHostingController.view.backgroundColor = UIColor.clear
//
// // UX fade-in
// newOverlayHostingController.view.alpha = 0
//
// addChild(newOverlayHostingController)
// view.addSubview(newOverlayHostingController.view)
// newOverlayHostingController.didMove(toParent: self)
//
// NSLayoutConstraint.activate([
// newOverlayHostingController.view.topAnchor.constraint(equalTo: videoContentView.topAnchor),
// 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
// }
//
// self.currentOverlayHostingController = newOverlayHostingController
//
// // 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
// TODO: Look at injecting viewModel into the environment so it updates the current overlay
// Overlay
if let currentOverlayHostingController = currentOverlayHostingController {
// UX fade-out
UIView.animate(withDuration: 0.5) {
currentOverlayHostingController.view.alpha = 0
} completion: { _ in
currentOverlayHostingController.view.isHidden = true
currentOverlayHostingController.view.removeFromSuperview()
currentOverlayHostingController.removeFromParent()
// self.currentOverlayHostingController = nil
}
}
let newOverlayView = tvOSVLCOverlay(viewModel: viewModel)
let newOverlayHostingController = UIHostingController(rootView: newOverlayView)
newOverlayHostingController.view.translatesAutoresizingMaskIntoConstraints = false
newOverlayHostingController.view.backgroundColor = UIColor.clear
// UX fade-in
newOverlayHostingController.view.alpha = 0
addChild(newOverlayHostingController)
view.addSubview(newOverlayHostingController.view)
newOverlayHostingController.didMove(toParent: self)
NSLayoutConstraint.activate([
newOverlayHostingController.view.topAnchor.constraint(equalTo: videoContentView.topAnchor),
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
}
self.currentOverlayHostingController = newOverlayHostingController
// 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()
restartOverlayDismissTimer()
restartOverlayDismissTimer(interval: 5)
}
// MARK: setupViewModelListeners
@ -384,40 +394,58 @@ extension VLCPlayerViewController {
extension VLCPlayerViewController {
private func showOverlay() {
// guard let overlayHostingController = currentOverlayHostingController else { return }
//
// guard overlayHostingController.view.alpha != 1 else { return }
//
// UIView.animate(withDuration: 0.2) {
// overlayHostingController.view.alpha = 1
// }
guard let overlayHostingController = currentOverlayHostingController else { return }
guard overlayHostingController.view.alpha != 1 else { return }
UIView.animate(withDuration: 0.2) {
overlayHostingController.view.alpha = 1
}
}
private func hideOverlay() {
// guard let overlayHostingController = currentOverlayHostingController else { return }
//
// guard overlayHostingController.view.alpha != 0 else { return }
//
// UIView.animate(withDuration: 0.2) {
// overlayHostingController.view.alpha = 0
// }
guard let overlayHostingController = currentOverlayHostingController else { return }
guard overlayHostingController.view.alpha != 0 else { return }
UIView.animate(withDuration: 0.2) {
overlayHostingController.view.alpha = 0
}
}
private func toggleOverlay() {
// guard let overlayHostingController = currentOverlayHostingController else { return }
//
// if overlayHostingController.view.alpha < 1 {
// showOverlay()
// } else {
// hideOverlay()
// }
if displayingOverlay {
hideOverlay()
} else {
showOverlay()
}
}
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
extension VLCPlayerViewController {
private func restartOverlayDismissTimer(interval: Double = 3) {
private func restartOverlayDismissTimer(interval: Double = 5) {
self.overlayDismissTimer?.invalidate()
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
func didSelectMenu() {
stopOverlayDismissTimer()
// stopOverlayDismissTimer()
//
// hideOverlay()
// showOverlayContent()
}
// TODO: Implement properly in overlays
func didDeselectMenu() {
restartOverlayDismissTimer()
}
func didSelectBackward() {
@ -571,7 +602,8 @@ extension VLCPlayerViewController: PlayerOverlayDelegate {
case .playing:
viewModel.sendPauseReport(paused: true)
vlcMediaPlayer.pause()
restartOverlayDismissTimer(interval: 5)
showOverlay()
restartOverlayDismissTimer(interval: 10)
case .paused:
viewModel.sendPauseReport(paused: false)
vlcMediaPlayer.play()
@ -609,4 +641,8 @@ extension VLCPlayerViewController: PlayerOverlayDelegate {
setupMediaPlayer(newViewModel: viewModel.nextItemVideoPlayerViewModel!)
startPlayback()
}
func didFocusOnButton() {
restartOverlayDismissTimer(interval: 8)
}
}

View File

@ -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))
}
}
}

View File

@ -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)
}
}

View File

@ -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
}
}

View File

@ -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 sliders 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 sliders 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 sliders 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 sliders 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 Sliders 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 Sliders 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 Sliders 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)
}
}

View File

@ -291,6 +291,11 @@
E173DA5226D04AAF00CC4EB7 /* ColorExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = E173DA5126D04AAF00CC4EB7 /* ColorExtension.swift */; };
E173DA5426D050F500CC4EB7 /* ServerDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E173DA5326D050F500CC4EB7 /* ServerDetailViewModel.swift */; };
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 */; };
E18845F626DD631E00B0C5B7 /* BaseItemDto+Stackable.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18845F426DD631E00B0C5B7 /* BaseItemDto+Stackable.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>"; };
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>"; };
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>"; };
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>"; };
@ -714,6 +724,8 @@
children = (
E1C812C7277AE40900918266 /* NativePlayerViewController.swift */,
E1C812C6277AE40900918266 /* PlayerOverlayDelegate.swift */,
E178859C2780F5300094FBCF /* tvOSSLider */,
E17885A7278130690094FBCF /* tvOSOverlay */,
E1C812C8277AE40900918266 /* VideoPlayerView.swift */,
E1384943278036C70024FB48 /* VLCPlayerViewController.swift */,
);
@ -864,6 +876,7 @@
53116A18268B947A003024C9 /* PlainLinkButton.swift */,
536D3D80267BDFC60004248C /* PortraitItemElement.swift */,
536D3D87267C17350004248C /* PublicUserButton.swift */,
E17885A3278105170094FBCF /* SFSymbolButton.swift */,
);
path = Components;
sourceTree = "<group>";
@ -1287,6 +1300,24 @@
path = ItemView;
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 */ = {
isa = PBXGroup;
children = (
@ -1832,18 +1863,22 @@
E1C812CC277AE40A00918266 /* VideoPlayerView.swift in Sources */,
53CD2A40268A49C2002ABD4E /* ItemView.swift in Sources */,
53CD2A42268A4B38002ABD4E /* MovieItemView.swift in Sources */,
E178859E2780F53B0094FBCF /* SliderView.swift in Sources */,
536D3D7F267BDF100004248C /* LatestMediaView.swift in Sources */,
E1384944278036C70024FB48 /* VLCPlayerViewController.swift in Sources */,
091B5A8E268315D400D78B61 /* UDPBroadCastConnection.swift in Sources */,
E10EAA50277BBCC4000269ED /* CGSizeExtensions.swift in Sources */,
E17885A6278130610094FBCF /* tvOSOverlayContent.swift in Sources */,
E1FCD08926C35A0D007C8DCF /* NetworkError.swift in Sources */,
531690ED267ABF46005D8AB9 /* ContinueWatchingView.swift in Sources */,
E17885A4278105170094FBCF /* SFSymbolButton.swift in Sources */,
E13DD3ED27178A54009D4DAF /* UserSignInViewModel.swift in Sources */,
62EC3530267666A5000E9F2D /* SessionManager.swift in Sources */,
62671DB327159C1800199D95 /* ItemCoordinator.swift in Sources */,
E1AD104B26D94822003E4A08 /* DetailItem.swift in Sources */,
E13DD3E227176BD3009D4DAF /* ServerListViewModel.swift in Sources */,
53272539268C20100035FBF1 /* EpisodeItemView.swift in Sources */,
E178859B2780F1F40094FBCF /* tvOSSlider.swift in Sources */,
531690F7267ACC00005D8AB9 /* LandscapeItemElement.swift in Sources */,
62E632E1267D30CA0063E547 /* LibraryViewModel.swift in Sources */,
535870A82669D8AE00D05A09 /* StringExtensions.swift in Sources */,
@ -1859,6 +1894,7 @@
C4BE0767271FC109003F4AD1 /* TVLibrariesViewModel.swift in Sources */,
E193D53727193F8700900D82 /* LibraryListCoordinator.swift in Sources */,
E100720726BDABC100CE3E31 /* MediaPlayButtonRowView.swift in Sources */,
E17885A02780F55C0094FBCF /* tvOSVLCOverlay.swift in Sources */,
E193D54D2719426600900D82 /* LibraryFilterView.swift in Sources */,
E193D53927193F8E00900D82 /* SearchCoordinator.swift in Sources */,
E193D4D927193CAC00900D82 /* PortraitImageStackable.swift in Sources */,

View File

@ -75,9 +75,6 @@ final class MainTabCoordinator: TabCoordinatable {
}
@ViewBuilder func makeSettingsTab(isActive: Bool) -> some View {
HStack {
Image(systemName: "gearshape.fill")
Text("Settings")
}
Image(systemName: "gearshape.fill")
}
}

View File

@ -11,7 +11,7 @@ import SwiftUI
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
public static let systemFill = Color(UIColor.white)
@ -23,3 +23,7 @@ extension Color {
public static let tertiarySystemFill = Color(UIColor.tertiarySystemBackground)
#endif
}
extension UIColor {
static let jellyfinPurple = UIColor(red: 172 / 255, green: 92 / 255, blue: 195 / 255, alpha: 1)
}