Merge pull request #250 from LePips/ios-video-player-refactor
Video Player Refactor and More
This commit is contained in:
commit
feedcadfc4
|
@ -14,6 +14,15 @@ struct JellyfinPlayer_tvOSApp: App {
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
MainCoordinator().view()
|
MainCoordinator().view()
|
||||||
|
.onAppear {
|
||||||
|
JellyfinPlayer_tvOSApp.setupAppearance()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func setupAppearance() {
|
||||||
|
let scenes = UIApplication.shared.connectedScenes
|
||||||
|
let windowScene = scenes.first as? UIWindowScene
|
||||||
|
windowScene?.windows.first?.overrideUserInterfaceStyle = .dark
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -70,7 +70,7 @@ struct LandscapeItemElement: View {
|
||||||
.frame(width: 445, height: 90)
|
.frame(width: 445, height: 90)
|
||||||
.mask(CutOffShadow())
|
.mask(CutOffShadow())
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
Text("CONTINUE • \(item.getItemProgressString())")
|
Text("CONTINUE • \(item.getItemProgressString() ?? "")")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.fontWeight(.medium)
|
.fontWeight(.medium)
|
||||||
.offset(y: 5)
|
.offset(y: 5)
|
||||||
|
@ -80,7 +80,7 @@ struct LandscapeItemElement: View {
|
||||||
.opacity(0.4)
|
.opacity(0.4)
|
||||||
.frame(minWidth: 100, maxWidth: .infinity, minHeight: 12, maxHeight: 12)
|
.frame(minWidth: 100, maxWidth: .infinity, minHeight: 12, maxHeight: 12)
|
||||||
RoundedRectangle(cornerRadius: 6)
|
RoundedRectangle(cornerRadius: 6)
|
||||||
.fill(Color(red: 172/255, green: 92/255, blue: 195/255))
|
.fill(Color.jellyfinPurple)
|
||||||
.frame(width: CGFloat(item.userData?.playedPercentage ?? 0 * 4.45 - 0.16), height: 12)
|
.frame(width: CGFloat(item.userData?.playedPercentage ?? 0 * 4.45 - 0.16), height: 12)
|
||||||
}
|
}
|
||||||
}.padding(12)
|
}.padding(12)
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
//
|
//
|
||||||
/*
|
/*
|
||||||
* SwiftFin is subject to the terms of the Mozilla Public
|
* SwiftFin is subject to the terms of the Mozilla Public
|
||||||
* License, v2.0. If a copy of the MPL was not distributed with this
|
* 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/.
|
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
@ -18,11 +18,12 @@ struct MediaPlayButtonRowView: View {
|
||||||
HStack {
|
HStack {
|
||||||
VStack {
|
VStack {
|
||||||
Button {
|
Button {
|
||||||
self.itemRouter.route(to: \.videoPlayer, viewModel.item)
|
itemRouter.route(to: \.videoPlayer, viewModel.itemVideoPlayerViewModel!)
|
||||||
} label: {
|
} label: {
|
||||||
MediaViewActionButton(icon: "play.fill", scrollView: $wrappedScrollView)
|
MediaViewActionButton(icon: "play.fill", scrollView: $wrappedScrollView)
|
||||||
}
|
}
|
||||||
Text(viewModel.item.getItemProgressString() != "" ? "\(viewModel.item.getItemProgressString()) left" : L10n.play)
|
|
||||||
|
Text(viewModel.item.getItemProgressString() != "" ? "\(viewModel.item.getItemProgressString() ?? "") left" : L10n.play)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
}
|
}
|
||||||
VStack {
|
VStack {
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -28,12 +28,15 @@
|
||||||
<true/>
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
<key>UILaunchScreen</key>
|
<key>UILaunchScreen</key>
|
||||||
<dict/>
|
<dict>
|
||||||
|
<key>UIColorName</key>
|
||||||
|
<string>LaunchScreenBackground</string>
|
||||||
|
</dict>
|
||||||
<key>UIRequiredDeviceCapabilities</key>
|
<key>UIRequiredDeviceCapabilities</key>
|
||||||
<array>
|
<array>
|
||||||
<string>arm64</string>
|
<string>arm64</string>
|
||||||
</array>
|
</array>
|
||||||
<key>UIUserInterfaceStyle</key>
|
<key>UIUserInterfaceStyle</key>
|
||||||
<string>Automatic</string>
|
<string>Dark</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|
|
@ -26,9 +26,7 @@ struct BasicAppSettingsView: View {
|
||||||
ForEach(self.viewModel.appearances, id: \.self) { appearance in
|
ForEach(self.viewModel.appearances, id: \.self) { appearance in
|
||||||
Text(appearance.localizedName).tag(appearance.rawValue)
|
Text(appearance.localizedName).tag(appearance.rawValue)
|
||||||
}
|
}
|
||||||
}.onChange(of: appAppearance, perform: { _ in
|
}
|
||||||
UIApplication.shared.windows.first?.overrideUserInterfaceStyle = appAppearance.style
|
|
||||||
})
|
|
||||||
} header: {
|
} header: {
|
||||||
L10n.accessibility.text
|
L10n.accessibility.text
|
||||||
}
|
}
|
||||||
|
@ -41,7 +39,7 @@ struct BasicAppSettingsView: View {
|
||||||
}
|
}
|
||||||
.alert(L10n.reset, isPresented: $resetTapped, actions: {
|
.alert(L10n.reset, isPresented: $resetTapped, actions: {
|
||||||
Button(role: .destructive) {
|
Button(role: .destructive) {
|
||||||
viewModel.reset()
|
viewModel.resetAppSettings()
|
||||||
basicAppSettingsRouter.dismissCoordinator()
|
basicAppSettingsRouter.dismissCoordinator()
|
||||||
} label: {
|
} label: {
|
||||||
L10n.reset.text
|
L10n.reset.text
|
||||||
|
|
|
@ -17,38 +17,54 @@ struct HomeView: View {
|
||||||
@State var showingSettings = false
|
@State var showingSettings = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ZStack {
|
||||||
|
Color.black
|
||||||
|
.ignoresSafeArea()
|
||||||
|
|
||||||
if viewModel.isLoading {
|
if viewModel.isLoading {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
|
.scaleEffect(2)
|
||||||
} else {
|
} else {
|
||||||
LazyVStack(alignment: .leading) {
|
ScrollView {
|
||||||
if !viewModel.resumeItems.isEmpty {
|
LazyVStack(alignment: .leading) {
|
||||||
ContinueWatchingView(items: viewModel.resumeItems)
|
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 })
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !viewModel.nextUpItems.isEmpty {
|
||||||
|
NextUpView(items: viewModel.nextUpItems)
|
||||||
|
}
|
||||||
|
|
||||||
|
ForEach(viewModel.libraries, id: \.self) { library in
|
||||||
|
Button {
|
||||||
|
self.homeRouter.route(to: \.modalLibrary, (.init(parentID: library.id, 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: library.id ?? "")
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(minLength: 100)
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button {
|
||||||
|
viewModel.refresh()
|
||||||
|
} label: {
|
||||||
|
Text("Refresh")
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.focusSection()
|
||||||
}
|
}
|
||||||
Spacer().frame(height: 30)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,64 @@
|
||||||
|
//
|
||||||
|
/*
|
||||||
|
* SwiftFin is subject to the terms of the Mozilla Public
|
||||||
|
* License, v2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* Copyright 2021 Aiden Vigue & Jellyfin Contributors
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Defaults
|
||||||
|
import Introspect
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct CinematicEpisodeItemView: View {
|
||||||
|
|
||||||
|
@ObservedObject var viewModel: EpisodeItemViewModel
|
||||||
|
@State var wrappedScrollView: UIScrollView?
|
||||||
|
@Default(.showPosterLabels) var showPosterLabels
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
|
||||||
|
ImageView(src: viewModel.item.getBackdropImage(maxWidth: 1920),
|
||||||
|
bh: viewModel.item.getBackdropImageBlurHash())
|
||||||
|
.ignoresSafeArea()
|
||||||
|
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
|
||||||
|
CinematicItemViewTopRow(viewModel: viewModel, wrappedScrollView: wrappedScrollView)
|
||||||
|
.focusSection()
|
||||||
|
.frame(height: UIScreen.main.bounds.height - 10)
|
||||||
|
|
||||||
|
ZStack(alignment: .topLeading) {
|
||||||
|
|
||||||
|
Color.black.ignoresSafeArea()
|
||||||
|
.frame(minHeight: UIScreen.main.bounds.height)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 20) {
|
||||||
|
|
||||||
|
CinematicItemAboutView(viewModel: viewModel)
|
||||||
|
|
||||||
|
EpisodesRowView(viewModel: EpisodesRowViewModel(episodeItemViewModel: viewModel))
|
||||||
|
.focusSection()
|
||||||
|
|
||||||
|
if !viewModel.similarItems.isEmpty {
|
||||||
|
PortraitItemsRowView(rowTitle: "Recommended",
|
||||||
|
items: viewModel.similarItems,
|
||||||
|
showItemTitles: showPosterLabels)
|
||||||
|
}
|
||||||
|
|
||||||
|
ItemDetailsView(viewModel: viewModel)
|
||||||
|
}
|
||||||
|
.padding(.top, 50)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.introspectScrollView { scrollView in
|
||||||
|
wrappedScrollView = scrollView
|
||||||
|
}
|
||||||
|
.ignoresSafeArea()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,43 @@
|
||||||
|
//
|
||||||
|
/*
|
||||||
|
* 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 CinematicItemAboutView: View {
|
||||||
|
|
||||||
|
@ObservedObject var viewModel: ItemViewModel
|
||||||
|
@FocusState private var focused: Bool
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(alignment: .top, spacing: 10) {
|
||||||
|
ImageView(src: viewModel.item.portraitHeaderViewURL(maxWidth: 257))
|
||||||
|
.frame(width: 257, height: 380)
|
||||||
|
.cornerRadius(10)
|
||||||
|
|
||||||
|
ZStack(alignment: .topLeading) {
|
||||||
|
Color(UIColor.darkGray).opacity(focused ? 0.2 : 0)
|
||||||
|
.cornerRadius(30)
|
||||||
|
.frame(height: 380)
|
||||||
|
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
Text("About")
|
||||||
|
.font(.title3)
|
||||||
|
|
||||||
|
Text(viewModel.item.overview ?? "No details available")
|
||||||
|
.padding(.top, 2)
|
||||||
|
.lineLimit(7)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.focusable()
|
||||||
|
.focused($focused)
|
||||||
|
.padding(.horizontal, 50)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,144 @@
|
||||||
|
//
|
||||||
|
/*
|
||||||
|
* 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 CinematicItemViewTopRow: View {
|
||||||
|
|
||||||
|
@EnvironmentObject var itemRouter: ItemCoordinator.Router
|
||||||
|
@ObservedObject var viewModel: ItemViewModel
|
||||||
|
@Environment(\.isFocused) var envFocused: Bool
|
||||||
|
@State var focused: Bool = false
|
||||||
|
@State var wrappedScrollView: UIScrollView?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack(alignment: .bottom) {
|
||||||
|
LinearGradient(gradient: Gradient(colors: [.clear, .black.opacity(0.8), .black]),
|
||||||
|
startPoint: .top,
|
||||||
|
endPoint: .bottom)
|
||||||
|
.ignoresSafeArea()
|
||||||
|
.frame(height: 210)
|
||||||
|
|
||||||
|
VStack {
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
HStack(alignment: .bottom) {
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
HStack(alignment: .PlayInformationAlignmentGuide) {
|
||||||
|
|
||||||
|
// MARK: Play
|
||||||
|
Button {
|
||||||
|
itemRouter.route(to: \.videoPlayer, viewModel.itemVideoPlayerViewModel!)
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 15) {
|
||||||
|
Image(systemName: "play.fill")
|
||||||
|
.foregroundColor(viewModel.playButtonItem == nil ? Color(UIColor.secondaryLabel) : Color.black)
|
||||||
|
.font(.title3)
|
||||||
|
Text(viewModel.playButtonText())
|
||||||
|
.foregroundColor(viewModel.playButtonItem == nil ? Color(UIColor.secondaryLabel) : Color.black)
|
||||||
|
// .font(.title3)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
}
|
||||||
|
.frame(width: 230, height: 100)
|
||||||
|
.background(viewModel.playButtonItem == nil ? Color.secondarySystemFill : Color.white)
|
||||||
|
.cornerRadius(10)
|
||||||
|
|
||||||
|
// ZStack {
|
||||||
|
// Color.white.frame(width: 230, height: 100)
|
||||||
|
//
|
||||||
|
// Text("Play")
|
||||||
|
// .font(.title3)
|
||||||
|
// .foregroundColor(.black)
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
.buttonStyle(CardButtonStyle())
|
||||||
|
.disabled(viewModel.itemVideoPlayerViewModel == nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 5) {
|
||||||
|
Text(viewModel.item.name ?? "")
|
||||||
|
.font(.title2)
|
||||||
|
.lineLimit(2)
|
||||||
|
|
||||||
|
if let seriesName = viewModel.item.seriesName, let episodeLocator = viewModel.item.getEpisodeLocator() {
|
||||||
|
Text("\(seriesName) - \(episodeLocator)")
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(alignment: .PlayInformationAlignmentGuide, spacing: 20) {
|
||||||
|
|
||||||
|
if let runtime = viewModel.item.getItemRuntime() {
|
||||||
|
Text(runtime)
|
||||||
|
.font(.subheadline)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let productionYear = viewModel.item.productionYear {
|
||||||
|
Text(String(productionYear))
|
||||||
|
.font(.subheadline)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let officialRating = viewModel.item.officialRating {
|
||||||
|
Text(officialRating)
|
||||||
|
.font(.subheadline)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.lineLimit(1)
|
||||||
|
.padding(EdgeInsets(top: 1, leading: 4, bottom: 1, trailing: 4))
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: 2)
|
||||||
|
.stroke(Color.secondary, lineWidth: 1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 50)
|
||||||
|
.padding(.bottom, 50)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
.onChange(of: envFocused) { envFocus in
|
||||||
|
if envFocus == true {
|
||||||
|
wrappedScrollView?.scrollToTop()
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) {
|
||||||
|
wrappedScrollView?.scrollToTop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
withAnimation(.linear(duration: 0.15)) {
|
||||||
|
self.focused = envFocus
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension HorizontalAlignment {
|
||||||
|
|
||||||
|
private struct TitleSubtitleAlignment: AlignmentID {
|
||||||
|
static func defaultValue(in context: ViewDimensions) -> CGFloat {
|
||||||
|
context[HorizontalAlignment.leading]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static let EpisodeSeriesAlignmentGuide = HorizontalAlignment(TitleSubtitleAlignment.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
extension VerticalAlignment {
|
||||||
|
|
||||||
|
private struct PlayInformationAlignment: AlignmentID {
|
||||||
|
static func defaultValue(in context: ViewDimensions) -> CGFloat {
|
||||||
|
context[VerticalAlignment.bottom]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static let PlayInformationAlignmentGuide = VerticalAlignment(PlayInformationAlignment.self)
|
||||||
|
}
|
|
@ -0,0 +1,48 @@
|
||||||
|
//
|
||||||
|
/*
|
||||||
|
* 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 CinematicItemViewTopRowButton<Content: View>: View {
|
||||||
|
@Environment(\.isFocused) var envFocused: Bool
|
||||||
|
@State var focused: Bool = false
|
||||||
|
@State var wrappedScrollView: UIScrollView?
|
||||||
|
var content: () -> Content
|
||||||
|
|
||||||
|
@FocusState private var buttonFocused: Bool
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
content()
|
||||||
|
.focused($buttonFocused)
|
||||||
|
.onChange(of: envFocused) { envFocus in
|
||||||
|
if envFocus == true {
|
||||||
|
wrappedScrollView?.scrollToTop()
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) {
|
||||||
|
wrappedScrollView?.scrollToTop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
withAnimation(.linear(duration: 0.15)) {
|
||||||
|
self.focused = envFocus
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: buttonFocused) { newValue in
|
||||||
|
if newValue {
|
||||||
|
wrappedScrollView?.scrollToTop()
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) {
|
||||||
|
wrappedScrollView?.scrollToTop()
|
||||||
|
}
|
||||||
|
|
||||||
|
withAnimation(.linear(duration: 0.15)) {
|
||||||
|
self.focused = newValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,61 @@
|
||||||
|
//
|
||||||
|
/*
|
||||||
|
* SwiftFin is subject to the terms of the Mozilla Public
|
||||||
|
* License, v2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* Copyright 2021 Aiden Vigue & Jellyfin Contributors
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Defaults
|
||||||
|
import Introspect
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct CinematicMovieItemView: View {
|
||||||
|
|
||||||
|
@ObservedObject var viewModel: MovieItemViewModel
|
||||||
|
@State var wrappedScrollView: UIScrollView?
|
||||||
|
@Default(.showPosterLabels) var showPosterLabels
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
|
||||||
|
ImageView(src: viewModel.item.getBackdropImage(maxWidth: 1920),
|
||||||
|
bh: viewModel.item.getBackdropImageBlurHash())
|
||||||
|
.ignoresSafeArea()
|
||||||
|
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
|
||||||
|
CinematicItemViewTopRow(viewModel: viewModel, wrappedScrollView: wrappedScrollView)
|
||||||
|
.focusSection()
|
||||||
|
.frame(height: UIScreen.main.bounds.height - 10)
|
||||||
|
|
||||||
|
ZStack(alignment: .topLeading) {
|
||||||
|
|
||||||
|
Color.black.ignoresSafeArea()
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 20) {
|
||||||
|
|
||||||
|
CinematicItemAboutView(viewModel: viewModel)
|
||||||
|
|
||||||
|
if !viewModel.similarItems.isEmpty {
|
||||||
|
PortraitItemsRowView(rowTitle: "Recommended",
|
||||||
|
items: viewModel.similarItems,
|
||||||
|
showItemTitles: showPosterLabels)
|
||||||
|
}
|
||||||
|
|
||||||
|
ItemDetailsView(viewModel: viewModel)
|
||||||
|
}
|
||||||
|
.padding(.top, 50)
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.introspectScrollView { scrollView in
|
||||||
|
wrappedScrollView = scrollView
|
||||||
|
}
|
||||||
|
.ignoresSafeArea()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -11,6 +11,8 @@ import SwiftUI
|
||||||
import JellyfinAPI
|
import JellyfinAPI
|
||||||
|
|
||||||
struct EpisodeItemView: View {
|
struct EpisodeItemView: View {
|
||||||
|
|
||||||
|
@EnvironmentObject var itemRouter: ItemCoordinator.Router
|
||||||
@ObservedObject var viewModel: EpisodeItemViewModel
|
@ObservedObject var viewModel: EpisodeItemViewModel
|
||||||
|
|
||||||
@State var actors: [BaseItemPerson] = []
|
@State var actors: [BaseItemPerson] = []
|
||||||
|
@ -57,10 +59,13 @@ struct EpisodeItemView: View {
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
}
|
}
|
||||||
Text(viewModel.item.getItemRuntime()).font(.subheadline)
|
if let runtime = viewModel.item.getItemRuntime() {
|
||||||
.fontWeight(.medium)
|
Text(runtime).font(.subheadline)
|
||||||
.foregroundColor(.secondary)
|
.fontWeight(.medium)
|
||||||
.lineLimit(1)
|
.foregroundColor(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
|
||||||
if viewModel.item.officialRating != nil {
|
if viewModel.item.officialRating != nil {
|
||||||
Text(viewModel.item.officialRating!).font(.subheadline)
|
Text(viewModel.item.officialRating!).font(.subheadline)
|
||||||
.fontWeight(.semibold)
|
.fontWeight(.semibold)
|
||||||
|
@ -118,36 +123,9 @@ struct EpisodeItemView: View {
|
||||||
.font(.body)
|
.font(.body)
|
||||||
.fontWeight(.medium)
|
.fontWeight(.medium)
|
||||||
.foregroundColor(.primary)
|
.foregroundColor(.primary)
|
||||||
|
|
||||||
HStack {
|
MediaPlayButtonRowView(viewModel: viewModel)
|
||||||
VStack {
|
.environmentObject(itemRouter)
|
||||||
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")
|
|
||||||
}
|
|
||||||
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)
|
||||||
|
|
||||||
|
|
|
@ -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 EpisodesRowView: View {
|
||||||
|
|
||||||
|
@EnvironmentObject var itemRouter: ItemCoordinator.Router
|
||||||
|
@ObservedObject var viewModel: EpisodesRowViewModel
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
|
||||||
|
Text(viewModel.selectedSeason?.name ?? "Episodes")
|
||||||
|
.font(.title3)
|
||||||
|
.padding(.horizontal, 50)
|
||||||
|
|
||||||
|
ScrollView(.horizontal) {
|
||||||
|
ScrollViewReader { reader in
|
||||||
|
HStack(alignment: .top) {
|
||||||
|
if viewModel.isLoading {
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
|
||||||
|
ZStack {
|
||||||
|
Color.secondary.ignoresSafeArea()
|
||||||
|
|
||||||
|
ProgressView()
|
||||||
|
}
|
||||||
|
.mask(Rectangle().frame(width: 500, height: 280))
|
||||||
|
.frame(width: 500, height: 280)
|
||||||
|
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
Text("S-E-")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Text("--")
|
||||||
|
.font(.footnote)
|
||||||
|
.padding(.bottom, 1)
|
||||||
|
Text("--")
|
||||||
|
.font(.caption)
|
||||||
|
.fontWeight(.light)
|
||||||
|
.lineLimit(4)
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.frame(width: 500)
|
||||||
|
.focusable()
|
||||||
|
} else if let selectedSeason = viewModel.selectedSeason {
|
||||||
|
if viewModel.seasonsEpisodes[selectedSeason]!.isEmpty {
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
|
||||||
|
Color.secondary
|
||||||
|
.mask(Rectangle().frame(width: 500, height: 280))
|
||||||
|
.frame(width: 500, height: 280)
|
||||||
|
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
Text("--")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Text("No episodes available")
|
||||||
|
.font(.footnote)
|
||||||
|
.padding(.bottom, 1)
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.frame(width: 500)
|
||||||
|
.focusable()
|
||||||
|
} else {
|
||||||
|
ForEach(viewModel.seasonsEpisodes[selectedSeason]!, id:\.self) { episode in
|
||||||
|
Button {
|
||||||
|
itemRouter.route(to: \.item, episode)
|
||||||
|
} label: {
|
||||||
|
HStack(alignment: .top) {
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
|
||||||
|
ImageView(src: episode.getBackdropImage(maxWidth: 445),
|
||||||
|
bh: episode.getBackdropImageBlurHash())
|
||||||
|
.mask(Rectangle().frame(width: 500, height: 280))
|
||||||
|
.frame(width: 500, height: 280)
|
||||||
|
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
Text(episode.getEpisodeLocator() ?? "")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Text(episode.name ?? "")
|
||||||
|
.font(.footnote)
|
||||||
|
.padding(.bottom, 1)
|
||||||
|
Text(episode.overview ?? "")
|
||||||
|
.font(.caption)
|
||||||
|
.fontWeight(.light)
|
||||||
|
.lineLimit(4)
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.frame(width: 500)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(PlainButtonStyle())
|
||||||
|
.id(episode.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 50)
|
||||||
|
.padding(.vertical)
|
||||||
|
.onChange(of: viewModel.selectedSeason) { _ in
|
||||||
|
if viewModel.selectedSeason?.id == viewModel.episodeItemViewModel.item.seasonId {
|
||||||
|
reader.scrollTo(viewModel.episodeItemViewModel.item.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: viewModel.seasonsEpisodes) { _ in
|
||||||
|
if viewModel.selectedSeason?.id == viewModel.episodeItemViewModel.item.seasonId {
|
||||||
|
reader.scrollTo(viewModel.episodeItemViewModel.item.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.edgesIgnoringSafeArea(.horizontal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,88 @@
|
||||||
|
//
|
||||||
|
/*
|
||||||
|
* 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 ItemDetailsView: View {
|
||||||
|
|
||||||
|
@ObservedObject var viewModel: ItemViewModel
|
||||||
|
@FocusState private var focused: Bool
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
|
||||||
|
ZStack(alignment: .leading) {
|
||||||
|
|
||||||
|
Color(UIColor.darkGray).opacity(focused ? 0.2 : 0)
|
||||||
|
.cornerRadius(30, corners: [.topLeft, .topRight])
|
||||||
|
|
||||||
|
HStack(alignment: .top) {
|
||||||
|
VStack(alignment: .leading, spacing: 20) {
|
||||||
|
Text("Information")
|
||||||
|
.font(.title3)
|
||||||
|
.padding(.bottom, 5)
|
||||||
|
|
||||||
|
ForEach(viewModel.informationItems, id: \.self.title) { informationItem in
|
||||||
|
ItemDetail(title: informationItem.title, content: informationItem.content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 20) {
|
||||||
|
Text("Media")
|
||||||
|
.font(.title3)
|
||||||
|
.padding(.bottom, 5)
|
||||||
|
|
||||||
|
ForEach(viewModel.mediaItems, id: \.self.title) { mediaItem in
|
||||||
|
ItemDetail(title: mediaItem.title, content: mediaItem.content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.ignoresSafeArea()
|
||||||
|
.focusable()
|
||||||
|
.focused($focused)
|
||||||
|
.padding(50)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fileprivate struct ItemDetail: View {
|
||||||
|
|
||||||
|
let title: String
|
||||||
|
let content: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
Text(title)
|
||||||
|
.font(.body)
|
||||||
|
Text(content)
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RoundedCorner: Shape {
|
||||||
|
|
||||||
|
var radius: CGFloat = .infinity
|
||||||
|
var corners: UIRectCorner = .allCorners
|
||||||
|
|
||||||
|
func path(in rect: CGRect) -> Path {
|
||||||
|
let path = UIBezierPath(roundedRect: rect, byRoundingCorners: corners, cornerRadii: CGSize(width: radius, height: radius))
|
||||||
|
return Path(path.cgPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
func cornerRadius(_ radius: CGFloat, corners: UIRectCorner) -> some View {
|
||||||
|
clipShape( RoundedCorner(radius: radius, corners: corners) )
|
||||||
|
}
|
||||||
|
}
|
|
@ -5,9 +5,10 @@
|
||||||
* Copyright 2021 Aiden Vigue & Jellyfin Contributors
|
* Copyright 2021 Aiden Vigue & Jellyfin Contributors
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import SwiftUI
|
import Defaults
|
||||||
import Introspect
|
import Introspect
|
||||||
import JellyfinAPI
|
import JellyfinAPI
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
// Useless view necessary in tvOS because of iOS's implementation
|
// Useless view necessary in tvOS because of iOS's implementation
|
||||||
struct ItemNavigationView: View {
|
struct ItemNavigationView: View {
|
||||||
|
@ -23,6 +24,10 @@ struct ItemNavigationView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ItemView: View {
|
struct ItemView: View {
|
||||||
|
|
||||||
|
@Default(.tvOSEpisodeItemCinematicView) var tvOSEpisodeItemCinematicView
|
||||||
|
@Default(.tvOSMovieItemCinematicView) var tvOSMovieItemCinematicView
|
||||||
|
|
||||||
private var item: BaseItemDto
|
private var item: BaseItemDto
|
||||||
|
|
||||||
init(item: BaseItemDto) {
|
init(item: BaseItemDto) {
|
||||||
|
@ -32,13 +37,21 @@ struct ItemView: View {
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
Group {
|
||||||
if item.type == "Movie" {
|
if item.type == "Movie" {
|
||||||
MovieItemView(viewModel: .init(item: item))
|
if tvOSMovieItemCinematicView {
|
||||||
|
CinematicMovieItemView(viewModel: MovieItemViewModel(item: item))
|
||||||
|
} else {
|
||||||
|
MovieItemView(viewModel: MovieItemViewModel(item: item))
|
||||||
|
}
|
||||||
} else if item.type == "Series" {
|
} else if item.type == "Series" {
|
||||||
SeriesItemView(viewModel: .init(item: item))
|
SeriesItemView(viewModel: .init(item: item))
|
||||||
} else if item.type == "Season" {
|
} else if item.type == "Season" {
|
||||||
SeasonItemView(viewModel: .init(item: item))
|
SeasonItemView(viewModel: .init(item: item))
|
||||||
} else if item.type == "Episode" {
|
} else if item.type == "Episode" {
|
||||||
EpisodeItemView(viewModel: .init(item: item))
|
if tvOSEpisodeItemCinematicView {
|
||||||
|
CinematicEpisodeItemView(viewModel: EpisodeItemViewModel(item: item))
|
||||||
|
} else {
|
||||||
|
EpisodeItemView(viewModel: EpisodeItemViewModel(item: item))
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
Text(L10n.notImplementedYetWithType(item.type ?? ""))
|
Text(L10n.notImplementedYetWithType(item.type ?? ""))
|
||||||
}
|
}
|
||||||
|
|
|
@ -59,10 +59,12 @@ struct MovieItemView: View {
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
}
|
}
|
||||||
Text(viewModel.item.getItemRuntime()).font(.subheadline)
|
if let runtime = viewModel.item.getItemRuntime() {
|
||||||
.fontWeight(.medium)
|
Text(runtime).font(.subheadline)
|
||||||
.foregroundColor(.secondary)
|
.fontWeight(.medium)
|
||||||
.lineLimit(1)
|
.foregroundColor(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
if viewModel.item.officialRating != nil {
|
if viewModel.item.officialRating != nil {
|
||||||
Text(viewModel.item.officialRating!).font(.subheadline)
|
Text(viewModel.item.officialRating!).font(.subheadline)
|
||||||
.fontWeight(.semibold)
|
.fontWeight(.semibold)
|
||||||
|
|
|
@ -0,0 +1,62 @@
|
||||||
|
//
|
||||||
|
/*
|
||||||
|
* 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 JellyfinAPI
|
||||||
|
|
||||||
|
struct PortraitItemsRowView: View {
|
||||||
|
|
||||||
|
@EnvironmentObject var itemRouter: ItemCoordinator.Router
|
||||||
|
|
||||||
|
let rowTitle: String
|
||||||
|
let items: [BaseItemDto]
|
||||||
|
let showItemTitles: Bool
|
||||||
|
|
||||||
|
init(rowTitle: String, items: [BaseItemDto], showItemTitles: Bool = true) {
|
||||||
|
self.rowTitle = rowTitle
|
||||||
|
self.items = items
|
||||||
|
self.showItemTitles = showItemTitles
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
|
||||||
|
Text(rowTitle)
|
||||||
|
.font(.title3)
|
||||||
|
.padding(.horizontal, 50)
|
||||||
|
|
||||||
|
ScrollView(.horizontal) {
|
||||||
|
HStack(alignment: .top) {
|
||||||
|
ForEach(items, id: \.self) { item in
|
||||||
|
|
||||||
|
VStack(spacing: 15) {
|
||||||
|
Button {
|
||||||
|
itemRouter.route(to: \.item, item)
|
||||||
|
} label: {
|
||||||
|
ImageView(src: item.portraitHeaderViewURL(maxWidth: 257))
|
||||||
|
.frame(width: 257, height: 380)
|
||||||
|
}
|
||||||
|
.frame(height: 380)
|
||||||
|
.buttonStyle(PlainButtonStyle())
|
||||||
|
|
||||||
|
if showItemTitles {
|
||||||
|
Text(item.title)
|
||||||
|
.lineLimit(2)
|
||||||
|
.frame(width: 257)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 50)
|
||||||
|
.padding(.vertical)
|
||||||
|
}
|
||||||
|
.edgesIgnoringSafeArea(.horizontal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -11,6 +11,8 @@ import SwiftUI
|
||||||
import JellyfinAPI
|
import JellyfinAPI
|
||||||
|
|
||||||
struct SeasonItemView: View {
|
struct SeasonItemView: View {
|
||||||
|
|
||||||
|
@EnvironmentObject var itemRouter: ItemCoordinator.Router
|
||||||
@ObservedObject var viewModel: SeasonItemViewModel
|
@ObservedObject var viewModel: SeasonItemViewModel
|
||||||
@State var wrappedScrollView: UIScrollView?
|
@State var wrappedScrollView: UIScrollView?
|
||||||
|
|
||||||
|
@ -101,10 +103,15 @@ struct SeasonItemView: View {
|
||||||
ScrollView(.horizontal) {
|
ScrollView(.horizontal) {
|
||||||
LazyHStack {
|
LazyHStack {
|
||||||
Spacer().frame(width: 45)
|
Spacer().frame(width: 45)
|
||||||
|
|
||||||
ForEach(viewModel.episodes, id: \.id) { episode in
|
ForEach(viewModel.episodes, id: \.id) { episode in
|
||||||
NavigationLink(destination: ItemView(item: episode)) {
|
|
||||||
|
Button {
|
||||||
|
itemRouter.route(to: \.item, episode)
|
||||||
|
} label: {
|
||||||
LandscapeItemElement(item: episode, inSeasonView: true)
|
LandscapeItemElement(item: episode, inSeasonView: true)
|
||||||
}.buttonStyle(PlainNavigationLinkButtonStyle())
|
}
|
||||||
|
.buttonStyle(PlainNavigationLinkButtonStyle())
|
||||||
}
|
}
|
||||||
Spacer().frame(width: 45)
|
Spacer().frame(width: 45)
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,6 +11,8 @@ import SwiftUI
|
||||||
import JellyfinAPI
|
import JellyfinAPI
|
||||||
|
|
||||||
struct SeriesItemView: View {
|
struct SeriesItemView: View {
|
||||||
|
|
||||||
|
@EnvironmentObject var itemRouter: ItemCoordinator.Router
|
||||||
@ObservedObject var viewModel: SeriesItemViewModel
|
@ObservedObject var viewModel: SeriesItemViewModel
|
||||||
|
|
||||||
@State var actors: [BaseItemPerson] = []
|
@State var actors: [BaseItemPerson] = []
|
||||||
|
@ -141,10 +143,16 @@ struct SeriesItemView: View {
|
||||||
ScrollView(.horizontal) {
|
ScrollView(.horizontal) {
|
||||||
LazyHStack {
|
LazyHStack {
|
||||||
Spacer().frame(width: 45)
|
Spacer().frame(width: 45)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
ForEach(viewModel.seasons, id: \.id) { season in
|
ForEach(viewModel.seasons, id: \.id) { season in
|
||||||
NavigationLink(destination: ItemView(item: season)) {
|
Button {
|
||||||
|
itemRouter.route(to: \.item, season)
|
||||||
|
} label: {
|
||||||
PortraitItemElement(item: season)
|
PortraitItemElement(item: season)
|
||||||
}.buttonStyle(PlainNavigationLinkButtonStyle())
|
}
|
||||||
|
.buttonStyle(PlainNavigationLinkButtonStyle())
|
||||||
}
|
}
|
||||||
Spacer().frame(width: 45)
|
Spacer().frame(width: 45)
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,8 +13,9 @@ struct LatestMediaView: View {
|
||||||
|
|
||||||
@StateObject var tempViewModel = ViewModel()
|
@StateObject var tempViewModel = ViewModel()
|
||||||
@State var items: [BaseItemDto] = []
|
@State var items: [BaseItemDto] = []
|
||||||
private var library_id: String = ""
|
|
||||||
@State private var viewDidLoad: Bool = false
|
@State private var viewDidLoad: Bool = false
|
||||||
|
|
||||||
|
private var library_id: String = ""
|
||||||
|
|
||||||
init(usingParentID: String) {
|
init(usingParentID: String) {
|
||||||
library_id = usingParentID
|
library_id = usingParentID
|
||||||
|
@ -26,15 +27,13 @@ struct LatestMediaView: View {
|
||||||
}
|
}
|
||||||
viewDidLoad = true
|
viewDidLoad = true
|
||||||
|
|
||||||
DispatchQueue.global(qos: .userInitiated).async {
|
UserLibraryAPI.getLatestMedia(userId: SessionManager.main.currentLogin.user.id, parentId: library_id, fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people, .chapters], enableUserData: true, limit: 12)
|
||||||
UserLibraryAPI.getLatestMedia(userId: SessionManager.main.currentLogin.user.id, parentId: library_id, fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people], enableUserData: true, limit: 12)
|
.sink(receiveCompletion: { completion in
|
||||||
.sink(receiveCompletion: { completion in
|
print(completion)
|
||||||
print(completion)
|
}, receiveValue: { response in
|
||||||
}, receiveValue: { response in
|
items = response
|
||||||
items = response
|
})
|
||||||
})
|
.store(in: &tempViewModel.cancellables)
|
||||||
.store(in: &tempViewModel.cancellables)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
|
|
@ -7,6 +7,7 @@
|
||||||
* Copyright 2021 Aiden Vigue & Jellyfin Contributors
|
* Copyright 2021 Aiden Vigue & Jellyfin Contributors
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import Defaults
|
||||||
import Foundation
|
import Foundation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
@ -14,6 +15,8 @@ struct LibraryListView: View {
|
||||||
@EnvironmentObject var mainCoordinator: MainCoordinator.Router
|
@EnvironmentObject var mainCoordinator: MainCoordinator.Router
|
||||||
@EnvironmentObject var libraryListRouter: LibraryListCoordinator.Router
|
@EnvironmentObject var libraryListRouter: LibraryListCoordinator.Router
|
||||||
@StateObject var viewModel = LibraryListViewModel()
|
@StateObject var viewModel = LibraryListViewModel()
|
||||||
|
|
||||||
|
@Default(.Experimental.liveTVAlphaEnabled) var liveTVAlphaEnabled
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
|
@ -23,32 +26,55 @@ struct LibraryListView: View {
|
||||||
if library.collectionType ?? "" == "movies" || library.collectionType ?? "" == "tvshows" || library.collectionType ?? "" == "music" {
|
if library.collectionType ?? "" == "movies" || library.collectionType ?? "" == "tvshows" || library.collectionType ?? "" == "music" {
|
||||||
EmptyView()
|
EmptyView()
|
||||||
} else {
|
} else {
|
||||||
Button() {
|
if library.collectionType == "livetv" {
|
||||||
if library.collectionType == "livetv" {
|
if liveTVAlphaEnabled {
|
||||||
self.mainCoordinator.root(\.liveTV)
|
Button() {
|
||||||
} else {
|
self.mainCoordinator.root(\.liveTV)
|
||||||
|
}
|
||||||
|
label: {
|
||||||
|
ZStack {
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
VStack {
|
||||||
|
Text(library.name ?? "")
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.font(.title2)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}.padding(32)
|
||||||
|
}
|
||||||
|
.frame(minWidth: 100, maxWidth: .infinity)
|
||||||
|
.frame(height: 100)
|
||||||
|
}
|
||||||
|
.cornerRadius(10)
|
||||||
|
.shadow(radius: 5)
|
||||||
|
.padding(.bottom, 5)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Button() {
|
||||||
self.libraryListRouter.route(to: \.library, (viewModel: LibraryViewModel(), title: library.name ?? ""))
|
self.libraryListRouter.route(to: \.library, (viewModel: LibraryViewModel(), title: library.name ?? ""))
|
||||||
}
|
}
|
||||||
}
|
label: {
|
||||||
label: {
|
ZStack {
|
||||||
ZStack {
|
HStack {
|
||||||
HStack {
|
Spacer()
|
||||||
Spacer()
|
VStack {
|
||||||
VStack {
|
Text(library.name ?? "")
|
||||||
Text(library.name ?? "")
|
.foregroundColor(.white)
|
||||||
.foregroundColor(.white)
|
.font(.title2)
|
||||||
.font(.title2)
|
.fontWeight(.semibold)
|
||||||
.fontWeight(.semibold)
|
}
|
||||||
}
|
Spacer()
|
||||||
Spacer()
|
}.padding(32)
|
||||||
}.padding(32)
|
}
|
||||||
|
.frame(minWidth: 100, maxWidth: .infinity)
|
||||||
|
.frame(height: 100)
|
||||||
}
|
}
|
||||||
.frame(minWidth: 100, maxWidth: .infinity)
|
.cornerRadius(10)
|
||||||
.frame(height: 100)
|
.shadow(radius: 5)
|
||||||
|
.padding(.bottom, 5)
|
||||||
}
|
}
|
||||||
.cornerRadius(10)
|
|
||||||
.shadow(radius: 5)
|
|
||||||
.padding(.bottom, 5)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -22,6 +22,7 @@ struct ServerDetailView: View {
|
||||||
Text(SessionManager.main.currentLogin.server.name)
|
Text(SessionManager.main.currentLogin.server.name)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
|
.focusable()
|
||||||
|
|
||||||
HStack {
|
HStack {
|
||||||
Text("URI")
|
Text("URI")
|
||||||
|
|
|
@ -67,7 +67,7 @@ struct ServerListView: View {
|
||||||
Text("Connect to a Jellyfin server to get started")
|
Text("Connect to a Jellyfin server to get started")
|
||||||
.frame(minWidth: 50, maxWidth: 500)
|
.frame(minWidth: 50, maxWidth: 500)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
.font(.callout)
|
.font(.body)
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
serverListRouter.route(to: \.connectToServer)
|
serverListRouter.route(to: \.connectToServer)
|
||||||
|
@ -75,8 +75,12 @@ struct ServerListView: View {
|
||||||
L10n.connect.text
|
L10n.connect.text
|
||||||
.bold()
|
.bold()
|
||||||
.font(.callout)
|
.font(.callout)
|
||||||
|
.padding(.vertical)
|
||||||
|
.padding(.horizontal, 30)
|
||||||
|
.background(Color.jellyfinPurple)
|
||||||
}
|
}
|
||||||
.padding(.top, 40)
|
.padding(.top, 40)
|
||||||
|
.buttonStyle(CardButtonStyle())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,79 +0,0 @@
|
||||||
/* JellyfinPlayer/Swiftfin is subject to the terms of the Mozilla Public
|
|
||||||
* License, v2.0. If a copy of the MPL was not distributed with this
|
|
||||||
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
|
||||||
*
|
|
||||||
* Copyright 2021 Aiden Vigue & Jellyfin Contributors
|
|
||||||
*/
|
|
||||||
|
|
||||||
import CoreData
|
|
||||||
import SwiftUI
|
|
||||||
import Defaults
|
|
||||||
import JellyfinAPI
|
|
||||||
|
|
||||||
struct SettingsView: View {
|
|
||||||
|
|
||||||
@ObservedObject var viewModel: SettingsViewModel
|
|
||||||
|
|
||||||
@Default(.inNetworkBandwidth) var inNetworkStreamBitrate
|
|
||||||
@Default(.outOfNetworkBandwidth) var outOfNetworkStreamBitrate
|
|
||||||
@Default(.isAutoSelectSubtitles) var isAutoSelectSubtitles
|
|
||||||
@Default(.autoSelectSubtitlesLangCode) var autoSelectSubtitlesLangcode
|
|
||||||
@Default(.autoSelectAudioLangCode) var autoSelectAudioLangcode
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Form {
|
|
||||||
Section(header: L10n.playbackSettings.text) {
|
|
||||||
Picker("Default local quality", selection: $inNetworkStreamBitrate) {
|
|
||||||
ForEach(self.viewModel.bitrates, id: \.self) { bitrate in
|
|
||||||
Text(bitrate.name).tag(bitrate.value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Picker("Default remote quality", selection: $outOfNetworkStreamBitrate) {
|
|
||||||
ForEach(self.viewModel.bitrates, id: \.self) { bitrate in
|
|
||||||
Text(bitrate.name).tag(bitrate.value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Section(header: L10n.accessibility.text) {
|
|
||||||
Toggle("Automatically show subtitles", isOn: $isAutoSelectSubtitles)
|
|
||||||
SearchablePicker(label: "Preferred subtitle language",
|
|
||||||
options: viewModel.langs,
|
|
||||||
optionToString: { $0.name },
|
|
||||||
selected: Binding<TrackLanguage>(
|
|
||||||
get: { viewModel.langs.first(where: { $0.isoCode == autoSelectSubtitlesLangcode }) ?? .auto },
|
|
||||||
set: {autoSelectSubtitlesLangcode = $0.isoCode}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
SearchablePicker(label: "Preferred audio language",
|
|
||||||
options: viewModel.langs,
|
|
||||||
optionToString: { $0.name },
|
|
||||||
selected: Binding<TrackLanguage>(
|
|
||||||
get: { viewModel.langs.first(where: { $0.isoCode == autoSelectAudioLangcode }) ?? .auto },
|
|
||||||
set: { autoSelectAudioLangcode = $0.isoCode}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Section(header: Text(SessionManager.main.currentLogin.server.name)) {
|
|
||||||
HStack {
|
|
||||||
Text(L10n.signedInAsWithString(SessionManager.main.currentLogin.user.username)).foregroundColor(.primary)
|
|
||||||
Spacer()
|
|
||||||
Button {
|
|
||||||
SwiftfinNotificationCenter.main.post(name: SwiftfinNotificationCenter.Keys.didSignOut, object: nil)
|
|
||||||
} label: {
|
|
||||||
L10n.switchUser.text.font(.callout)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Button {
|
|
||||||
SessionManager.main.logout()
|
|
||||||
} label: {
|
|
||||||
Text("Sign out").font(.callout)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.leading, 90)
|
|
||||||
.padding(.trailing, 90)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -0,0 +1,31 @@
|
||||||
|
//
|
||||||
|
/*
|
||||||
|
* SwiftFin is subject to the terms of the Mozilla Public
|
||||||
|
* License, v2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* Copyright 2021 Aiden Vigue & Jellyfin Contributors
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Defaults
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ExperimentalSettingsView: View {
|
||||||
|
|
||||||
|
@Default(.Experimental.syncSubtitleStateWithAdjacent) var syncSubtitleStateWithAdjacent
|
||||||
|
@Default(.Experimental.liveTVAlphaEnabled) var liveTVAlphaEnabled
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Form {
|
||||||
|
Section {
|
||||||
|
|
||||||
|
Toggle("Sync Subtitles with Adjacent Episodes", isOn: $syncSubtitleStateWithAdjacent)
|
||||||
|
|
||||||
|
Toggle("Live TV (Alpha)", isOn: $liveTVAlphaEnabled)
|
||||||
|
|
||||||
|
} header: {
|
||||||
|
Text("Experimental")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,29 @@
|
||||||
|
//
|
||||||
|
/*
|
||||||
|
* SwiftFin is subject to the terms of the Mozilla Public
|
||||||
|
* License, v2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* Copyright 2021 Aiden Vigue & Jellyfin Contributors
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Defaults
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct OverlaySettingsView: View {
|
||||||
|
|
||||||
|
@Default(.shouldShowPlayPreviousItem) var shouldShowPlayPreviousItem
|
||||||
|
@Default(.shouldShowPlayNextItem) var shouldShowPlayNextItem
|
||||||
|
@Default(.shouldShowAutoPlay) var shouldShowAutoPlay
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Form {
|
||||||
|
Section(header: Text("Overlay")) {
|
||||||
|
|
||||||
|
Toggle("\(Image(systemName: "chevron.left.circle")) Play Previous Item", isOn: $shouldShowPlayPreviousItem)
|
||||||
|
Toggle("\(Image(systemName: "chevron.right.circle")) Play Next Item", isOn: $shouldShowPlayNextItem)
|
||||||
|
Toggle("\(Image(systemName: "play.circle.fill")) Auto Play", isOn: $shouldShowAutoPlay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,131 @@
|
||||||
|
/* JellyfinPlayer/Swiftfin is subject to the terms of the Mozilla Public
|
||||||
|
* License, v2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* Copyright 2021 Aiden Vigue & Jellyfin Contributors
|
||||||
|
*/
|
||||||
|
|
||||||
|
import CoreData
|
||||||
|
import SwiftUI
|
||||||
|
import Defaults
|
||||||
|
import JellyfinAPI
|
||||||
|
|
||||||
|
struct SettingsView: View {
|
||||||
|
|
||||||
|
@EnvironmentObject var settingsRouter: SettingsCoordinator.Router
|
||||||
|
@ObservedObject var viewModel: SettingsViewModel
|
||||||
|
|
||||||
|
@Default(.autoSelectAudioLangCode) var autoSelectAudioLangcode
|
||||||
|
@Default(.videoPlayerJumpForward) var jumpForwardLength
|
||||||
|
@Default(.videoPlayerJumpBackward) var jumpBackwardLength
|
||||||
|
@Default(.downActionShowsMenu) var downActionShowsMenu
|
||||||
|
@Default(.confirmClose) var confirmClose
|
||||||
|
@Default(.tvOSEpisodeItemCinematicView) var tvOSEpisodeItemCinematicView
|
||||||
|
@Default(.tvOSMovieItemCinematicView) var tvOSMovieItemCinematicView
|
||||||
|
@Default(.showPosterLabels) var showPosterLabels
|
||||||
|
@Default(.resumeOffset) var resumeOffset
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
GeometryReader { reader in
|
||||||
|
HStack {
|
||||||
|
|
||||||
|
Image(uiImage: UIImage(named: "App Icon")!)
|
||||||
|
.cornerRadius(30)
|
||||||
|
.scaleEffect(2)
|
||||||
|
.frame(width: reader.size.width / 2)
|
||||||
|
|
||||||
|
Form {
|
||||||
|
Section(header: EmptyView()) {
|
||||||
|
HStack {
|
||||||
|
Text("User")
|
||||||
|
Spacer()
|
||||||
|
Text(viewModel.user.username)
|
||||||
|
.foregroundColor(.jellyfinPurple)
|
||||||
|
}
|
||||||
|
.focusable()
|
||||||
|
|
||||||
|
Button {
|
||||||
|
settingsRouter.route(to: \.serverDetail)
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Text("Server")
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
Spacer()
|
||||||
|
Text(viewModel.server.name)
|
||||||
|
.foregroundColor(.jellyfinPurple)
|
||||||
|
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.foregroundColor(.jellyfinPurple)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
SessionManager.main.logout()
|
||||||
|
} label: {
|
||||||
|
Text("Switch User")
|
||||||
|
.foregroundColor(Color.jellyfinPurple)
|
||||||
|
.font(.callout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section(header: Text("Video Player")) {
|
||||||
|
Picker("Jump Forward Length", selection: $jumpForwardLength) {
|
||||||
|
ForEach(VideoPlayerJumpLength.allCases, id: \.self) { length in
|
||||||
|
Text(length.label).tag(length.rawValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Picker("Jump Backward Length", selection: $jumpBackwardLength) {
|
||||||
|
ForEach(VideoPlayerJumpLength.allCases, id: \.self) { length in
|
||||||
|
Text(length.label).tag(length.rawValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Toggle("Resume 5 Second Offset", isOn: $resumeOffset)
|
||||||
|
|
||||||
|
Toggle("Press Down for Menu", isOn: $downActionShowsMenu)
|
||||||
|
|
||||||
|
Toggle("Confirm Close", isOn: $confirmClose)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
settingsRouter.route(to: \.overlaySettings)
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Text("Overlay")
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
settingsRouter.route(to: \.experimentalSettings)
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Text("Experimental")
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Toggle("Episode Item Cinematic View", isOn: $tvOSEpisodeItemCinematicView)
|
||||||
|
Toggle("Movie Item Cinematic View", isOn: $tvOSMovieItemCinematicView)
|
||||||
|
Toggle("Show Poster Labels", isOn: $showPosterLabels)
|
||||||
|
|
||||||
|
} header: {
|
||||||
|
Text("Appearance")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SettingsView_Previews: PreviewProvider {
|
||||||
|
static var previews: some View {
|
||||||
|
SettingsView(viewModel: SettingsViewModel(server: .sample, user: .sample))
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,66 +0,0 @@
|
||||||
//
|
|
||||||
/*
|
|
||||||
* SwiftFin is subject to the terms of the Mozilla Public
|
|
||||||
* License, v2.0. If a copy of the MPL was not distributed with this
|
|
||||||
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
|
||||||
*
|
|
||||||
* Copyright 2021 Aiden Vigue & Jellyfin Contributors
|
|
||||||
*/
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
class AudioViewController: InfoTabViewController {
|
|
||||||
|
|
||||||
override func viewDidLoad() {
|
|
||||||
super.viewDidLoad()
|
|
||||||
|
|
||||||
tabBarItem.title = "Audio"
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
func prepareAudioView(audioTracks: [AudioTrack], selectedTrack: Int32, delegate: VideoPlayerSettingsDelegate) {
|
|
||||||
let contentView = UIHostingController(rootView: AudioView(selectedTrack: selectedTrack, audioTrackArray: audioTracks, delegate: delegate))
|
|
||||||
self.view.addSubview(contentView.view)
|
|
||||||
contentView.view.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
contentView.view.topAnchor.constraint(equalTo: view.topAnchor).isActive = true
|
|
||||||
contentView.view.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true
|
|
||||||
contentView.view.leftAnchor.constraint(equalTo: view.leftAnchor).isActive = true
|
|
||||||
contentView.view.rightAnchor.constraint(equalTo: view.rightAnchor).isActive = true
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
struct AudioView: View {
|
|
||||||
|
|
||||||
@State var selectedTrack: Int32 = -1
|
|
||||||
@State var audioTrackArray: [AudioTrack] = []
|
|
||||||
|
|
||||||
weak var delegate: VideoPlayerSettingsDelegate?
|
|
||||||
|
|
||||||
var body : some View {
|
|
||||||
NavigationView {
|
|
||||||
VStack {
|
|
||||||
List(audioTrackArray, id: \.id) { track in
|
|
||||||
Button(action: {
|
|
||||||
delegate?.selectNew(audioTrack: track.id)
|
|
||||||
selectedTrack = track.id
|
|
||||||
}, label: {
|
|
||||||
HStack(spacing: 10) {
|
|
||||||
if track.id == selectedTrack {
|
|
||||||
Image(systemName: "checkmark")
|
|
||||||
} else {
|
|
||||||
Image(systemName: "checkmark")
|
|
||||||
.hidden()
|
|
||||||
}
|
|
||||||
Text(track.name)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(width: 400)
|
|
||||||
.frame(maxHeight: 400)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,59 +0,0 @@
|
||||||
//
|
|
||||||
/*
|
|
||||||
* SwiftFin is subject to the terms of the Mozilla Public
|
|
||||||
* License, v2.0. If a copy of the MPL was not distributed with this
|
|
||||||
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
|
||||||
*
|
|
||||||
* Copyright 2021 Aiden Vigue & Jellyfin Contributors
|
|
||||||
*/
|
|
||||||
|
|
||||||
import TVUIKit
|
|
||||||
import JellyfinAPI
|
|
||||||
|
|
||||||
class InfoTabViewController: UIViewController {
|
|
||||||
var height: CGFloat = 420
|
|
||||||
}
|
|
||||||
|
|
||||||
class InfoTabBarViewController: UITabBarController, UIGestureRecognizerDelegate {
|
|
||||||
|
|
||||||
var videoPlayer: VideoPlayerViewController?
|
|
||||||
var subtitleViewController: SubtitlesViewController?
|
|
||||||
var audioViewController: AudioViewController?
|
|
||||||
var mediaInfoController: MediaInfoViewController?
|
|
||||||
|
|
||||||
override func viewDidLoad() {
|
|
||||||
super.viewDidLoad()
|
|
||||||
mediaInfoController = MediaInfoViewController()
|
|
||||||
audioViewController = AudioViewController()
|
|
||||||
subtitleViewController = SubtitlesViewController()
|
|
||||||
|
|
||||||
viewControllers = [mediaInfoController!, audioViewController!, subtitleViewController!]
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
func setupInfoViews(mediaItem: BaseItemDto, subtitleTracks: [Subtitle], selectedSubtitleTrack: Int32, audioTracks: [AudioTrack], selectedAudioTrack: Int32, delegate: VideoPlayerSettingsDelegate) {
|
|
||||||
|
|
||||||
mediaInfoController?.setMedia(item: mediaItem)
|
|
||||||
|
|
||||||
audioViewController?.prepareAudioView(audioTracks: audioTracks, selectedTrack: selectedAudioTrack, delegate: delegate)
|
|
||||||
|
|
||||||
subtitleViewController?.prepareSubtitleView(subtitleTracks: subtitleTracks, selectedTrack: selectedSubtitleTrack, delegate: delegate)
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
override func tabBar(_ tabBar: UITabBar, didSelect item: UITabBarItem) {
|
|
||||||
|
|
||||||
if let index = tabBar.items?.firstIndex(of: item),
|
|
||||||
let tabViewController = viewControllers?[index] as? InfoTabViewController,
|
|
||||||
let width = videoPlayer?.infoPanelContainerView.frame.width {
|
|
||||||
let height = tabViewController.height + tabBar.frame.size.height
|
|
||||||
UIView.animate(withDuration: 0.6, delay: 0, options: .curveEaseOut) { [self] in
|
|
||||||
videoPlayer?.infoPanelContainerView.frame = CGRect(x: 88, y: 87, width: width, height: height)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,121 +0,0 @@
|
||||||
//
|
|
||||||
/*
|
|
||||||
* SwiftFin is subject to the terms of the Mozilla Public
|
|
||||||
* License, v2.0. If a copy of the MPL was not distributed with this
|
|
||||||
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
|
||||||
*
|
|
||||||
* Copyright 2021 Aiden Vigue & Jellyfin Contributors
|
|
||||||
*/
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import JellyfinAPI
|
|
||||||
|
|
||||||
class MediaInfoViewController: InfoTabViewController {
|
|
||||||
private var contentView: UIHostingController<MediaInfoView>!
|
|
||||||
|
|
||||||
override func viewDidLoad() {
|
|
||||||
super.viewDidLoad()
|
|
||||||
|
|
||||||
tabBarItem.title = "Info"
|
|
||||||
}
|
|
||||||
|
|
||||||
func setMedia(item: BaseItemDto) {
|
|
||||||
contentView = UIHostingController(rootView: MediaInfoView(item: item))
|
|
||||||
self.view.addSubview(contentView.view)
|
|
||||||
contentView.view.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
contentView.view.topAnchor.constraint(equalTo: view.topAnchor).isActive = true
|
|
||||||
contentView.view.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true
|
|
||||||
contentView.view.leftAnchor.constraint(equalTo: view.leftAnchor).isActive = true
|
|
||||||
contentView.view.rightAnchor.constraint(equalTo: view.rightAnchor).isActive = true
|
|
||||||
|
|
||||||
height = self.view.frame.height
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct MediaInfoView: View {
|
|
||||||
@State var item: BaseItemDto?
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
if let item = item {
|
|
||||||
HStack(spacing: 30) {
|
|
||||||
VStack {
|
|
||||||
ImageView(src: item.type == "Episode" ? item.getSeriesPrimaryImage(maxWidth: 200) : item.getPrimaryImage(maxWidth: 200), bh: item.type == "Episode" ? item.getSeriesPrimaryImageBlurHash() : item.getPrimaryImageBlurHash())
|
|
||||||
.frame(width: 200, height: 300)
|
|
||||||
.cornerRadius(10)
|
|
||||||
.ignoresSafeArea()
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
|
||||||
if item.type == "Episode" {
|
|
||||||
Text(item.seriesName ?? "Series")
|
|
||||||
.fontWeight(.bold)
|
|
||||||
|
|
||||||
HStack {
|
|
||||||
Text(item.name ?? "Episode")
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
|
|
||||||
Text(item.getEpisodeLocator() ?? "")
|
|
||||||
|
|
||||||
if let date = item.premiereDate {
|
|
||||||
Text(formatDate(date: date))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Text(item.name ?? "Movie")
|
|
||||||
.fontWeight(.bold)
|
|
||||||
}
|
|
||||||
|
|
||||||
HStack(spacing: 10) {
|
|
||||||
if item.type != "Episode" {
|
|
||||||
if let year = item.productionYear {
|
|
||||||
Text(String(year))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if item.runTimeTicks != nil {
|
|
||||||
Text("•")
|
|
||||||
Text(item.getItemRuntime())
|
|
||||||
}
|
|
||||||
|
|
||||||
if let rating = item.officialRating {
|
|
||||||
Text("•")
|
|
||||||
|
|
||||||
Text("\(rating)").font(.subheadline)
|
|
||||||
.fontWeight(.semibold)
|
|
||||||
.padding(EdgeInsets(top: 1, leading: 4, bottom: 1, trailing: 4))
|
|
||||||
.overlay(RoundedRectangle(cornerRadius: 2)
|
|
||||||
.stroke(Color.secondary, lineWidth: 1))
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
|
|
||||||
if let overview = item.overview {
|
|
||||||
Text(overview)
|
|
||||||
.padding(.top)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
}
|
|
||||||
.padding(.leading, 350)
|
|
||||||
.padding(.trailing, 125)
|
|
||||||
} else {
|
|
||||||
EmptyView()
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
func formatDate(date: Date) -> String {
|
|
||||||
let formatter = DateFormatter()
|
|
||||||
formatter.dateFormat = "MMM d, yyyy"
|
|
||||||
|
|
||||||
return formatter.string(from: date)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -0,0 +1,164 @@
|
||||||
|
//
|
||||||
|
/*
|
||||||
|
* 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 AVKit
|
||||||
|
import Combine
|
||||||
|
import JellyfinAPI
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
class NativePlayerViewController: AVPlayerViewController {
|
||||||
|
|
||||||
|
let viewModel: VideoPlayerViewModel
|
||||||
|
|
||||||
|
var timeObserverToken: Any?
|
||||||
|
|
||||||
|
var lastProgressTicks: Int64 = 0
|
||||||
|
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
init(viewModel: VideoPlayerViewModel) {
|
||||||
|
|
||||||
|
self.viewModel = viewModel
|
||||||
|
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
|
||||||
|
let player = AVPlayer(url: viewModel.hlsURL)
|
||||||
|
|
||||||
|
player.appliesMediaSelectionCriteriaAutomatically = false
|
||||||
|
player.currentItem?.externalMetadata = createMetadata()
|
||||||
|
player.currentItem?.navigationMarkerGroups = createNavigationMarkerGroups()
|
||||||
|
|
||||||
|
// let chevron = UIImage(systemName: "chevron.right.circle.fill")!
|
||||||
|
// let testAction = UIAction(title: "Next", image: chevron) { action in
|
||||||
|
// SessionAPI.sendSystemCommand(sessionId: viewModel.response.playSessionId!, command: .setSubtitleStreamIndex)
|
||||||
|
// .sink { completion in
|
||||||
|
// print(completion)
|
||||||
|
// } receiveValue: { _ in
|
||||||
|
// print("idk but we're here")
|
||||||
|
// }
|
||||||
|
// .store(in: &self.cancellables)
|
||||||
|
// }
|
||||||
|
|
||||||
|
// self.transportBarCustomMenuItems = [testAction]
|
||||||
|
|
||||||
|
// self.infoViewActions.append(testAction)
|
||||||
|
|
||||||
|
let timeScale = CMTimeScale(NSEC_PER_SEC)
|
||||||
|
let time = CMTime(seconds: 5, preferredTimescale: timeScale)
|
||||||
|
|
||||||
|
timeObserverToken = player.addPeriodicTimeObserver(forInterval: time, queue: .main) { [weak self] time in
|
||||||
|
// print("Timer timed: \(time)")
|
||||||
|
|
||||||
|
if time.seconds != 0 {
|
||||||
|
self?.sendProgressReport(seconds: time.seconds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.player = player
|
||||||
|
|
||||||
|
self.allowsPictureInPicturePlayback = true
|
||||||
|
self.player?.allowsExternalPlayback = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func createMetadata() -> [AVMetadataItem] {
|
||||||
|
let allMetadata: [AVMetadataIdentifier: Any] = [
|
||||||
|
.commonIdentifierTitle: viewModel.title,
|
||||||
|
.iTunesMetadataTrackSubTitle: viewModel.subtitle ?? "",
|
||||||
|
.commonIdentifierArtwork: UIImage(data: try! Data(contentsOf: viewModel.item.getBackdropImage(maxWidth: 200)))?.pngData() as Any,
|
||||||
|
.commonIdentifierDescription: viewModel.item.overview ?? "",
|
||||||
|
.iTunesMetadataContentRating: viewModel.item.officialRating ?? "",
|
||||||
|
.quickTimeMetadataGenre: viewModel.item.genres?.first ?? ""
|
||||||
|
]
|
||||||
|
|
||||||
|
return allMetadata.compactMap { createMetadataItem(for:$0, value:$1) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func createMetadataItem(for identifier: AVMetadataIdentifier,
|
||||||
|
value: Any) -> AVMetadataItem {
|
||||||
|
let item = AVMutableMetadataItem()
|
||||||
|
item.identifier = identifier
|
||||||
|
item.value = value as? NSCopying & NSObjectProtocol
|
||||||
|
// Specify "und" to indicate an undefined language.
|
||||||
|
item.extendedLanguageTag = "und"
|
||||||
|
return item.copy() as! AVMetadataItem
|
||||||
|
}
|
||||||
|
|
||||||
|
private func createNavigationMarkerGroups() -> [AVNavigationMarkersGroup] {
|
||||||
|
guard let chapters = viewModel.item.chapters else { return [] }
|
||||||
|
|
||||||
|
var metadataGroups: [AVTimedMetadataGroup] = []
|
||||||
|
|
||||||
|
// TODO: Determine range between chapters
|
||||||
|
chapters.forEach { chapterInfo in
|
||||||
|
var chapterMetadata: [AVMetadataItem] = []
|
||||||
|
|
||||||
|
let titleItem = createMetadataItem(for: .commonIdentifierTitle, value: chapterInfo.name ?? "No Name")
|
||||||
|
chapterMetadata.append(titleItem)
|
||||||
|
|
||||||
|
let imageItem = createMetadataItem(for: .commonIdentifierArtwork, value: UIImage(data: try! Data(contentsOf: viewModel.item.getBackdropImage(maxWidth: 200)))?.pngData() as Any)
|
||||||
|
chapterMetadata.append(imageItem)
|
||||||
|
|
||||||
|
let startTime = CMTimeMake(value: chapterInfo.startPositionTicks ?? 0, timescale: 10_000_000)
|
||||||
|
let endTime = CMTimeMake(value: (chapterInfo.startPositionTicks ?? 0) + 50_000_000, timescale: 10_000_000)
|
||||||
|
let timeRange = CMTimeRangeFromTimeToTime(start: startTime, end: endTime)
|
||||||
|
|
||||||
|
metadataGroups.append(AVTimedMetadataGroup(items: chapterMetadata, timeRange: timeRange))
|
||||||
|
}
|
||||||
|
|
||||||
|
return [AVNavigationMarkersGroup(title: nil, timedNavigationMarkers: metadataGroups)]
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewWillDisappear(_ animated: Bool) {
|
||||||
|
super.viewWillDisappear(animated)
|
||||||
|
|
||||||
|
stop()
|
||||||
|
removePeriodicTimeObserver()
|
||||||
|
}
|
||||||
|
|
||||||
|
func removePeriodicTimeObserver() {
|
||||||
|
if let timeObserverToken = timeObserverToken {
|
||||||
|
player?.removeTimeObserver(timeObserverToken)
|
||||||
|
self.timeObserverToken = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidAppear(_ animated: Bool) {
|
||||||
|
super.viewDidAppear(animated)
|
||||||
|
|
||||||
|
player?.seek(to: CMTimeMake(value: viewModel.item.userData?.playbackPositionTicks ?? 0, timescale: 10_000_000), toleranceBefore: CMTimeMake(value: 5, timescale: 1), toleranceAfter: CMTimeMake(value: 5, timescale: 1), completionHandler: { _ in
|
||||||
|
self.play()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private func play() {
|
||||||
|
player?.play()
|
||||||
|
|
||||||
|
// viewModel.sendPlayReport(startTimeTicks: viewModel.item.userData?.playbackPositionTicks ?? 0)
|
||||||
|
viewModel.sendPlayReport()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sendProgressReport(seconds: Double) {
|
||||||
|
// viewModel.sendProgressReport(ticks: Int64(seconds) * 10_000_000)
|
||||||
|
viewModel.sendProgressReport()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stop() {
|
||||||
|
self.player?.pause()
|
||||||
|
viewModel.sendStopReport()
|
||||||
|
// viewModel.sendStopReport(ticks: 10_000_000)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,31 @@
|
||||||
|
//
|
||||||
|
/*
|
||||||
|
* 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 Foundation
|
||||||
|
|
||||||
|
protocol PlayerOverlayDelegate {
|
||||||
|
|
||||||
|
func didSelectClose()
|
||||||
|
func didSelectMenu()
|
||||||
|
|
||||||
|
func didSelectBackward()
|
||||||
|
func didSelectForward()
|
||||||
|
func didSelectMain()
|
||||||
|
|
||||||
|
func didGenerallyTap()
|
||||||
|
|
||||||
|
func didBeginScrubbing()
|
||||||
|
func didEndScrubbing()
|
||||||
|
|
||||||
|
func didSelectAudioStream(index: Int)
|
||||||
|
func didSelectSubtitleStream(index: Int)
|
||||||
|
|
||||||
|
func didSelectPlayPreviousItem()
|
||||||
|
func didSelectPlayNextItem()
|
||||||
|
}
|
|
@ -1,67 +0,0 @@
|
||||||
//
|
|
||||||
/*
|
|
||||||
* SwiftFin is subject to the terms of the Mozilla Public
|
|
||||||
* License, v2.0. If a copy of the MPL was not distributed with this
|
|
||||||
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
|
||||||
*
|
|
||||||
* Copyright 2021 Aiden Vigue & Jellyfin Contributors
|
|
||||||
*/
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
class SubtitlesViewController: InfoTabViewController {
|
|
||||||
|
|
||||||
override func viewDidLoad() {
|
|
||||||
super.viewDidLoad()
|
|
||||||
|
|
||||||
tabBarItem.title = "Subtitles"
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
func prepareSubtitleView(subtitleTracks: [Subtitle], selectedTrack: Int32, delegate: VideoPlayerSettingsDelegate) {
|
|
||||||
let contentView = UIHostingController(rootView: SubtitleView(selectedTrack: selectedTrack, subtitleTrackArray: subtitleTracks, delegate: delegate))
|
|
||||||
self.view.addSubview(contentView.view)
|
|
||||||
contentView.view.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
contentView.view.topAnchor.constraint(equalTo: view.topAnchor).isActive = true
|
|
||||||
contentView.view.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true
|
|
||||||
contentView.view.leftAnchor.constraint(equalTo: view.leftAnchor).isActive = true
|
|
||||||
contentView.view.rightAnchor.constraint(equalTo: view.rightAnchor).isActive = true
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct SubtitleView: View {
|
|
||||||
|
|
||||||
@State var selectedTrack: Int32 = -1
|
|
||||||
@State var subtitleTrackArray: [Subtitle] = []
|
|
||||||
|
|
||||||
weak var delegate: VideoPlayerSettingsDelegate?
|
|
||||||
|
|
||||||
var body : some View {
|
|
||||||
NavigationView {
|
|
||||||
VStack {
|
|
||||||
List(subtitleTrackArray, id: \.id) { track in
|
|
||||||
Button(action: {
|
|
||||||
delegate?.selectNew(subtitleTrack: track.id)
|
|
||||||
selectedTrack = track.id
|
|
||||||
}, label: {
|
|
||||||
HStack(spacing: 10) {
|
|
||||||
if track.id == selectedTrack {
|
|
||||||
Image(systemName: "checkmark")
|
|
||||||
} else {
|
|
||||||
Image(systemName: "checkmark")
|
|
||||||
.hidden()
|
|
||||||
}
|
|
||||||
Text(track.name)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(width: 400)
|
|
||||||
.frame(maxHeight: 400)
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -0,0 +1,822 @@
|
||||||
|
//
|
||||||
|
/*
|
||||||
|
* 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 AVKit
|
||||||
|
import AVFoundation
|
||||||
|
import Combine
|
||||||
|
import Defaults
|
||||||
|
import JellyfinAPI
|
||||||
|
import MediaPlayer
|
||||||
|
import TVVLCKit
|
||||||
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
// TODO: Look at making the VLC player layer a view
|
||||||
|
|
||||||
|
class VLCPlayerViewController: UIViewController {
|
||||||
|
|
||||||
|
// MARK: variables
|
||||||
|
|
||||||
|
private var viewModel: VideoPlayerViewModel
|
||||||
|
private var vlcMediaPlayer = VLCMediaPlayer()
|
||||||
|
private var lastPlayerTicks: Int64 = 0
|
||||||
|
private var lastProgressReportTicks: Int64 = 0
|
||||||
|
private var viewModelListeners = Set<AnyCancellable>()
|
||||||
|
private var overlayDismissTimer: Timer?
|
||||||
|
private var confirmCloseOverlayDismissTimer: Timer?
|
||||||
|
|
||||||
|
private var currentPlayerTicks: Int64 {
|
||||||
|
return Int64(vlcMediaPlayer.time.intValue) * 100_000
|
||||||
|
}
|
||||||
|
|
||||||
|
private var displayingOverlay: Bool {
|
||||||
|
return currentOverlayHostingController?.view.alpha ?? 0 > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private var displayingContentOverlay: Bool {
|
||||||
|
return currentOverlayContentHostingController?.view.alpha ?? 0 > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private var displayingConfirmClose: Bool {
|
||||||
|
return currentConfirmCloseHostingController?.view.alpha ?? 0 > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private lazy var videoContentView = makeVideoContentView()
|
||||||
|
private lazy var jumpBackwardOverlayView = makeJumpBackwardOverlayView()
|
||||||
|
private lazy var jumpForwardOverlayView = makeJumpForwardOverlayView()
|
||||||
|
private var currentOverlayHostingController: UIHostingController<tvOSVLCOverlay>?
|
||||||
|
private var currentOverlayContentHostingController: UIHostingController<SmallMediaStreamSelectionView>?
|
||||||
|
private var currentConfirmCloseHostingController: UIHostingController<ConfirmCloseOverlay>?
|
||||||
|
|
||||||
|
// MARK: init
|
||||||
|
|
||||||
|
init(viewModel: VideoPlayerViewModel) {
|
||||||
|
|
||||||
|
self.viewModel = viewModel
|
||||||
|
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
|
||||||
|
viewModel.playerOverlayDelegate = self
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupSubviews() {
|
||||||
|
view.addSubview(videoContentView)
|
||||||
|
view.addSubview(jumpForwardOverlayView)
|
||||||
|
view.addSubview(jumpBackwardOverlayView)
|
||||||
|
|
||||||
|
jumpBackwardOverlayView.alpha = 0
|
||||||
|
jumpForwardOverlayView.alpha = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupConstraints() {
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
videoContentView.topAnchor.constraint(equalTo: view.topAnchor),
|
||||||
|
videoContentView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||||
|
videoContentView.leftAnchor.constraint(equalTo: view.leftAnchor),
|
||||||
|
videoContentView.rightAnchor.constraint(equalTo: view.rightAnchor)
|
||||||
|
])
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
jumpBackwardOverlayView.leftAnchor.constraint(equalTo: view.leftAnchor, constant: 300),
|
||||||
|
jumpBackwardOverlayView.centerYAnchor.constraint(equalTo: view.centerYAnchor)
|
||||||
|
])
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
jumpForwardOverlayView.rightAnchor.constraint(equalTo: view.rightAnchor, constant: -300),
|
||||||
|
jumpForwardOverlayView.centerYAnchor.constraint(equalTo: view.centerYAnchor)
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: viewWillDisappear
|
||||||
|
|
||||||
|
override func viewWillDisappear(_ animated: Bool) {
|
||||||
|
super.viewWillDisappear(animated)
|
||||||
|
|
||||||
|
didSelectClose()
|
||||||
|
|
||||||
|
let defaultNotificationCenter = NotificationCenter.default
|
||||||
|
defaultNotificationCenter.removeObserver(self, name: UIApplication.willTerminateNotification, object: nil)
|
||||||
|
defaultNotificationCenter.removeObserver(self, name: UIApplication.willResignActiveNotification, object: nil)
|
||||||
|
defaultNotificationCenter.removeObserver(self, name: UIApplication.didEnterBackgroundNotification, object: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: viewDidLoad
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
|
||||||
|
setupSubviews()
|
||||||
|
setupConstraints()
|
||||||
|
|
||||||
|
view.backgroundColor = .black
|
||||||
|
|
||||||
|
// Outside of 'setupMediaPlayer' such that they
|
||||||
|
// aren't unnecessarily set more than once
|
||||||
|
vlcMediaPlayer.delegate = self
|
||||||
|
vlcMediaPlayer.drawable = videoContentView
|
||||||
|
|
||||||
|
// TODO: custom font sizes
|
||||||
|
vlcMediaPlayer.perform(Selector(("setTextRendererFontSize:")), with: 16)
|
||||||
|
|
||||||
|
setupMediaPlayer(newViewModel: viewModel)
|
||||||
|
|
||||||
|
setupPanGestureRecognizer()
|
||||||
|
|
||||||
|
addButtonPressRecognizer(pressType: .menu, action: #selector(didPressMenu))
|
||||||
|
|
||||||
|
let defaultNotificationCenter = NotificationCenter.default
|
||||||
|
defaultNotificationCenter.addObserver(self, selector: #selector(appWillTerminate), name: UIApplication.willTerminateNotification, object: nil)
|
||||||
|
defaultNotificationCenter.addObserver(self, selector: #selector(appWillResignActive), name: UIApplication.willResignActiveNotification, object: nil)
|
||||||
|
defaultNotificationCenter.addObserver(self, selector: #selector(appWillResignActive), name: UIApplication.didEnterBackgroundNotification, object: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func appWillTerminate() {
|
||||||
|
viewModel.sendStopReport()
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func appWillResignActive() {
|
||||||
|
showOverlay()
|
||||||
|
|
||||||
|
stopOverlayDismissTimer()
|
||||||
|
|
||||||
|
vlcMediaPlayer.pause()
|
||||||
|
|
||||||
|
viewModel.sendPauseReport(paused: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidAppear(_ animated: Bool) {
|
||||||
|
super.viewDidAppear(animated)
|
||||||
|
|
||||||
|
startPlayback()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: subviews
|
||||||
|
|
||||||
|
private func makeVideoContentView() -> UIView {
|
||||||
|
let view = UIView()
|
||||||
|
view.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
view.backgroundColor = .black
|
||||||
|
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeJumpBackwardOverlayView() -> UIImageView {
|
||||||
|
let symbolConfig = UIImage.SymbolConfiguration(pointSize: 72)
|
||||||
|
let forwardSymbolImage = UIImage(systemName: viewModel.jumpBackwardLength.backwardImageLabel, withConfiguration: symbolConfig)
|
||||||
|
let imageView = UIImageView(image: forwardSymbolImage)
|
||||||
|
imageView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
|
||||||
|
return imageView
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeJumpForwardOverlayView() -> UIImageView {
|
||||||
|
let symbolConfig = UIImage.SymbolConfiguration(pointSize: 72)
|
||||||
|
let forwardSymbolImage = UIImage(systemName: viewModel.jumpForwardLength.forwardImageLabel, withConfiguration: symbolConfig)
|
||||||
|
let imageView = UIImageView(image: forwardSymbolImage)
|
||||||
|
imageView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
|
||||||
|
return imageView
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupPanGestureRecognizer() {
|
||||||
|
let panGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(userPanned(panGestureRecognizer:)))
|
||||||
|
view.addGestureRecognizer(panGestureRecognizer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: pressesBegan
|
||||||
|
override func pressesBegan(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
|
||||||
|
guard let buttonPress = presses.first?.type else { return }
|
||||||
|
|
||||||
|
switch(buttonPress) {
|
||||||
|
case .menu: () // Captured by other recognizer
|
||||||
|
case .playPause:
|
||||||
|
hideConfirmCloseOverlay()
|
||||||
|
|
||||||
|
didSelectMain()
|
||||||
|
case .select:
|
||||||
|
hideConfirmCloseOverlay()
|
||||||
|
|
||||||
|
didGenerallyTap()
|
||||||
|
case .upArrow:
|
||||||
|
hideConfirmCloseOverlay()
|
||||||
|
case .downArrow:
|
||||||
|
hideConfirmCloseOverlay()
|
||||||
|
|
||||||
|
if Defaults[.downActionShowsMenu] {
|
||||||
|
if !displayingContentOverlay {
|
||||||
|
didSelectMenu()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case .leftArrow:
|
||||||
|
hideConfirmCloseOverlay()
|
||||||
|
|
||||||
|
if !displayingContentOverlay {
|
||||||
|
didSelectBackward()
|
||||||
|
}
|
||||||
|
case .rightArrow:
|
||||||
|
hideConfirmCloseOverlay()
|
||||||
|
|
||||||
|
if !displayingContentOverlay {
|
||||||
|
didSelectForward()
|
||||||
|
}
|
||||||
|
case .pageUp: ()
|
||||||
|
case .pageDown: ()
|
||||||
|
@unknown default: ()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func addButtonPressRecognizer(pressType: UIPress.PressType, action: Selector) {
|
||||||
|
let pressRecognizer = UITapGestureRecognizer()
|
||||||
|
pressRecognizer.addTarget(self, action: action)
|
||||||
|
pressRecognizer.allowedPressTypes = [NSNumber(value: pressType.rawValue)]
|
||||||
|
view.addGestureRecognizer(pressRecognizer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: didPressMenu
|
||||||
|
@objc private func didPressMenu() {
|
||||||
|
if displayingOverlay {
|
||||||
|
hideOverlay()
|
||||||
|
} else if displayingContentOverlay {
|
||||||
|
hideOverlayContent()
|
||||||
|
|
||||||
|
showOverlay()
|
||||||
|
restartOverlayDismissTimer()
|
||||||
|
} else if viewModel.confirmClose && !displayingConfirmClose {
|
||||||
|
|
||||||
|
showConfirmCloseOverlay()
|
||||||
|
restartConfirmCloseDismissTimer()
|
||||||
|
|
||||||
|
} else {
|
||||||
|
vlcMediaPlayer.pause()
|
||||||
|
|
||||||
|
dismiss(animated: true, completion: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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
|
||||||
|
|
||||||
|
// Main 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
// Media Stream selection
|
||||||
|
if let currentOverlayContentHostingController = currentOverlayContentHostingController {
|
||||||
|
currentOverlayContentHostingController.view.isHidden = true
|
||||||
|
|
||||||
|
currentOverlayContentHostingController.view.removeFromSuperview()
|
||||||
|
currentOverlayContentHostingController.removeFromParent()
|
||||||
|
}
|
||||||
|
|
||||||
|
let newSmallMenuOverlayView = SmallMediaStreamSelectionView(viewModel: viewModel)
|
||||||
|
|
||||||
|
let newOverlayContentHostingController = UIHostingController(rootView: newSmallMenuOverlayView)
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
// Confirm close
|
||||||
|
if let currentConfirmCloseHostingController = currentConfirmCloseHostingController {
|
||||||
|
currentConfirmCloseHostingController.view.isHidden = true
|
||||||
|
|
||||||
|
currentConfirmCloseHostingController.view.removeFromSuperview()
|
||||||
|
currentConfirmCloseHostingController.removeFromParent()
|
||||||
|
}
|
||||||
|
|
||||||
|
let newConfirmCloseOverlay = ConfirmCloseOverlay()
|
||||||
|
|
||||||
|
let newConfirmCloseHostingController = UIHostingController(rootView: newConfirmCloseOverlay)
|
||||||
|
|
||||||
|
newConfirmCloseHostingController.view.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
newConfirmCloseHostingController.view.backgroundColor = UIColor.clear
|
||||||
|
|
||||||
|
newConfirmCloseHostingController.view.alpha = 0
|
||||||
|
|
||||||
|
addChild(newConfirmCloseHostingController)
|
||||||
|
view.addSubview(newConfirmCloseHostingController.view)
|
||||||
|
newConfirmCloseHostingController.didMove(toParent: self)
|
||||||
|
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
newConfirmCloseHostingController.view.topAnchor.constraint(equalTo: videoContentView.topAnchor),
|
||||||
|
newConfirmCloseHostingController.view.bottomAnchor.constraint(equalTo: videoContentView.bottomAnchor),
|
||||||
|
newConfirmCloseHostingController.view.leftAnchor.constraint(equalTo: videoContentView.leftAnchor),
|
||||||
|
newConfirmCloseHostingController.view.rightAnchor.constraint(equalTo: videoContentView.rightAnchor)
|
||||||
|
])
|
||||||
|
|
||||||
|
self.currentConfirmCloseHostingController = newConfirmCloseHostingController
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: setupMediaPlayer
|
||||||
|
extension VLCPlayerViewController {
|
||||||
|
|
||||||
|
/// Main function that handles setting up the media player with the current VideoPlayerViewModel
|
||||||
|
/// and also takes the role of setting the 'viewModel' property with the given viewModel
|
||||||
|
///
|
||||||
|
/// Use case for this is setting new media within the same VLCPlayerViewController
|
||||||
|
func setupMediaPlayer(newViewModel: VideoPlayerViewModel) {
|
||||||
|
|
||||||
|
stopOverlayDismissTimer()
|
||||||
|
|
||||||
|
// Stop current media if there is one
|
||||||
|
if vlcMediaPlayer.media != nil {
|
||||||
|
viewModelListeners.forEach({ $0.cancel() })
|
||||||
|
|
||||||
|
vlcMediaPlayer.stop()
|
||||||
|
viewModel.sendStopReport()
|
||||||
|
viewModel.playerOverlayDelegate = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
lastPlayerTicks = newViewModel.item.userData?.playbackPositionTicks ?? 0
|
||||||
|
lastProgressReportTicks = newViewModel.item.userData?.playbackPositionTicks ?? 0
|
||||||
|
|
||||||
|
// TODO: Custom buffer/cache amounts
|
||||||
|
|
||||||
|
let media = VLCMedia(url: newViewModel.streamURL)
|
||||||
|
media.addOption("--prefetch-buffer-size=1048576")
|
||||||
|
media.addOption("--network-caching=5000")
|
||||||
|
|
||||||
|
vlcMediaPlayer.media = media
|
||||||
|
|
||||||
|
setupOverlayHostingController(viewModel: newViewModel)
|
||||||
|
setupViewModelListeners(viewModel: newViewModel)
|
||||||
|
|
||||||
|
newViewModel.getAdjacentEpisodes()
|
||||||
|
newViewModel.playerOverlayDelegate = self
|
||||||
|
|
||||||
|
let startPercentage = newViewModel.item.userData?.playedPercentage ?? 0
|
||||||
|
|
||||||
|
if startPercentage > 0 {
|
||||||
|
if viewModel.resumeOffset {
|
||||||
|
let videoDurationSeconds = Double(viewModel.item.runTimeTicks! / 10_000_000)
|
||||||
|
var startSeconds = round((startPercentage / 100) * videoDurationSeconds)
|
||||||
|
startSeconds = startSeconds.subtract(5, floor: 0)
|
||||||
|
let newStartPercentage = startSeconds / videoDurationSeconds
|
||||||
|
newViewModel.sliderPercentage = newStartPercentage
|
||||||
|
} else {
|
||||||
|
newViewModel.sliderPercentage = startPercentage / 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModel = newViewModel
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: startPlayback
|
||||||
|
func startPlayback() {
|
||||||
|
vlcMediaPlayer.play()
|
||||||
|
|
||||||
|
setMediaPlayerTimeAtCurrentSlider()
|
||||||
|
|
||||||
|
viewModel.sendPlayReport()
|
||||||
|
|
||||||
|
restartOverlayDismissTimer(interval: 5)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: setupViewModelListeners
|
||||||
|
|
||||||
|
private func setupViewModelListeners(viewModel: VideoPlayerViewModel) {
|
||||||
|
viewModel.$playbackSpeed.sink { newSpeed in
|
||||||
|
self.vlcMediaPlayer.rate = Float(newSpeed.rawValue)
|
||||||
|
}.store(in: &viewModelListeners)
|
||||||
|
|
||||||
|
viewModel.$sliderIsScrubbing.sink { sliderIsScrubbing in
|
||||||
|
if sliderIsScrubbing {
|
||||||
|
self.didBeginScrubbing()
|
||||||
|
} else {
|
||||||
|
self.didEndScrubbing()
|
||||||
|
}
|
||||||
|
}.store(in: &viewModelListeners)
|
||||||
|
|
||||||
|
viewModel.$selectedAudioStreamIndex.sink { newAudioStreamIndex in
|
||||||
|
self.didSelectAudioStream(index: newAudioStreamIndex)
|
||||||
|
}.store(in: &viewModelListeners)
|
||||||
|
|
||||||
|
viewModel.$selectedSubtitleStreamIndex.sink { newSubtitleStreamIndex in
|
||||||
|
self.didSelectSubtitleStream(index: newSubtitleStreamIndex)
|
||||||
|
}.store(in: &viewModelListeners)
|
||||||
|
|
||||||
|
viewModel.$subtitlesEnabled.sink { newSubtitlesEnabled in
|
||||||
|
self.didToggleSubtitles(newValue: newSubtitlesEnabled)
|
||||||
|
}.store(in: &viewModelListeners)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setMediaPlayerTimeAtCurrentSlider() {
|
||||||
|
// Necessary math as VLCMediaPlayer doesn't work well
|
||||||
|
// by just setting the position
|
||||||
|
let videoPosition = Double(vlcMediaPlayer.time.intValue / 1000)
|
||||||
|
let videoDuration = Double(viewModel.item.runTimeTicks! / 10_000_000)
|
||||||
|
let secondsScrubbedTo = round(viewModel.sliderPercentage * videoDuration)
|
||||||
|
let newPositionOffset = secondsScrubbedTo - videoPosition
|
||||||
|
|
||||||
|
if newPositionOffset > 0 {
|
||||||
|
vlcMediaPlayer.jumpForward(Int32(newPositionOffset))
|
||||||
|
} else {
|
||||||
|
vlcMediaPlayer.jumpBackward(Int32(abs(newPositionOffset)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Show/Hide Overlay
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func toggleOverlay() {
|
||||||
|
if displayingOverlay {
|
||||||
|
hideOverlay()
|
||||||
|
} else {
|
||||||
|
showOverlay()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func showOverlayContent() {
|
||||||
|
guard let currentOverlayContentHostingController = currentOverlayContentHostingController else { return }
|
||||||
|
|
||||||
|
guard currentOverlayContentHostingController.view.alpha != 1 else { return }
|
||||||
|
|
||||||
|
currentOverlayContentHostingController.view.setNeedsFocusUpdate()
|
||||||
|
currentOverlayContentHostingController.setNeedsFocusUpdate()
|
||||||
|
setNeedsFocusUpdate()
|
||||||
|
|
||||||
|
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 }
|
||||||
|
|
||||||
|
setNeedsFocusUpdate()
|
||||||
|
|
||||||
|
UIView.animate(withDuration: 0.2) {
|
||||||
|
currentOverlayContentHostingController.view.alpha = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Show/Hide Jump
|
||||||
|
extension VLCPlayerViewController {
|
||||||
|
|
||||||
|
private func flashJumpBackwardOverlay() {
|
||||||
|
jumpBackwardOverlayView.layer.removeAllAnimations()
|
||||||
|
|
||||||
|
UIView.animate(withDuration: 0.1) {
|
||||||
|
self.jumpBackwardOverlayView.alpha = 1
|
||||||
|
} completion: { _ in
|
||||||
|
self.hideJumpBackwardOverlay()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func hideJumpBackwardOverlay() {
|
||||||
|
UIView.animate(withDuration: 0.3) {
|
||||||
|
self.jumpBackwardOverlayView.alpha = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func flashJumpFowardOverlay() {
|
||||||
|
jumpForwardOverlayView.layer.removeAllAnimations()
|
||||||
|
|
||||||
|
UIView.animate(withDuration: 0.1) {
|
||||||
|
self.jumpForwardOverlayView.alpha = 1
|
||||||
|
} completion: { _ in
|
||||||
|
self.hideJumpForwardOverlay()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func hideJumpForwardOverlay() {
|
||||||
|
UIView.animate(withDuration: 0.3) {
|
||||||
|
self.jumpForwardOverlayView.alpha = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Show/Hide Confirm close
|
||||||
|
extension VLCPlayerViewController {
|
||||||
|
|
||||||
|
private func showConfirmCloseOverlay() {
|
||||||
|
guard let currentConfirmCloseHostingController = currentConfirmCloseHostingController else { return }
|
||||||
|
|
||||||
|
UIView.animate(withDuration: 0.2) {
|
||||||
|
currentConfirmCloseHostingController.view.alpha = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func hideConfirmCloseOverlay() {
|
||||||
|
guard let currentConfirmCloseHostingController = currentConfirmCloseHostingController else { return }
|
||||||
|
|
||||||
|
UIView.animate(withDuration: 0.5) {
|
||||||
|
currentConfirmCloseHostingController.view.alpha = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: OverlayTimer
|
||||||
|
extension VLCPlayerViewController {
|
||||||
|
|
||||||
|
private func restartOverlayDismissTimer(interval: Double = 5) {
|
||||||
|
self.overlayDismissTimer?.invalidate()
|
||||||
|
self.overlayDismissTimer = Timer.scheduledTimer(timeInterval: interval, target: self, selector: #selector(dismissTimerFired), userInfo: nil, repeats: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func dismissTimerFired() {
|
||||||
|
hideOverlay()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stopOverlayDismissTimer() {
|
||||||
|
overlayDismissTimer?.invalidate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Confirm Close Overlay Timer
|
||||||
|
extension VLCPlayerViewController {
|
||||||
|
|
||||||
|
private func restartConfirmCloseDismissTimer() {
|
||||||
|
self.confirmCloseOverlayDismissTimer?.invalidate()
|
||||||
|
self.confirmCloseOverlayDismissTimer = Timer.scheduledTimer(timeInterval: 5, target: self, selector: #selector(confirmCloseTimerFired), userInfo: nil, repeats: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func confirmCloseTimerFired() {
|
||||||
|
hideConfirmCloseOverlay()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stopConfirmCloseDismissTimer() {
|
||||||
|
confirmCloseOverlayDismissTimer?.invalidate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: VLCMediaPlayerDelegate
|
||||||
|
extension VLCPlayerViewController: VLCMediaPlayerDelegate {
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: mediaPlayerStateChanged
|
||||||
|
func mediaPlayerStateChanged(_ aNotification: Notification!) {
|
||||||
|
|
||||||
|
// Don't show buffering if paused, usually here while scrubbing
|
||||||
|
if vlcMediaPlayer.state == .buffering && viewModel.playerState == .paused {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModel.playerState = vlcMediaPlayer.state
|
||||||
|
|
||||||
|
if vlcMediaPlayer.state == VLCMediaPlayerState.ended {
|
||||||
|
if viewModel.autoplayEnabled && viewModel.nextItemVideoPlayerViewModel != nil {
|
||||||
|
didSelectPlayNextItem()
|
||||||
|
} else {
|
||||||
|
didSelectClose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: mediaPlayerTimeChanged
|
||||||
|
func mediaPlayerTimeChanged(_ aNotification: Notification!) {
|
||||||
|
|
||||||
|
if !viewModel.sliderIsScrubbing {
|
||||||
|
viewModel.sliderPercentage = Double(vlcMediaPlayer.position)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Have to manually set playing because VLCMediaPlayer doesn't
|
||||||
|
// properly set it itself
|
||||||
|
if abs(currentPlayerTicks - lastPlayerTicks) >= 10_000 {
|
||||||
|
viewModel.playerState = VLCMediaPlayerState.playing
|
||||||
|
}
|
||||||
|
|
||||||
|
// If needing to fix subtitle streams during playback
|
||||||
|
if vlcMediaPlayer.currentVideoSubTitleIndex != viewModel.selectedSubtitleStreamIndex && viewModel.subtitlesEnabled {
|
||||||
|
didSelectSubtitleStream(index: viewModel.selectedSubtitleStreamIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
if vlcMediaPlayer.currentAudioTrackIndex != viewModel.selectedAudioStreamIndex {
|
||||||
|
didSelectAudioStream(index: viewModel.selectedAudioStreamIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
lastPlayerTicks = currentPlayerTicks
|
||||||
|
|
||||||
|
// Send progress report every 5 seconds
|
||||||
|
if abs(lastProgressReportTicks - currentPlayerTicks) >= 500_000_000 {
|
||||||
|
viewModel.sendProgressReport()
|
||||||
|
|
||||||
|
lastProgressReportTicks = currentPlayerTicks
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: PlayerOverlayDelegate
|
||||||
|
extension VLCPlayerViewController: PlayerOverlayDelegate {
|
||||||
|
|
||||||
|
func didSelectAudioStream(index: Int) {
|
||||||
|
vlcMediaPlayer.currentAudioTrackIndex = Int32(index)
|
||||||
|
|
||||||
|
viewModel.sendProgressReport()
|
||||||
|
|
||||||
|
lastProgressReportTicks = currentPlayerTicks
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Do not call when setting to index -1
|
||||||
|
func didSelectSubtitleStream(index: Int) {
|
||||||
|
|
||||||
|
viewModel.subtitlesEnabled = true
|
||||||
|
vlcMediaPlayer.currentVideoSubTitleIndex = Int32(index)
|
||||||
|
|
||||||
|
viewModel.sendProgressReport()
|
||||||
|
|
||||||
|
lastProgressReportTicks = currentPlayerTicks
|
||||||
|
}
|
||||||
|
|
||||||
|
func didSelectClose() {
|
||||||
|
vlcMediaPlayer.stop()
|
||||||
|
|
||||||
|
viewModel.sendStopReport()
|
||||||
|
|
||||||
|
dismiss(animated: true, completion: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func didToggleSubtitles(newValue: Bool) {
|
||||||
|
if newValue {
|
||||||
|
vlcMediaPlayer.currentVideoSubTitleIndex = Int32(viewModel.selectedSubtitleStreamIndex)
|
||||||
|
} else {
|
||||||
|
vlcMediaPlayer.currentVideoSubTitleIndex = -1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func didSelectMenu() {
|
||||||
|
stopOverlayDismissTimer()
|
||||||
|
|
||||||
|
hideOverlay()
|
||||||
|
showOverlayContent()
|
||||||
|
}
|
||||||
|
|
||||||
|
func didSelectBackward() {
|
||||||
|
|
||||||
|
flashJumpBackwardOverlay()
|
||||||
|
|
||||||
|
vlcMediaPlayer.jumpBackward(viewModel.jumpBackwardLength.rawValue)
|
||||||
|
|
||||||
|
if displayingOverlay {
|
||||||
|
restartOverlayDismissTimer()
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModel.sendProgressReport()
|
||||||
|
|
||||||
|
lastProgressReportTicks = currentPlayerTicks
|
||||||
|
}
|
||||||
|
|
||||||
|
func didSelectForward() {
|
||||||
|
|
||||||
|
flashJumpFowardOverlay()
|
||||||
|
|
||||||
|
vlcMediaPlayer.jumpForward(viewModel.jumpForwardLength.rawValue)
|
||||||
|
|
||||||
|
if displayingOverlay {
|
||||||
|
restartOverlayDismissTimer()
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModel.sendProgressReport()
|
||||||
|
|
||||||
|
lastProgressReportTicks = currentPlayerTicks
|
||||||
|
}
|
||||||
|
|
||||||
|
func didSelectMain() {
|
||||||
|
|
||||||
|
switch viewModel.playerState {
|
||||||
|
case .buffering:
|
||||||
|
vlcMediaPlayer.play()
|
||||||
|
restartOverlayDismissTimer()
|
||||||
|
case .playing:
|
||||||
|
viewModel.sendPauseReport(paused: true)
|
||||||
|
vlcMediaPlayer.pause()
|
||||||
|
|
||||||
|
showOverlay()
|
||||||
|
restartOverlayDismissTimer(interval: 5)
|
||||||
|
case .paused:
|
||||||
|
viewModel.sendPauseReport(paused: false)
|
||||||
|
vlcMediaPlayer.play()
|
||||||
|
restartOverlayDismissTimer()
|
||||||
|
default: ()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func didGenerallyTap() {
|
||||||
|
toggleOverlay()
|
||||||
|
|
||||||
|
restartOverlayDismissTimer(interval: 5)
|
||||||
|
}
|
||||||
|
|
||||||
|
func didBeginScrubbing() {
|
||||||
|
stopOverlayDismissTimer()
|
||||||
|
}
|
||||||
|
|
||||||
|
func didEndScrubbing() {
|
||||||
|
setMediaPlayerTimeAtCurrentSlider()
|
||||||
|
|
||||||
|
restartOverlayDismissTimer()
|
||||||
|
|
||||||
|
viewModel.sendProgressReport()
|
||||||
|
|
||||||
|
lastProgressReportTicks = currentPlayerTicks
|
||||||
|
}
|
||||||
|
|
||||||
|
func didSelectPlayPreviousItem() {
|
||||||
|
if let previousItemVideoPlayerViewModel = viewModel.previousItemVideoPlayerViewModel {
|
||||||
|
setupMediaPlayer(newViewModel: previousItemVideoPlayerViewModel)
|
||||||
|
startPlayback()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func didSelectPlayNextItem() {
|
||||||
|
if let nextItemVideoPlayerViewModel = viewModel.nextItemVideoPlayerViewModel {
|
||||||
|
setupMediaPlayer(newViewModel: nextItemVideoPlayerViewModel)
|
||||||
|
startPlayback()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,126 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<document type="com.apple.InterfaceBuilder.AppleTV.Storyboard" version="3.0" toolsVersion="18122" targetRuntime="AppleTV" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
|
|
||||||
<device id="appleTV" appearance="light"/>
|
|
||||||
<dependencies>
|
|
||||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="18093"/>
|
|
||||||
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
|
|
||||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
|
||||||
</dependencies>
|
|
||||||
<scenes>
|
|
||||||
<!--Info Tab Bar View Controller-->
|
|
||||||
<scene sceneID="NE2-Ez-3qW">
|
|
||||||
<objects>
|
|
||||||
<tabBarController id="odZ-Ww-zvF" customClass="InfoTabBarViewController" customModule="JellyfinPlayer_tvOS" customModuleProvider="target" sceneMemberID="viewController">
|
|
||||||
<tabBar key="tabBar" opaque="NO" contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" translucent="NO" id="YVR-nj-bPt">
|
|
||||||
<rect key="frame" x="0.0" y="0.0" width="1920" height="68"/>
|
|
||||||
<autoresizingMask key="autoresizingMask"/>
|
|
||||||
<color key="tintColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
|
||||||
<color key="barTintColor" red="0.101966925" green="0.1019589528" blue="0.1101123616" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
|
||||||
</tabBar>
|
|
||||||
</tabBarController>
|
|
||||||
<placeholder placeholderIdentifier="IBFirstResponder" id="LdX-BO-e71" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
|
|
||||||
</objects>
|
|
||||||
<point key="canvasLocation" x="-5664" y="320"/>
|
|
||||||
</scene>
|
|
||||||
<!--Video Player View Controller-->
|
|
||||||
<scene sceneID="9lE-WX-96o">
|
|
||||||
<objects>
|
|
||||||
<viewController storyboardIdentifier="VideoPlayer" id="Xgj-up-wSf" customClass="VideoPlayerViewController" customModule="JellyfinPlayer_tvOS" customModuleProvider="target" sceneMemberID="viewController">
|
|
||||||
<layoutGuides>
|
|
||||||
<viewControllerLayoutGuide type="top" id="aTn-mJ-5lt"/>
|
|
||||||
<viewControllerLayoutGuide type="bottom" id="BrO-eL-FPV"/>
|
|
||||||
</layoutGuides>
|
|
||||||
<view key="view" contentMode="scaleToFill" id="Oo6-ab-TCE">
|
|
||||||
<rect key="frame" x="0.0" y="0.0" width="1920" height="1080"/>
|
|
||||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
|
||||||
<subviews>
|
|
||||||
<view userInteractionEnabled="NO" contentMode="scaleToFill" id="Ibg-CP-gb2" userLabel="VideoPlayer">
|
|
||||||
<rect key="frame" x="0.0" y="0.0" width="1920" height="1080"/>
|
|
||||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
|
||||||
<viewLayoutGuide key="safeArea" id="jNU-Xf-Kyx"/>
|
|
||||||
<accessibility key="accessibilityConfiguration">
|
|
||||||
<accessibilityTraits key="traits" notEnabled="YES"/>
|
|
||||||
</accessibility>
|
|
||||||
</view>
|
|
||||||
<view hidden="YES" contentMode="scaleToFill" id="OG6-kk-N7Z" userLabel="Controls">
|
|
||||||
<rect key="frame" x="-1" y="0.0" width="1920" height="1080"/>
|
|
||||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
|
||||||
<subviews>
|
|
||||||
<view contentMode="scaleToFill" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="eh8-uG-9Wz" userLabel="GradientView">
|
|
||||||
<rect key="frame" x="0.0" y="700" width="1925" height="391"/>
|
|
||||||
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
|
|
||||||
</view>
|
|
||||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" fixedFrame="YES" text="-00:00:00" textAlignment="right" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" translatesAutoresizingMaskIntoConstraints="NO" id="NyZ-z0-56J" userLabel="RemainingTimeLabel">
|
|
||||||
<rect key="frame" x="1694" y="1007" width="139" height="36"/>
|
|
||||||
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
|
|
||||||
<fontDescription key="fontDescription" type="system" pointSize="30"/>
|
|
||||||
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
|
||||||
<nil key="highlightedColor"/>
|
|
||||||
</label>
|
|
||||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" fixedFrame="YES" text="00:00:00" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="CL5-ko-ceu" userLabel="CurrentTimeLabel">
|
|
||||||
<rect key="frame" x="88" y="1007" width="140" height="36"/>
|
|
||||||
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
|
|
||||||
<fontDescription key="fontDescription" type="system" pointSize="30"/>
|
|
||||||
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
|
||||||
<nil key="highlightedColor"/>
|
|
||||||
</label>
|
|
||||||
<view contentMode="scaleToFill" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="CQO-wl-bxv" userLabel="TransportBar">
|
|
||||||
<rect key="frame" x="88" y="981" width="1744" height="11"/>
|
|
||||||
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
|
|
||||||
<subviews>
|
|
||||||
<view contentMode="scaleToFill" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="yrg-ru-QSH" userLabel="Scrubber">
|
|
||||||
<rect key="frame" x="0.0" y="0.0" width="2" height="10"/>
|
|
||||||
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
|
|
||||||
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
|
||||||
</view>
|
|
||||||
<label hidden="YES" opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" fixedFrame="YES" text="00:00" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="HfD-p0-JMA" userLabel="ScrubLabel">
|
|
||||||
<rect key="frame" x="50" y="-60" width="140" height="36"/>
|
|
||||||
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
|
|
||||||
<fontDescription key="fontDescription" type="system" pointSize="28"/>
|
|
||||||
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
|
||||||
<nil key="highlightedColor"/>
|
|
||||||
</label>
|
|
||||||
</subviews>
|
|
||||||
<color key="backgroundColor" white="0.33333333333333331" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
|
||||||
</view>
|
|
||||||
</subviews>
|
|
||||||
<viewLayoutGuide key="safeArea" id="IS7-IU-teh"/>
|
|
||||||
<accessibility key="accessibilityConfiguration">
|
|
||||||
<accessibilityTraits key="traits" allowsDirectInteraction="YES"/>
|
|
||||||
</accessibility>
|
|
||||||
</view>
|
|
||||||
<containerView opaque="NO" contentMode="scaleToFill" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="lie-K8-LNT">
|
|
||||||
<rect key="frame" x="88" y="87" width="1744" height="635"/>
|
|
||||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" flexibleMinY="YES"/>
|
|
||||||
<connections>
|
|
||||||
<segue destination="odZ-Ww-zvF" kind="embed" identifier="infoView" id="i7y-hI-hVh"/>
|
|
||||||
</connections>
|
|
||||||
</containerView>
|
|
||||||
<activityIndicatorView opaque="NO" contentMode="scaleToFill" horizontalHuggingPriority="750" verticalHuggingPriority="750" fixedFrame="YES" animating="YES" style="large" translatesAutoresizingMaskIntoConstraints="NO" id="WHW-kl-wkr">
|
|
||||||
<rect key="frame" x="928" y="508" width="64" height="64"/>
|
|
||||||
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
|
|
||||||
<color key="color" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
|
||||||
</activityIndicatorView>
|
|
||||||
</subviews>
|
|
||||||
<viewLayoutGuide key="safeArea" id="Rbh-h3-eDf"/>
|
|
||||||
<color key="backgroundColor" white="0.0" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
|
||||||
</view>
|
|
||||||
<connections>
|
|
||||||
<outlet property="activityIndicator" destination="WHW-kl-wkr" id="7aF-qL-t4E"/>
|
|
||||||
<outlet property="controlsView" destination="OG6-kk-N7Z" id="8Ed-du-EpL"/>
|
|
||||||
<outlet property="currentTimeLabel" destination="CL5-ko-ceu" id="gZB-h5-TGd"/>
|
|
||||||
<outlet property="gradientView" destination="eh8-uG-9Wz" id="fBa-EG-C6z"/>
|
|
||||||
<outlet property="infoPanelContainerView" destination="lie-K8-LNT" id="4io-B3-qE3"/>
|
|
||||||
<outlet property="remainingTimeLabel" destination="NyZ-z0-56J" id="Opj-7c-cIE"/>
|
|
||||||
<outlet property="scrubLabel" destination="HfD-p0-JMA" id="R28-Fa-v9d"/>
|
|
||||||
<outlet property="scrubberView" destination="yrg-ru-QSH" id="ylv-C7-RNl"/>
|
|
||||||
<outlet property="transportBarView" destination="CQO-wl-bxv" id="tQv-bp-jYq"/>
|
|
||||||
<outlet property="videoContentView" destination="Ibg-CP-gb2" id="vnQ-7F-8AU"/>
|
|
||||||
</connections>
|
|
||||||
</viewController>
|
|
||||||
<placeholder placeholderIdentifier="IBFirstResponder" id="7uX-ET-Cqw" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
|
|
||||||
</objects>
|
|
||||||
<point key="canvasLocation" x="-5664" y="-1259"/>
|
|
||||||
</scene>
|
|
||||||
</scenes>
|
|
||||||
</document>
|
|
|
@ -1,34 +0,0 @@
|
||||||
//
|
|
||||||
/*
|
|
||||||
* SwiftFin is subject to the terms of the Mozilla Public
|
|
||||||
* License, v2.0. If a copy of the MPL was not distributed with this
|
|
||||||
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
|
||||||
*
|
|
||||||
* Copyright 2021 Aiden Vigue & Jellyfin Contributors
|
|
||||||
*/
|
|
||||||
|
|
||||||
import Stinsen
|
|
||||||
import SwiftUI
|
|
||||||
import JellyfinAPI
|
|
||||||
|
|
||||||
struct VideoPlayerView: UIViewControllerRepresentable {
|
|
||||||
@EnvironmentObject var router: VideoPlayerCoordinator.Router
|
|
||||||
|
|
||||||
var item: BaseItemDto
|
|
||||||
|
|
||||||
func makeUIViewController(context: Context) -> some UIViewController {
|
|
||||||
|
|
||||||
let storyboard = UIStoryboard(name: "VideoPlayer", bundle: nil)
|
|
||||||
let viewController = storyboard.instantiateViewController(withIdentifier: "VideoPlayer") as! VideoPlayerViewController
|
|
||||||
viewController.manifest = item
|
|
||||||
viewController.backAction = {
|
|
||||||
self.router.dismissCoordinator()
|
|
||||||
}
|
|
||||||
|
|
||||||
return viewController
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) {
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -0,0 +1,43 @@
|
||||||
|
//
|
||||||
|
/*
|
||||||
|
* 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 UIKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct NativePlayerView: UIViewControllerRepresentable {
|
||||||
|
|
||||||
|
let viewModel: VideoPlayerViewModel
|
||||||
|
|
||||||
|
typealias UIViewControllerType = NativePlayerViewController
|
||||||
|
|
||||||
|
func makeUIViewController(context: Context) -> NativePlayerViewController {
|
||||||
|
|
||||||
|
return NativePlayerViewController(viewModel: viewModel)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIViewController(_ uiViewController: NativePlayerViewController, context: Context) {
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct VLCPlayerView: UIViewControllerRepresentable {
|
||||||
|
|
||||||
|
let viewModel: VideoPlayerViewModel
|
||||||
|
|
||||||
|
typealias UIViewControllerType = VLCPlayerViewController
|
||||||
|
|
||||||
|
func makeUIViewController(context: Context) -> VLCPlayerViewController {
|
||||||
|
|
||||||
|
return VLCPlayerViewController(viewModel: viewModel)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIViewController(_ uiViewController: VLCPlayerViewController, context: Context) {
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,773 +0,0 @@
|
||||||
//
|
|
||||||
/*
|
|
||||||
* SwiftFin is subject to the terms of the Mozilla Public
|
|
||||||
* License, v2.0. If a copy of the MPL was not distributed with this
|
|
||||||
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
|
||||||
*
|
|
||||||
* Copyright 2021 Aiden Vigue & Jellyfin Contributors
|
|
||||||
*/
|
|
||||||
|
|
||||||
import TVUIKit
|
|
||||||
import TVVLCKit
|
|
||||||
import MediaPlayer
|
|
||||||
import JellyfinAPI
|
|
||||||
import Combine
|
|
||||||
import Defaults
|
|
||||||
|
|
||||||
protocol VideoPlayerSettingsDelegate: AnyObject {
|
|
||||||
func selectNew(audioTrack id: Int32)
|
|
||||||
func selectNew(subtitleTrack id: Int32)
|
|
||||||
}
|
|
||||||
|
|
||||||
class VideoPlayerViewController: UIViewController, VideoPlayerSettingsDelegate, VLCMediaPlayerDelegate, VLCMediaDelegate, UIGestureRecognizerDelegate {
|
|
||||||
|
|
||||||
@IBOutlet weak var videoContentView: UIView!
|
|
||||||
@IBOutlet weak var controlsView: UIView!
|
|
||||||
|
|
||||||
@IBOutlet weak var activityIndicator: UIActivityIndicatorView!
|
|
||||||
|
|
||||||
@IBOutlet weak var transportBarView: UIView!
|
|
||||||
@IBOutlet weak var scrubberView: UIView!
|
|
||||||
@IBOutlet weak var scrubLabel: UILabel!
|
|
||||||
@IBOutlet weak var gradientView: UIView!
|
|
||||||
|
|
||||||
@IBOutlet weak var currentTimeLabel: UILabel!
|
|
||||||
@IBOutlet weak var remainingTimeLabel: UILabel!
|
|
||||||
|
|
||||||
@IBOutlet weak var infoPanelContainerView: UIView!
|
|
||||||
|
|
||||||
var infoTabBarViewController: InfoTabBarViewController?
|
|
||||||
var focusedOnTabBar: Bool = false
|
|
||||||
var showingInfoPanel: Bool = false
|
|
||||||
|
|
||||||
var mediaPlayer = VLCMediaPlayer()
|
|
||||||
|
|
||||||
var lastProgressReportTime: Double = 0
|
|
||||||
var lastTime: Float = 0.0
|
|
||||||
var startTime: Int = 0
|
|
||||||
|
|
||||||
var selectedAudioTrack: Int32 = -1
|
|
||||||
var selectedCaptionTrack: Int32 = -1
|
|
||||||
|
|
||||||
var subtitleTrackArray: [Subtitle] = []
|
|
||||||
var audioTrackArray: [AudioTrack] = []
|
|
||||||
|
|
||||||
var playing: Bool = false
|
|
||||||
var seeking: Bool = false
|
|
||||||
var showingControls: Bool = false
|
|
||||||
var loading: Bool = true
|
|
||||||
|
|
||||||
var initialSeekPos: CGFloat = 0
|
|
||||||
var videoPos: Double = 0
|
|
||||||
var videoDuration: Double = 0
|
|
||||||
var controlsAppearTime: Double = 0
|
|
||||||
|
|
||||||
var manifest: BaseItemDto = BaseItemDto()
|
|
||||||
var playbackItem = PlaybackItem()
|
|
||||||
var playSessionId: String = ""
|
|
||||||
var backAction = {}
|
|
||||||
|
|
||||||
var cancellables = Set<AnyCancellable>()
|
|
||||||
|
|
||||||
override func didUpdateFocus(in context: UIFocusUpdateContext, with coordinator: UIFocusAnimationCoordinator) {
|
|
||||||
|
|
||||||
super.didUpdateFocus(in: context, with: coordinator)
|
|
||||||
|
|
||||||
// Check if focused on the tab bar, allows for swipe up to dismiss the info panel
|
|
||||||
if let nextFocused = context.nextFocusedView,
|
|
||||||
nextFocused.description.contains("UITabBarButton") {
|
|
||||||
// Set value after half a second so info panel is not dismissed instantly when swiping up from content
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
|
||||||
self.focusedOnTabBar = true
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
focusedOnTabBar = false
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
override func viewDidLoad() {
|
|
||||||
super.viewDidLoad()
|
|
||||||
|
|
||||||
activityIndicator.isHidden = false
|
|
||||||
activityIndicator.startAnimating()
|
|
||||||
|
|
||||||
mediaPlayer.delegate = self
|
|
||||||
mediaPlayer.drawable = videoContentView
|
|
||||||
|
|
||||||
if let runTimeTicks = manifest.runTimeTicks {
|
|
||||||
videoDuration = Double(runTimeTicks / 10_000_000)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Black gradient behind transport bar
|
|
||||||
let gradientLayer: CAGradientLayer = CAGradientLayer()
|
|
||||||
gradientLayer.frame.size = self.gradientView.frame.size
|
|
||||||
gradientLayer.colors = [UIColor.black.withAlphaComponent(0.6).cgColor, UIColor.black.withAlphaComponent(0).cgColor]
|
|
||||||
gradientLayer.startPoint = CGPoint(x: 0.0, y: 1.0)
|
|
||||||
gradientLayer.endPoint = CGPoint(x: 0.0, y: 0.0)
|
|
||||||
self.gradientView.layer.addSublayer(gradientLayer)
|
|
||||||
|
|
||||||
infoPanelContainerView.center = CGPoint(x: infoPanelContainerView.center.x, y: -infoPanelContainerView.frame.height)
|
|
||||||
infoPanelContainerView.layer.cornerRadius = 40
|
|
||||||
|
|
||||||
let blurEffectView = UIVisualEffectView(effect: UIBlurEffect(style: .regular))
|
|
||||||
blurEffectView.frame = infoPanelContainerView.bounds
|
|
||||||
blurEffectView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
|
||||||
blurEffectView.layer.cornerRadius = 40
|
|
||||||
blurEffectView.clipsToBounds = true
|
|
||||||
infoPanelContainerView.addSubview(blurEffectView)
|
|
||||||
infoPanelContainerView.sendSubviewToBack(blurEffectView)
|
|
||||||
|
|
||||||
transportBarView.layer.cornerRadius = CGFloat(5)
|
|
||||||
|
|
||||||
setupGestures()
|
|
||||||
|
|
||||||
fetchVideo()
|
|
||||||
|
|
||||||
setupNowPlayingCC()
|
|
||||||
|
|
||||||
// Adjust subtitle size
|
|
||||||
mediaPlayer.perform(Selector(("setTextRendererFontSize:")), with: 16)
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
func fetchVideo() {
|
|
||||||
// Fetch max bitrate from UserDefaults depending on current connection mode
|
|
||||||
let maxBitrate = Defaults[.inNetworkBandwidth]
|
|
||||||
|
|
||||||
// Build a device profile
|
|
||||||
let builder = DeviceProfileBuilder()
|
|
||||||
builder.setMaxBitrate(bitrate: maxBitrate)
|
|
||||||
let profile = builder.buildProfile()
|
|
||||||
|
|
||||||
let currentUser = SessionManager.main.currentLogin.user
|
|
||||||
|
|
||||||
let playbackInfo = PlaybackInfoDto(userId: currentUser.id, maxStreamingBitrate: Int(maxBitrate), startTimeTicks: manifest.userData?.playbackPositionTicks ?? 0, deviceProfile: profile, autoOpenLiveStream: true)
|
|
||||||
|
|
||||||
DispatchQueue.global(qos: .userInitiated).async { [self] in
|
|
||||||
MediaInfoAPI.getPostedPlaybackInfo(itemId: manifest.id!, userId: currentUser.id, maxStreamingBitrate: Int(maxBitrate), startTimeTicks: manifest.userData?.playbackPositionTicks ?? 0, autoOpenLiveStream: true, playbackInfoDto: playbackInfo)
|
|
||||||
.sink(receiveCompletion: { result in
|
|
||||||
print(result)
|
|
||||||
}, receiveValue: { [self] response in
|
|
||||||
|
|
||||||
videoContentView.setNeedsLayout()
|
|
||||||
videoContentView.setNeedsDisplay()
|
|
||||||
|
|
||||||
playSessionId = response.playSessionId ?? ""
|
|
||||||
|
|
||||||
guard let mediaSource = response.mediaSources?.first.self else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let item = PlaybackItem()
|
|
||||||
let streamURL: URL
|
|
||||||
|
|
||||||
// Item is being transcoded by request of server
|
|
||||||
if let transcodiungUrl = mediaSource.transcodingUrl {
|
|
||||||
item.videoType = .transcode
|
|
||||||
streamURL = URL(string: "\(SessionManager.main.currentLogin.server.currentURI)\(transcodiungUrl)")!
|
|
||||||
}
|
|
||||||
// Item will be directly played by the client
|
|
||||||
else {
|
|
||||||
item.videoType = .directPlay
|
|
||||||
// streamURL = URL(string: "\(SessionManager.main.currentLogin.server.currentURI)/Videos/\(manifest.id!)/stream?Static=true&mediaSourceId=\(manifest.id!)&deviceId=\(SessionManager.current.deviceID)&api_key=\(SessionManager.current.accessToken)&Tag=\(mediaSource.eTag!)")!
|
|
||||||
streamURL = URL(string: "\(SessionManager.main.currentLogin.server.currentURI)/Videos/\(manifest.id!)/stream?Static=true&mediaSourceId=\(manifest.id!)&Tag=\(mediaSource.eTag ?? "")")!
|
|
||||||
}
|
|
||||||
|
|
||||||
item.videoUrl = streamURL
|
|
||||||
|
|
||||||
let disableSubtitleTrack = Subtitle(name: "None", id: -1, url: nil, delivery: .embed, codec: "", languageCode: "")
|
|
||||||
subtitleTrackArray.append(disableSubtitleTrack)
|
|
||||||
|
|
||||||
// Loop through media streams and add to array
|
|
||||||
for stream in mediaSource.mediaStreams! {
|
|
||||||
|
|
||||||
if stream.type == .subtitle {
|
|
||||||
var deliveryUrl: URL?
|
|
||||||
|
|
||||||
if stream.deliveryMethod == .external {
|
|
||||||
deliveryUrl = URL(string: "\(SessionManager.main.currentLogin.server.currentURI)\(stream.deliveryUrl!)")!
|
|
||||||
}
|
|
||||||
|
|
||||||
let subtitle = Subtitle(name: stream.displayTitle ?? "Unknown", id: Int32(stream.index!), url: deliveryUrl, delivery: stream.deliveryMethod!, codec: stream.codec ?? "webvtt", languageCode: stream.language ?? "")
|
|
||||||
|
|
||||||
if stream.isDefault == true {
|
|
||||||
selectedCaptionTrack = Int32(stream.index!)
|
|
||||||
}
|
|
||||||
|
|
||||||
if subtitle.delivery != .encode {
|
|
||||||
subtitleTrackArray.append(subtitle)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if stream.type == .audio {
|
|
||||||
let track = AudioTrack(name: stream.displayTitle!, languageCode: stream.language ?? "", id: Int32(stream.index!))
|
|
||||||
|
|
||||||
if stream.isDefault! == true {
|
|
||||||
selectedAudioTrack = Int32(stream.index!)
|
|
||||||
}
|
|
||||||
|
|
||||||
audioTrackArray.append(track)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no default audio tracks select the first one
|
|
||||||
if selectedAudioTrack == -1 && !audioTrackArray.isEmpty {
|
|
||||||
selectedAudioTrack = audioTrackArray.first!.id
|
|
||||||
}
|
|
||||||
|
|
||||||
self.sendPlayReport()
|
|
||||||
playbackItem = item
|
|
||||||
|
|
||||||
mediaPlayer.media = VLCMedia(url: playbackItem.videoUrl)
|
|
||||||
mediaPlayer.media.delegate = self
|
|
||||||
mediaPlayer.play()
|
|
||||||
|
|
||||||
// 1 second = 10,000,000 ticks
|
|
||||||
|
|
||||||
if let rawStartTicks = manifest.userData?.playbackPositionTicks {
|
|
||||||
mediaPlayer.jumpForward(Int32(rawStartTicks / 10_000_000))
|
|
||||||
}
|
|
||||||
|
|
||||||
subtitleTrackArray.forEach { sub in
|
|
||||||
if sub.id != -1 && sub.delivery == .external && sub.codec != "subrip" {
|
|
||||||
mediaPlayer.addPlaybackSlave(sub.url!, type: .subtitle, enforce: false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
playing = true
|
|
||||||
setupInfoPanel()
|
|
||||||
|
|
||||||
})
|
|
||||||
.store(in: &cancellables)
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func setupNowPlayingCC() {
|
|
||||||
let commandCenter = MPRemoteCommandCenter.shared()
|
|
||||||
commandCenter.playCommand.isEnabled = true
|
|
||||||
commandCenter.pauseCommand.isEnabled = true
|
|
||||||
|
|
||||||
commandCenter.skipBackwardCommand.isEnabled = true
|
|
||||||
commandCenter.skipBackwardCommand.preferredIntervals = [15]
|
|
||||||
|
|
||||||
commandCenter.skipForwardCommand.isEnabled = true
|
|
||||||
commandCenter.skipForwardCommand.preferredIntervals = [30]
|
|
||||||
|
|
||||||
commandCenter.changePlaybackPositionCommand.isEnabled = true
|
|
||||||
commandCenter.enableLanguageOptionCommand.isEnabled = true
|
|
||||||
|
|
||||||
// Add handler for Pause Command
|
|
||||||
commandCenter.pauseCommand.addTarget { _ in
|
|
||||||
self.pause()
|
|
||||||
self.showingControls = true
|
|
||||||
self.controlsView.isHidden = false
|
|
||||||
self.controlsAppearTime = CACurrentMediaTime()
|
|
||||||
return .success
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add handler for Play command
|
|
||||||
commandCenter.playCommand.addTarget { _ in
|
|
||||||
self.play()
|
|
||||||
self.showingControls = false
|
|
||||||
self.controlsView.isHidden = true
|
|
||||||
return .success
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add handler for FF command
|
|
||||||
commandCenter.skipForwardCommand.addTarget { _ in
|
|
||||||
self.mediaPlayer.jumpForward(30)
|
|
||||||
self.sendProgressReport(eventName: "timeupdate")
|
|
||||||
return .success
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add handler for RW command
|
|
||||||
commandCenter.skipBackwardCommand.addTarget { _ in
|
|
||||||
self.mediaPlayer.jumpBackward(15)
|
|
||||||
self.sendProgressReport(eventName: "timeupdate")
|
|
||||||
return .success
|
|
||||||
}
|
|
||||||
|
|
||||||
// Scrubber
|
|
||||||
commandCenter.changePlaybackPositionCommand.addTarget { [weak self](remoteEvent) -> MPRemoteCommandHandlerStatus in
|
|
||||||
guard let self = self else {return .commandFailed}
|
|
||||||
|
|
||||||
if let event = remoteEvent as? MPChangePlaybackPositionCommandEvent {
|
|
||||||
let targetSeconds = event.positionTime
|
|
||||||
let videoPosition = Double(self.mediaPlayer.time.intValue / 1000)
|
|
||||||
let offset = targetSeconds - videoPosition
|
|
||||||
|
|
||||||
if offset > 0 {
|
|
||||||
self.mediaPlayer.jumpForward(Int32(offset))
|
|
||||||
} else {
|
|
||||||
self.mediaPlayer.jumpBackward(Int32(abs(offset)))
|
|
||||||
}
|
|
||||||
self.sendProgressReport(eventName: "unpause")
|
|
||||||
|
|
||||||
return .success
|
|
||||||
} else {
|
|
||||||
return .commandFailed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var runTicks = 0
|
|
||||||
var playbackTicks = 0
|
|
||||||
|
|
||||||
if let ticks = manifest.runTimeTicks {
|
|
||||||
runTicks = Int(ticks / 10_000_000)
|
|
||||||
}
|
|
||||||
|
|
||||||
if let ticks = manifest.userData?.playbackPositionTicks {
|
|
||||||
playbackTicks = Int(ticks / 10_000_000)
|
|
||||||
}
|
|
||||||
|
|
||||||
var nowPlayingInfo = [String: Any]()
|
|
||||||
|
|
||||||
nowPlayingInfo[MPMediaItemPropertyTitle] = manifest.name ?? "Jellyfin Video"
|
|
||||||
if manifest.type == "Episode" {
|
|
||||||
nowPlayingInfo[MPMediaItemPropertyArtist] = "\(manifest.seriesName ?? manifest.name ?? "") • \(manifest.getEpisodeLocator())"
|
|
||||||
}
|
|
||||||
nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = 0.0
|
|
||||||
nowPlayingInfo[MPNowPlayingInfoPropertyMediaType] = AVMediaType.video
|
|
||||||
nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = runTicks
|
|
||||||
nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = playbackTicks
|
|
||||||
|
|
||||||
if let imageData = NSData(contentsOf: manifest.getPrimaryImage(maxWidth: 500)) {
|
|
||||||
if let artworkImage = UIImage(data: imageData as Data) {
|
|
||||||
let artwork = MPMediaItemArtwork.init(boundsSize: artworkImage.size, requestHandler: { (_) -> UIImage in
|
|
||||||
return artworkImage
|
|
||||||
})
|
|
||||||
nowPlayingInfo[MPMediaItemPropertyArtwork] = artwork
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
|
|
||||||
|
|
||||||
UIApplication.shared.beginReceivingRemoteControlEvents()
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateNowPlayingCenter(time: Double?, playing: Bool?) {
|
|
||||||
|
|
||||||
var nowPlayingInfo = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [String: Any]()
|
|
||||||
|
|
||||||
if let playing = playing {
|
|
||||||
nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = playing ? 1.0 : 0.0
|
|
||||||
}
|
|
||||||
|
|
||||||
nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = mediaPlayer.time.intValue / 1000
|
|
||||||
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
|
|
||||||
}
|
|
||||||
|
|
||||||
// Grabs a reference to the info panel view controller
|
|
||||||
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
|
|
||||||
if segue.identifier == "infoView" {
|
|
||||||
infoTabBarViewController = segue.destination as? InfoTabBarViewController
|
|
||||||
infoTabBarViewController?.videoPlayer = self
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: Player functions
|
|
||||||
// Animate the scrubber when playing state changes
|
|
||||||
func animateScrubber() {
|
|
||||||
let y: CGFloat = playing ? 0 : -20
|
|
||||||
let height: CGFloat = playing ? 10 : 30
|
|
||||||
|
|
||||||
UIView.animate(withDuration: 0.2, delay: 0, options: .curveEaseIn, animations: {
|
|
||||||
self.scrubberView.frame = CGRect(x: self.scrubberView.frame.minX, y: y, width: 2, height: height)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func pause() {
|
|
||||||
playing = false
|
|
||||||
mediaPlayer.pause()
|
|
||||||
|
|
||||||
self.sendProgressReport(eventName: "pause")
|
|
||||||
|
|
||||||
self.updateNowPlayingCenter(time: nil, playing: false)
|
|
||||||
|
|
||||||
animateScrubber()
|
|
||||||
|
|
||||||
self.scrubLabel.frame = CGRect(x: self.scrubberView.frame.minX - self.scrubLabel.frame.width/2, y: self.scrubLabel.frame.minY, width: self.scrubLabel.frame.width, height: self.scrubLabel.frame.height)
|
|
||||||
}
|
|
||||||
|
|
||||||
func play () {
|
|
||||||
playing = true
|
|
||||||
mediaPlayer.play()
|
|
||||||
|
|
||||||
self.updateNowPlayingCenter(time: nil, playing: true)
|
|
||||||
|
|
||||||
self.sendProgressReport(eventName: "unpause")
|
|
||||||
|
|
||||||
animateScrubber()
|
|
||||||
}
|
|
||||||
|
|
||||||
func toggleInfoContainer() {
|
|
||||||
showingInfoPanel.toggle()
|
|
||||||
|
|
||||||
infoTabBarViewController?.view.isUserInteractionEnabled = showingInfoPanel
|
|
||||||
|
|
||||||
if showingInfoPanel && seeking {
|
|
||||||
scrubLabel.isHidden = true
|
|
||||||
UIView.animate(withDuration: 0.4, delay: 0, options: .curveEaseOut, animations: {
|
|
||||||
self.scrubberView.frame = CGRect(x: self.initialSeekPos, y: self.scrubberView.frame.minY, width: 2, height: self.scrubberView.frame.height)
|
|
||||||
}) { _ in
|
|
||||||
self.scrubLabel.frame = CGRect(x: (self.initialSeekPos - self.scrubLabel.frame.width/2), y: self.scrubLabel.frame.minY, width: self.scrubLabel.frame.width, height: self.scrubLabel.frame.height)
|
|
||||||
self.scrubLabel.text = self.currentTimeLabel.text
|
|
||||||
}
|
|
||||||
seeking = false
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
UIView.animate(withDuration: 0.4, delay: 0, options: .curveEaseOut) { [self] in
|
|
||||||
let size = infoPanelContainerView.frame.size
|
|
||||||
let y: CGFloat = showingInfoPanel ? 87 : -size.height
|
|
||||||
|
|
||||||
infoPanelContainerView.frame = CGRect(x: 88, y: y, width: size.width, height: size.height)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: Gestures
|
|
||||||
override func pressesBegan(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
|
|
||||||
for item in presses {
|
|
||||||
if item.type == .select {
|
|
||||||
selectButtonTapped()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func setupGestures() {
|
|
||||||
self.becomeFirstResponder()
|
|
||||||
|
|
||||||
// vlc crap
|
|
||||||
videoContentView.gestureRecognizers?.forEach { gr in
|
|
||||||
videoContentView.removeGestureRecognizer(gr)
|
|
||||||
}
|
|
||||||
videoContentView.subviews.forEach { sv in
|
|
||||||
sv.gestureRecognizers?.forEach { gr in
|
|
||||||
sv.removeGestureRecognizer(gr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let playPauseGesture = UITapGestureRecognizer(target: self, action: #selector(self.selectButtonTapped))
|
|
||||||
let playPauseType = UIPress.PressType.playPause
|
|
||||||
playPauseGesture.allowedPressTypes = [NSNumber(value: playPauseType.rawValue)]
|
|
||||||
view.addGestureRecognizer(playPauseGesture)
|
|
||||||
|
|
||||||
let backTapGesture = UITapGestureRecognizer(target: self, action: #selector(self.backButtonPressed(tap:)))
|
|
||||||
let backPress = UIPress.PressType.menu
|
|
||||||
backTapGesture.allowedPressTypes = [NSNumber(value: backPress.rawValue)]
|
|
||||||
view.addGestureRecognizer(backTapGesture)
|
|
||||||
|
|
||||||
let panGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(self.userPanned(panGestureRecognizer:)))
|
|
||||||
view.addGestureRecognizer(panGestureRecognizer)
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc func backButtonPressed(tap: UITapGestureRecognizer) {
|
|
||||||
// Dismiss info panel
|
|
||||||
if showingInfoPanel {
|
|
||||||
if focusedOnTabBar {
|
|
||||||
toggleInfoContainer()
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cancel seek and move back to initial position
|
|
||||||
if seeking {
|
|
||||||
scrubLabel.isHidden = true
|
|
||||||
UIView.animate(withDuration: 0.4, delay: 0, options: .curveEaseOut, animations: {
|
|
||||||
self.scrubberView.frame = CGRect(x: self.initialSeekPos, y: 0, width: 2, height: 10)
|
|
||||||
})
|
|
||||||
play()
|
|
||||||
seeking = false
|
|
||||||
} else {
|
|
||||||
// Dismiss view
|
|
||||||
self.resignFirstResponder()
|
|
||||||
mediaPlayer.stop()
|
|
||||||
sendStopReport()
|
|
||||||
backAction()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc func userPanned(panGestureRecognizer: UIPanGestureRecognizer) {
|
|
||||||
if loading {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let translation = panGestureRecognizer.translation(in: view)
|
|
||||||
let velocity = panGestureRecognizer.velocity(in: view)
|
|
||||||
|
|
||||||
// Swiped up - Handle dismissing info panel
|
|
||||||
if translation.y < -200 && (focusedOnTabBar && showingInfoPanel) {
|
|
||||||
toggleInfoContainer()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if showingInfoPanel {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Swiped down - Show the info panel
|
|
||||||
if translation.y > 200 {
|
|
||||||
toggleInfoContainer()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ignore seek if video is playing
|
|
||||||
if playing {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save current position if seek is cancelled and show the scrubLabel
|
|
||||||
if !seeking {
|
|
||||||
initialSeekPos = self.scrubberView.frame.minX
|
|
||||||
seeking = true
|
|
||||||
self.scrubLabel.isHidden = false
|
|
||||||
}
|
|
||||||
|
|
||||||
let newPos = (self.scrubberView.frame.minX + velocity.x/100).clamped(to: 0...transportBarView.frame.width)
|
|
||||||
|
|
||||||
UIView.animate(withDuration: 0.8, delay: 0, options: .curveEaseOut, animations: {
|
|
||||||
let time = (Double(self.scrubberView.frame.minX) * self.videoDuration) / Double(self.transportBarView.frame.width)
|
|
||||||
|
|
||||||
self.scrubberView.frame = CGRect(x: newPos, y: self.scrubberView.frame.minY, width: 2, height: 30)
|
|
||||||
self.scrubLabel.frame = CGRect(x: (newPos - self.scrubLabel.frame.width/2), y: self.scrubLabel.frame.minY, width: self.scrubLabel.frame.width, height: self.scrubLabel.frame.height)
|
|
||||||
self.scrubLabel.text = (self.formatSecondsToHMS(time))
|
|
||||||
|
|
||||||
})
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Play/Pause or Select is pressed on the AppleTV remote
|
|
||||||
@objc func selectButtonTapped() {
|
|
||||||
print("select")
|
|
||||||
if loading {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
showingControls = true
|
|
||||||
controlsView.isHidden = false
|
|
||||||
controlsAppearTime = CACurrentMediaTime()
|
|
||||||
|
|
||||||
// Move to seeked position
|
|
||||||
if seeking {
|
|
||||||
scrubLabel.isHidden = true
|
|
||||||
|
|
||||||
// Move current time to the scrubbed position
|
|
||||||
UIView.animate(withDuration: 0.4, delay: 0, options: .curveEaseOut, animations: { [self] in
|
|
||||||
|
|
||||||
self.currentTimeLabel.frame = CGRect(x: CGFloat(scrubLabel.frame.minX + transportBarView.frame.minX), y: currentTimeLabel.frame.minY, width: currentTimeLabel.frame.width, height: currentTimeLabel.frame.height)
|
|
||||||
|
|
||||||
})
|
|
||||||
|
|
||||||
let time = (Double(self.scrubberView.frame.minX) * self.videoDuration) / Double(self.transportBarView.frame.width)
|
|
||||||
|
|
||||||
self.currentTimeLabel.text = self.scrubLabel.text
|
|
||||||
self.remainingTimeLabel.text = "-" + formatSecondsToHMS(videoDuration - time)
|
|
||||||
|
|
||||||
mediaPlayer.position = Float(self.scrubberView.frame.minX) / Float(self.transportBarView.frame.width)
|
|
||||||
|
|
||||||
play()
|
|
||||||
|
|
||||||
seeking = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
playing ? pause() : play()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: Jellyfin Playstate updates
|
|
||||||
func sendProgressReport(eventName: String) {
|
|
||||||
updateNowPlayingCenter(time: nil, playing: mediaPlayer.state == .playing)
|
|
||||||
|
|
||||||
if (eventName == "timeupdate" && mediaPlayer.state == .playing) || eventName != "timeupdate" {
|
|
||||||
var ticks: Int64 = Int64(mediaPlayer.position * Float(manifest.runTimeTicks ?? 0))
|
|
||||||
if ticks == 0 {
|
|
||||||
ticks = manifest.userData?.playbackPositionTicks ?? 0
|
|
||||||
}
|
|
||||||
|
|
||||||
let progressInfo = PlaybackProgressInfo(canSeek: true, item: manifest, itemId: manifest.id, sessionId: playSessionId, mediaSourceId: manifest.id, audioStreamIndex: Int(selectedAudioTrack), subtitleStreamIndex: Int(selectedCaptionTrack), isPaused: (!playing), isMuted: false, positionTicks: ticks, playbackStartTimeTicks: Int64(startTime), volumeLevel: 100, brightness: 100, aspectRatio: nil, playMethod: playbackItem.videoType, liveStreamId: nil, playSessionId: playSessionId, repeatMode: .repeatNone, nowPlayingQueue: [], playlistItemId: "playlistItem0")
|
|
||||||
|
|
||||||
PlaystateAPI.reportPlaybackProgress(playbackProgressInfo: progressInfo)
|
|
||||||
.sink(receiveCompletion: { result in
|
|
||||||
print(result)
|
|
||||||
}, receiveValue: { _ in
|
|
||||||
print("Playback progress report sent!")
|
|
||||||
})
|
|
||||||
.store(in: &cancellables)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func sendStopReport() {
|
|
||||||
let stopInfo = PlaybackStopInfo(item: manifest, itemId: manifest.id, sessionId: playSessionId, mediaSourceId: manifest.id, positionTicks: Int64(mediaPlayer.position * Float(manifest.runTimeTicks ?? 0)), liveStreamId: nil, playSessionId: playSessionId, failed: nil, nextMediaType: nil, playlistItemId: "playlistItem0", nowPlayingQueue: [])
|
|
||||||
|
|
||||||
PlaystateAPI.reportPlaybackStopped(playbackStopInfo: stopInfo)
|
|
||||||
.sink(receiveCompletion: { result in
|
|
||||||
print(result)
|
|
||||||
}, receiveValue: { _ in
|
|
||||||
print("Playback stop report sent!")
|
|
||||||
})
|
|
||||||
.store(in: &cancellables)
|
|
||||||
}
|
|
||||||
|
|
||||||
func sendPlayReport() {
|
|
||||||
startTime = Int(Date().timeIntervalSince1970) * 10000000
|
|
||||||
|
|
||||||
print("sending play report!")
|
|
||||||
|
|
||||||
let startInfo = PlaybackStartInfo(canSeek: true, item: manifest, itemId: manifest.id, sessionId: playSessionId, mediaSourceId: manifest.id, audioStreamIndex: Int(selectedAudioTrack), subtitleStreamIndex: Int(selectedCaptionTrack), isPaused: false, isMuted: false, positionTicks: manifest.userData?.playbackPositionTicks, playbackStartTimeTicks: Int64(startTime), volumeLevel: 100, brightness: 100, aspectRatio: nil, playMethod: playbackItem.videoType, liveStreamId: nil, playSessionId: playSessionId, repeatMode: .repeatNone, nowPlayingQueue: [], playlistItemId: "playlistItem0")
|
|
||||||
|
|
||||||
PlaystateAPI.reportPlaybackStart(playbackStartInfo: startInfo)
|
|
||||||
.sink(receiveCompletion: { result in
|
|
||||||
print(result)
|
|
||||||
}, receiveValue: { _ in
|
|
||||||
print("Playback start report sent!")
|
|
||||||
})
|
|
||||||
.store(in: &cancellables)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: VLC Delegate
|
|
||||||
|
|
||||||
func mediaPlayerStateChanged(_ aNotification: Notification!) {
|
|
||||||
let currentState: VLCMediaPlayerState = mediaPlayer.state
|
|
||||||
switch currentState {
|
|
||||||
case .buffering:
|
|
||||||
print("Video is buffering")
|
|
||||||
loading = true
|
|
||||||
activityIndicator.isHidden = false
|
|
||||||
activityIndicator.startAnimating()
|
|
||||||
mediaPlayer.pause()
|
|
||||||
usleep(10000)
|
|
||||||
mediaPlayer.play()
|
|
||||||
break
|
|
||||||
case .stopped:
|
|
||||||
print("stopped")
|
|
||||||
|
|
||||||
break
|
|
||||||
case .ended:
|
|
||||||
print("ended")
|
|
||||||
|
|
||||||
break
|
|
||||||
case .opening:
|
|
||||||
print("opening")
|
|
||||||
|
|
||||||
break
|
|
||||||
case .paused:
|
|
||||||
print("paused")
|
|
||||||
|
|
||||||
break
|
|
||||||
case .playing:
|
|
||||||
print("Video is playing")
|
|
||||||
loading = false
|
|
||||||
sendProgressReport(eventName: "unpause")
|
|
||||||
DispatchQueue.main.async { [self] in
|
|
||||||
activityIndicator.isHidden = true
|
|
||||||
activityIndicator.stopAnimating()
|
|
||||||
}
|
|
||||||
playing = true
|
|
||||||
break
|
|
||||||
case .error:
|
|
||||||
print("error")
|
|
||||||
break
|
|
||||||
case .esAdded:
|
|
||||||
print("esAdded")
|
|
||||||
break
|
|
||||||
default:
|
|
||||||
print("default")
|
|
||||||
break
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
// Move time along transport bar
|
|
||||||
func mediaPlayerTimeChanged(_ aNotification: Notification!) {
|
|
||||||
|
|
||||||
if loading {
|
|
||||||
loading = false
|
|
||||||
DispatchQueue.main.async { [self] in
|
|
||||||
activityIndicator.isHidden = true
|
|
||||||
activityIndicator.stopAnimating()
|
|
||||||
}
|
|
||||||
updateNowPlayingCenter(time: nil, playing: true)
|
|
||||||
}
|
|
||||||
|
|
||||||
let time = mediaPlayer.position
|
|
||||||
if time != lastTime {
|
|
||||||
self.currentTimeLabel.text = formatSecondsToHMS(Double(mediaPlayer.time.intValue/1000))
|
|
||||||
self.remainingTimeLabel.text = "-" + formatSecondsToHMS(Double(abs(mediaPlayer.remainingTime.intValue/1000)))
|
|
||||||
|
|
||||||
self.videoPos = Double(mediaPlayer.position)
|
|
||||||
|
|
||||||
let newPos = videoPos * Double(self.transportBarView.frame.width)
|
|
||||||
if !newPos.isNaN && self.playing {
|
|
||||||
self.scrubberView.frame = CGRect(x: newPos, y: 0, width: 2, height: 10)
|
|
||||||
self.currentTimeLabel.frame = CGRect(x: CGFloat(newPos) + transportBarView.frame.minX - currentTimeLabel.frame.width/2, y: currentTimeLabel.frame.minY, width: currentTimeLabel.frame.width, height: currentTimeLabel.frame.height)
|
|
||||||
}
|
|
||||||
|
|
||||||
if showingControls {
|
|
||||||
if CACurrentMediaTime() - controlsAppearTime > 5 {
|
|
||||||
showingControls = false
|
|
||||||
UIView.animate(withDuration: 0.5, delay: 0, options: .curveEaseOut, animations: {
|
|
||||||
self.controlsView.alpha = 0.0
|
|
||||||
}, completion: { (_: Bool) in
|
|
||||||
self.controlsView.isHidden = true
|
|
||||||
self.controlsView.alpha = 1
|
|
||||||
})
|
|
||||||
controlsAppearTime = 999_999_999_999_999
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
lastTime = time
|
|
||||||
|
|
||||||
if CACurrentMediaTime() - lastProgressReportTime > 5 {
|
|
||||||
sendProgressReport(eventName: "timeupdate")
|
|
||||||
lastProgressReportTime = CACurrentMediaTime()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: Settings Delegate
|
|
||||||
func selectNew(audioTrack id: Int32) {
|
|
||||||
selectedAudioTrack = id
|
|
||||||
mediaPlayer.currentAudioTrackIndex = id
|
|
||||||
}
|
|
||||||
|
|
||||||
func selectNew(subtitleTrack id: Int32) {
|
|
||||||
selectedCaptionTrack = id
|
|
||||||
mediaPlayer.currentVideoSubTitleIndex = id
|
|
||||||
}
|
|
||||||
|
|
||||||
func setupInfoPanel() {
|
|
||||||
infoTabBarViewController?.setupInfoViews(mediaItem: manifest, subtitleTracks: subtitleTrackArray, selectedSubtitleTrack: selectedCaptionTrack, audioTracks: audioTrackArray, selectedAudioTrack: selectedAudioTrack, delegate: self)
|
|
||||||
}
|
|
||||||
|
|
||||||
func formatSecondsToHMS(_ seconds: Double) -> String {
|
|
||||||
let timeHMSFormatter: DateComponentsFormatter = {
|
|
||||||
let formatter = DateComponentsFormatter()
|
|
||||||
formatter.unitsStyle = .positional
|
|
||||||
formatter.allowedUnits = seconds >= 3600 ?
|
|
||||||
[.hour, .minute, .second] :
|
|
||||||
[.minute, .second]
|
|
||||||
formatter.zeroFormattingBehavior = .pad
|
|
||||||
return formatter
|
|
||||||
}()
|
|
||||||
|
|
||||||
guard !seconds.isNaN,
|
|
||||||
let text = timeHMSFormatter.string(from: seconds) else {
|
|
||||||
return "00:00"
|
|
||||||
}
|
|
||||||
|
|
||||||
return text.hasPrefix("0") && text.count > 4 ?
|
|
||||||
.init(text.dropFirst()) : text
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension Comparable {
|
|
||||||
func clamped(to limits: ClosedRange<Self>) -> Self {
|
|
||||||
return min(max(self, limits.lowerBound), limits.upperBound)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -0,0 +1,40 @@
|
||||||
|
//
|
||||||
|
/*
|
||||||
|
* 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 ConfirmCloseOverlay: View {
|
||||||
|
var body: some View {
|
||||||
|
VStack {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "chevron.left.circle.fill")
|
||||||
|
.font(.system(size: 96))
|
||||||
|
.padding(3)
|
||||||
|
.background(Color.black.opacity(0.4).mask(Circle()))
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ConfirmCloseOverlay_Previews: PreviewProvider {
|
||||||
|
static var previews: some View {
|
||||||
|
ZStack {
|
||||||
|
Color.red.ignoresSafeArea()
|
||||||
|
|
||||||
|
ConfirmCloseOverlay()
|
||||||
|
.ignoresSafeArea()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,254 @@
|
||||||
|
//
|
||||||
|
/*
|
||||||
|
* 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
|
||||||
|
|
||||||
|
// TODO: Needs replacement/reworking
|
||||||
|
struct SmallMediaStreamSelectionView: View {
|
||||||
|
|
||||||
|
enum Layer: Hashable {
|
||||||
|
case subtitles
|
||||||
|
case audio
|
||||||
|
case playbackSpeed
|
||||||
|
}
|
||||||
|
|
||||||
|
enum MediaSection: Hashable {
|
||||||
|
case titles
|
||||||
|
case items
|
||||||
|
}
|
||||||
|
|
||||||
|
@ObservedObject var viewModel: VideoPlayerViewModel
|
||||||
|
|
||||||
|
@State private var updateFocusedLayer: Layer = .subtitles
|
||||||
|
|
||||||
|
@FocusState private var subtitlesFocused: Bool
|
||||||
|
@FocusState private var audioFocused: Bool
|
||||||
|
@FocusState private var playbackSpeedFocused: Bool
|
||||||
|
@FocusState private var focusedSection: MediaSection?
|
||||||
|
@FocusState private var focusedLayer: Layer? {
|
||||||
|
willSet {
|
||||||
|
updateFocusedLayer = newValue!
|
||||||
|
|
||||||
|
if focusedSection == .titles {
|
||||||
|
lastFocusedLayer = newValue!
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@State private var lastFocusedLayer: Layer = .subtitles
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack(alignment: .bottom) {
|
||||||
|
LinearGradient(gradient: Gradient(colors: [.clear, .black.opacity(0.8), .black]),
|
||||||
|
startPoint: .top,
|
||||||
|
endPoint: .bottom)
|
||||||
|
.ignoresSafeArea()
|
||||||
|
.frame(height: 300)
|
||||||
|
|
||||||
|
VStack {
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
|
||||||
|
// MARK: Subtitle Header
|
||||||
|
Button {
|
||||||
|
updateFocusedLayer = .subtitles
|
||||||
|
focusedLayer = .subtitles
|
||||||
|
} label: {
|
||||||
|
if updateFocusedLayer == .subtitles {
|
||||||
|
HStack(spacing: 15) {
|
||||||
|
Image(systemName: "captions.bubble")
|
||||||
|
Text("Subtitles")
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color.white)
|
||||||
|
.foregroundColor(.black)
|
||||||
|
} else {
|
||||||
|
HStack(spacing: 15) {
|
||||||
|
Image(systemName: "captions.bubble")
|
||||||
|
Text("Subtitles")
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(PlainButtonStyle())
|
||||||
|
.background(Color.clear)
|
||||||
|
.focused($focusedLayer, equals: .subtitles)
|
||||||
|
.focused($subtitlesFocused)
|
||||||
|
.onChange(of: subtitlesFocused) { isFocused in
|
||||||
|
if isFocused {
|
||||||
|
focusedLayer = .subtitles
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Audio Header
|
||||||
|
Button {
|
||||||
|
updateFocusedLayer = .audio
|
||||||
|
focusedLayer = .audio
|
||||||
|
} label: {
|
||||||
|
if updateFocusedLayer == .audio {
|
||||||
|
HStack(spacing: 15) {
|
||||||
|
Image(systemName: "speaker.wave.3")
|
||||||
|
Text("Audio")
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color.white)
|
||||||
|
.foregroundColor(.black)
|
||||||
|
} else {
|
||||||
|
HStack(spacing: 15) {
|
||||||
|
Image(systemName: "speaker.wave.3")
|
||||||
|
Text("Audio")
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(PlainButtonStyle())
|
||||||
|
.background(Color.clear)
|
||||||
|
.focused($focusedLayer, equals: .audio)
|
||||||
|
.focused($audioFocused)
|
||||||
|
.onChange(of: audioFocused) { isFocused in
|
||||||
|
if isFocused {
|
||||||
|
focusedLayer = .audio
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Playback Speed Header
|
||||||
|
Button {
|
||||||
|
updateFocusedLayer = .playbackSpeed
|
||||||
|
focusedLayer = .playbackSpeed
|
||||||
|
} label: {
|
||||||
|
if updateFocusedLayer == .playbackSpeed {
|
||||||
|
HStack(spacing: 15) {
|
||||||
|
Image(systemName: "speedometer")
|
||||||
|
Text("Playback Speed")
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color.white)
|
||||||
|
.foregroundColor(.black)
|
||||||
|
} else {
|
||||||
|
HStack(spacing: 15) {
|
||||||
|
Image(systemName: "speedometer")
|
||||||
|
Text("Playback Speed")
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(PlainButtonStyle())
|
||||||
|
.background(Color.clear)
|
||||||
|
.focused($focusedLayer, equals: .playbackSpeed)
|
||||||
|
.focused($playbackSpeedFocused)
|
||||||
|
.onChange(of: playbackSpeedFocused) { isFocused in
|
||||||
|
if isFocused {
|
||||||
|
focusedLayer = .playbackSpeed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.focusSection()
|
||||||
|
.focused($focusedSection, equals: .titles)
|
||||||
|
.onChange(of: focusedSection) { newSection in
|
||||||
|
if focusedSection == .titles {
|
||||||
|
if lastFocusedLayer == .subtitles {
|
||||||
|
subtitlesFocused = true
|
||||||
|
} else if lastFocusedLayer == .audio {
|
||||||
|
audioFocused = true
|
||||||
|
} else if lastFocusedLayer == .playbackSpeed {
|
||||||
|
playbackSpeedFocused = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if updateFocusedLayer == .subtitles && lastFocusedLayer == .subtitles {
|
||||||
|
// MARK: Subtitles
|
||||||
|
|
||||||
|
ScrollView(.horizontal) {
|
||||||
|
HStack {
|
||||||
|
if viewModel.subtitleStreams.isEmpty {
|
||||||
|
Button {
|
||||||
|
|
||||||
|
} label: {
|
||||||
|
Text("None")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ForEach(viewModel.subtitleStreams, id: \.self) { subtitleStream in
|
||||||
|
Button {
|
||||||
|
viewModel.selectedSubtitleStreamIndex = subtitleStream.index ?? -1
|
||||||
|
} label: {
|
||||||
|
if subtitleStream.index == viewModel.selectedSubtitleStreamIndex {
|
||||||
|
Label(subtitleStream.displayTitle ?? "No Title", systemImage: "checkmark")
|
||||||
|
} else {
|
||||||
|
Text(subtitleStream.displayTitle ?? "No Title")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical)
|
||||||
|
.focusSection()
|
||||||
|
.focused($focusedSection, equals: .items)
|
||||||
|
}
|
||||||
|
} else if updateFocusedLayer == .audio && lastFocusedLayer == .audio {
|
||||||
|
// MARK: Audio
|
||||||
|
|
||||||
|
ScrollView(.horizontal) {
|
||||||
|
HStack {
|
||||||
|
if viewModel.audioStreams.isEmpty {
|
||||||
|
Button {
|
||||||
|
|
||||||
|
} label: {
|
||||||
|
Text("None")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ForEach(viewModel.audioStreams, id: \.self) { audioStream in
|
||||||
|
Button {
|
||||||
|
viewModel.selectedAudioStreamIndex = audioStream.index ?? -1
|
||||||
|
} label: {
|
||||||
|
if audioStream.index == viewModel.selectedAudioStreamIndex {
|
||||||
|
Label(audioStream.displayTitle ?? "No Title", systemImage: "checkmark")
|
||||||
|
} else {
|
||||||
|
Text(audioStream.displayTitle ?? "No Title")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical)
|
||||||
|
.focusSection()
|
||||||
|
.focused($focusedSection, equals: .items)
|
||||||
|
}
|
||||||
|
} else if updateFocusedLayer == .playbackSpeed && lastFocusedLayer == .playbackSpeed {
|
||||||
|
// MARK: Rates
|
||||||
|
|
||||||
|
ScrollView(.horizontal) {
|
||||||
|
HStack {
|
||||||
|
ForEach(PlaybackSpeed.allCases, id: \.self) { playbackSpeed in
|
||||||
|
Button {
|
||||||
|
viewModel.playbackSpeed = playbackSpeed
|
||||||
|
} label: {
|
||||||
|
if playbackSpeed == viewModel.playbackSpeed {
|
||||||
|
Label(playbackSpeed.displayTitle, systemImage: "checkmark")
|
||||||
|
} else {
|
||||||
|
Text(playbackSpeed.displayTitle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical)
|
||||||
|
.focusSection()
|
||||||
|
.focused($focusedSection, equals: .items)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,164 @@
|
||||||
|
//
|
||||||
|
/*
|
||||||
|
* SwiftFin is subject to the terms of the Mozilla Public
|
||||||
|
* License, v2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* Copyright 2021 Aiden Vigue & Jellyfin Contributors
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Defaults
|
||||||
|
import JellyfinAPI
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct tvOSVLCOverlay: View {
|
||||||
|
|
||||||
|
@ObservedObject var viewModel: VideoPlayerViewModel
|
||||||
|
@Default(.downActionShowsMenu) var downActionShowsMenu
|
||||||
|
|
||||||
|
@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.8), .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(.lightGray)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(viewModel.title)
|
||||||
|
.font(.title3)
|
||||||
|
.fontWeight(.bold)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if viewModel.shouldShowPlayPreviousItem {
|
||||||
|
SFSymbolButton(systemName: "chevron.left.circle", action: {
|
||||||
|
viewModel.playerOverlayDelegate?.didSelectPlayPreviousItem()
|
||||||
|
})
|
||||||
|
.frame(maxWidth: 30, maxHeight: 30)
|
||||||
|
.disabled(viewModel.previousItemVideoPlayerViewModel == nil)
|
||||||
|
.foregroundColor(viewModel.nextItemVideoPlayerViewModel == nil ? .gray : .white)
|
||||||
|
}
|
||||||
|
|
||||||
|
if viewModel.shouldShowPlayNextItem {
|
||||||
|
SFSymbolButton(systemName: "chevron.right.circle", action: {
|
||||||
|
viewModel.playerOverlayDelegate?.didSelectPlayNextItem()
|
||||||
|
})
|
||||||
|
.frame(maxWidth: 30, maxHeight: 30)
|
||||||
|
.disabled(viewModel.nextItemVideoPlayerViewModel == nil)
|
||||||
|
.foregroundColor(viewModel.nextItemVideoPlayerViewModel == nil ? .gray : .white)
|
||||||
|
}
|
||||||
|
|
||||||
|
if viewModel.shouldShowAutoPlay {
|
||||||
|
if viewModel.autoplayEnabled {
|
||||||
|
SFSymbolButton(systemName: "play.circle.fill") {
|
||||||
|
viewModel.autoplayEnabled.toggle()
|
||||||
|
}
|
||||||
|
.frame(maxWidth: 30, maxHeight: 30)
|
||||||
|
} else {
|
||||||
|
SFSymbolButton(systemName: "stop.circle") {
|
||||||
|
viewModel.autoplayEnabled.toggle()
|
||||||
|
}
|
||||||
|
.frame(maxWidth: 30, maxHeight: 30)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !viewModel.subtitleStreams.isEmpty {
|
||||||
|
if viewModel.subtitlesEnabled {
|
||||||
|
SFSymbolButton(systemName: "captions.bubble.fill") {
|
||||||
|
viewModel.subtitlesEnabled.toggle()
|
||||||
|
}
|
||||||
|
.frame(maxWidth: 30, maxHeight: 30)
|
||||||
|
} else {
|
||||||
|
SFSymbolButton(systemName: "captions.bubble") {
|
||||||
|
viewModel.subtitlesEnabled.toggle()
|
||||||
|
}
|
||||||
|
.frame(maxWidth: 30, maxHeight: 30)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !downActionShowsMenu {
|
||||||
|
SFSymbolButton(systemName: "ellipsis.circle") {
|
||||||
|
viewModel.playerOverlayDelegate?.didSelectMenu()
|
||||||
|
}
|
||||||
|
.frame(maxWidth: 30, maxHeight: 30)
|
||||||
|
} }
|
||||||
|
.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 let videoPlayerViewModel = VideoPlayerViewModel(item: BaseItemDto(),
|
||||||
|
title: "Glorious Purpose",
|
||||||
|
subtitle: "Loki - S1E1",
|
||||||
|
streamURL: URL(string: "www.apple.com")!,
|
||||||
|
hlsURL: URL(string: "www.apple.com")!,
|
||||||
|
response: PlaybackInfoResponse(),
|
||||||
|
audioStreams: [MediaStream(displayTitle: "English", index: -1)],
|
||||||
|
subtitleStreams: [MediaStream(displayTitle: "None", index: -1)],
|
||||||
|
selectedAudioStreamIndex: -1,
|
||||||
|
selectedSubtitleStreamIndex: -1,
|
||||||
|
subtitlesEnabled: true,
|
||||||
|
autoplayEnabled: false,
|
||||||
|
overlayType: .compact,
|
||||||
|
shouldShowPlayPreviousItem: true,
|
||||||
|
shouldShowPlayNextItem: true,
|
||||||
|
shouldShowAutoPlay: true)
|
||||||
|
|
||||||
|
static var previews: some View {
|
||||||
|
ZStack {
|
||||||
|
Color.red
|
||||||
|
.ignoresSafeArea()
|
||||||
|
|
||||||
|
tvOSVLCOverlay(viewModel: videoPlayerViewModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,39 @@
|
||||||
|
//
|
||||||
|
/*
|
||||||
|
* 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
|
||||||
|
|
||||||
|
// TODO: look at adjusting value dependent on item runtime
|
||||||
|
private let maxValue: Double = 1000
|
||||||
|
|
||||||
|
func updateUIView(_ uiView: TvOSSlider, context: Context) {
|
||||||
|
guard !viewModel.sliderIsScrubbing else { return }
|
||||||
|
uiView.value = Float(maxValue * viewModel.sliderPercentage)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
slider.fineTunningVelocityThreshold = 1000
|
||||||
|
|
||||||
|
return slider
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,556 @@
|
||||||
|
//
|
||||||
|
/*
|
||||||
|
* 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
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
|
||||||
|
public var fineTunningVelocityThreshold: Float = 600
|
||||||
|
|
||||||
|
/**
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModel.sliderPercentage = Double(percent)
|
||||||
|
viewModel.sliderIsScrubbing = false
|
||||||
|
}
|
||||||
|
|
||||||
|
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:
|
||||||
|
|
||||||
|
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 {
|
||||||
|
viewModel.sliderIsScrubbing = false
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
File diff suppressed because it is too large
Load Diff
|
@ -1,6 +1,6 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<Scheme
|
<Scheme
|
||||||
LastUpgradeVersion = "1300"
|
LastUpgradeVersion = "1320"
|
||||||
version = "1.3">
|
version = "1.3">
|
||||||
<BuildAction
|
<BuildAction
|
||||||
parallelizeBuildables = "YES"
|
parallelizeBuildables = "YES"
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<Scheme
|
<Scheme
|
||||||
LastUpgradeVersion = "1300"
|
LastUpgradeVersion = "1320"
|
||||||
version = "1.3">
|
version = "1.3">
|
||||||
<BuildAction
|
<BuildAction
|
||||||
parallelizeBuildables = "YES"
|
parallelizeBuildables = "YES"
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<Scheme
|
<Scheme
|
||||||
LastUpgradeVersion = "1300"
|
LastUpgradeVersion = "1320"
|
||||||
wasCreatedForAppExtension = "YES"
|
wasCreatedForAppExtension = "YES"
|
||||||
version = "2.0">
|
version = "2.0">
|
||||||
<BuildAction
|
<BuildAction
|
||||||
|
@ -101,7 +101,6 @@
|
||||||
savedToolIdentifier = ""
|
savedToolIdentifier = ""
|
||||||
useCustomWorkingDirectory = "NO"
|
useCustomWorkingDirectory = "NO"
|
||||||
debugDocumentVersioning = "YES"
|
debugDocumentVersioning = "YES"
|
||||||
askForAppToLaunch = "Yes"
|
|
||||||
launchAutomaticallySubstyle = "2">
|
launchAutomaticallySubstyle = "2">
|
||||||
<BuildableProductRunnable
|
<BuildableProductRunnable
|
||||||
runnableDebuggingMode = "0">
|
runnableDebuggingMode = "0">
|
||||||
|
|
|
@ -109,6 +109,15 @@
|
||||||
"version": "0.1.3"
|
"version": "0.1.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"package": "Sliders",
|
||||||
|
"repositoryURL": "https://github.com/spacenation/swiftui-sliders",
|
||||||
|
"state": {
|
||||||
|
"branch": "master",
|
||||||
|
"revision": "518bed3bfc7bd522f3c49404a0d1efb98fa1bf2c",
|
||||||
|
"version": null
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"package": "SwiftUICollection",
|
"package": "SwiftUICollection",
|
||||||
"repositoryURL": "https://github.com/ABJC/SwiftUICollection",
|
"repositoryURL": "https://github.com/ABJC/SwiftUICollection",
|
||||||
|
|
|
@ -7,6 +7,7 @@
|
||||||
* Copyright 2021 Aiden Vigue & Jellyfin Contributors
|
* Copyright 2021 Aiden Vigue & Jellyfin Contributors
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import AVFAudio
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
|
@ -17,6 +18,13 @@ class AppDelegate: NSObject, UIApplicationDelegate {
|
||||||
|
|
||||||
// Lazily initialize datastack
|
// Lazily initialize datastack
|
||||||
_ = SwiftfinStore.dataStack
|
_ = SwiftfinStore.dataStack
|
||||||
|
|
||||||
|
let audioSession = AVAudioSession.sharedInstance()
|
||||||
|
do {
|
||||||
|
try audioSession.setCategory(.playback)
|
||||||
|
} catch {
|
||||||
|
print("setting category AVAudioSessionCategoryPlayback failed")
|
||||||
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,82 +0,0 @@
|
||||||
//
|
|
||||||
/*
|
|
||||||
* SwiftFin is subject to the terms of the Mozilla Public
|
|
||||||
* License, v2.0. If a copy of the MPL was not distributed with this
|
|
||||||
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
|
||||||
*
|
|
||||||
* Copyright 2021 Aiden Vigue & Jellyfin Contributors
|
|
||||||
*/
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import MessageUI
|
|
||||||
|
|
||||||
class EmailHelper: NSObject, MFMailComposeViewControllerDelegate {
|
|
||||||
|
|
||||||
public static let shared = EmailHelper()
|
|
||||||
|
|
||||||
override private init() { }
|
|
||||||
|
|
||||||
func sendLogs(logURL: URL) {
|
|
||||||
if !MFMailComposeViewController.canSendMail() {
|
|
||||||
// Utilities.showErrorBanner(title: "No mail account found", subtitle: "Please setup a mail account")
|
|
||||||
return // EXIT
|
|
||||||
}
|
|
||||||
|
|
||||||
let picker = MFMailComposeViewController()
|
|
||||||
|
|
||||||
let fileManager = FileManager()
|
|
||||||
let data = fileManager.contents(atPath: logURL.path)
|
|
||||||
|
|
||||||
picker.setSubject("[DEV-BUG] SwiftFin")
|
|
||||||
picker
|
|
||||||
.setMessageBody("Please don't edit this email.\n Please don't change the subject. \nUDID: \(UIDevice.current.identifierForVendor?.uuidString ?? "NIL")\n",
|
|
||||||
isHTML: false)
|
|
||||||
picker.setToRecipients(["SwiftFin Bug Reports <swiftfin-bugs@jellyfin.org>"])
|
|
||||||
picker.addAttachmentData(data!, mimeType: "text/plain", fileName: logURL.lastPathComponent)
|
|
||||||
picker.mailComposeDelegate = self
|
|
||||||
|
|
||||||
EmailHelper.getRootViewController()?.present(picker, animated: true, completion: nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func mailComposeController(_ controller: MFMailComposeViewController, didFinishWith result: MFMailComposeResult, error: Error?) {
|
|
||||||
EmailHelper.getRootViewController()?.dismiss(animated: true, completion: nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func getRootViewController() -> UIViewController? {
|
|
||||||
UIApplication.shared.windows.first?.rootViewController
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A view modifier that detects shaking and calls a function of our choosing.
|
|
||||||
struct DeviceShakeViewModifier: ViewModifier {
|
|
||||||
let action: () -> Void
|
|
||||||
|
|
||||||
func body(content: Self.Content) -> some View {
|
|
||||||
content
|
|
||||||
.onAppear()
|
|
||||||
.onReceive(NotificationCenter.default.publisher(for: UIDevice.deviceDidShakeNotification)) { _ in
|
|
||||||
action()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A View extension to make the modifier easier to use.
|
|
||||||
extension View {
|
|
||||||
func onShake(perform action: @escaping () -> Void) -> some View {
|
|
||||||
modifier(DeviceShakeViewModifier(action: action))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The notification we'll send when a shake gesture happens.
|
|
||||||
extension UIDevice {
|
|
||||||
static let deviceDidShakeNotification = Notification.Name(rawValue: "deviceDidShakeNotification")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Override the default behavior of shake gestures to send our notification instead.
|
|
||||||
extension UIWindow {
|
|
||||||
override open func motionEnded(_ motion: UIEvent.EventSubtype, with event: UIEvent?) {
|
|
||||||
if motion == .motionShake {
|
|
||||||
NotificationCenter.default.post(name: UIDevice.deviceDidShakeNotification, object: nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -15,20 +15,16 @@ import SwiftUI
|
||||||
struct JellyfinPlayerApp: App {
|
struct JellyfinPlayerApp: App {
|
||||||
|
|
||||||
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
||||||
@Default(.appAppearance) var appAppearance
|
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
EmptyView()
|
EmptyView()
|
||||||
.ignoresSafeArea()
|
.ignoresSafeArea()
|
||||||
.onAppear {
|
.withHostingWindow({ window in
|
||||||
setupAppearance()
|
|
||||||
}
|
|
||||||
.withHostingWindow { window in
|
|
||||||
window?.rootViewController = PreferenceUIHostingController(wrappedView: MainCoordinator().view())
|
window?.rootViewController = PreferenceUIHostingController(wrappedView: MainCoordinator().view())
|
||||||
}
|
})
|
||||||
.onShake {
|
.onAppear {
|
||||||
EmailHelper.shared.sendLogs(logURL: LogManager.shared.logFileURL())
|
JellyfinPlayerApp.setupAppearance()
|
||||||
}
|
}
|
||||||
.onOpenURL { url in
|
.onOpenURL { url in
|
||||||
AppURLHandler.shared.processDeepLink(url: url)
|
AppURLHandler.shared.processDeepLink(url: url)
|
||||||
|
@ -36,8 +32,10 @@ struct JellyfinPlayerApp: App {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setupAppearance() {
|
static func setupAppearance() {
|
||||||
UIApplication.shared.windows.first?.overrideUserInterfaceStyle = appAppearance.style
|
let scenes = UIApplication.shared.connectedScenes
|
||||||
|
let windowScene = scenes.first as? UIWindowScene
|
||||||
|
windowScene?.windows.first?.overrideUserInterfaceStyle = Defaults[.appAppearance].style
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -60,7 +60,6 @@ struct EpisodeCardVStackView: View {
|
||||||
.overlay(
|
.overlay(
|
||||||
Rectangle()
|
Rectangle()
|
||||||
.fill(Color.jellyfinPurple)
|
.fill(Color.jellyfinPurple)
|
||||||
.mask(ProgressBar())
|
|
||||||
.frame(width: CGFloat(item.userData?.playedPercentage ?? 0 * 1.5), height: 7)
|
.frame(width: CGFloat(item.userData?.playedPercentage ?? 0 * 1.5), height: 7)
|
||||||
.padding(0), alignment: .bottomLeading
|
.padding(0), alignment: .bottomLeading
|
||||||
)
|
)
|
||||||
|
@ -81,10 +80,12 @@ struct EpisodeCardVStackView: View {
|
||||||
.fontWeight(.medium)
|
.fontWeight(.medium)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
Text(item.getItemRuntime())
|
if let runtime = item.getItemRuntime() {
|
||||||
.font(.subheadline)
|
Text(runtime)
|
||||||
.fontWeight(.medium)
|
.font(.subheadline)
|
||||||
.foregroundColor(.secondary)
|
.fontWeight(.medium)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,7 +13,6 @@ struct PillHStackView<ItemType: PillStackable>: View {
|
||||||
|
|
||||||
let title: String
|
let title: String
|
||||||
let items: [ItemType]
|
let items: [ItemType]
|
||||||
// let navigationView: (ItemType) -> NavigationView
|
|
||||||
let selectedAction: (ItemType) -> Void
|
let selectedAction: (ItemType) -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
|
|
@ -12,66 +12,71 @@ import SwiftUI
|
||||||
struct PortraitImageHStackView<TopBarView: View, ItemType: PortraitImageStackable>: View {
|
struct PortraitImageHStackView<TopBarView: View, ItemType: PortraitImageStackable>: View {
|
||||||
|
|
||||||
let items: [ItemType]
|
let items: [ItemType]
|
||||||
let maxWidth: Int
|
let maxWidth: CGFloat
|
||||||
let horizontalAlignment: HorizontalAlignment
|
let horizontalAlignment: HorizontalAlignment
|
||||||
|
let textAlignment: TextAlignment
|
||||||
let topBarView: () -> TopBarView
|
let topBarView: () -> TopBarView
|
||||||
let selectedAction: (ItemType) -> Void
|
let selectedAction: (ItemType) -> Void
|
||||||
|
|
||||||
init(items: [ItemType], maxWidth: Int, horizontalAlignment: HorizontalAlignment = .leading, topBarView: @escaping () -> TopBarView, selectedAction: @escaping (ItemType) -> Void) {
|
init(items: [ItemType],
|
||||||
|
maxWidth: CGFloat = 110,
|
||||||
|
horizontalAlignment: HorizontalAlignment = .leading,
|
||||||
|
textAlignment: TextAlignment = .leading,
|
||||||
|
topBarView: @escaping () -> TopBarView,
|
||||||
|
selectedAction: @escaping (ItemType) -> Void) {
|
||||||
self.items = items
|
self.items = items
|
||||||
self.maxWidth = maxWidth
|
self.maxWidth = maxWidth
|
||||||
self.horizontalAlignment = horizontalAlignment
|
self.horizontalAlignment = horizontalAlignment
|
||||||
|
self.textAlignment = textAlignment
|
||||||
self.topBarView = topBarView
|
self.topBarView = topBarView
|
||||||
self.selectedAction = selectedAction
|
self.selectedAction = selectedAction
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
topBarView()
|
topBarView()
|
||||||
|
|
||||||
ScrollView(.horizontal, showsIndicators: false) {
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
VStack {
|
HStack(alignment: .top, spacing: 15) {
|
||||||
Spacer().frame(height: 8)
|
ForEach(items, id: \.self.portraitImageID) { item in
|
||||||
HStack(alignment: .top) {
|
Button {
|
||||||
|
selectedAction(item)
|
||||||
Spacer().frame(width: 16)
|
} label: {
|
||||||
|
VStack(alignment: horizontalAlignment) {
|
||||||
ForEach(items, id: \.title) { item in
|
ImageView(src: item.imageURLContsructor(maxWidth: Int(maxWidth)),
|
||||||
Button {
|
bh: item.blurHash,
|
||||||
selectedAction(item)
|
failureInitials: item.failureInitials)
|
||||||
} label: {
|
.frame(width: maxWidth, height: maxWidth * 1.5)
|
||||||
VStack {
|
.cornerRadius(10)
|
||||||
ImageView(src: item.imageURLContsructor(maxWidth: maxWidth),
|
.shadow(radius: 4, y: 2)
|
||||||
bh: item.blurHash,
|
|
||||||
failureInitials: item.failureInitials)
|
|
||||||
.frame(width: 100, height: CGFloat(maxWidth))
|
|
||||||
.cornerRadius(10)
|
|
||||||
.shadow(radius: 4, y: 2)
|
|
||||||
|
|
||||||
|
if item.showTitle {
|
||||||
Text(item.title)
|
Text(item.title)
|
||||||
.font(.footnote)
|
.font(.footnote)
|
||||||
.fontWeight(.regular)
|
.fontWeight(.regular)
|
||||||
.frame(width: 100)
|
|
||||||
.foregroundColor(.primary)
|
.foregroundColor(.primary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(textAlignment)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
.lineLimit(2)
|
.lineLimit(2)
|
||||||
|
}
|
||||||
|
|
||||||
if let description = item.description {
|
if let description = item.subtitle {
|
||||||
Text(description)
|
Text(description)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.fontWeight(.medium)
|
.fontWeight(.medium)
|
||||||
.frame(width: 100)
|
.foregroundColor(.secondary)
|
||||||
.foregroundColor(.secondary)
|
.multilineTextAlignment(textAlignment)
|
||||||
.multilineTextAlignment(.center)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
.lineLimit(2)
|
.lineLimit(2)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.frame(width: maxWidth)
|
||||||
}
|
}
|
||||||
Spacer().frame(width: UIDevice.current.userInterfaceIdiom == .pad ? 16 : 55)
|
.padding(.bottom)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}.padding(.top, -3)
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -22,10 +22,9 @@ struct PortraitItemView: View {
|
||||||
.shadow(radius: 4, y: 2)
|
.shadow(radius: 4, y: 2)
|
||||||
.shadow(radius: 4, y: 2)
|
.shadow(radius: 4, y: 2)
|
||||||
.overlay(Rectangle()
|
.overlay(Rectangle()
|
||||||
.fill(Color(red: 172 / 255, green: 92 / 255, blue: 195 / 255))
|
.fill(Color.jellyfinPurple)
|
||||||
.mask(ProgressBar())
|
.frame(width: CGFloat(item.userData?.playedPercentage ?? 0), height: 7)
|
||||||
.frame(width: CGFloat(item.userData?.playedPercentage ?? 0), height: 7)
|
.padding(0), alignment: .bottomLeading)
|
||||||
.padding(0), alignment: .bottomLeading)
|
|
||||||
.overlay(ZStack {
|
.overlay(ZStack {
|
||||||
if item.userData?.isFavorite ?? false {
|
if item.userData?.isFavorite ?? false {
|
||||||
Image(systemName: "circle.fill")
|
Image(systemName: "circle.fill")
|
||||||
|
|
|
@ -0,0 +1,41 @@
|
||||||
|
//
|
||||||
|
/*
|
||||||
|
* 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 PrimaryButtonView: View {
|
||||||
|
|
||||||
|
private let title: String
|
||||||
|
private let action: () -> Void
|
||||||
|
|
||||||
|
init(title: String, _ action: @escaping () -> Void) {
|
||||||
|
self.title = title
|
||||||
|
self.action = action
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button {
|
||||||
|
action()
|
||||||
|
} label: {
|
||||||
|
ZStack {
|
||||||
|
Rectangle()
|
||||||
|
.foregroundColor(Color(UIColor.systemPurple))
|
||||||
|
.frame(maxWidth: 400, maxHeight: 50)
|
||||||
|
.frame(height: 50)
|
||||||
|
.cornerRadius(10)
|
||||||
|
.padding(.horizontal, 30)
|
||||||
|
.padding([.top, .bottom], 20)
|
||||||
|
|
||||||
|
Text(title)
|
||||||
|
.foregroundColor(Color.white)
|
||||||
|
.bold()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -15,7 +15,9 @@ struct BasicAppSettingsView: View {
|
||||||
|
|
||||||
@EnvironmentObject var basicAppSettingsRouter: BasicAppSettingsCoordinator.Router
|
@EnvironmentObject var basicAppSettingsRouter: BasicAppSettingsCoordinator.Router
|
||||||
@ObservedObject var viewModel: BasicAppSettingsViewModel
|
@ObservedObject var viewModel: BasicAppSettingsViewModel
|
||||||
@State var resetTapped: Bool = false
|
@State var resetUserSettingsTapped: Bool = false
|
||||||
|
@State var resetAppSettingsTapped: Bool = false
|
||||||
|
@State var removeAllUsersTapped: Bool = false
|
||||||
|
|
||||||
@Default(.appAppearance) var appAppearance
|
@Default(.appAppearance) var appAppearance
|
||||||
@Default(.defaultHTTPScheme) var defaultHTTPScheme
|
@Default(.defaultHTTPScheme) var defaultHTTPScheme
|
||||||
|
@ -27,9 +29,7 @@ struct BasicAppSettingsView: View {
|
||||||
ForEach(self.viewModel.appearances, id: \.self) { appearance in
|
ForEach(self.viewModel.appearances, id: \.self) { appearance in
|
||||||
Text(appearance.localizedName).tag(appearance.rawValue)
|
Text(appearance.localizedName).tag(appearance.rawValue)
|
||||||
}
|
}
|
||||||
}.onChange(of: appAppearance, perform: { _ in
|
}
|
||||||
UIApplication.shared.windows.first?.overrideUserInterfaceStyle = appAppearance.style
|
|
||||||
})
|
|
||||||
} header: {
|
} header: {
|
||||||
L10n.accessibility.text
|
L10n.accessibility.text
|
||||||
}
|
}
|
||||||
|
@ -45,15 +45,40 @@ struct BasicAppSettingsView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
resetTapped = true
|
resetUserSettingsTapped = true
|
||||||
|
} label: {
|
||||||
|
Text("Reset User Settings")
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
resetAppSettingsTapped = true
|
||||||
|
} label: {
|
||||||
|
Text("Reset App Settings")
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
removeAllUsersTapped = true
|
||||||
|
} label: {
|
||||||
|
Text("Remove All Users")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.alert("Reset User Settings", isPresented: $resetUserSettingsTapped, actions: {
|
||||||
|
Button(role: .destructive) {
|
||||||
|
viewModel.resetUserSettings()
|
||||||
} label: {
|
} label: {
|
||||||
L10n.reset.text
|
L10n.reset.text
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
.alert(L10n.reset, isPresented: $resetTapped, actions: {
|
.alert("Reset App Settings", isPresented: $resetAppSettingsTapped, actions: {
|
||||||
Button(role: .destructive) {
|
Button(role: .destructive) {
|
||||||
viewModel.reset()
|
viewModel.resetAppSettings()
|
||||||
basicAppSettingsRouter.dismissCoordinator()
|
} label: {
|
||||||
|
L10n.reset.text
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.alert("Remove All Users", isPresented: $removeAllUsersTapped, actions: {
|
||||||
|
Button(role: .destructive) {
|
||||||
|
viewModel.removeAllUsers()
|
||||||
} label: {
|
} label: {
|
||||||
L10n.reset.text
|
L10n.reset.text
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,71 +9,79 @@
|
||||||
import JellyfinAPI
|
import JellyfinAPI
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct ProgressBar: Shape {
|
|
||||||
func path(in rect: CGRect) -> Path {
|
|
||||||
var path = Path()
|
|
||||||
|
|
||||||
let tl = CGPoint(x: rect.minX, y: rect.minY)
|
|
||||||
let tr = CGPoint(x: rect.maxX, y: rect.minY)
|
|
||||||
let br = CGPoint(x: rect.maxX, y: rect.maxY)
|
|
||||||
let bls = CGPoint(x: rect.minX + 10, y: rect.maxY)
|
|
||||||
let blc = CGPoint(x: rect.minX + 10, y: rect.maxY - 10)
|
|
||||||
|
|
||||||
path.move(to: tl)
|
|
||||||
path.addLine(to: tr)
|
|
||||||
path.addLine(to: br)
|
|
||||||
path.addLine(to: bls)
|
|
||||||
path.addRelativeArc(center: blc, radius: 10,
|
|
||||||
startAngle: Angle.degrees(90), delta: Angle.degrees(90))
|
|
||||||
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ContinueWatchingView: View {
|
struct ContinueWatchingView: View {
|
||||||
|
|
||||||
@EnvironmentObject var homeRouter: HomeCoordinator.Router
|
@EnvironmentObject var homeRouter: HomeCoordinator.Router
|
||||||
var items: [BaseItemDto]
|
@ObservedObject var viewModel: HomeViewModel
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView(.horizontal, showsIndicators: false) {
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
LazyHStack {
|
HStack(alignment: .top, spacing: 20) {
|
||||||
ForEach(items, id: \.id) { item in
|
ForEach(viewModel.resumeItems, id: \.id) { item in
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
homeRouter.route(to: \.item, item)
|
homeRouter.route(to: \.item, item)
|
||||||
} label: {
|
} label: {
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
ImageView(src: item.getBackdropImage(maxWidth: 320), bh: item.getBackdropImageBlurHash())
|
|
||||||
.frame(width: 320, height: 180)
|
ZStack {
|
||||||
.cornerRadius(10)
|
ImageView(src: item.getBackdropImage(maxWidth: 320), bh: item.getBackdropImageBlurHash())
|
||||||
.shadow(radius: 4, y: 2)
|
.frame(width: 320, height: 180)
|
||||||
.shadow(radius: 4, y: 2)
|
|
||||||
.overlay(Rectangle()
|
HStack {
|
||||||
.fill(Color(red: 172 / 255, green: 92 / 255, blue: 195 / 255))
|
VStack{
|
||||||
.mask(ProgressBar())
|
|
||||||
.frame(width: CGFloat((item.userData?.playedPercentage ?? 0) * 3.2), height: 7)
|
Spacer()
|
||||||
.padding(0), alignment: .bottomLeading)
|
|
||||||
HStack {
|
ZStack(alignment: .bottom) {
|
||||||
|
|
||||||
|
LinearGradient(colors: [.clear, .black.opacity(0.5), .black.opacity(0.7)],
|
||||||
|
startPoint: .top,
|
||||||
|
endPoint: .bottom)
|
||||||
|
.frame(height: 35)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
Text(item.getItemProgressString() ?? "Continue")
|
||||||
|
.font(.subheadline)
|
||||||
|
.padding(.bottom, 5)
|
||||||
|
.padding(.leading, 10)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Color.jellyfinPurple
|
||||||
|
.frame(width: 320 * (item.userData?.playedPercentage ?? 0) / 100, height: 7)
|
||||||
|
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(width: 320, height: 180)
|
||||||
|
.mask(Rectangle().cornerRadius(10))
|
||||||
|
.shadow(radius: 4, y: 2)
|
||||||
|
|
||||||
|
VStack(alignment: .leading) {
|
||||||
Text("\(item.seriesName ?? item.name ?? "")")
|
Text("\(item.seriesName ?? item.name ?? "")")
|
||||||
.font(.callout)
|
.font(.callout)
|
||||||
.fontWeight(.semibold)
|
.fontWeight(.semibold)
|
||||||
.foregroundColor(.primary)
|
.foregroundColor(.primary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
if item.type == "Episode" {
|
|
||||||
Text("• \(item.getEpisodeLocator() ?? "") - \(item.name ?? "")")
|
if item.itemType == .episode {
|
||||||
|
Text(item.getEpisodeLocator() ?? "")
|
||||||
.font(.callout)
|
.font(.callout)
|
||||||
.fontWeight(.semibold)
|
.fontWeight(.medium)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.offset(x: -1.4)
|
|
||||||
}
|
}
|
||||||
Spacer()
|
}
|
||||||
}.frame(width: 320, alignment: .leading)
|
}
|
||||||
}.padding(.top, 10)
|
|
||||||
.padding(.bottom, 5)
|
|
||||||
}
|
}
|
||||||
}.padding(.trailing, 16)
|
}
|
||||||
}.frame(height: 215)
|
}
|
||||||
.padding(EdgeInsets(top: 8, leading: 20, bottom: 10, trailing: 2))
|
.padding(.horizontal)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -20,41 +20,80 @@ struct HomeView: View {
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
var innerBody: some View {
|
var innerBody: some View {
|
||||||
if viewModel.isLoading {
|
if let errorMessage = viewModel.errorMessage {
|
||||||
|
VStack(spacing: 5) {
|
||||||
|
if viewModel.isLoading {
|
||||||
|
ProgressView()
|
||||||
|
.frame(width: 100, height: 100)
|
||||||
|
.scaleEffect(2)
|
||||||
|
} else {
|
||||||
|
Image(systemName: "xmark.circle.fill")
|
||||||
|
.font(.system(size: 72))
|
||||||
|
.foregroundColor(Color.red)
|
||||||
|
.frame(width: 100, height: 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text("\(errorMessage.code)")
|
||||||
|
Text(errorMessage.displayMessage)
|
||||||
|
.frame(minWidth: 50, maxWidth: 240)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
|
||||||
|
PrimaryButtonView(title: "Retry") {
|
||||||
|
viewModel.refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.offset(y: -50)
|
||||||
|
} else if viewModel.isLoading {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
|
.frame(width: 100, height: 100)
|
||||||
|
.scaleEffect(2)
|
||||||
} else {
|
} else {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
if !viewModel.resumeItems.isEmpty {
|
if !viewModel.resumeItems.isEmpty {
|
||||||
ContinueWatchingView(items: viewModel.resumeItems)
|
ContinueWatchingView(viewModel: viewModel)
|
||||||
}
|
}
|
||||||
if !viewModel.nextUpItems.isEmpty {
|
if !viewModel.nextUpItems.isEmpty {
|
||||||
NextUpView(items: viewModel.nextUpItems)
|
PortraitImageHStackView(items: viewModel.nextUpItems,
|
||||||
}
|
horizontalAlignment: .leading) {
|
||||||
|
L10n.nextUp.text
|
||||||
ForEach(viewModel.libraries, id: \.self) { library in
|
|
||||||
HStack {
|
|
||||||
Text(L10n.latestWithString(library.name ?? ""))
|
|
||||||
.font(.title2)
|
.font(.title2)
|
||||||
.fontWeight(.bold)
|
.fontWeight(.bold)
|
||||||
Spacer()
|
.padding()
|
||||||
Button {
|
} selectedAction: { item in
|
||||||
homeRouter
|
homeRouter.route(to: \.item, item)
|
||||||
.route(to: \.library, (viewModel: .init(parentID: library.id!,
|
}
|
||||||
filters: viewModel.recentFilterSet),
|
|
||||||
title: library.name ?? ""))
|
}
|
||||||
} label: {
|
|
||||||
HStack {
|
ForEach(viewModel.libraries, id: \.self) { library in
|
||||||
L10n.seeAll.text.font(.subheadline).fontWeight(.bold)
|
|
||||||
Image(systemName: "chevron.right").font(Font.subheadline.bold())
|
LatestMediaView(viewModel: LatestMediaViewModel(libraryID: library.id!)) {
|
||||||
|
HStack {
|
||||||
|
Text(L10n.latestWithString(library.name ?? ""))
|
||||||
|
.font(.title2)
|
||||||
|
.fontWeight(.bold)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button {
|
||||||
|
homeRouter
|
||||||
|
.route(to: \.library, (viewModel: .init(parentID: library.id!,
|
||||||
|
filters: viewModel.recentFilterSet),
|
||||||
|
title: library.name ?? ""))
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
L10n.seeAll.text.font(.subheadline).fontWeight(.bold)
|
||||||
|
Image(systemName: "chevron.right").font(Font.subheadline.bold())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}.padding(.leading, 16)
|
.padding()
|
||||||
.padding(.trailing, 16)
|
}
|
||||||
LatestMediaView(viewModel: .init(libraryID: library.id!))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.bottom, UIDevice.current.userInterfaceIdiom == .phone ? 20 : 30)
|
.padding(.bottom, 50)
|
||||||
}
|
}
|
||||||
.introspectScrollView { scrollView in
|
.introspectScrollView { scrollView in
|
||||||
let control = UIRefreshControl()
|
let control = UIRefreshControl()
|
||||||
|
|
|
@ -0,0 +1,153 @@
|
||||||
|
//
|
||||||
|
/*
|
||||||
|
* 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 JellyfinAPI
|
||||||
|
|
||||||
|
struct EpisodesRowView: View {
|
||||||
|
|
||||||
|
@EnvironmentObject var itemRouter: ItemCoordinator.Router
|
||||||
|
@ObservedObject var viewModel: EpisodesRowViewModel
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Menu {
|
||||||
|
ForEach(Array(viewModel.seasonsEpisodes.keys).sorted(by: { $0.name ?? "" < $1.name ?? ""}), id:\.self) { season in
|
||||||
|
Button {
|
||||||
|
viewModel.selectedSeason = season
|
||||||
|
} label: {
|
||||||
|
if season.id == viewModel.selectedSeason?.id {
|
||||||
|
Label(season.name ?? "Season", systemImage: "checkmark")
|
||||||
|
} else {
|
||||||
|
Text(season.name ?? "Season")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 5) {
|
||||||
|
Text(viewModel.selectedSeason?.name ?? "Unknown")
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.fixedSize()
|
||||||
|
Image(systemName: "chevron.down")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
ScrollViewReader { reader in
|
||||||
|
HStack(alignment: .top, spacing: 15) {
|
||||||
|
if viewModel.isLoading {
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
|
||||||
|
ZStack {
|
||||||
|
Color.gray.ignoresSafeArea()
|
||||||
|
|
||||||
|
ProgressView()
|
||||||
|
}
|
||||||
|
.mask(Rectangle().frame(width: 200, height: 112).cornerRadius(10))
|
||||||
|
.frame(width: 200, height: 112)
|
||||||
|
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
Text("S-E-")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Text("--")
|
||||||
|
.font(.body)
|
||||||
|
.padding(.bottom, 1)
|
||||||
|
.lineLimit(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.frame(width: 200)
|
||||||
|
.shadow(radius: 4, y: 2)
|
||||||
|
} else if let selectedSeason = viewModel.selectedSeason {
|
||||||
|
if viewModel.seasonsEpisodes[selectedSeason]!.isEmpty {
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
|
||||||
|
Color.gray.ignoresSafeArea()
|
||||||
|
.mask(Rectangle().frame(width: 200, height: 112).cornerRadius(10))
|
||||||
|
.frame(width: 200, height: 112)
|
||||||
|
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
Text("--")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Text("No episodes available")
|
||||||
|
.font(.body)
|
||||||
|
.padding(.bottom, 1)
|
||||||
|
.lineLimit(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.frame(width: 200)
|
||||||
|
.shadow(radius: 4, y: 2)
|
||||||
|
} else {
|
||||||
|
ForEach(viewModel.seasonsEpisodes[selectedSeason]!, id:\.self) { episode in
|
||||||
|
Button {
|
||||||
|
itemRouter.route(to: \.item, episode)
|
||||||
|
} label: {
|
||||||
|
HStack(alignment: .top) {
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
|
||||||
|
ImageView(src: episode.getBackdropImage(maxWidth: 200),
|
||||||
|
bh: episode.getBackdropImageBlurHash())
|
||||||
|
.mask(Rectangle().frame(width: 200, height: 112).cornerRadius(10))
|
||||||
|
.frame(width: 200, height: 112)
|
||||||
|
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
Text(episode.getEpisodeLocator() ?? "")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Text(episode.name ?? "")
|
||||||
|
.font(.body)
|
||||||
|
.padding(.bottom, 1)
|
||||||
|
.lineLimit(2)
|
||||||
|
Text(episode.overview ?? "")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.fontWeight(.light)
|
||||||
|
.lineLimit(3)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.frame(width: 200)
|
||||||
|
.shadow(radius: 4, y: 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(PlainButtonStyle())
|
||||||
|
.id(episode.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
.onChange(of: viewModel.selectedSeason) { _ in
|
||||||
|
if viewModel.selectedSeason?.id == viewModel.episodeItemViewModel.item.seasonId {
|
||||||
|
reader.scrollTo(viewModel.episodeItemViewModel.item.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: viewModel.seasonsEpisodes) { _ in
|
||||||
|
if viewModel.selectedSeason?.id == viewModel.episodeItemViewModel.item.seasonId {
|
||||||
|
reader.scrollTo(viewModel.episodeItemViewModel.item.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.edgesIgnoringSafeArea(.horizontal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -9,11 +9,6 @@ import Introspect
|
||||||
import JellyfinAPI
|
import JellyfinAPI
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
class VideoPlayerItem: ObservableObject {
|
|
||||||
@Published var shouldShowPlayer: Bool = false
|
|
||||||
@Published var itemToPlay = BaseItemDto()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Intermediary view for ItemView to set navigation bar settings
|
// Intermediary view for ItemView to set navigation bar settings
|
||||||
struct ItemNavigationView: View {
|
struct ItemNavigationView: View {
|
||||||
private let item: BaseItemDto
|
private let item: BaseItemDto
|
||||||
|
@ -24,17 +19,18 @@ struct ItemNavigationView: View {
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ItemView(item: item)
|
ItemView(item: item)
|
||||||
.navigationBarTitle("", displayMode: .inline)
|
.navigationBarTitle(item.name ?? "", displayMode: .inline)
|
||||||
|
.introspectNavigationController { navigationController in
|
||||||
|
let textAttributes = [NSAttributedString.Key.foregroundColor: UIColor.clear]
|
||||||
|
navigationController.navigationBar.titleTextAttributes = textAttributes
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct ItemView: View {
|
private struct ItemView: View {
|
||||||
@EnvironmentObject var itemRouter: ItemCoordinator.Router
|
@EnvironmentObject var itemRouter: ItemCoordinator.Router
|
||||||
|
|
||||||
@State private var videoIsLoading: Bool = false // This variable is only changed by the underlying VLC view.
|
|
||||||
@State private var viewDidLoad: Bool = false
|
|
||||||
@State private var orientation: UIDeviceOrientation = .unknown
|
@State private var orientation: UIDeviceOrientation = .unknown
|
||||||
@StateObject private var videoPlayerItem = VideoPlayerItem()
|
|
||||||
@Environment(\.horizontalSizeClass) private var hSizeClass
|
@Environment(\.horizontalSizeClass) private var hSizeClass
|
||||||
@Environment(\.verticalSizeClass) private var vSizeClass
|
@Environment(\.verticalSizeClass) private var vSizeClass
|
||||||
|
|
||||||
|
@ -68,21 +64,6 @@ private struct ItemView: View {
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "ellipsis.circle.fill")
|
Image(systemName: "ellipsis.circle.fill")
|
||||||
}
|
}
|
||||||
case .episode:
|
|
||||||
Menu {
|
|
||||||
Button {
|
|
||||||
(viewModel as? EpisodeItemViewModel)?.routeToSeriesItem()
|
|
||||||
} label: {
|
|
||||||
Label("Show Series", systemImage: "text.below.photo")
|
|
||||||
}
|
|
||||||
Button {
|
|
||||||
(viewModel as? EpisodeItemViewModel)?.routeToSeasonItem()
|
|
||||||
} label: {
|
|
||||||
Label("Show Season", systemImage: "square.fill.text.grid.1x2")
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
Image(systemName: "ellipsis.circle.fill")
|
|
||||||
}
|
|
||||||
default:
|
default:
|
||||||
EmptyView()
|
EmptyView()
|
||||||
}
|
}
|
||||||
|
@ -91,19 +72,13 @@ private struct ItemView: View {
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
Group {
|
||||||
if hSizeClass == .compact && vSizeClass == .regular {
|
if hSizeClass == .compact && vSizeClass == .regular {
|
||||||
ItemPortraitMainView(videoIsLoading: $videoIsLoading)
|
ItemPortraitMainView()
|
||||||
.environmentObject(videoPlayerItem)
|
|
||||||
.environmentObject(viewModel)
|
.environmentObject(viewModel)
|
||||||
} else {
|
} else {
|
||||||
ItemLandscapeMainView(videoIsLoading: $videoIsLoading)
|
ItemLandscapeMainView()
|
||||||
.environmentObject(videoPlayerItem)
|
|
||||||
.environmentObject(viewModel)
|
.environmentObject(viewModel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onReceive(videoPlayerItem.$shouldShowPlayer) { flag in
|
|
||||||
guard flag else { return }
|
|
||||||
self.itemRouter.route(to: \.videoPlayer, viewModel.item)
|
|
||||||
}
|
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItemGroup(placement: .navigationBarTrailing) {
|
ToolbarItemGroup(placement: .navigationBarTrailing) {
|
||||||
toolbarItemContent
|
toolbarItemContent
|
||||||
|
|
|
@ -7,12 +7,15 @@
|
||||||
* Copyright 2021 Aiden Vigue & Jellyfin Contributors
|
* Copyright 2021 Aiden Vigue & Jellyfin Contributors
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import Defaults
|
||||||
import JellyfinAPI
|
import JellyfinAPI
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct ItemViewBody: View {
|
struct ItemViewBody: View {
|
||||||
|
|
||||||
@EnvironmentObject var itemRouter: ItemCoordinator.Router
|
@EnvironmentObject var itemRouter: ItemCoordinator.Router
|
||||||
@EnvironmentObject private var viewModel: ItemViewModel
|
@EnvironmentObject private var viewModel: ItemViewModel
|
||||||
|
@Default(.showCastAndCrew) var showCastAndCrew
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
|
@ -27,13 +30,11 @@ struct ItemViewBody: View {
|
||||||
|
|
||||||
if let seriesViewModel = viewModel as? SeriesItemViewModel {
|
if let seriesViewModel = viewModel as? SeriesItemViewModel {
|
||||||
PortraitImageHStackView(items: seriesViewModel.seasons,
|
PortraitImageHStackView(items: seriesViewModel.seasons,
|
||||||
maxWidth: 150,
|
|
||||||
topBarView: {
|
topBarView: {
|
||||||
L10n.seasons.text
|
L10n.seasons.text
|
||||||
.font(.callout)
|
|
||||||
.fontWeight(.semibold)
|
.fontWeight(.semibold)
|
||||||
.padding(.top, 3)
|
.padding(.bottom)
|
||||||
.padding(.leading, 16)
|
.padding(.horizontal)
|
||||||
}, selectedAction: { season in
|
}, selectedAction: { season in
|
||||||
itemRouter.route(to: \.item, season)
|
itemRouter.route(to: \.item, season)
|
||||||
})
|
})
|
||||||
|
@ -46,6 +47,7 @@ struct ItemViewBody: View {
|
||||||
selectedAction: { genre in
|
selectedAction: { genre in
|
||||||
itemRouter.route(to: \.library, (viewModel: .init(genre: genre), title: genre.title))
|
itemRouter.route(to: \.library, (viewModel: .init(genre: genre), title: genre.title))
|
||||||
})
|
})
|
||||||
|
.padding(.bottom)
|
||||||
|
|
||||||
// MARK: Studios
|
// MARK: Studios
|
||||||
|
|
||||||
|
@ -53,42 +55,68 @@ struct ItemViewBody: View {
|
||||||
PillHStackView(title: L10n.studios,
|
PillHStackView(title: L10n.studios,
|
||||||
items: studios) { studio in
|
items: studios) { studio in
|
||||||
itemRouter.route(to: \.library, (viewModel: .init(studio: studio), title: studio.name ?? ""))
|
itemRouter.route(to: \.library, (viewModel: .init(studio: studio), title: studio.name ?? ""))
|
||||||
|
}
|
||||||
|
.padding(.bottom)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Episodes
|
||||||
|
|
||||||
|
if let episodeViewModel = viewModel as? EpisodeItemViewModel {
|
||||||
|
EpisodesRowView(viewModel: EpisodesRowViewModel(episodeItemViewModel: episodeViewModel))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Series
|
||||||
|
|
||||||
|
if let episodeViewModel = viewModel as? EpisodeItemViewModel {
|
||||||
|
if let seriesItem = episodeViewModel.series {
|
||||||
|
let a = [seriesItem]
|
||||||
|
PortraitImageHStackView(items: a) {
|
||||||
|
Text("Series")
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.padding(.bottom)
|
||||||
|
.padding(.horizontal)
|
||||||
|
} selectedAction: { seriesItem in
|
||||||
|
itemRouter.route(to: \.item, seriesItem)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Cast & Crew
|
// MARK: Cast & Crew
|
||||||
|
|
||||||
if let castAndCrew = viewModel.item.people {
|
if showCastAndCrew {
|
||||||
PortraitImageHStackView(items: castAndCrew.filter { BaseItemPerson.DisplayedType.allCasesRaw.contains($0.type ?? "") },
|
if let castAndCrew = viewModel.item.people {
|
||||||
maxWidth: 150,
|
PortraitImageHStackView(items: castAndCrew.filter { BaseItemPerson.DisplayedType.allCasesRaw.contains($0.type ?? "") },
|
||||||
topBarView: {
|
topBarView: {
|
||||||
Text("Cast & Crew")
|
Text("Cast & Crew")
|
||||||
.font(.callout)
|
.fontWeight(.semibold)
|
||||||
.fontWeight(.semibold)
|
.padding(.bottom)
|
||||||
.padding(.top, 3)
|
.padding(.horizontal)
|
||||||
.padding(.leading, 16)
|
},
|
||||||
},
|
selectedAction: { person in
|
||||||
selectedAction: { person in
|
itemRouter.route(to: \.library, (viewModel: .init(person: person), title: person.title))
|
||||||
itemRouter.route(to: \.library, (viewModel: .init(person: person), title: person.title))
|
})
|
||||||
})
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: More Like This
|
// MARK: More Like This
|
||||||
|
|
||||||
if !viewModel.similarItems.isEmpty {
|
if !viewModel.similarItems.isEmpty {
|
||||||
PortraitImageHStackView(items: viewModel.similarItems,
|
PortraitImageHStackView(items: viewModel.similarItems,
|
||||||
maxWidth: 150,
|
|
||||||
topBarView: {
|
topBarView: {
|
||||||
L10n.moreLikeThis.text
|
L10n.moreLikeThis.text
|
||||||
.font(.callout)
|
|
||||||
.fontWeight(.semibold)
|
.fontWeight(.semibold)
|
||||||
.padding(.top, 3)
|
.padding(.bottom)
|
||||||
.padding(.leading, 16)
|
.padding(.horizontal)
|
||||||
},
|
},
|
||||||
selectedAction: { item in
|
selectedAction: { item in
|
||||||
itemRouter.route(to: \.item, item)
|
itemRouter.route(to: \.item, item)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: Details
|
||||||
|
|
||||||
|
ItemViewDetailsView(viewModel: viewModel)
|
||||||
|
.padding()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,58 @@
|
||||||
|
//
|
||||||
|
/*
|
||||||
|
* 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 ItemViewDetailsView: View {
|
||||||
|
|
||||||
|
@ObservedObject var viewModel: ItemViewModel
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
|
||||||
|
if !viewModel.informationItems.isEmpty {
|
||||||
|
VStack(alignment: .leading, spacing: 20) {
|
||||||
|
Text("Information")
|
||||||
|
.font(.title3)
|
||||||
|
.fontWeight(.bold)
|
||||||
|
|
||||||
|
ForEach(viewModel.informationItems, id: \.self.title) { informationItem in
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(informationItem.title)
|
||||||
|
.font(.subheadline)
|
||||||
|
Text(informationItem.content)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(Color.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.bottom, 20)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !viewModel.mediaItems.isEmpty {
|
||||||
|
VStack(alignment: .leading, spacing: 20) {
|
||||||
|
Text("Media")
|
||||||
|
.font(.title3)
|
||||||
|
.fontWeight(.bold)
|
||||||
|
|
||||||
|
ForEach(viewModel.mediaItems, id: \.self.title) { mediaItem in
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(mediaItem.title)
|
||||||
|
.font(.subheadline)
|
||||||
|
Text(mediaItem.content)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(Color.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -12,13 +12,7 @@ import SwiftUI
|
||||||
|
|
||||||
struct ItemLandscapeMainView: View {
|
struct ItemLandscapeMainView: View {
|
||||||
@EnvironmentObject var itemRouter: ItemCoordinator.Router
|
@EnvironmentObject var itemRouter: ItemCoordinator.Router
|
||||||
@Binding private var videoIsLoading: Bool
|
|
||||||
@EnvironmentObject private var viewModel: ItemViewModel
|
@EnvironmentObject private var viewModel: ItemViewModel
|
||||||
@EnvironmentObject private var videoPlayerItem: VideoPlayerItem
|
|
||||||
|
|
||||||
init(videoIsLoading: Binding<Bool>) {
|
|
||||||
self._videoIsLoading = videoIsLoading
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: innerBody
|
// MARK: innerBody
|
||||||
|
|
||||||
|
@ -27,21 +21,17 @@ struct ItemLandscapeMainView: View {
|
||||||
// MARK: Sidebar Image
|
// MARK: Sidebar Image
|
||||||
|
|
||||||
VStack {
|
VStack {
|
||||||
ImageView(src: viewModel.item.getPrimaryImage(maxWidth: 130),
|
ImageView(src: viewModel.item.portraitHeaderViewURL(maxWidth: 130),
|
||||||
bh: viewModel.item.getPrimaryImageBlurHash())
|
bh: viewModel.item.getPrimaryImageBlurHash())
|
||||||
.frame(width: 130, height: 195)
|
.frame(width: 130, height: 195)
|
||||||
.cornerRadius(10)
|
.cornerRadius(10)
|
||||||
|
|
||||||
Spacer().frame(height: 15)
|
Spacer().frame(height: 15)
|
||||||
|
|
||||||
|
// MARK: Play
|
||||||
Button {
|
Button {
|
||||||
if let playButtonItem = viewModel.playButtonItem {
|
self.itemRouter.route(to: \.videoPlayer, viewModel.itemVideoPlayerViewModel!)
|
||||||
self.videoPlayerItem.itemToPlay = playButtonItem
|
|
||||||
self.videoPlayerItem.shouldShowPlayer = true
|
|
||||||
}
|
|
||||||
} label: {
|
} label: {
|
||||||
// MARK: Play
|
|
||||||
|
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: "play.fill")
|
Image(systemName: "play.fill")
|
||||||
.foregroundColor(viewModel.playButtonItem == nil ? Color(UIColor.secondaryLabel) : Color.white)
|
.foregroundColor(viewModel.playButtonItem == nil ? Color(UIColor.secondaryLabel) : Color.white)
|
||||||
|
@ -54,7 +44,8 @@ struct ItemLandscapeMainView: View {
|
||||||
.frame(width: 130, height: 40)
|
.frame(width: 130, height: 40)
|
||||||
.background(viewModel.playButtonItem == nil ? Color(UIColor.secondarySystemFill) : Color.jellyfinPurple)
|
.background(viewModel.playButtonItem == nil ? Color(UIColor.secondarySystemFill) : Color.jellyfinPurple)
|
||||||
.cornerRadius(10)
|
.cornerRadius(10)
|
||||||
}.disabled(viewModel.playButtonItem == nil)
|
}
|
||||||
|
.disabled(viewModel.playButtonItem == nil || viewModel.itemVideoPlayerViewModel == nil)
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
|
|
|
@ -28,11 +28,13 @@ struct ItemLandscapeTopBarView: View {
|
||||||
|
|
||||||
if viewModel.item.itemType.showDetails {
|
if viewModel.item.itemType.showDetails {
|
||||||
// MARK: Runtime
|
// MARK: Runtime
|
||||||
Text(viewModel.item.getItemRuntime())
|
if let runtime = viewModel.item.getItemRuntime() {
|
||||||
.font(.subheadline)
|
Text(runtime)
|
||||||
.fontWeight(.medium)
|
.font(.subheadline)
|
||||||
.foregroundColor(.secondary)
|
.fontWeight(.medium)
|
||||||
.padding(.leading, 16)
|
.foregroundColor(.secondary)
|
||||||
|
.padding(.leading, 16)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Details
|
// MARK: Details
|
||||||
|
|
|
@ -12,8 +12,8 @@ import JellyfinAPI
|
||||||
|
|
||||||
struct PortraitHeaderOverlayView: View {
|
struct PortraitHeaderOverlayView: View {
|
||||||
|
|
||||||
|
@EnvironmentObject var itemRouter: ItemCoordinator.Router
|
||||||
@EnvironmentObject private var viewModel: ItemViewModel
|
@EnvironmentObject private var viewModel: ItemViewModel
|
||||||
@EnvironmentObject private var videoPlayerItem: VideoPlayerItem
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
|
@ -38,11 +38,13 @@ struct PortraitHeaderOverlayView: View {
|
||||||
if viewModel.item.itemType.showDetails {
|
if viewModel.item.itemType.showDetails {
|
||||||
// MARK: Runtime
|
// MARK: Runtime
|
||||||
if viewModel.shouldDisplayRuntime() {
|
if viewModel.shouldDisplayRuntime() {
|
||||||
Text(viewModel.item.getItemRuntime())
|
if let runtime = viewModel.item.getItemRuntime() {
|
||||||
.font(.subheadline)
|
Text(runtime)
|
||||||
.fontWeight(.medium)
|
.font(.subheadline)
|
||||||
.foregroundColor(.secondary)
|
.fontWeight(.medium)
|
||||||
.lineLimit(1)
|
.foregroundColor(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -75,10 +77,7 @@ struct PortraitHeaderOverlayView: View {
|
||||||
|
|
||||||
// MARK: Play
|
// MARK: Play
|
||||||
Button {
|
Button {
|
||||||
if let playButtonItem = viewModel.playButtonItem {
|
self.itemRouter.route(to: \.videoPlayer, viewModel.itemVideoPlayerViewModel!)
|
||||||
self.videoPlayerItem.itemToPlay = playButtonItem
|
|
||||||
self.videoPlayerItem.shouldShowPlayer = true
|
|
||||||
}
|
|
||||||
} label: {
|
} label: {
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: "play.fill")
|
Image(systemName: "play.fill")
|
||||||
|
|
|
@ -11,14 +11,9 @@ import JellyfinAPI
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct ItemPortraitMainView: View {
|
struct ItemPortraitMainView: View {
|
||||||
|
|
||||||
@EnvironmentObject var itemRouter: ItemCoordinator.Router
|
@EnvironmentObject var itemRouter: ItemCoordinator.Router
|
||||||
@Binding private var videoIsLoading: Bool
|
|
||||||
@EnvironmentObject private var viewModel: ItemViewModel
|
@EnvironmentObject private var viewModel: ItemViewModel
|
||||||
@EnvironmentObject private var videoPlayerItem: VideoPlayerItem
|
|
||||||
|
|
||||||
init(videoIsLoading: Binding<Bool>) {
|
|
||||||
self._videoIsLoading = videoIsLoading
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: portraitHeaderView
|
// MARK: portraitHeaderView
|
||||||
|
|
||||||
|
|
|
@ -8,21 +8,18 @@
|
||||||
import Stinsen
|
import Stinsen
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct LatestMediaView: View {
|
struct LatestMediaView<TopBarView: View>: View {
|
||||||
|
|
||||||
@EnvironmentObject var homeRouter: HomeCoordinator.Router
|
@EnvironmentObject var homeRouter: HomeCoordinator.Router
|
||||||
@StateObject var viewModel: LatestMediaViewModel
|
@StateObject var viewModel: LatestMediaViewModel
|
||||||
|
var topBarView: () -> TopBarView
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView(.horizontal, showsIndicators: false) {
|
PortraitImageHStackView(items: viewModel.items,
|
||||||
LazyHStack {
|
horizontalAlignment: .leading) {
|
||||||
ForEach(viewModel.items, id: \.id) { item in
|
topBarView()
|
||||||
Button {
|
} selectedAction: { item in
|
||||||
homeRouter.route(to: \.item, item)
|
homeRouter.route(to: \.item, item)
|
||||||
} label: {
|
}
|
||||||
PortraitItemView(item: item)
|
|
||||||
}
|
|
||||||
}.padding(.trailing, 16)
|
|
||||||
}.padding(.leading, 20)
|
|
||||||
}.frame(height: 200)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -38,27 +38,6 @@ struct LibraryListView: View {
|
||||||
.shadow(radius: 5)
|
.shadow(radius: 5)
|
||||||
.padding(.bottom, 5)
|
.padding(.bottom, 5)
|
||||||
|
|
||||||
NavigationLink(destination: LazyView {
|
|
||||||
L10n.wip.text
|
|
||||||
}) {
|
|
||||||
ZStack {
|
|
||||||
HStack {
|
|
||||||
Spacer()
|
|
||||||
L10n.allGenres.text
|
|
||||||
.foregroundColor(.black)
|
|
||||||
.font(.subheadline)
|
|
||||||
.fontWeight(.semibold)
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(16)
|
|
||||||
.background(Color.white)
|
|
||||||
.frame(minWidth: 100, maxWidth: .infinity)
|
|
||||||
}
|
|
||||||
.cornerRadius(10)
|
|
||||||
.shadow(radius: 5)
|
|
||||||
.padding(.bottom, 15)
|
|
||||||
|
|
||||||
if !viewModel.isLoading {
|
if !viewModel.isLoading {
|
||||||
ForEach(viewModel.libraries, id: \.id) { library in
|
ForEach(viewModel.libraries, id: \.id) { library in
|
||||||
if library.collectionType ?? "" == "movies" || library.collectionType ?? "" == "tvshows" {
|
if library.collectionType ?? "" == "movies" || library.collectionType ?? "" == "tvshows" {
|
||||||
|
|
|
@ -1,85 +0,0 @@
|
||||||
/* JellyfinPlayer/Swiftfin is subject to the terms of the Mozilla Public
|
|
||||||
* License, v2.0. If a copy of the MPL was not distributed with this
|
|
||||||
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
|
||||||
*
|
|
||||||
* Copyright 2021 Aiden Vigue & Jellyfin Contributors
|
|
||||||
*/
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct LoadingView<Content>: View where Content: View {
|
|
||||||
@Environment(\.colorScheme) var colorScheme
|
|
||||||
@Binding var isShowing: Bool // should the modal be visible?
|
|
||||||
var content: () -> Content
|
|
||||||
var text: String? // the text to display under the ProgressView - defaults to "Loading..."
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
GeometryReader { _ in
|
|
||||||
ZStack(alignment: .center) {
|
|
||||||
// the content to display - if the modal is showing, we'll blur it
|
|
||||||
content()
|
|
||||||
.disabled(isShowing)
|
|
||||||
.blur(radius: isShowing ? 2 : 0)
|
|
||||||
|
|
||||||
// all contents inside here will only be shown when isShowing is true
|
|
||||||
if isShowing {
|
|
||||||
// this Rectangle is a semi-transparent black overlay
|
|
||||||
Rectangle()
|
|
||||||
.fill(Color.black).opacity(isShowing ? 0.6 : 0)
|
|
||||||
.edgesIgnoringSafeArea(.all)
|
|
||||||
|
|
||||||
// the magic bit - our ProgressView just displays an activity
|
|
||||||
// indicator, with some text underneath showing what we are doing
|
|
||||||
HStack {
|
|
||||||
ProgressView()
|
|
||||||
Text(text ?? L10n.loading).fontWeight(.semibold).font(.callout).offset(x: 60)
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
.padding(EdgeInsets(top: 20, leading: 20, bottom: 20, trailing: 10))
|
|
||||||
.frame(width: 250)
|
|
||||||
.background(colorScheme == .dark ? Color(UIColor.systemGray6) : Color.white)
|
|
||||||
.foregroundColor(Color.primary)
|
|
||||||
.cornerRadius(16)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct LoadingViewNoBlur<Content>: View where Content: View {
|
|
||||||
@Environment(\.colorScheme) var colorScheme
|
|
||||||
@Binding var isShowing: Bool // should the modal be visible?
|
|
||||||
var content: () -> Content
|
|
||||||
var text: String? // the text to display under the ProgressView - defaults to "Loading..."
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
GeometryReader { _ in
|
|
||||||
ZStack(alignment: .center) {
|
|
||||||
// the content to display - if the modal is showing, we'll blur it
|
|
||||||
content()
|
|
||||||
.disabled(isShowing)
|
|
||||||
|
|
||||||
// all contents inside here will only be shown when isShowing is true
|
|
||||||
if isShowing {
|
|
||||||
// this Rectangle is a semi-transparent black overlay
|
|
||||||
Rectangle()
|
|
||||||
.fill(Color.black).opacity(isShowing ? 0.6 : 0)
|
|
||||||
.edgesIgnoringSafeArea(.all)
|
|
||||||
|
|
||||||
// the magic bit - our ProgressView just displays an activity
|
|
||||||
// indicator, with some text underneath showing what we are doing
|
|
||||||
HStack {
|
|
||||||
ProgressView()
|
|
||||||
Text(text ?? L10n.loading).fontWeight(.semibold).font(.callout).offset(x: 60)
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
.padding(EdgeInsets(top: 20, leading: 20, bottom: 20, trailing: 10))
|
|
||||||
.frame(width: 250)
|
|
||||||
.background(colorScheme == .dark ? Color(UIColor.systemGray6) : Color.white)
|
|
||||||
.foregroundColor(Color.primary)
|
|
||||||
.cornerRadius(16)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -69,22 +69,8 @@ struct ServerListView: View {
|
||||||
.frame(minWidth: 50, maxWidth: 240)
|
.frame(minWidth: 50, maxWidth: 240)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
|
|
||||||
Button {
|
PrimaryButtonView(title: L10n.connect.stringValue) {
|
||||||
serverListRouter.route(to: \.connectToServer)
|
serverListRouter.route(to: \.connectToServer)
|
||||||
} label: {
|
|
||||||
ZStack {
|
|
||||||
Rectangle()
|
|
||||||
.foregroundColor(Color.jellyfinPurple)
|
|
||||||
.frame(maxWidth: 400, maxHeight: 50)
|
|
||||||
.frame(height: 50)
|
|
||||||
.cornerRadius(10)
|
|
||||||
.padding(.horizontal, 30)
|
|
||||||
.padding([.top, .bottom], 20)
|
|
||||||
|
|
||||||
L10n.connect.text
|
|
||||||
.foregroundColor(Color.white)
|
|
||||||
.bold()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,152 +0,0 @@
|
||||||
/* JellyfinPlayer/Swiftfin is subject to the terms of the Mozilla Public
|
|
||||||
* License, v2.0. If a copy of the MPL was not distributed with this
|
|
||||||
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
|
||||||
*
|
|
||||||
* Copyright 2021 Aiden Vigue & Jellyfin Contributors
|
|
||||||
*/
|
|
||||||
|
|
||||||
import CoreData
|
|
||||||
import Defaults
|
|
||||||
import Stinsen
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct SettingsView: View {
|
|
||||||
|
|
||||||
@EnvironmentObject var settingsRouter: SettingsCoordinator.Router
|
|
||||||
@ObservedObject var viewModel: SettingsViewModel
|
|
||||||
|
|
||||||
@Default(.inNetworkBandwidth) var inNetworkStreamBitrate
|
|
||||||
@Default(.outOfNetworkBandwidth) var outOfNetworkStreamBitrate
|
|
||||||
@Default(.isAutoSelectSubtitles) var isAutoSelectSubtitles
|
|
||||||
@Default(.autoSelectSubtitlesLangCode) var autoSelectSubtitlesLangcode
|
|
||||||
@Default(.autoSelectAudioLangCode) var autoSelectAudioLangcode
|
|
||||||
@Default(.appAppearance) var appAppearance
|
|
||||||
@Default(.videoPlayerJumpForward) var jumpForwardLength
|
|
||||||
@Default(.videoPlayerJumpBackward) var jumpBackwardLength
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Form {
|
|
||||||
Section(header: EmptyView()) {
|
|
||||||
|
|
||||||
// There is a bug where the SettingsView attmempts to remake itself upon signing out
|
|
||||||
// so this check is made
|
|
||||||
if SessionManager.main.currentLogin == nil {
|
|
||||||
HStack {
|
|
||||||
Text("User")
|
|
||||||
Spacer()
|
|
||||||
Text("")
|
|
||||||
.foregroundColor(.jellyfinPurple)
|
|
||||||
}
|
|
||||||
|
|
||||||
Button {
|
|
||||||
settingsRouter.route(to: \.serverDetail)
|
|
||||||
} label: {
|
|
||||||
HStack {
|
|
||||||
Text("Server")
|
|
||||||
Spacer()
|
|
||||||
Text("")
|
|
||||||
.foregroundColor(.jellyfinPurple)
|
|
||||||
|
|
||||||
Image(systemName: "chevron.right")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
HStack {
|
|
||||||
Text("User")
|
|
||||||
Spacer()
|
|
||||||
Text(SessionManager.main.currentLogin.user.username)
|
|
||||||
.foregroundColor(.jellyfinPurple)
|
|
||||||
}
|
|
||||||
|
|
||||||
Button {
|
|
||||||
settingsRouter.route(to: \.serverDetail)
|
|
||||||
} label: {
|
|
||||||
HStack {
|
|
||||||
Text("Server")
|
|
||||||
Spacer()
|
|
||||||
Text(SessionManager.main.currentLogin.server.name)
|
|
||||||
.foregroundColor(.jellyfinPurple)
|
|
||||||
|
|
||||||
Image(systemName: "chevron.right")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Button {
|
|
||||||
settingsRouter.dismissCoordinator {
|
|
||||||
SessionManager.main.logout()
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
Text("Sign out")
|
|
||||||
.font(.callout)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Section(header: Text("Playback")) {
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Picker("Jump Forward Length", selection: $jumpForwardLength) {
|
|
||||||
ForEach(self.viewModel.videoPlayerJumpLengths, id: \.self) { length in
|
|
||||||
Text(length.label).tag(length.rawValue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Picker("Jump Backward Length", selection: $jumpBackwardLength) {
|
|
||||||
ForEach(self.viewModel.videoPlayerJumpLengths, id: \.self) { length in
|
|
||||||
Text(length.label).tag(length.rawValue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 }))
|
|
||||||
Picker(L10n.appearance, selection: $appAppearance) {
|
|
||||||
ForEach(self.viewModel.appearances, id: \.self) { appearance in
|
|
||||||
Text(appearance.localizedName).tag(appearance.rawValue)
|
|
||||||
}
|
|
||||||
}.onChange(of: appAppearance, perform: { _ in
|
|
||||||
UIApplication.shared.windows.first?.overrideUserInterfaceStyle = appAppearance.style
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.navigationBarTitle("Settings", displayMode: .inline)
|
|
||||||
.toolbar {
|
|
||||||
ToolbarItemGroup(placement: .navigationBarLeading) {
|
|
||||||
Button {
|
|
||||||
settingsRouter.dismissCoordinator()
|
|
||||||
} label: {
|
|
||||||
Image(systemName: "xmark.circle.fill")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -0,0 +1,28 @@
|
||||||
|
//
|
||||||
|
/*
|
||||||
|
* SwiftFin is subject to the terms of the Mozilla Public
|
||||||
|
* License, v2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* Copyright 2021 Aiden Vigue & Jellyfin Contributors
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Defaults
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ExperimentalSettingsView: View {
|
||||||
|
|
||||||
|
@Default(.Experimental.syncSubtitleStateWithAdjacent) var syncSubtitleStateWithAdjacent
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Form {
|
||||||
|
Section {
|
||||||
|
|
||||||
|
Toggle("Sync Subtitles with Adjacent Episodes", isOn: $syncSubtitleStateWithAdjacent)
|
||||||
|
|
||||||
|
} header: {
|
||||||
|
Text("Experimental")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,37 @@
|
||||||
|
//
|
||||||
|
/*
|
||||||
|
* SwiftFin is subject to the terms of the Mozilla Public
|
||||||
|
* License, v2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* Copyright 2021 Aiden Vigue & Jellyfin Contributors
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Defaults
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct OverlaySettingsView: View {
|
||||||
|
|
||||||
|
@Default(.overlayType) var overlayType
|
||||||
|
@Default(.shouldShowPlayPreviousItem) var shouldShowPlayPreviousItem
|
||||||
|
@Default(.shouldShowPlayNextItem) var shouldShowPlayNextItem
|
||||||
|
@Default(.shouldShowAutoPlay) var shouldShowAutoPlay
|
||||||
|
@Default(.shouldShowJumpButtonsInOverlayMenu) var shouldShowJumpButtonsInOverlayMenu
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Form {
|
||||||
|
Section(header: Text("Overlay")) {
|
||||||
|
Picker("Overlay Type", selection: $overlayType) {
|
||||||
|
ForEach(OverlayType.allCases, id: \.self) { overlay in
|
||||||
|
Text(overlay.label).tag(overlay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Toggle("\(Image(systemName: "chevron.left.circle")) Play Previous Item", isOn: $shouldShowPlayPreviousItem)
|
||||||
|
Toggle("\(Image(systemName: "chevron.right.circle")) Play Next Item", isOn: $shouldShowPlayNextItem)
|
||||||
|
Toggle("\(Image(systemName: "play.circle.fill")) Auto Play", isOn: $shouldShowAutoPlay)
|
||||||
|
Toggle("Edit Jump Lengths", isOn: $shouldShowJumpButtonsInOverlayMenu)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,143 @@
|
||||||
|
/* JellyfinPlayer/Swiftfin is subject to the terms of the Mozilla Public
|
||||||
|
* License, v2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* Copyright 2021 Aiden Vigue & Jellyfin Contributors
|
||||||
|
*/
|
||||||
|
|
||||||
|
import CoreData
|
||||||
|
import Defaults
|
||||||
|
import Stinsen
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct SettingsView: View {
|
||||||
|
|
||||||
|
@EnvironmentObject var settingsRouter: SettingsCoordinator.Router
|
||||||
|
@ObservedObject var viewModel: SettingsViewModel
|
||||||
|
|
||||||
|
@Default(.inNetworkBandwidth) var inNetworkStreamBitrate
|
||||||
|
@Default(.outOfNetworkBandwidth) var outOfNetworkStreamBitrate
|
||||||
|
@Default(.isAutoSelectSubtitles) var isAutoSelectSubtitles
|
||||||
|
@Default(.autoSelectSubtitlesLangCode) var autoSelectSubtitlesLangcode
|
||||||
|
@Default(.autoSelectAudioLangCode) var autoSelectAudioLangcode
|
||||||
|
@Default(.appAppearance) var appAppearance
|
||||||
|
@Default(.overlayType) var overlayType
|
||||||
|
@Default(.videoPlayerJumpForward) var jumpForwardLength
|
||||||
|
@Default(.videoPlayerJumpBackward) var jumpBackwardLength
|
||||||
|
@Default(.jumpGesturesEnabled) var jumpGesturesEnabled
|
||||||
|
@Default(.showPosterLabels) var showPosterLabels
|
||||||
|
@Default(.showCastAndCrew) var showCastAndCrew
|
||||||
|
@Default(.resumeOffset) var resumeOffset
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Form {
|
||||||
|
Section(header: EmptyView()) {
|
||||||
|
HStack {
|
||||||
|
Text("User")
|
||||||
|
Spacer()
|
||||||
|
Text(viewModel.user.username)
|
||||||
|
.foregroundColor(.jellyfinPurple)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
settingsRouter.route(to: \.serverDetail)
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Text("Server")
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
Spacer()
|
||||||
|
Text(viewModel.server.name)
|
||||||
|
.foregroundColor(.jellyfinPurple)
|
||||||
|
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
settingsRouter.dismissCoordinator {
|
||||||
|
SessionManager.main.logout()
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Text("Switch User")
|
||||||
|
.font(.callout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section(header: Text("Networking")) {
|
||||||
|
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: Text("Video Player")) {
|
||||||
|
Picker("Jump Forward Length", selection: $jumpForwardLength) {
|
||||||
|
ForEach(VideoPlayerJumpLength.allCases, id: \.self) { length in
|
||||||
|
Text(length.label).tag(length.rawValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Picker("Jump Backward Length", selection: $jumpBackwardLength) {
|
||||||
|
ForEach(VideoPlayerJumpLength.allCases, id: \.self) { length in
|
||||||
|
Text(length.label).tag(length.rawValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Toggle("Jump Gestures Enabled", isOn: $jumpGesturesEnabled)
|
||||||
|
|
||||||
|
Toggle("Resume 5 Second Offset", isOn: $resumeOffset)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
settingsRouter.route(to: \.overlaySettings)
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Text("Overlay")
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
Spacer()
|
||||||
|
Text(overlayType.label)
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
settingsRouter.route(to: \.experimentalSettings)
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Text("Experimental")
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section(header: L10n.accessibility.text) {
|
||||||
|
Toggle("Show Poster Labels", isOn: $showPosterLabels)
|
||||||
|
Toggle("Show Cast and Crew", isOn: $showCastAndCrew)
|
||||||
|
|
||||||
|
Picker(L10n.appearance, selection: $appAppearance) {
|
||||||
|
ForEach(AppAppearance.allCases, id: \.self) { appearance in
|
||||||
|
Text(appearance.localizedName).tag(appearance.rawValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationBarTitle("Settings", displayMode: .inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItemGroup(placement: .navigationBarLeading) {
|
||||||
|
Button {
|
||||||
|
settingsRouter.dismissCoordinator()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "xmark.circle.fill")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,42 @@
|
||||||
|
//
|
||||||
|
/*
|
||||||
|
* 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 Foundation
|
||||||
|
|
||||||
|
enum PlaybackSpeed: Double, CaseIterable {
|
||||||
|
case quarter = 0.25
|
||||||
|
case half = 0.5
|
||||||
|
case threeQuarter = 0.75
|
||||||
|
case one = 1.0
|
||||||
|
case oneQuarter = 1.25
|
||||||
|
case oneHalf = 1.5
|
||||||
|
case oneThreeQuarter = 1.75
|
||||||
|
case two = 2.0
|
||||||
|
|
||||||
|
var displayTitle: String {
|
||||||
|
switch self {
|
||||||
|
case .quarter:
|
||||||
|
return "0.25x"
|
||||||
|
case .half:
|
||||||
|
return "0.5x"
|
||||||
|
case .threeQuarter:
|
||||||
|
return "0.75x"
|
||||||
|
case .one:
|
||||||
|
return "1x"
|
||||||
|
case .oneQuarter:
|
||||||
|
return "1.25x"
|
||||||
|
case .oneHalf:
|
||||||
|
return "1.5x"
|
||||||
|
case .oneThreeQuarter:
|
||||||
|
return "1.75x"
|
||||||
|
case .two:
|
||||||
|
return "2x"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,32 @@
|
||||||
|
//
|
||||||
|
/*
|
||||||
|
* 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 Foundation
|
||||||
|
|
||||||
|
protocol PlayerOverlayDelegate {
|
||||||
|
|
||||||
|
func didSelectClose()
|
||||||
|
func didSelectMenu()
|
||||||
|
func didDeselectMenu()
|
||||||
|
|
||||||
|
func didSelectBackward()
|
||||||
|
func didSelectForward()
|
||||||
|
func didSelectMain()
|
||||||
|
|
||||||
|
func didGenerallyTap()
|
||||||
|
|
||||||
|
func didBeginScrubbing()
|
||||||
|
func didEndScrubbing()
|
||||||
|
|
||||||
|
func didSelectAudioStream(index: Int)
|
||||||
|
func didSelectSubtitleStream(index: Int)
|
||||||
|
|
||||||
|
func didSelectPlayPreviousItem()
|
||||||
|
func didSelectPlayNextItem()
|
||||||
|
}
|
|
@ -0,0 +1,422 @@
|
||||||
|
/*
|
||||||
|
* 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 Combine
|
||||||
|
import Defaults
|
||||||
|
import JellyfinAPI
|
||||||
|
import MobileVLCKit
|
||||||
|
import Sliders
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct VLCPlayerOverlayView: View {
|
||||||
|
|
||||||
|
@ObservedObject var viewModel: VideoPlayerViewModel
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var mainButtonView: some View {
|
||||||
|
if viewModel.overlayType == .normal {
|
||||||
|
switch viewModel.playerState {
|
||||||
|
case .stopped, .paused:
|
||||||
|
Image(systemName: "play.fill")
|
||||||
|
.font(.system(size: 56, weight: .semibold, design: .default))
|
||||||
|
case .playing:
|
||||||
|
Image(systemName: "pause")
|
||||||
|
.font(.system(size: 56, weight: .semibold, design: .default))
|
||||||
|
default:
|
||||||
|
ProgressView()
|
||||||
|
.scaleEffect(2)
|
||||||
|
}
|
||||||
|
} else if viewModel.overlayType == .compact {
|
||||||
|
switch viewModel.playerState {
|
||||||
|
case .stopped, .paused:
|
||||||
|
Image(systemName: "play.fill")
|
||||||
|
.font(.system(size: 28, weight: .heavy, design: .default))
|
||||||
|
case .playing:
|
||||||
|
Image(systemName: "pause")
|
||||||
|
.font(.system(size: 28, weight: .heavy, design: .default))
|
||||||
|
default:
|
||||||
|
ProgressView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var mainBody: some View {
|
||||||
|
VStack {
|
||||||
|
|
||||||
|
// MARK: Top Bar
|
||||||
|
ZStack {
|
||||||
|
|
||||||
|
if viewModel.overlayType == .compact {
|
||||||
|
LinearGradient(gradient: Gradient(colors: [.black.opacity(0.7), .clear]),
|
||||||
|
startPoint: .top,
|
||||||
|
endPoint: .bottom)
|
||||||
|
.ignoresSafeArea()
|
||||||
|
.frame(height: 80)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .EpisodeSeriesAlignmentGuide) {
|
||||||
|
|
||||||
|
HStack(alignment: .center) {
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Button {
|
||||||
|
viewModel.playerOverlayDelegate?.didSelectClose()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "chevron.backward")
|
||||||
|
.padding()
|
||||||
|
.padding(.trailing, -10)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(viewModel.title)
|
||||||
|
.font(.system(size: 28, weight: .regular, design: .default))
|
||||||
|
.alignmentGuide(.EpisodeSeriesAlignmentGuide) { context in
|
||||||
|
context[.leading]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
HStack(spacing: 20) {
|
||||||
|
|
||||||
|
if viewModel.shouldShowPlayPreviousItem {
|
||||||
|
Button {
|
||||||
|
viewModel.playerOverlayDelegate?.didSelectPlayPreviousItem()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "chevron.left.circle")
|
||||||
|
}
|
||||||
|
.disabled(viewModel.previousItemVideoPlayerViewModel == nil)
|
||||||
|
.foregroundColor(viewModel.nextItemVideoPlayerViewModel == nil ? .gray : .white)
|
||||||
|
}
|
||||||
|
|
||||||
|
if viewModel.shouldShowPlayNextItem {
|
||||||
|
Button {
|
||||||
|
viewModel.playerOverlayDelegate?.didSelectPlayNextItem()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "chevron.right.circle")
|
||||||
|
}
|
||||||
|
.disabled(viewModel.nextItemVideoPlayerViewModel == nil)
|
||||||
|
.foregroundColor(viewModel.nextItemVideoPlayerViewModel == nil ? .gray : .white)
|
||||||
|
}
|
||||||
|
|
||||||
|
if viewModel.shouldShowAutoPlay {
|
||||||
|
Button {
|
||||||
|
viewModel.autoplayEnabled.toggle()
|
||||||
|
} label: {
|
||||||
|
if viewModel.autoplayEnabled {
|
||||||
|
Image(systemName: "play.circle.fill")
|
||||||
|
} else {
|
||||||
|
Image(systemName: "stop.circle")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !viewModel.subtitleStreams.isEmpty {
|
||||||
|
Button {
|
||||||
|
viewModel.subtitlesEnabled.toggle()
|
||||||
|
} label: {
|
||||||
|
if viewModel.subtitlesEnabled {
|
||||||
|
Image(systemName: "captions.bubble.fill")
|
||||||
|
} else {
|
||||||
|
Image(systemName: "captions.bubble")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(viewModel.selectedSubtitleStreamIndex == -1)
|
||||||
|
.foregroundColor(viewModel.selectedSubtitleStreamIndex == -1 ? .gray : .white)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Settings Menu
|
||||||
|
Menu {
|
||||||
|
|
||||||
|
Menu {
|
||||||
|
ForEach(viewModel.audioStreams, id: \.self) { audioStream in
|
||||||
|
Button {
|
||||||
|
viewModel.selectedAudioStreamIndex = audioStream.index ?? -1
|
||||||
|
} label: {
|
||||||
|
if audioStream.index == viewModel.selectedAudioStreamIndex {
|
||||||
|
Label.init(audioStream.displayTitle ?? "No Title", systemImage: "checkmark")
|
||||||
|
} else {
|
||||||
|
Text(audioStream.displayTitle ?? "No Title")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "speaker.wave.3")
|
||||||
|
Text("Audio")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Menu {
|
||||||
|
ForEach(viewModel.subtitleStreams, id: \.self) { subtitleStream in
|
||||||
|
Button {
|
||||||
|
viewModel.selectedSubtitleStreamIndex = subtitleStream.index ?? -1
|
||||||
|
} label: {
|
||||||
|
if subtitleStream.index == viewModel.selectedSubtitleStreamIndex {
|
||||||
|
Label.init(subtitleStream.displayTitle ?? "No Title", systemImage: "checkmark")
|
||||||
|
} else {
|
||||||
|
Text(subtitleStream.displayTitle ?? "No Title")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "captions.bubble")
|
||||||
|
Text("Subtitles")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Menu {
|
||||||
|
ForEach(PlaybackSpeed.allCases, id: \.self) { speed in
|
||||||
|
Button {
|
||||||
|
viewModel.playbackSpeed = speed
|
||||||
|
} label: {
|
||||||
|
if speed == viewModel.playbackSpeed {
|
||||||
|
Label(speed.displayTitle, systemImage: "checkmark")
|
||||||
|
} else {
|
||||||
|
Text(speed.displayTitle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "speedometer")
|
||||||
|
Text("Playback Speed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if viewModel.shouldShowJumpButtonsInOverlayMenu {
|
||||||
|
Menu {
|
||||||
|
ForEach(VideoPlayerJumpLength.allCases, id: \.self) { forwardLength in
|
||||||
|
Button {
|
||||||
|
viewModel.jumpForwardLength = forwardLength
|
||||||
|
} label: {
|
||||||
|
if forwardLength == viewModel.jumpForwardLength {
|
||||||
|
Label(forwardLength.shortLabel, systemImage: "checkmark")
|
||||||
|
} else {
|
||||||
|
Text(forwardLength.shortLabel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "goforward")
|
||||||
|
Text("Jump Forward Length")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Menu {
|
||||||
|
ForEach(VideoPlayerJumpLength.allCases, id: \.self) { backwardLength in
|
||||||
|
Button {
|
||||||
|
viewModel.jumpBackwardLength = backwardLength
|
||||||
|
} label: {
|
||||||
|
if backwardLength == viewModel.jumpBackwardLength {
|
||||||
|
Label(backwardLength.shortLabel, systemImage: "checkmark")
|
||||||
|
} else {
|
||||||
|
Text(backwardLength.shortLabel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "gobackward")
|
||||||
|
Text("Jump Backward Length")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "ellipsis.circle")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.font(.system(size: 24))
|
||||||
|
.frame(height: 50)
|
||||||
|
|
||||||
|
if let seriesTitle = viewModel.subtitle {
|
||||||
|
Text(seriesTitle)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(Color.gray)
|
||||||
|
.alignmentGuide(.EpisodeSeriesAlignmentGuide) { context in
|
||||||
|
context[.leading]
|
||||||
|
}
|
||||||
|
.offset(y: -10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, UIDevice.current.userInterfaceIdiom == .pad ? 50 : 0)
|
||||||
|
.padding(.top, UIDevice.current.userInterfaceIdiom == .pad ? 10 : 0)
|
||||||
|
|
||||||
|
// MARK: Center
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if viewModel.overlayType == .normal {
|
||||||
|
HStack(spacing: 80) {
|
||||||
|
Button {
|
||||||
|
viewModel.playerOverlayDelegate?.didSelectBackward()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: viewModel.jumpBackwardLength.backwardImageLabel)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
viewModel.playerOverlayDelegate?.didSelectMain()
|
||||||
|
} label: {
|
||||||
|
mainButtonView
|
||||||
|
}
|
||||||
|
.frame(width: 200)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
viewModel.playerOverlayDelegate?.didSelectForward()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: viewModel.jumpForwardLength.forwardImageLabel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.font(.system(size: 48))
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// MARK: Bottom Bar
|
||||||
|
ZStack {
|
||||||
|
|
||||||
|
if viewModel.overlayType == .compact {
|
||||||
|
LinearGradient(gradient: Gradient(colors: [.clear, .black.opacity(0.7)]),
|
||||||
|
startPoint: .top,
|
||||||
|
endPoint: .bottom)
|
||||||
|
.ignoresSafeArea()
|
||||||
|
.frame(height: 70)
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
|
||||||
|
if viewModel.overlayType == .compact {
|
||||||
|
HStack {
|
||||||
|
Button {
|
||||||
|
viewModel.playerOverlayDelegate?.didSelectBackward()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: viewModel.jumpBackwardLength.backwardImageLabel)
|
||||||
|
.padding(.horizontal, 5)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
viewModel.playerOverlayDelegate?.didSelectMain()
|
||||||
|
} label: {
|
||||||
|
mainButtonView
|
||||||
|
.frame(minWidth: 30, maxWidth: 30)
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
viewModel.playerOverlayDelegate?.didSelectForward()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: viewModel.jumpForwardLength.forwardImageLabel)
|
||||||
|
.padding(.horizontal, 5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.font(.system(size: 24, weight: .semibold, design: .default))
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(viewModel.leftLabelText)
|
||||||
|
.font(.system(size: 18, weight: .semibold, design: .default))
|
||||||
|
.frame(minWidth: 70, maxWidth: 70)
|
||||||
|
|
||||||
|
ValueSlider(value: $viewModel.sliderPercentage, onEditingChanged: { editing in
|
||||||
|
viewModel.sliderIsScrubbing = editing
|
||||||
|
})
|
||||||
|
.valueSliderStyle(
|
||||||
|
HorizontalValueSliderStyle(track:
|
||||||
|
HorizontalValueTrack(view:
|
||||||
|
Capsule().foregroundColor(.purple))
|
||||||
|
.background(Capsule().foregroundColor(Color.gray.opacity(0.25)))
|
||||||
|
.frame(height: 4),
|
||||||
|
thumb: Circle().foregroundColor(.purple)
|
||||||
|
.onLongPressGesture(perform: {
|
||||||
|
print("got it here")
|
||||||
|
}),
|
||||||
|
thumbSize: CGSize.Circle(radius: viewModel.sliderIsScrubbing ? 20 : 15),
|
||||||
|
thumbInteractiveSize: CGSize.Circle(radius: 40),
|
||||||
|
options: .defaultOptions)
|
||||||
|
)
|
||||||
|
.frame(maxHeight: 50)
|
||||||
|
|
||||||
|
Text(viewModel.rightLabelText)
|
||||||
|
.font(.system(size: 18, weight: .semibold, design: .default))
|
||||||
|
.frame(minWidth: 70, maxWidth: 70)
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
.frame(maxWidth: UIDevice.current.userInterfaceIdiom == .pad ? 800 : nil)
|
||||||
|
}
|
||||||
|
.frame(maxHeight: 50)
|
||||||
|
}
|
||||||
|
.ignoresSafeArea(edges: .top)
|
||||||
|
.tint(Color.white)
|
||||||
|
.foregroundColor(Color.white)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if viewModel.overlayType == .normal {
|
||||||
|
mainBody
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture {
|
||||||
|
viewModel.playerOverlayDelegate?.didGenerallyTap()
|
||||||
|
}
|
||||||
|
.background {
|
||||||
|
Color(uiColor: .black.withAlphaComponent(0.5))
|
||||||
|
.ignoresSafeArea()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
mainBody
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture {
|
||||||
|
viewModel.playerOverlayDelegate?.didGenerallyTap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct VLCPlayerCompactOverlayView_Previews: PreviewProvider {
|
||||||
|
|
||||||
|
static let videoPlayerViewModel = VideoPlayerViewModel(item: BaseItemDto(),
|
||||||
|
title: "Glorious Purpose",
|
||||||
|
subtitle: "Loki - S1E1",
|
||||||
|
streamURL: URL(string: "www.apple.com")!,
|
||||||
|
hlsURL: URL(string: "www.apple.com")!,
|
||||||
|
response: PlaybackInfoResponse(),
|
||||||
|
audioStreams: [MediaStream(displayTitle: "English", index: -1)],
|
||||||
|
subtitleStreams: [MediaStream(displayTitle: "None", index: -1)],
|
||||||
|
selectedAudioStreamIndex: -1,
|
||||||
|
selectedSubtitleStreamIndex: -1,
|
||||||
|
subtitlesEnabled: true,
|
||||||
|
autoplayEnabled: false,
|
||||||
|
overlayType: .compact,
|
||||||
|
shouldShowPlayPreviousItem: true,
|
||||||
|
shouldShowPlayNextItem: true,
|
||||||
|
shouldShowAutoPlay: true)
|
||||||
|
|
||||||
|
static var previews: some View {
|
||||||
|
ZStack {
|
||||||
|
Color.red
|
||||||
|
.ignoresSafeArea()
|
||||||
|
|
||||||
|
VLCPlayerOverlayView(viewModel: videoPlayerViewModel)
|
||||||
|
}
|
||||||
|
.previewInterfaceOrientation(.landscapeLeft)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: TitleSubtitleAlignment
|
||||||
|
extension HorizontalAlignment {
|
||||||
|
|
||||||
|
private struct TitleSubtitleAlignment: AlignmentID {
|
||||||
|
static func defaultValue(in context: ViewDimensions) -> CGFloat {
|
||||||
|
context[HorizontalAlignment.leading]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static let EpisodeSeriesAlignmentGuide = HorizontalAlignment(TitleSubtitleAlignment.self)
|
||||||
|
}
|
|
@ -0,0 +1,27 @@
|
||||||
|
//
|
||||||
|
/*
|
||||||
|
* 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 UIKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct VLCPlayerView: UIViewControllerRepresentable {
|
||||||
|
|
||||||
|
let viewModel: VideoPlayerViewModel
|
||||||
|
|
||||||
|
typealias UIViewControllerType = VLCPlayerViewController
|
||||||
|
|
||||||
|
func makeUIViewController(context: Context) -> VLCPlayerViewController {
|
||||||
|
|
||||||
|
return VLCPlayerViewController(viewModel: viewModel)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIViewController(_ uiViewController: VLCPlayerViewController, context: Context) {
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,677 @@
|
||||||
|
//
|
||||||
|
/*
|
||||||
|
* 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 AVKit
|
||||||
|
import AVFoundation
|
||||||
|
import Combine
|
||||||
|
import Defaults
|
||||||
|
import JellyfinAPI
|
||||||
|
import MediaPlayer
|
||||||
|
import MobileVLCKit
|
||||||
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
// TODO: Look at making the VLC player layer a view
|
||||||
|
|
||||||
|
class VLCPlayerViewController: UIViewController {
|
||||||
|
|
||||||
|
// MARK: variables
|
||||||
|
|
||||||
|
private var viewModel: VideoPlayerViewModel
|
||||||
|
private var vlcMediaPlayer = VLCMediaPlayer()
|
||||||
|
private var lastPlayerTicks: Int64 = 0
|
||||||
|
private var lastProgressReportTicks: Int64 = 0
|
||||||
|
private var viewModelListeners = Set<AnyCancellable>()
|
||||||
|
private var overlayDismissTimer: Timer?
|
||||||
|
|
||||||
|
private var currentPlayerTicks: Int64 {
|
||||||
|
return Int64(vlcMediaPlayer.time.intValue) * 100_000
|
||||||
|
}
|
||||||
|
|
||||||
|
private var displayingOverlay: Bool {
|
||||||
|
return currentOverlayHostingController?.view.alpha ?? 0 > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private lazy var videoContentView = makeVideoContentView()
|
||||||
|
private lazy var mainGestureView = makeTapGestureView()
|
||||||
|
private var currentOverlayHostingController: UIHostingController<VLCPlayerOverlayView>?
|
||||||
|
private var currentJumpBackwardOverlayView: UIImageView?
|
||||||
|
private var currentJumpForwardOverlayView: UIImageView?
|
||||||
|
|
||||||
|
// MARK: init
|
||||||
|
|
||||||
|
init(viewModel: VideoPlayerViewModel) {
|
||||||
|
|
||||||
|
self.viewModel = viewModel
|
||||||
|
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
|
||||||
|
viewModel.playerOverlayDelegate = self
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupSubviews() {
|
||||||
|
view.addSubview(videoContentView)
|
||||||
|
view.addSubview(mainGestureView)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupConstraints() {
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
videoContentView.topAnchor.constraint(equalTo: view.topAnchor),
|
||||||
|
videoContentView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||||
|
videoContentView.leftAnchor.constraint(equalTo: view.leftAnchor),
|
||||||
|
videoContentView.rightAnchor.constraint(equalTo: view.rightAnchor)
|
||||||
|
])
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
mainGestureView.topAnchor.constraint(equalTo: videoContentView.topAnchor),
|
||||||
|
mainGestureView.bottomAnchor.constraint(equalTo: videoContentView.bottomAnchor),
|
||||||
|
mainGestureView.leftAnchor.constraint(equalTo: videoContentView.leftAnchor),
|
||||||
|
mainGestureView.rightAnchor.constraint(equalTo: videoContentView.rightAnchor)
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: viewWillDisappear
|
||||||
|
|
||||||
|
override func viewWillDisappear(_ animated: Bool) {
|
||||||
|
super.viewWillDisappear(animated)
|
||||||
|
|
||||||
|
NotificationCenter.default.removeObserver(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: viewDidLoad
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
|
||||||
|
setupSubviews()
|
||||||
|
setupConstraints()
|
||||||
|
|
||||||
|
view.backgroundColor = .black
|
||||||
|
|
||||||
|
// These are kept outside of 'setupMediaPlayer' such that
|
||||||
|
// they aren't unnecessarily set more than once
|
||||||
|
vlcMediaPlayer.delegate = self
|
||||||
|
vlcMediaPlayer.drawable = videoContentView
|
||||||
|
|
||||||
|
// TODO: Custom subtitle sizes
|
||||||
|
vlcMediaPlayer.perform(Selector(("setTextRendererFontSize:")), with: 14)
|
||||||
|
|
||||||
|
setupMediaPlayer(newViewModel: viewModel)
|
||||||
|
|
||||||
|
refreshJumpBackwardOverlayView(with: viewModel.jumpBackwardLength)
|
||||||
|
refreshJumpForwardOverlayView(with: viewModel.jumpForwardLength)
|
||||||
|
|
||||||
|
let defaultNotificationCenter = NotificationCenter.default
|
||||||
|
defaultNotificationCenter.addObserver(self, selector: #selector(appWillTerminate), name: UIApplication.willTerminateNotification, object: nil)
|
||||||
|
defaultNotificationCenter.addObserver(self, selector: #selector(appWillResignActive), name: UIApplication.willResignActiveNotification, object: nil)
|
||||||
|
defaultNotificationCenter.addObserver(self, selector: #selector(appWillResignActive), name: UIApplication.didEnterBackgroundNotification, object: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func appWillTerminate() {
|
||||||
|
viewModel.sendStopReport()
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func appWillResignActive() {
|
||||||
|
showOverlay()
|
||||||
|
|
||||||
|
stopOverlayDismissTimer()
|
||||||
|
|
||||||
|
vlcMediaPlayer.pause()
|
||||||
|
|
||||||
|
viewModel.sendPauseReport(paused: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidAppear(_ animated: Bool) {
|
||||||
|
super.viewDidAppear(animated)
|
||||||
|
|
||||||
|
startPlayback()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: subviews
|
||||||
|
|
||||||
|
private func makeVideoContentView() -> UIView {
|
||||||
|
let view = UIView()
|
||||||
|
view.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
view.backgroundColor = .black
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
if viewModel.jumpGesturesEnabled {
|
||||||
|
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: 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let newOverlayView = VLCPlayerOverlayView(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 weird behavior when after setting the new overlays that the navigation bar pops up, re-hide it
|
||||||
|
self.navigationController?.isNavigationBarHidden = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshJumpBackwardOverlayView(with jumpBackwardLength: VideoPlayerJumpLength) {
|
||||||
|
|
||||||
|
if let currentJumpBackwardOverlayView = currentJumpBackwardOverlayView {
|
||||||
|
currentJumpBackwardOverlayView.removeFromSuperview()
|
||||||
|
}
|
||||||
|
|
||||||
|
let symbolConfig = UIImage.SymbolConfiguration(pointSize: 48)
|
||||||
|
let backwardSymbolImage = UIImage(systemName: jumpBackwardLength.backwardImageLabel, withConfiguration: symbolConfig)
|
||||||
|
let newJumpBackwardImageView = UIImageView(image: backwardSymbolImage)
|
||||||
|
|
||||||
|
newJumpBackwardImageView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
newJumpBackwardImageView.tintColor = .white
|
||||||
|
|
||||||
|
newJumpBackwardImageView.alpha = 0
|
||||||
|
|
||||||
|
view.addSubview(newJumpBackwardImageView)
|
||||||
|
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
newJumpBackwardImageView.leftAnchor.constraint(equalTo: view.leftAnchor, constant: 150),
|
||||||
|
newJumpBackwardImageView.centerYAnchor.constraint(equalTo: view.centerYAnchor)
|
||||||
|
])
|
||||||
|
|
||||||
|
currentJumpBackwardOverlayView = newJumpBackwardImageView
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshJumpForwardOverlayView(with jumpForwardLength: VideoPlayerJumpLength) {
|
||||||
|
|
||||||
|
if let currentJumpForwardOverlayView = currentJumpForwardOverlayView {
|
||||||
|
currentJumpForwardOverlayView.removeFromSuperview()
|
||||||
|
}
|
||||||
|
|
||||||
|
let symbolConfig = UIImage.SymbolConfiguration(pointSize: 48)
|
||||||
|
let forwardSymbolImage = UIImage(systemName: jumpForwardLength.forwardImageLabel, withConfiguration: symbolConfig)
|
||||||
|
let newJumpForwardImageView = UIImageView(image: forwardSymbolImage)
|
||||||
|
|
||||||
|
newJumpForwardImageView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
newJumpForwardImageView.tintColor = .white
|
||||||
|
|
||||||
|
newJumpForwardImageView.alpha = 0
|
||||||
|
|
||||||
|
view.addSubview(newJumpForwardImageView)
|
||||||
|
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
newJumpForwardImageView.rightAnchor.constraint(equalTo: view.rightAnchor, constant: -150),
|
||||||
|
newJumpForwardImageView.centerYAnchor.constraint(equalTo: view.centerYAnchor)
|
||||||
|
])
|
||||||
|
|
||||||
|
currentJumpForwardOverlayView = newJumpForwardImageView
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: setupMediaPlayer
|
||||||
|
extension VLCPlayerViewController {
|
||||||
|
|
||||||
|
/// Main function that handles setting up the media player with the current VideoPlayerViewModel
|
||||||
|
/// and also takes the role of setting the 'viewModel' property with the given viewModel
|
||||||
|
///
|
||||||
|
/// Use case for this is setting new media within the same VLCPlayerViewController
|
||||||
|
func setupMediaPlayer(newViewModel: VideoPlayerViewModel) {
|
||||||
|
|
||||||
|
stopOverlayDismissTimer()
|
||||||
|
|
||||||
|
// Stop current media if there is one
|
||||||
|
if vlcMediaPlayer.media != nil {
|
||||||
|
viewModelListeners.forEach({ $0.cancel() })
|
||||||
|
|
||||||
|
vlcMediaPlayer.stop()
|
||||||
|
viewModel.sendStopReport()
|
||||||
|
viewModel.playerOverlayDelegate = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
lastPlayerTicks = newViewModel.item.userData?.playbackPositionTicks ?? 0
|
||||||
|
lastProgressReportTicks = newViewModel.item.userData?.playbackPositionTicks ?? 0
|
||||||
|
|
||||||
|
let media = VLCMedia(url: newViewModel.streamURL)
|
||||||
|
media.addOption("--prefetch-buffer-size=1048576")
|
||||||
|
media.addOption("--network-caching=5000")
|
||||||
|
|
||||||
|
vlcMediaPlayer.media = media
|
||||||
|
|
||||||
|
setupOverlayHostingController(viewModel: newViewModel)
|
||||||
|
setupViewModelListeners(viewModel: newViewModel)
|
||||||
|
|
||||||
|
newViewModel.getAdjacentEpisodes()
|
||||||
|
newViewModel.playerOverlayDelegate = self
|
||||||
|
|
||||||
|
let startPercentage = newViewModel.item.userData?.playedPercentage ?? 0
|
||||||
|
|
||||||
|
if startPercentage > 0 {
|
||||||
|
if viewModel.resumeOffset {
|
||||||
|
let videoDurationSeconds = Double(viewModel.item.runTimeTicks! / 10_000_000)
|
||||||
|
var startSeconds = round((startPercentage / 100) * videoDurationSeconds)
|
||||||
|
startSeconds = startSeconds.subtract(5, floor: 0)
|
||||||
|
let newStartPercentage = startSeconds / videoDurationSeconds
|
||||||
|
newViewModel.sliderPercentage = newStartPercentage
|
||||||
|
} else {
|
||||||
|
newViewModel.sliderPercentage = startPercentage / 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModel = newViewModel
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: startPlayback
|
||||||
|
func startPlayback() {
|
||||||
|
vlcMediaPlayer.play()
|
||||||
|
|
||||||
|
setMediaPlayerTimeAtCurrentSlider()
|
||||||
|
|
||||||
|
viewModel.sendPlayReport()
|
||||||
|
|
||||||
|
restartOverlayDismissTimer()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: setupViewModelListeners
|
||||||
|
|
||||||
|
private func setupViewModelListeners(viewModel: VideoPlayerViewModel) {
|
||||||
|
|
||||||
|
viewModel.$playbackSpeed.sink { newSpeed in
|
||||||
|
self.vlcMediaPlayer.rate = Float(newSpeed.rawValue)
|
||||||
|
}.store(in: &viewModelListeners)
|
||||||
|
|
||||||
|
viewModel.$sliderIsScrubbing.sink { sliderIsScrubbing in
|
||||||
|
if sliderIsScrubbing {
|
||||||
|
self.didBeginScrubbing()
|
||||||
|
} else {
|
||||||
|
self.didEndScrubbing()
|
||||||
|
}
|
||||||
|
}.store(in: &viewModelListeners)
|
||||||
|
|
||||||
|
viewModel.$selectedAudioStreamIndex.sink { newAudioStreamIndex in
|
||||||
|
self.didSelectAudioStream(index: newAudioStreamIndex)
|
||||||
|
}.store(in: &viewModelListeners)
|
||||||
|
|
||||||
|
viewModel.$selectedSubtitleStreamIndex.sink { newSubtitleStreamIndex in
|
||||||
|
self.didSelectSubtitleStream(index: newSubtitleStreamIndex)
|
||||||
|
}.store(in: &viewModelListeners)
|
||||||
|
|
||||||
|
viewModel.$subtitlesEnabled.sink { newSubtitlesEnabled in
|
||||||
|
self.didToggleSubtitles(newValue: newSubtitlesEnabled)
|
||||||
|
}.store(in: &viewModelListeners)
|
||||||
|
|
||||||
|
viewModel.$jumpBackwardLength.sink { newJumpBackwardLength in
|
||||||
|
self.refreshJumpBackwardOverlayView(with: newJumpBackwardLength)
|
||||||
|
}.store(in: &viewModelListeners)
|
||||||
|
|
||||||
|
viewModel.$jumpForwardLength.sink { newJumpForwardLength in
|
||||||
|
self.refreshJumpForwardOverlayView(with: newJumpForwardLength)
|
||||||
|
}.store(in: &viewModelListeners)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setMediaPlayerTimeAtCurrentSlider() {
|
||||||
|
// Necessary math as VLCMediaPlayer doesn't work well
|
||||||
|
// by just setting the position
|
||||||
|
let videoPosition = Double(vlcMediaPlayer.time.intValue / 1000)
|
||||||
|
let videoDuration = Double(viewModel.item.runTimeTicks! / 10_000_000)
|
||||||
|
let secondsScrubbedTo = round(viewModel.sliderPercentage * videoDuration)
|
||||||
|
let newPositionOffset = secondsScrubbedTo - videoPosition
|
||||||
|
|
||||||
|
if newPositionOffset > 0 {
|
||||||
|
vlcMediaPlayer.jumpForward(Int32(newPositionOffset))
|
||||||
|
} else {
|
||||||
|
vlcMediaPlayer.jumpBackward(Int32(abs(newPositionOffset)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Show/Hide Overlay
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func toggleOverlay() {
|
||||||
|
guard let overlayHostingController = currentOverlayHostingController else { return }
|
||||||
|
|
||||||
|
if overlayHostingController.view.alpha < 1 {
|
||||||
|
showOverlay()
|
||||||
|
} else {
|
||||||
|
hideOverlay()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Show/Hide Jump
|
||||||
|
extension VLCPlayerViewController {
|
||||||
|
|
||||||
|
private func flashJumpBackwardOverlay() {
|
||||||
|
guard !displayingOverlay, let currentJumpBackwardOverlayView = currentJumpBackwardOverlayView else { return }
|
||||||
|
|
||||||
|
currentJumpBackwardOverlayView.layer.removeAllAnimations()
|
||||||
|
|
||||||
|
UIView.animate(withDuration: 0.1) {
|
||||||
|
currentJumpBackwardOverlayView.alpha = 1
|
||||||
|
} completion: { _ in
|
||||||
|
self.hideJumpBackwardOverlay()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func hideJumpBackwardOverlay() {
|
||||||
|
guard let currentJumpBackwardOverlayView = currentJumpBackwardOverlayView else { return }
|
||||||
|
|
||||||
|
UIView.animate(withDuration: 0.3) {
|
||||||
|
currentJumpBackwardOverlayView.alpha = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func flashJumpFowardOverlay() {
|
||||||
|
guard !displayingOverlay, let currentJumpForwardOverlayView = currentJumpForwardOverlayView else { return }
|
||||||
|
|
||||||
|
currentJumpForwardOverlayView.layer.removeAllAnimations()
|
||||||
|
|
||||||
|
UIView.animate(withDuration: 0.1) {
|
||||||
|
currentJumpForwardOverlayView.alpha = 1
|
||||||
|
} completion: { _ in
|
||||||
|
self.hideJumpForwardOverlay()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func hideJumpForwardOverlay() {
|
||||||
|
guard let currentJumpForwardOverlayView = currentJumpForwardOverlayView else { return }
|
||||||
|
|
||||||
|
UIView.animate(withDuration: 0.3) {
|
||||||
|
currentJumpForwardOverlayView.alpha = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: OverlayTimer
|
||||||
|
extension VLCPlayerViewController {
|
||||||
|
|
||||||
|
private func restartOverlayDismissTimer(interval: Double = 3) {
|
||||||
|
self.overlayDismissTimer?.invalidate()
|
||||||
|
self.overlayDismissTimer = Timer.scheduledTimer(timeInterval: interval, target: self, selector: #selector(dismissTimerFired), userInfo: nil, repeats: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func dismissTimerFired() {
|
||||||
|
self.hideOverlay()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stopOverlayDismissTimer() {
|
||||||
|
self.overlayDismissTimer?.invalidate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: VLCMediaPlayerDelegate
|
||||||
|
extension VLCPlayerViewController: VLCMediaPlayerDelegate {
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: mediaPlayerStateChanged
|
||||||
|
func mediaPlayerStateChanged(_ aNotification: Notification!) {
|
||||||
|
|
||||||
|
// Don't show buffering if paused, usually here while scrubbing
|
||||||
|
if vlcMediaPlayer.state == .buffering && viewModel.playerState == .paused {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModel.playerState = vlcMediaPlayer.state
|
||||||
|
|
||||||
|
if vlcMediaPlayer.state == VLCMediaPlayerState.ended {
|
||||||
|
if viewModel.autoplayEnabled && viewModel.nextItemVideoPlayerViewModel != nil {
|
||||||
|
didSelectPlayNextItem()
|
||||||
|
} else {
|
||||||
|
didSelectClose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: mediaPlayerTimeChanged
|
||||||
|
func mediaPlayerTimeChanged(_ aNotification: Notification!) {
|
||||||
|
|
||||||
|
if !viewModel.sliderIsScrubbing {
|
||||||
|
viewModel.sliderPercentage = Double(vlcMediaPlayer.position)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Have to manually set playing because VLCMediaPlayer doesn't
|
||||||
|
// properly set it itself
|
||||||
|
if abs(currentPlayerTicks - lastPlayerTicks) >= 10_000 {
|
||||||
|
viewModel.playerState = VLCMediaPlayerState.playing
|
||||||
|
}
|
||||||
|
|
||||||
|
// If needing to fix subtitle streams during playback
|
||||||
|
if vlcMediaPlayer.currentVideoSubTitleIndex != viewModel.selectedSubtitleStreamIndex && viewModel.subtitlesEnabled {
|
||||||
|
didSelectSubtitleStream(index: viewModel.selectedSubtitleStreamIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If needing to fix audio stream during playback
|
||||||
|
if vlcMediaPlayer.currentAudioTrackIndex != viewModel.selectedAudioStreamIndex {
|
||||||
|
didSelectAudioStream(index: viewModel.selectedAudioStreamIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
lastPlayerTicks = currentPlayerTicks
|
||||||
|
|
||||||
|
// Send progress report every 5 seconds
|
||||||
|
if abs(lastProgressReportTicks - currentPlayerTicks) >= 500_000_000 {
|
||||||
|
viewModel.sendProgressReport()
|
||||||
|
|
||||||
|
lastProgressReportTicks = currentPlayerTicks
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: PlayerOverlayDelegate and more
|
||||||
|
extension VLCPlayerViewController: PlayerOverlayDelegate {
|
||||||
|
|
||||||
|
func didSelectAudioStream(index: Int) {
|
||||||
|
vlcMediaPlayer.currentAudioTrackIndex = Int32(index)
|
||||||
|
|
||||||
|
viewModel.sendProgressReport()
|
||||||
|
|
||||||
|
lastProgressReportTicks = currentPlayerTicks
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Do not call when setting to index -1
|
||||||
|
func didSelectSubtitleStream(index: Int) {
|
||||||
|
viewModel.subtitlesEnabled = true
|
||||||
|
vlcMediaPlayer.currentVideoSubTitleIndex = Int32(index)
|
||||||
|
|
||||||
|
viewModel.sendProgressReport()
|
||||||
|
|
||||||
|
lastProgressReportTicks = currentPlayerTicks
|
||||||
|
}
|
||||||
|
|
||||||
|
func didSelectClose() {
|
||||||
|
vlcMediaPlayer.stop()
|
||||||
|
|
||||||
|
viewModel.sendStopReport()
|
||||||
|
|
||||||
|
dismiss(animated: true, completion: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func didToggleSubtitles(newValue: Bool) {
|
||||||
|
if newValue {
|
||||||
|
vlcMediaPlayer.currentVideoSubTitleIndex = Int32(viewModel.selectedSubtitleStreamIndex)
|
||||||
|
} else {
|
||||||
|
vlcMediaPlayer.currentVideoSubTitleIndex = -1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Implement properly in overlays
|
||||||
|
func didSelectMenu() {
|
||||||
|
stopOverlayDismissTimer()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Implement properly in overlays
|
||||||
|
func didDeselectMenu() {
|
||||||
|
restartOverlayDismissTimer()
|
||||||
|
}
|
||||||
|
|
||||||
|
func didSelectBackward() {
|
||||||
|
|
||||||
|
flashJumpBackwardOverlay()
|
||||||
|
|
||||||
|
vlcMediaPlayer.jumpBackward(viewModel.jumpBackwardLength.rawValue)
|
||||||
|
|
||||||
|
if displayingOverlay {
|
||||||
|
restartOverlayDismissTimer()
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModel.sendProgressReport()
|
||||||
|
|
||||||
|
lastProgressReportTicks = currentPlayerTicks
|
||||||
|
}
|
||||||
|
|
||||||
|
func didSelectForward() {
|
||||||
|
|
||||||
|
flashJumpFowardOverlay()
|
||||||
|
|
||||||
|
vlcMediaPlayer.jumpForward(viewModel.jumpForwardLength.rawValue)
|
||||||
|
|
||||||
|
if displayingOverlay {
|
||||||
|
restartOverlayDismissTimer()
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModel.sendProgressReport()
|
||||||
|
|
||||||
|
lastProgressReportTicks = currentPlayerTicks
|
||||||
|
}
|
||||||
|
|
||||||
|
func didSelectMain() {
|
||||||
|
|
||||||
|
switch viewModel.playerState {
|
||||||
|
case .buffering:
|
||||||
|
vlcMediaPlayer.play()
|
||||||
|
restartOverlayDismissTimer()
|
||||||
|
case .playing:
|
||||||
|
viewModel.sendPauseReport(paused: true)
|
||||||
|
vlcMediaPlayer.pause()
|
||||||
|
restartOverlayDismissTimer(interval: 5)
|
||||||
|
case .paused:
|
||||||
|
viewModel.sendPauseReport(paused: false)
|
||||||
|
vlcMediaPlayer.play()
|
||||||
|
restartOverlayDismissTimer()
|
||||||
|
default: ()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func didGenerallyTap() {
|
||||||
|
toggleOverlay()
|
||||||
|
|
||||||
|
restartOverlayDismissTimer(interval: 5)
|
||||||
|
}
|
||||||
|
|
||||||
|
func didBeginScrubbing() {
|
||||||
|
stopOverlayDismissTimer()
|
||||||
|
}
|
||||||
|
|
||||||
|
func didEndScrubbing() {
|
||||||
|
setMediaPlayerTimeAtCurrentSlider()
|
||||||
|
|
||||||
|
restartOverlayDismissTimer()
|
||||||
|
|
||||||
|
viewModel.sendProgressReport()
|
||||||
|
|
||||||
|
lastProgressReportTicks = currentPlayerTicks
|
||||||
|
}
|
||||||
|
|
||||||
|
func didSelectPlayPreviousItem() {
|
||||||
|
if let previousItemVideoPlayerViewModel = viewModel.previousItemVideoPlayerViewModel {
|
||||||
|
setupMediaPlayer(newViewModel: previousItemVideoPlayerViewModel)
|
||||||
|
startPlayback()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func didSelectPlayNextItem() {
|
||||||
|
if let nextItemVideoPlayerViewModel = viewModel.nextItemVideoPlayerViewModel {
|
||||||
|
setupMediaPlayer(newViewModel: nextItemVideoPlayerViewModel)
|
||||||
|
startPlayback()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,252 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="18122" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
|
|
||||||
<device id="retina6_5" orientation="landscape" appearance="light"/>
|
|
||||||
<dependencies>
|
|
||||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="18093"/>
|
|
||||||
<capability name="Image references" minToolsVersion="12.0"/>
|
|
||||||
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
|
|
||||||
<capability name="System colors in document resources" minToolsVersion="11.0"/>
|
|
||||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
|
||||||
</dependencies>
|
|
||||||
<scenes>
|
|
||||||
<!--Player View Controller-->
|
|
||||||
<scene sceneID="s0d-6b-0kx">
|
|
||||||
<objects>
|
|
||||||
<viewController storyboardIdentifier="VideoPlayer" id="Y6W-OH-hqX" customClass="PlayerViewController" customModule="JellyfinPlayer_iOS" customModuleProvider="target" sceneMemberID="viewController">
|
|
||||||
<view key="view" autoresizesSubviews="NO" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="IQg-r0-AeH">
|
|
||||||
<rect key="frame" x="0.0" y="0.0" width="896" height="414"/>
|
|
||||||
<subviews>
|
|
||||||
<view autoresizesSubviews="NO" tag="1" contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Tsh-rC-BwO" userLabel="VideoContentView">
|
|
||||||
<rect key="frame" x="31" y="0.0" width="834" height="414"/>
|
|
||||||
<viewLayoutGuide key="safeArea" id="aVY-BC-PZU"/>
|
|
||||||
<color key="backgroundColor" white="0.0" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
|
||||||
<gestureRecognizers/>
|
|
||||||
<connections>
|
|
||||||
<outletCollection property="gestureRecognizers" destination="Tag-oM-Uha" appends="YES" id="AlY-fE-iBg"/>
|
|
||||||
</connections>
|
|
||||||
</view>
|
|
||||||
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="Qcb-Fb-qZl" userLabel="VideoControlsView">
|
|
||||||
<rect key="frame" x="0.0" y="0.0" width="896" height="414"/>
|
|
||||||
<subviews>
|
|
||||||
<slider opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" minValue="0.0" maxValue="1" translatesAutoresizingMaskIntoConstraints="NO" id="e9f-8l-RdN" userLabel="SeekSlider">
|
|
||||||
<rect key="frame" x="133" y="355" width="630" height="31"/>
|
|
||||||
<color key="tintColor" red="0.66666666666666663" green="0.36078431372549019" blue="0.76470588235294112" alpha="1" colorSpace="calibratedRGB"/>
|
|
||||||
<color key="thumbTintColor" red="0.66666666666666663" green="0.36078431372549019" blue="0.76470588235294112" alpha="1" colorSpace="calibratedRGB"/>
|
|
||||||
<connections>
|
|
||||||
<action selector="seekSliderEnd:" destination="Y6W-OH-hqX" eventType="touchUpInside" id="m4l-h6-V0d"/>
|
|
||||||
<action selector="seekSliderStart:" destination="Y6W-OH-hqX" eventType="touchDown" id="it4-Bp-hPL"/>
|
|
||||||
<action selector="seekSliderValueChanged:" destination="Y6W-OH-hqX" eventType="valueChanged" id="tfF-Zl-CdU"/>
|
|
||||||
</connections>
|
|
||||||
</slider>
|
|
||||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="-:--:--" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="qft-iu-f1z" userLabel="Time Left Text">
|
|
||||||
<rect key="frame" x="766" y="353" width="91" height="34"/>
|
|
||||||
<constraints>
|
|
||||||
<constraint firstAttribute="width" constant="91" id="LbL-h0-EYA"/>
|
|
||||||
<constraint firstAttribute="height" constant="34" id="OkD-Dr-Ina"/>
|
|
||||||
</constraints>
|
|
||||||
<fontDescription key="fontDescription" type="system" weight="semibold" pointSize="18"/>
|
|
||||||
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
|
||||||
<nil key="highlightedColor"/>
|
|
||||||
</label>
|
|
||||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="-:--:--" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="knf-PP-UIS" userLabel="Time Text">
|
|
||||||
<rect key="frame" x="39" y="353" width="91" height="34"/>
|
|
||||||
<constraints>
|
|
||||||
<constraint firstAttribute="width" constant="91" id="FcP-Mk-OIL"/>
|
|
||||||
<constraint firstAttribute="height" constant="34" id="yXx-PI-kXn"/>
|
|
||||||
</constraints>
|
|
||||||
<fontDescription key="fontDescription" type="system" weight="semibold" pointSize="18"/>
|
|
||||||
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
|
||||||
<nil key="highlightedColor"/>
|
|
||||||
</label>
|
|
||||||
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="t2L-Oz-fe9" userLabel="MainActionButton">
|
|
||||||
<rect key="frame" x="406.66666666666669" y="165.66666666666666" width="83" height="83"/>
|
|
||||||
<constraints>
|
|
||||||
<constraint firstAttribute="width" constant="83" id="PdD-nW-y9r"/>
|
|
||||||
<constraint firstAttribute="height" constant="83" id="e9j-PI-Ic4"/>
|
|
||||||
</constraints>
|
|
||||||
<fontDescription key="fontDescription" type="system" pointSize="18"/>
|
|
||||||
<color key="tintColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
|
||||||
<state key="normal">
|
|
||||||
<imageReference key="image" image="play.slash.fill" catalog="system" symbolScale="default"/>
|
|
||||||
<preferredSymbolConfiguration key="preferredSymbolConfiguration" configurationType="pointSize" pointSize="55" scale="default"/>
|
|
||||||
</state>
|
|
||||||
<connections>
|
|
||||||
<action selector="mainActionButtonPressed:" destination="Y6W-OH-hqX" eventType="touchUpInside" id="qBH-T0-6R4"/>
|
|
||||||
</connections>
|
|
||||||
</button>
|
|
||||||
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="rLx-SN-RHr">
|
|
||||||
<rect key="frame" x="30" y="22" width="60" height="60"/>
|
|
||||||
<constraints>
|
|
||||||
<constraint firstAttribute="height" constant="60" id="jwh-l2-ARL"/>
|
|
||||||
<constraint firstAttribute="width" constant="60" id="rcS-W1-m4V"/>
|
|
||||||
</constraints>
|
|
||||||
<fontDescription key="fontDescription" type="system" pointSize="22"/>
|
|
||||||
<color key="tintColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
|
||||||
<state key="normal" image="chevron.backward" catalog="system">
|
|
||||||
<color key="titleColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
|
||||||
<preferredSymbolConfiguration key="preferredSymbolConfiguration" configurationType="pointSize" pointSize="24"/>
|
|
||||||
</state>
|
|
||||||
<connections>
|
|
||||||
<action selector="exitButtonPressed:" destination="Y6W-OH-hqX" eventType="touchUpInside" id="XHc-OR-kc8"/>
|
|
||||||
</connections>
|
|
||||||
</button>
|
|
||||||
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="bYM-Xp-bZO">
|
|
||||||
<rect key="frame" x="213" y="169" width="75" height="76"/>
|
|
||||||
<constraints>
|
|
||||||
<constraint firstAttribute="height" constant="76" id="5lC-V1-lHH"/>
|
|
||||||
<constraint firstAttribute="width" constant="75" id="IPn-pO-Rxo"/>
|
|
||||||
</constraints>
|
|
||||||
<color key="tintColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
|
||||||
<state key="normal" image="gobackward.15" catalog="system">
|
|
||||||
<preferredSymbolConfiguration key="preferredSymbolConfiguration" configurationType="pointSize" pointSize="35"/>
|
|
||||||
</state>
|
|
||||||
<connections>
|
|
||||||
<action selector="jumpBackTapped:" destination="Y6W-OH-hqX" eventType="touchUpInside" id="4vd-25-cCB"/>
|
|
||||||
</connections>
|
|
||||||
</button>
|
|
||||||
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="An8-jF-FAY">
|
|
||||||
<rect key="frame" x="608" y="169" width="75" height="76"/>
|
|
||||||
<constraints>
|
|
||||||
<constraint firstAttribute="height" constant="76" id="huv-QZ-HSc"/>
|
|
||||||
<constraint firstAttribute="width" constant="75" id="uPN-A8-EV1"/>
|
|
||||||
</constraints>
|
|
||||||
<color key="tintColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
|
||||||
<state key="normal" image="goforward.30" catalog="system">
|
|
||||||
<preferredSymbolConfiguration key="preferredSymbolConfiguration" configurationType="pointSize" pointSize="35"/>
|
|
||||||
</state>
|
|
||||||
<connections>
|
|
||||||
<action selector="jumpForwardTapped:" destination="Y6W-OH-hqX" eventType="touchUpInside" id="I6H-fd-Mn8"/>
|
|
||||||
</connections>
|
|
||||||
</button>
|
|
||||||
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" id="riN-y1-ABZ">
|
|
||||||
<rect key="frame" x="817" y="32" width="40" height="40"/>
|
|
||||||
<autoresizingMask key="autoresizingMask" flexibleMinX="YES" flexibleMaxY="YES"/>
|
|
||||||
<color key="tintColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
|
||||||
<state key="normal" image="gear" catalog="system">
|
|
||||||
<color key="titleColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
|
||||||
<preferredSymbolConfiguration key="preferredSymbolConfiguration" configurationType="pointSize" pointSize="23"/>
|
|
||||||
</state>
|
|
||||||
<connections>
|
|
||||||
<action selector="settingsButtonTapped:" destination="Y6W-OH-hqX" eventType="touchUpInside" id="NeC-px-2TY"/>
|
|
||||||
</connections>
|
|
||||||
</button>
|
|
||||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Loading" textAlignment="center" lineBreakMode="tailTruncation" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="o8N-R1-DhT">
|
|
||||||
<rect key="frame" x="106" y="23.333333333333332" width="684" height="57.666666666666671"/>
|
|
||||||
<accessibility key="accessibilityConfiguration">
|
|
||||||
<accessibilityTraits key="traits" staticText="YES" header="YES"/>
|
|
||||||
</accessibility>
|
|
||||||
<fontDescription key="fontDescription" type="system" weight="semibold" pointSize="19"/>
|
|
||||||
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
|
||||||
<color key="highlightedColor" systemColor="labelColor"/>
|
|
||||||
</label>
|
|
||||||
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" id="XVc-27-uDe" userLabel="Cast Button">
|
|
||||||
<rect key="frame" x="766" y="32" width="40" height="40"/>
|
|
||||||
<autoresizingMask key="autoresizingMask" flexibleMinX="YES" flexibleMaxY="YES"/>
|
|
||||||
<color key="tintColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
|
||||||
<state key="normal">
|
|
||||||
<color key="titleColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
|
||||||
<imageReference key="image" image="CastDisconnected"/>
|
|
||||||
<preferredSymbolConfiguration key="preferredSymbolConfiguration" scale="large"/>
|
|
||||||
</state>
|
|
||||||
<connections>
|
|
||||||
<action selector="castButtonPressed:" destination="Y6W-OH-hqX" eventType="touchUpInside" id="LwK-pi-uQ2"/>
|
|
||||||
</connections>
|
|
||||||
</button>
|
|
||||||
</subviews>
|
|
||||||
<color key="backgroundColor" white="0.0" alpha="0.5954241071428571" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
|
||||||
<gestureRecognizers/>
|
|
||||||
<constraints>
|
|
||||||
<constraint firstItem="t2L-Oz-fe9" firstAttribute="top" secondItem="o8N-R1-DhT" secondAttribute="bottom" constant="84.666666666666657" id="1y1-QQ-N95"/>
|
|
||||||
<constraint firstAttribute="bottom" secondItem="e9f-8l-RdN" secondAttribute="bottom" constant="29" id="231-rB-qDs"/>
|
|
||||||
<constraint firstAttribute="trailing" secondItem="qft-iu-f1z" secondAttribute="trailing" constant="39" id="2Ie-OW-sUL"/>
|
|
||||||
<constraint firstItem="An8-jF-FAY" firstAttribute="leading" secondItem="t2L-Oz-fe9" secondAttribute="trailing" constant="118.5" id="2zE-ul-pOh"/>
|
|
||||||
<constraint firstItem="An8-jF-FAY" firstAttribute="centerY" secondItem="t2L-Oz-fe9" secondAttribute="centerY" id="36i-Q2-D1K"/>
|
|
||||||
<constraint firstItem="t2L-Oz-fe9" firstAttribute="centerX" secondItem="Qcb-Fb-qZl" secondAttribute="centerX" id="3Gw-QD-lQX"/>
|
|
||||||
<constraint firstItem="o8N-R1-DhT" firstAttribute="centerY" secondItem="riN-y1-ABZ" secondAttribute="centerY" id="Hs5-Bc-iPB"/>
|
|
||||||
<constraint firstAttribute="bottom" secondItem="qft-iu-f1z" secondAttribute="bottom" constant="27" id="NPi-py-0qd"/>
|
|
||||||
<constraint firstItem="rLx-SN-RHr" firstAttribute="leading" secondItem="Qcb-Fb-qZl" secondAttribute="leading" constant="30" id="Oe7-LK-6Tl"/>
|
|
||||||
<constraint firstItem="e9f-8l-RdN" firstAttribute="leading" secondItem="knf-PP-UIS" secondAttribute="trailing" constant="5" id="ShK-80-ij1"/>
|
|
||||||
<constraint firstItem="t2L-Oz-fe9" firstAttribute="centerY" secondItem="Qcb-Fb-qZl" secondAttribute="centerY" id="TOk-sG-UXV"/>
|
|
||||||
<constraint firstItem="knf-PP-UIS" firstAttribute="leading" secondItem="Qcb-Fb-qZl" secondAttribute="leading" constant="39" id="XNC-Q4-nE0"/>
|
|
||||||
<constraint firstItem="o8N-R1-DhT" firstAttribute="centerX" secondItem="t2L-Oz-fe9" secondAttribute="centerX" id="a5g-8U-9S5"/>
|
|
||||||
<constraint firstAttribute="bottom" secondItem="qft-iu-f1z" secondAttribute="bottom" constant="27" id="aOB-Uz-cbQ"/>
|
|
||||||
<constraint firstItem="qft-iu-f1z" firstAttribute="leading" secondItem="e9f-8l-RdN" secondAttribute="trailing" constant="5" id="auL-Vv-ZMV"/>
|
|
||||||
<constraint firstItem="bYM-Xp-bZO" firstAttribute="top" secondItem="An8-jF-FAY" secondAttribute="top" id="cVS-eI-vv2"/>
|
|
||||||
<constraint firstItem="t2L-Oz-fe9" firstAttribute="leading" secondItem="bYM-Xp-bZO" secondAttribute="trailing" constant="118.5" id="fci-L5-1f6"/>
|
|
||||||
<constraint firstItem="e9f-8l-RdN" firstAttribute="centerX" secondItem="Qcb-Fb-qZl" secondAttribute="centerX" id="jFy-Sb-aYi"/>
|
|
||||||
<constraint firstAttribute="bottom" secondItem="knf-PP-UIS" secondAttribute="bottom" constant="27" id="nLN-ju-9qC"/>
|
|
||||||
<constraint firstItem="o8N-R1-DhT" firstAttribute="leading" secondItem="rLx-SN-RHr" secondAttribute="trailing" constant="16" id="qnV-Qf-y9m"/>
|
|
||||||
<constraint firstItem="rLx-SN-RHr" firstAttribute="top" secondItem="Qcb-Fb-qZl" secondAttribute="top" constant="22" id="v4G-B1-7y6"/>
|
|
||||||
</constraints>
|
|
||||||
<connections>
|
|
||||||
<outletCollection property="gestureRecognizers" destination="iQW-fW-KWT" appends="YES" id="H09-88-nzQ"/>
|
|
||||||
</connections>
|
|
||||||
</view>
|
|
||||||
<view hidden="YES" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="CY9-gw-dv8" userLabel="UpNextView">
|
|
||||||
<rect key="frame" x="672" y="254" width="224" height="160"/>
|
|
||||||
<color key="backgroundColor" red="0.34509803921568627" green="0.33725490196078434" blue="0.83921568627450982" alpha="0.0" colorSpace="custom" customColorSpace="sRGB"/>
|
|
||||||
<constraints>
|
|
||||||
<constraint firstAttribute="height" constant="160" id="IyL-p4-Y54"/>
|
|
||||||
<constraint firstAttribute="width" constant="224" id="rFU-Nq-Qmj"/>
|
|
||||||
</constraints>
|
|
||||||
</view>
|
|
||||||
</subviews>
|
|
||||||
<viewLayoutGuide key="safeArea" id="zud-b9-RyD"/>
|
|
||||||
<color key="backgroundColor" white="0.0" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
|
||||||
<gestureRecognizers/>
|
|
||||||
<constraints>
|
|
||||||
<constraint firstItem="Qcb-Fb-qZl" firstAttribute="top" secondItem="zud-b9-RyD" secondAttribute="top" id="0rU-S8-2ZG"/>
|
|
||||||
<constraint firstItem="Tsh-rC-BwO" firstAttribute="bottom" secondItem="IQg-r0-AeH" secondAttribute="bottom" id="CLQ-xL-eMg"/>
|
|
||||||
<constraint firstAttribute="trailing" secondItem="CY9-gw-dv8" secondAttribute="trailing" id="GAY-9O-TMP"/>
|
|
||||||
<constraint firstItem="zud-b9-RyD" firstAttribute="trailing" secondItem="Tsh-rC-BwO" secondAttribute="trailing" constant="-13" id="MTY-zG-Jfx"/>
|
|
||||||
<constraint firstAttribute="trailing" secondItem="Qcb-Fb-qZl" secondAttribute="trailing" id="N96-TI-UDZ"/>
|
|
||||||
<constraint firstAttribute="trailing" secondItem="CY9-gw-dv8" secondAttribute="trailing" id="VY1-j7-qK2"/>
|
|
||||||
<constraint firstAttribute="bottom" secondItem="CY9-gw-dv8" secondAttribute="bottom" id="Wtk-gJ-gF4"/>
|
|
||||||
<constraint firstItem="Qcb-Fb-qZl" firstAttribute="leading" secondItem="IQg-r0-AeH" secondAttribute="leading" id="ctC-7w-DiS"/>
|
|
||||||
<constraint firstItem="Tsh-rC-BwO" firstAttribute="leading" secondItem="zud-b9-RyD" secondAttribute="leading" constant="-13" id="cw7-9C-iua"/>
|
|
||||||
<constraint firstItem="Tsh-rC-BwO" firstAttribute="top" secondItem="zud-b9-RyD" secondAttribute="top" id="d4Q-bp-K4m"/>
|
|
||||||
<constraint firstAttribute="bottom" secondItem="Qcb-Fb-qZl" secondAttribute="bottom" id="gmY-zx-4Ed"/>
|
|
||||||
</constraints>
|
|
||||||
</view>
|
|
||||||
<connections>
|
|
||||||
<outlet property="castButton" destination="XVc-27-uDe" id="FII-I9-nHf"/>
|
|
||||||
<outlet property="jumpBackButton" destination="bYM-Xp-bZO" id="K2u-5Q-dkm"/>
|
|
||||||
<outlet property="jumpForwardButton" destination="An8-jF-FAY" id="4hN-YB-yVd"/>
|
|
||||||
<outlet property="mainActionButton" destination="t2L-Oz-fe9" id="nQR-2e-64l"/>
|
|
||||||
<outlet property="playerSettingsButton" destination="riN-y1-ABZ" id="I6r-z9-Jy2"/>
|
|
||||||
<outlet property="seekSlider" destination="e9f-8l-RdN" id="b3H-tn-TPG"/>
|
|
||||||
<outlet property="timeLeftText" destination="qft-iu-f1z" id="cSg-fO-9nF"/>
|
|
||||||
<outlet property="timeText" destination="knf-PP-UIS" id="KhK-BX-rqT"/>
|
|
||||||
<outlet property="titleLabel" destination="o8N-R1-DhT" id="E7D-iU-bMi"/>
|
|
||||||
<outlet property="upNextView" destination="CY9-gw-dv8" id="BP6-bc-6Vk"/>
|
|
||||||
<outlet property="videoContentView" destination="Tsh-rC-BwO" id="5uR-No-wLy"/>
|
|
||||||
<outlet property="videoControlsView" destination="Qcb-Fb-qZl" id="Z1U-Qr-8ND"/>
|
|
||||||
</connections>
|
|
||||||
</viewController>
|
|
||||||
<placeholder placeholderIdentifier="IBFirstResponder" id="Ief-a0-LHa" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
|
|
||||||
<tapGestureRecognizer id="Tag-oM-Uha">
|
|
||||||
<connections>
|
|
||||||
<action selector="contentViewTapped:" destination="Y6W-OH-hqX" id="uq5-EN-60x"/>
|
|
||||||
</connections>
|
|
||||||
</tapGestureRecognizer>
|
|
||||||
<tapGestureRecognizer id="iQW-fW-KWT">
|
|
||||||
<connections>
|
|
||||||
<action selector="controlViewTapped:" destination="Y6W-OH-hqX" id="0lD-A7-3TP"/>
|
|
||||||
</connections>
|
|
||||||
</tapGestureRecognizer>
|
|
||||||
</objects>
|
|
||||||
<point key="canvasLocation" x="129.24107142857142" y="71.014492753623188"/>
|
|
||||||
</scene>
|
|
||||||
</scenes>
|
|
||||||
<resources>
|
|
||||||
<image name="CastDisconnected" width="24" height="24"/>
|
|
||||||
<image name="chevron.backward" catalog="system" width="96" height="128"/>
|
|
||||||
<image name="gear" catalog="system" width="128" height="119"/>
|
|
||||||
<image name="gobackward.15" catalog="system" width="121" height="128"/>
|
|
||||||
<image name="goforward.30" catalog="system" width="121" height="128"/>
|
|
||||||
<image name="play.slash.fill" catalog="system" width="116" height="128"/>
|
|
||||||
<systemColor name="labelColor">
|
|
||||||
<color white="0.0" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
|
||||||
</systemColor>
|
|
||||||
</resources>
|
|
||||||
</document>
|
|
File diff suppressed because it is too large
Load Diff
|
@ -1,93 +0,0 @@
|
||||||
/* JellyfinPlayer/Swiftfin is subject to the terms of the Mozilla Public
|
|
||||||
* License, v2.0. If a copy of the MPL was not distributed with this
|
|
||||||
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
|
||||||
*
|
|
||||||
* Copyright 2021 Aiden Vigue & Jellyfin Contributors
|
|
||||||
*/
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
class VideoPlayerCastDeviceSelectorView: UIViewController {
|
|
||||||
private var contentView: UIHostingController<VideoPlayerCastDeviceSelector>!
|
|
||||||
weak var delegate: PlayerViewController?
|
|
||||||
|
|
||||||
override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
|
|
||||||
.landscape
|
|
||||||
}
|
|
||||||
|
|
||||||
override func viewDidLoad() {
|
|
||||||
super.viewDidLoad()
|
|
||||||
contentView = UIHostingController(rootView: VideoPlayerCastDeviceSelector(delegate: self.delegate ?? PlayerViewController()))
|
|
||||||
self.view.addSubview(contentView.view)
|
|
||||||
contentView.view.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
contentView.view.topAnchor.constraint(equalTo: view.topAnchor).isActive = true
|
|
||||||
contentView.view.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true
|
|
||||||
contentView.view.leftAnchor.constraint(equalTo: view.leftAnchor).isActive = true
|
|
||||||
contentView.view.rightAnchor.constraint(equalTo: view.rightAnchor).isActive = true
|
|
||||||
}
|
|
||||||
|
|
||||||
override func viewWillDisappear(_ animated: Bool) {
|
|
||||||
super.viewWillDisappear(animated)
|
|
||||||
self.delegate?.castPopoverDismissed()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct VideoPlayerCastDeviceSelector: View {
|
|
||||||
weak var delegate: PlayerViewController!
|
|
||||||
|
|
||||||
init(delegate: PlayerViewController) {
|
|
||||||
self.delegate = delegate
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
NavigationView {
|
|
||||||
Group {
|
|
||||||
if !delegate.discoveredCastDevices.isEmpty {
|
|
||||||
List(delegate.discoveredCastDevices, id: \.deviceID) { device in
|
|
||||||
HStack {
|
|
||||||
Text(device.friendlyName!)
|
|
||||||
.font(.subheadline)
|
|
||||||
.fontWeight(.medium)
|
|
||||||
Spacer()
|
|
||||||
Button {
|
|
||||||
delegate.selectedCastDevice = device
|
|
||||||
delegate?.castDeviceChanged()
|
|
||||||
delegate?.castPopoverDismissed()
|
|
||||||
} label: {
|
|
||||||
HStack {
|
|
||||||
L10n.connect.text
|
|
||||||
.font(.caption)
|
|
||||||
.fontWeight(.medium)
|
|
||||||
Image(systemName: "bonjour")
|
|
||||||
.font(.caption)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
L10n.noCastdevicesfound.text
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
.font(.subheadline)
|
|
||||||
.fontWeight(.medium)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
|
||||||
.navigationTitle(L10n.selectCastDestination)
|
|
||||||
.toolbar {
|
|
||||||
ToolbarItemGroup(placement: .navigationBarLeading) {
|
|
||||||
if UIDevice.current.userInterfaceIdiom == .phone {
|
|
||||||
Button {
|
|
||||||
delegate?.castPopoverDismissed()
|
|
||||||
} label: {
|
|
||||||
HStack {
|
|
||||||
Image(systemName: "chevron.left")
|
|
||||||
L10n.back.text.font(.callout)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}.offset(y: UIDevice.current.userInterfaceIdiom == .pad ? 14 : 0)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,89 +0,0 @@
|
||||||
/* JellyfinPlayer/Swiftfin is subject to the terms of the Mozilla Public
|
|
||||||
* License, v2.0. If a copy of the MPL was not distributed with this
|
|
||||||
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
|
||||||
*
|
|
||||||
* Copyright 2021 Aiden Vigue & Jellyfin Contributors
|
|
||||||
*/
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
class VideoPlayerSettingsView: UINavigationController {
|
|
||||||
private var contentView: UIHostingController<VideoPlayerSettings>!
|
|
||||||
weak var playerDelegate: PlayerViewController?
|
|
||||||
|
|
||||||
override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
|
|
||||||
.landscape
|
|
||||||
}
|
|
||||||
|
|
||||||
override func viewDidLoad() {
|
|
||||||
super.viewDidLoad()
|
|
||||||
|
|
||||||
self.viewControllers = [UIHostingController(rootView: VideoPlayerSettings(delegate: self.playerDelegate ?? PlayerViewController()))]
|
|
||||||
}
|
|
||||||
|
|
||||||
override func viewWillDisappear(_ animated: Bool) {
|
|
||||||
super.viewWillDisappear(animated)
|
|
||||||
self.playerDelegate?.settingsPopoverDismissed()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct VideoPlayerSettings: View {
|
|
||||||
weak var delegate: PlayerViewController!
|
|
||||||
@State var captionTrack: Int32 = -99
|
|
||||||
@State var audioTrack: Int32 = -99
|
|
||||||
@State var playbackSpeedSelection: Int = 3
|
|
||||||
|
|
||||||
init(delegate: PlayerViewController) {
|
|
||||||
self.delegate = delegate
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Form {
|
|
||||||
Picker(L10n.closedCaptions, selection: $captionTrack) {
|
|
||||||
ForEach(delegate.subtitleTrackArray, id: \.id) { caption in
|
|
||||||
Text(caption.name).tag(caption.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onChange(of: captionTrack) { track in
|
|
||||||
self.delegate.subtitleTrackChanged(newTrackID: track)
|
|
||||||
}
|
|
||||||
Picker(L10n.audioTrack, selection: $audioTrack) {
|
|
||||||
ForEach(delegate.audioTrackArray, id: \.id) { caption in
|
|
||||||
Text(caption.name).tag(caption.id).lineLimit(1)
|
|
||||||
}
|
|
||||||
}.onChange(of: audioTrack) { track in
|
|
||||||
self.delegate.audioTrackChanged(newTrackID: track)
|
|
||||||
}
|
|
||||||
Picker(L10n.playbackSpeed, selection: $playbackSpeedSelection) {
|
|
||||||
ForEach(delegate.playbackSpeeds.indices, id: \.self) { speedIndex in
|
|
||||||
let speed = delegate.playbackSpeeds[speedIndex]
|
|
||||||
Text("\(String(speed))x").tag(speedIndex)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onChange(of: playbackSpeedSelection, perform: { index in
|
|
||||||
self.delegate.playbackSpeedChanged(index: index)
|
|
||||||
})
|
|
||||||
}.navigationBarTitleDisplayMode(.inline)
|
|
||||||
.navigationTitle(L10n.audioAndCaptions)
|
|
||||||
.toolbar {
|
|
||||||
ToolbarItemGroup(placement: .navigationBarLeading) {
|
|
||||||
if UIDevice.current.userInterfaceIdiom == .phone {
|
|
||||||
Button {
|
|
||||||
self.delegate.settingsPopoverDismissed()
|
|
||||||
} label: {
|
|
||||||
HStack {
|
|
||||||
Image(systemName: "chevron.left")
|
|
||||||
L10n.back.text.font(.callout)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}.offset(y: UIDevice.current.userInterfaceIdiom == .pad ? 14 : 0)
|
|
||||||
.onAppear(perform: {
|
|
||||||
captionTrack = self.delegate.selectedCaptionTrack
|
|
||||||
audioTrack = self.delegate.selectedAudioTrack
|
|
||||||
playbackSpeedSelection = self.delegate.selectedPlaybackSpeedIndex
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,54 +0,0 @@
|
||||||
//
|
|
||||||
/*
|
|
||||||
* SwiftFin is subject to the terms of the Mozilla Public
|
|
||||||
* License, v2.0. If a copy of the MPL was not distributed with this
|
|
||||||
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
|
||||||
*
|
|
||||||
* Copyright 2021 Aiden Vigue & Jellyfin Contributors
|
|
||||||
*/
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import JellyfinAPI
|
|
||||||
|
|
||||||
class UpNextViewModel: ObservableObject {
|
|
||||||
@Published var largeView: Bool = false
|
|
||||||
@Published var item: BaseItemDto?
|
|
||||||
weak var delegate: PlayerViewController?
|
|
||||||
|
|
||||||
func nextUp() {
|
|
||||||
if delegate != nil {
|
|
||||||
delegate?.setPlayerToNextUp()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct VideoUpNextView: View {
|
|
||||||
|
|
||||||
@ObservedObject var viewModel: UpNextViewModel
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Button {
|
|
||||||
viewModel.nextUp()
|
|
||||||
} label: {
|
|
||||||
HStack {
|
|
||||||
VStack {
|
|
||||||
L10n.playNext.text
|
|
||||||
.foregroundColor(.white)
|
|
||||||
.font(.subheadline)
|
|
||||||
.fontWeight(.semibold)
|
|
||||||
Text(viewModel.item?.getEpisodeLocator() ?? "")
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
.font(.caption)
|
|
||||||
}
|
|
||||||
Image(systemName: "play.fill")
|
|
||||||
.foregroundColor(.white)
|
|
||||||
.font(.subheadline)
|
|
||||||
}
|
|
||||||
.frame(width: 120, height: 35)
|
|
||||||
.background(Color.jellyfinPurple)
|
|
||||||
.cornerRadius(10)
|
|
||||||
}.buttonStyle(PlainButtonStyle())
|
|
||||||
.frame(width: 120, height: 35)
|
|
||||||
.shadow(color: .black, radius: 20)
|
|
||||||
}
|
|
||||||
}
|
|
4
Podfile
4
Podfile
|
@ -5,14 +5,14 @@ def shared_pods
|
||||||
end
|
end
|
||||||
|
|
||||||
target 'JellyfinPlayer iOS' do
|
target 'JellyfinPlayer iOS' do
|
||||||
platform :ios, '14.0'
|
platform :ios, '15.0'
|
||||||
shared_pods
|
shared_pods
|
||||||
pod 'google-cast-sdk'
|
pod 'google-cast-sdk'
|
||||||
pod 'MobileVLCKit'
|
pod 'MobileVLCKit'
|
||||||
pod 'SwizzleSwift'
|
pod 'SwizzleSwift'
|
||||||
end
|
end
|
||||||
target 'JellyfinPlayer tvOS' do
|
target 'JellyfinPlayer tvOS' do
|
||||||
platform :tvos, '14.0'
|
platform :tvos, '15.0'
|
||||||
shared_pods
|
shared_pods
|
||||||
pod 'TVVLCKit'
|
pod 'TVVLCKit'
|
||||||
end
|
end
|
||||||
|
|
|
@ -35,8 +35,8 @@ final class ItemCoordinator: NavigationCoordinatable {
|
||||||
ItemCoordinator(item: item)
|
ItemCoordinator(item: item)
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeVideoPlayer(item: BaseItemDto) -> NavigationViewCoordinator<VideoPlayerCoordinator> {
|
func makeVideoPlayer(viewModel: VideoPlayerViewModel) -> NavigationViewCoordinator<VideoPlayerCoordinator> {
|
||||||
NavigationViewCoordinator(VideoPlayerCoordinator(item: item))
|
NavigationViewCoordinator(VideoPlayerCoordinator(viewModel: viewModel))
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder func makeStart() -> some View {
|
@ViewBuilder func makeStart() -> some View {
|
||||||
|
|
|
@ -23,8 +23,9 @@ final class LiveTVChannelsCoordinator: NavigationCoordinatable {
|
||||||
return NavigationViewCoordinator(ItemCoordinator(item: item))
|
return NavigationViewCoordinator(ItemCoordinator(item: item))
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeVideoPlayer(item: BaseItemDto) -> NavigationViewCoordinator<VideoPlayerCoordinator> {
|
func makeVideoPlayer(item: BaseItemDto) -> NavigationViewCoordinator<EmptyViewCoordinator> {
|
||||||
NavigationViewCoordinator(VideoPlayerCoordinator(item: item))
|
// NavigationViewCoordinator(VideoPlayerCoordinator(item: item))
|
||||||
|
NavigationViewCoordinator(EmptyViewCoordinator())
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
|
@ -32,3 +33,14 @@ final class LiveTVChannelsCoordinator: NavigationCoordinatable {
|
||||||
LiveTVChannelsView()
|
LiveTVChannelsView()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final class EmptyViewCoordinator: NavigationCoordinatable {
|
||||||
|
let stack = NavigationStack(initial: \EmptyViewCoordinator.start)
|
||||||
|
|
||||||
|
@Root var start = makeStart
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
func makeStart() -> some View {
|
||||||
|
Text("Empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -19,8 +19,9 @@ final class LiveTVProgramsCoordinator: NavigationCoordinatable {
|
||||||
@Root var start = makeStart
|
@Root var start = makeStart
|
||||||
@Route(.fullScreen) var videoPlayer = makeVideoPlayer
|
@Route(.fullScreen) var videoPlayer = makeVideoPlayer
|
||||||
|
|
||||||
func makeVideoPlayer(item: BaseItemDto) -> NavigationViewCoordinator<VideoPlayerCoordinator> {
|
func makeVideoPlayer(item: BaseItemDto) -> NavigationViewCoordinator<EmptyViewCoordinator> {
|
||||||
NavigationViewCoordinator(VideoPlayerCoordinator(item: item))
|
// NavigationViewCoordinator(VideoPlayerCoordinator(item: item))
|
||||||
|
NavigationViewCoordinator(EmptyViewCoordinator())
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
|
|
|
@ -7,6 +7,8 @@
|
||||||
* Copyright 2021 Aiden Vigue & Jellyfin Contributors
|
* Copyright 2021 Aiden Vigue & Jellyfin Contributors
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import Combine
|
||||||
|
import Defaults
|
||||||
import Foundation
|
import Foundation
|
||||||
import Nuke
|
import Nuke
|
||||||
import Stinsen
|
import Stinsen
|
||||||
|
@ -18,6 +20,8 @@ final class MainCoordinator: NavigationCoordinatable {
|
||||||
|
|
||||||
@Root var mainTab = makeMainTab
|
@Root var mainTab = makeMainTab
|
||||||
@Root var serverList = makeServerList
|
@Root var serverList = makeServerList
|
||||||
|
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
if SessionManager.main.currentLogin != nil {
|
if SessionManager.main.currentLogin != nil {
|
||||||
|
@ -45,6 +49,12 @@ final class MainCoordinator: NavigationCoordinatable {
|
||||||
nc.addObserver(self, selector: #selector(didLogOut), name: SwiftfinNotificationCenter.Keys.didSignOut, object: nil)
|
nc.addObserver(self, selector: #selector(didLogOut), name: SwiftfinNotificationCenter.Keys.didSignOut, object: nil)
|
||||||
nc.addObserver(self, selector: #selector(processDeepLink), name: SwiftfinNotificationCenter.Keys.processDeepLink, object: nil)
|
nc.addObserver(self, selector: #selector(processDeepLink), name: SwiftfinNotificationCenter.Keys.processDeepLink, object: nil)
|
||||||
nc.addObserver(self, selector: #selector(didChangeServerCurrentURI), name: SwiftfinNotificationCenter.Keys.didChangeServerCurrentURI, object: nil)
|
nc.addObserver(self, selector: #selector(didChangeServerCurrentURI), name: SwiftfinNotificationCenter.Keys.didChangeServerCurrentURI, object: nil)
|
||||||
|
|
||||||
|
Defaults.publisher(.appAppearance)
|
||||||
|
.sink { _ in
|
||||||
|
JellyfinPlayerApp.setupAppearance()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func didLogIn() {
|
@objc func didLogIn() {
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
//
|
//
|
||||||
/*
|
/*
|
||||||
* SwiftFin is subject to the terms of the Mozilla Public
|
* SwiftFin is subject to the terms of the Mozilla Public
|
||||||
* License, v2.0. If a copy of the MPL was not distributed with this
|
* 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/.
|
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
@ -18,7 +18,15 @@ final class MainCoordinator: NavigationCoordinatable {
|
||||||
@Root var mainTab = makeMainTab
|
@Root var mainTab = makeMainTab
|
||||||
@Root var serverList = makeServerList
|
@Root var serverList = makeServerList
|
||||||
@Root var liveTV = makeLiveTV
|
@Root var liveTV = makeLiveTV
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
func customize(_ view: AnyView) -> some View {
|
||||||
|
view.background {
|
||||||
|
Color.black
|
||||||
|
.ignoresSafeArea()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
if SessionManager.main.currentLogin != nil {
|
if SessionManager.main.currentLogin != nil {
|
||||||
self.stack = NavigationStack(initial: \MainCoordinator.mainTab)
|
self.stack = NavigationStack(initial: \MainCoordinator.mainTab)
|
||||||
|
@ -52,7 +60,7 @@ final class MainCoordinator: NavigationCoordinatable {
|
||||||
func makeServerList() -> NavigationViewCoordinator<ServerListCoordinator> {
|
func makeServerList() -> NavigationViewCoordinator<ServerListCoordinator> {
|
||||||
NavigationViewCoordinator(ServerListCoordinator())
|
NavigationViewCoordinator(ServerListCoordinator())
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeLiveTV() -> LiveTVTabCoordinator {
|
func makeLiveTV() -> LiveTVTabCoordinator {
|
||||||
LiveTVTabCoordinator()
|
LiveTVTabCoordinator()
|
||||||
}
|
}
|
||||||
|
|
|
@ -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")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -17,13 +17,24 @@ final class SettingsCoordinator: NavigationCoordinatable {
|
||||||
|
|
||||||
@Root var start = makeStart
|
@Root var start = makeStart
|
||||||
@Route(.push) var serverDetail = makeServerDetail
|
@Route(.push) var serverDetail = makeServerDetail
|
||||||
|
@Route(.push) var overlaySettings = makeOverlaySettings
|
||||||
|
@Route(.push) var experimentalSettings = makeExperimentalSettings
|
||||||
|
|
||||||
@ViewBuilder func makeServerDetail() -> some View {
|
@ViewBuilder func makeServerDetail() -> some View {
|
||||||
let viewModel = ServerDetailViewModel(server: SessionManager.main.currentLogin.server)
|
let viewModel = ServerDetailViewModel(server: SessionManager.main.currentLogin.server)
|
||||||
ServerDetailView(viewModel: viewModel)
|
ServerDetailView(viewModel: viewModel)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ViewBuilder func makeOverlaySettings() -> some View {
|
||||||
|
OverlaySettingsView()
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder func makeExperimentalSettings() -> some View {
|
||||||
|
ExperimentalSettingsView()
|
||||||
|
}
|
||||||
|
|
||||||
@ViewBuilder func makeStart() -> some View {
|
@ViewBuilder func makeStart() -> some View {
|
||||||
SettingsView(viewModel: .init())
|
let viewModel = SettingsViewModel(server: SessionManager.main.currentLogin.server, user: SessionManager.main.currentLogin.user)
|
||||||
|
SettingsView(viewModel: viewModel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,6 +7,7 @@
|
||||||
* Copyright 2021 Aiden Vigue & Jellyfin Contributors
|
* Copyright 2021 Aiden Vigue & Jellyfin Contributors
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import Defaults
|
||||||
import Foundation
|
import Foundation
|
||||||
import JellyfinAPI
|
import JellyfinAPI
|
||||||
import Stinsen
|
import Stinsen
|
||||||
|
@ -18,14 +19,20 @@ final class VideoPlayerCoordinator: NavigationCoordinatable {
|
||||||
|
|
||||||
@Root var start = makeStart
|
@Root var start = makeStart
|
||||||
|
|
||||||
let item: BaseItemDto
|
let viewModel: VideoPlayerViewModel
|
||||||
|
|
||||||
init(item: BaseItemDto) {
|
init(viewModel: VideoPlayerViewModel) {
|
||||||
self.item = item
|
self.viewModel = viewModel
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder func makeStart() -> some View {
|
@ViewBuilder func makeStart() -> some View {
|
||||||
VideoPlayerView(item: item)
|
PreferenceUIHostingControllerView {
|
||||||
.ignoresSafeArea()
|
VLCPlayerView(viewModel: self.viewModel)
|
||||||
|
.navigationBarHidden(true)
|
||||||
|
.statusBar(hidden: true)
|
||||||
|
.ignoresSafeArea()
|
||||||
|
.prefersHomeIndicatorAutoHidden(true)
|
||||||
|
.supportedOrientations(UIDevice.current.userInterfaceIdiom == .pad ? .all : .landscape)
|
||||||
|
}.ignoresSafeArea()
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -0,0 +1,34 @@
|
||||||
|
//
|
||||||
|
/*
|
||||||
|
* SwiftFin is subject to the terms of the Mozilla Public
|
||||||
|
* License, v2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*
|
||||||
|
* Copyright 2021 Aiden Vigue & Jellyfin Contributors
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Defaults
|
||||||
|
import Foundation
|
||||||
|
import JellyfinAPI
|
||||||
|
import Stinsen
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
final class VideoPlayerCoordinator: NavigationCoordinatable {
|
||||||
|
|
||||||
|
let stack = NavigationStack(initial: \VideoPlayerCoordinator.start)
|
||||||
|
|
||||||
|
@Root var start = makeStart
|
||||||
|
|
||||||
|
let viewModel: VideoPlayerViewModel
|
||||||
|
|
||||||
|
init(viewModel: VideoPlayerViewModel) {
|
||||||
|
self.viewModel = viewModel
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder func makeStart() -> some View {
|
||||||
|
VLCPlayerView(viewModel: viewModel)
|
||||||
|
.navigationBarHidden(true)
|
||||||
|
.ignoresSafeArea()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,17 @@
|
||||||
|
//
|
||||||
|
/*
|
||||||
|
* 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 UIKit
|
||||||
|
|
||||||
|
extension CGSize {
|
||||||
|
|
||||||
|
static func Circle(radius: CGFloat) -> CGSize {
|
||||||
|
return CGSize(width: radius, height: radius)
|
||||||
|
}
|
||||||
|
}
|
|
@ -11,15 +11,20 @@ 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)
|
||||||
public static let secondarySystemFill = Color(UIColor.gray)
|
public static let secondarySystemFill = Color(UIColor.gray)
|
||||||
public static let tertiarySystemFill = Color(UIColor.black)
|
public static let tertiarySystemFill = Color(UIColor.black)
|
||||||
|
public static let lightGray = Color(UIColor.lightGray)
|
||||||
#else
|
#else
|
||||||
public static let systemFill = Color(UIColor.systemFill)
|
public static let systemFill = Color(UIColor.systemFill)
|
||||||
public static let secondarySystemFill = Color(UIColor.secondarySystemBackground)
|
public static let secondarySystemFill = Color(UIColor.secondarySystemBackground)
|
||||||
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)
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,23 @@
|
||||||
|
//
|
||||||
|
/*
|
||||||
|
* 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 Foundation
|
||||||
|
|
||||||
|
extension Double {
|
||||||
|
|
||||||
|
func subtract(_ other: Double, floor: Double) -> Double {
|
||||||
|
var v = self - other
|
||||||
|
|
||||||
|
if v < floor {
|
||||||
|
v += abs(floor - v)
|
||||||
|
}
|
||||||
|
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
|
@ -7,24 +7,36 @@
|
||||||
* Copyright 2021 Aiden Vigue & Jellyfin Contributors
|
* Copyright 2021 Aiden Vigue & Jellyfin Contributors
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import Defaults
|
||||||
import Foundation
|
import Foundation
|
||||||
import JellyfinAPI
|
import JellyfinAPI
|
||||||
|
|
||||||
// MARK: PortraitImageStackable
|
// MARK: PortraitImageStackable
|
||||||
extension BaseItemDto: PortraitImageStackable {
|
extension BaseItemDto: PortraitImageStackable {
|
||||||
|
public var portraitImageID: String {
|
||||||
|
return id ?? "no id"
|
||||||
|
}
|
||||||
|
|
||||||
public func imageURLContsructor(maxWidth: Int) -> URL {
|
public func imageURLContsructor(maxWidth: Int) -> URL {
|
||||||
return self.getPrimaryImage(maxWidth: maxWidth)
|
switch self.itemType {
|
||||||
|
case .episode:
|
||||||
|
return getSeriesPrimaryImage(maxWidth: maxWidth)
|
||||||
|
default:
|
||||||
|
return self.getPrimaryImage(maxWidth: maxWidth)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public var title: String {
|
public var title: String {
|
||||||
return self.name ?? ""
|
switch self.itemType {
|
||||||
|
case .episode:
|
||||||
|
return self.seriesName ?? self.name ?? ""
|
||||||
|
default:
|
||||||
|
return self.name ?? ""
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public var description: String? {
|
public var subtitle: String? {
|
||||||
switch self.itemType {
|
switch self.itemType {
|
||||||
case .season:
|
|
||||||
guard let productionYear = productionYear else { return nil }
|
|
||||||
return "\(productionYear)"
|
|
||||||
case .episode:
|
case .episode:
|
||||||
return getEpisodeLocator()
|
return getEpisodeLocator()
|
||||||
default:
|
default:
|
||||||
|
@ -41,4 +53,13 @@ extension BaseItemDto: PortraitImageStackable {
|
||||||
let initials = name.split(separator: " ").compactMap({ String($0).first })
|
let initials = name.split(separator: " ").compactMap({ String($0).first })
|
||||||
return String(initials)
|
return String(initials)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public var showTitle: Bool {
|
||||||
|
switch self.itemType {
|
||||||
|
case .episode, .series, .movie:
|
||||||
|
return Defaults[.showPosterLabels]
|
||||||
|
default:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue