Merge pull request #250 from LePips/ios-video-player-refactor

Video Player Refactor and More
This commit is contained in:
aiden 3 2022-01-05 19:59:30 -05:00 committed by GitHub
commit feedcadfc4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
123 changed files with 6665 additions and 3979 deletions

View File

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

View File

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

View File

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

View File

@ -0,0 +1,56 @@
//
/*
* SwiftFin is subject to the terms of the Mozilla Public
* License, v2.0. If a copy of the MPL was not distributed with this
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
*
* Copyright 2021 Aiden Vigue & Jellyfin Contributors
*/
import SwiftUI
import UIKit
struct SFSymbolButton: UIViewRepresentable {
let systemName: String
let action: () -> Void
private let pointSize: CGFloat
init(systemName: String, pointSize: CGFloat = 24, action: @escaping () -> Void) {
self.systemName = systemName
self.action = action
self.pointSize = pointSize
}
func makeUIView(context: Context) -> some UIButton {
var configuration = UIButton.Configuration.plain()
configuration.cornerStyle = .capsule
let buttonAction = UIAction(title: "") { action in
self.action()
}
let button = UIButton(configuration: configuration, primaryAction: buttonAction)
let symbolImageConfig = UIImage.SymbolConfiguration(pointSize: pointSize)
let symbolImage = UIImage(systemName: systemName, withConfiguration: symbolImageConfig)
button.setImage(symbolImage, for: .normal)
return button
}
func updateUIView(_ uiView: UIViewType, context: Context) {
}
}
extension SFSymbolButton: Hashable {
static func == (lhs: SFSymbolButton, rhs: SFSymbolButton) -> Bool {
return lhs.systemName == rhs.systemName
}
func hash(into hasher: inout Hasher) {
hasher.combine(systemName)
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,134 @@
//
/*
* SwiftFin is subject to the terms of the Mozilla Public
* License, v2.0. If a copy of the MPL was not distributed with this
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
*
* Copyright 2021 Aiden Vigue & Jellyfin Contributors
*/
import JellyfinAPI
import SwiftUI
struct 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)
}
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,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))
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 sliders current value.
@IBInspectable
public var value: Float {
get {
return storedValue
}
set {
storedValue = min(maximumValue, newValue)
storedValue = max(minimumValue, storedValue)
var offset = trackView.bounds.width * CGFloat((storedValue - minimumValue) / (maximumValue - minimumValue))
offset = min(trackView.bounds.width, offset)
thumbViewCenterXConstraint.constant = offset
}
}
/// The minimum value of the slider.
@IBInspectable
public var minimumValue: Float = defaultMinimumValue {
didSet {
value = max(value, minimumValue)
}
}
/// The maximum value of the slider.
@IBInspectable
public var maximumValue: Float = defaultMaximumValue {
didSet {
value = min(value, maximumValue)
}
}
/// A Boolean value indicating whether changes in the sliders value generate continuous update events.
@IBInspectable
public var isContinuous: Bool = defaultIsContinuous
/// The color used to tint the default minimum track images.
@IBInspectable
public var minimumTrackTintColor: UIColor? = defaultMininumTrackTintColor {
didSet {
minimumTrackView.backgroundColor = minimumTrackTintColor
}
}
/// The color used to tint the default maximum track images.
@IBInspectable
public var maximumTrackTintColor: UIColor? {
didSet {
maximumTrackView.backgroundColor = maximumTrackTintColor
}
}
/// The color used to tint the default thumb images.
@IBInspectable
public var thumbTintColor: UIColor = defaultThumbTintColor {
didSet {
thumbView.backgroundColor = thumbTintColor
}
}
/// Scale factor applied to the slider when receiving the focus
@IBInspectable
public var focusScaleFactor: CGFloat = defaultFocusScaleFactor {
didSet {
updateStateDependantViews()
}
}
/// Value added or subtracted from the current value on steps left or right updates
public var stepValue: Float = defaultStepValue
/// Damping value for panning gestures
public var panDampingValue: Float = 5
// Size for thumb view
public var thumbSize: CGFloat = 30
public var fineTunningVelocityThreshold: Float = 600
/**
Sets the sliders current value, allowing you to animate the change visually.
- Parameters:
- value: The new value to assign to the value property
- animated: Specify true to animate the change in value; otherwise, specify false to update the sliders appearance immediately. Animations are performed asynchronously and do not block the calling thread.
*/
public func setValue(_ value: Float, animated: Bool) {
self.value = value
stopDeceleratingTimer()
if animated {
UIView.animate(withDuration: animationDuration) {
self.setNeedsLayout()
self.layoutIfNeeded()
}
}
}
/**
Assigns a minimum track image to the specified control states.
- Parameters:
- image: The minimum track image to associate with the specified states.
- state: The control state with which to associate the image.
*/
public func setMinimumTrackImage(_ image: UIImage?, for state: UIControl.State) {
minimumTrackViewImages[state.rawValue] = image
updateStateDependantViews()
}
/**
Assigns a maximum track image to the specified control states.
- Parameters:
- image: The maximum track image to associate with the specified states.
- state: The control state with which to associate the image.
*/
public func setMaximumTrackImage(_ image: UIImage?, for state: UIControl.State) {
maximumTrackViewImages[state.rawValue] = image
updateStateDependantViews()
}
/**
Assigns a thumb image to the specified control states.
- Parameters:
- image: The thumb image to associate with the specified states.
- state: The control state with which to associate the image.
*/
public func setThumbImage(_ image: UIImage?, for state: UIControl.State) {
thumbViewImages[state.rawValue] = image
updateStateDependantViews()
}
/// The minimum track image currently being used to render the slider.
public var currentMinimumTrackImage: UIImage? {
return minimumTrackView.image
}
/// Contains the maximum track image currently being used to render the slider.
public var currentMaximumTrackImage: UIImage? {
return maximumTrackView.image
}
/// The thumb image currently being used to render the slider.
public var currentThumbImage: UIImage? {
return thumbView.image
}
/**
Returns the minimum track image associated with the specified control state.
- Parameters:
- state: The control state whose minimum track image you want to use. Specify a single control state value for this parameter.
- Returns: The minimum track image associated with the specified state, or nil if no image has been set. This method might also return nil if you specify multiple control states in the state parameter. For a description of track images, see Customizing the Sliders Appearance.
*/
public func minimumTrackImage(for state: UIControl.State) -> UIImage? {
return minimumTrackViewImages[state.rawValue]
}
/**
Returns the maximum track image associated with the specified control state.
- Parameters:
- state: The control state whose maximum track image you want to use. Specify a single control state value for this parameter.
- Returns: The maximum track image associated with the specified state, or nil if an appropriate image could not be retrieved. This method might return nil if you specify multiple control states in the state parameter. For a description of track images, see Customizing the Sliders Appearance.
*/
public func maximumTrackImage(for state: UIControl.State) -> UIImage? {
return maximumTrackViewImages[state.rawValue]
}
/**
Returns the thumb image associated with the specified control state.
- Parameters:
- state: The control state whose thumb image you want to use. Specify a single control state value for this parameter.
- Returns: The thumb image associated with the specified state, or nil if an appropriate image could not be retrieved. This method might return nil if you specify multiple control states in the state parameter. For a description of track and thumb images, see Customizing the Sliders Appearance.
*/
public func thumbImage(for state: UIControl.State) -> UIImage? {
return thumbViewImages[state.rawValue]
}
// MARK: - Initializers
/// :nodoc:
// public override init(frame: CGRect) {
// super.init(frame: frame)
// setUpView()
// }
/// :nodoc:
// public required init?(coder aDecoder: NSCoder) {
// super.init(coder: aDecoder)
// setUpView()
// }
// MARK: VideoPlayerVieModel init
init(viewModel: VideoPlayerViewModel) {
self.viewModel = viewModel
super.init(frame: .zero)
setUpView()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
NotificationCenter.default.removeObserver(self)
}
// MARK: - UIControlStates
/// :nodoc:
public override var isEnabled: Bool {
didSet {
panGestureRecognizer.isEnabled = isEnabled
updateStateDependantViews()
}
}
/// :nodoc:
public override var isSelected: Bool {
didSet {
updateStateDependantViews()
}
}
/// :nodoc:
public override var isHighlighted: Bool {
didSet {
updateStateDependantViews()
}
}
/// :nodoc:
public override func didUpdateFocus(in context: UIFocusUpdateContext, with coordinator: UIFocusAnimationCoordinator) {
coordinator.addCoordinatedAnimations({
self.updateStateDependantViews()
}, completion: nil)
}
// MARK: - Private
private let viewModel: VideoPlayerViewModel!
private typealias ControlState = UInt
public var storedValue: Float = defaultValue
private var thumbViewImages: [ControlState: UIImage] = [:]
private var thumbView: UIImageView!
private var trackViewImages: [ControlState: UIImage] = [:]
private var trackView: UIImageView!
private var minimumTrackViewImages: [ControlState: UIImage] = [:]
private var minimumTrackView: UIImageView!
private var maximumTrackViewImages: [ControlState: UIImage] = [:]
private var maximumTrackView: UIImageView!
private var panGestureRecognizer: UIPanGestureRecognizer!
private var leftTapGestureRecognizer: UITapGestureRecognizer!
private var rightTapGestureRecognizer: UITapGestureRecognizer!
private var thumbViewCenterXConstraint: NSLayoutConstraint!
private var dPadState: DPadState = .select
private weak var deceleratingTimer: Timer?
private var deceleratingVelocity: Float = 0
private var thumbViewCenterXConstraintConstant: Float = 0
private func setUpView() {
setUpTrackView()
setUpMinimumTrackView()
setUpMaximumTrackView()
setUpThumbView()
setUpTrackViewConstraints()
setUpMinimumTrackViewConstraints()
setUpMaximumTrackViewConstraints()
setUpThumbViewConstraints()
setUpGestures()
NotificationCenter.default.addObserver(self, selector: #selector(controllerConnected(note:)), name: .GCControllerDidConnect, object: nil)
updateStateDependantViews()
}
private func setUpThumbView() {
thumbView = UIImageView()
thumbView.layer.cornerRadius = thumbSize / 6
thumbView.backgroundColor = thumbTintColor
addSubview(thumbView)
}
private func setUpTrackView() {
trackView = UIImageView()
trackView.layer.cornerRadius = trackViewHeight/2
trackView.backgroundColor = defaultTrackColor.withAlphaComponent(0.3)
addSubview(trackView)
}
private func setUpMinimumTrackView() {
minimumTrackView = UIImageView()
minimumTrackView.layer.cornerRadius = trackViewHeight / 2
minimumTrackView.backgroundColor = minimumTrackTintColor
addSubview(minimumTrackView)
}
private func setUpMaximumTrackView() {
maximumTrackView = UIImageView()
maximumTrackView.layer.cornerRadius = trackViewHeight / 2
maximumTrackView.backgroundColor = maximumTrackTintColor
addSubview(maximumTrackView)
}
private func setUpTrackViewConstraints() {
trackView.translatesAutoresizingMaskIntoConstraints = false
trackView.leadingAnchor.constraint(equalTo: leadingAnchor).isActive = true
trackView.trailingAnchor.constraint(equalTo: trailingAnchor).isActive = true
trackView.centerYAnchor.constraint(equalTo: centerYAnchor).isActive = true
trackView.heightAnchor.constraint(equalToConstant: trackViewHeight).isActive = true
}
private func setUpMinimumTrackViewConstraints() {
minimumTrackView.translatesAutoresizingMaskIntoConstraints = false
minimumTrackView.leadingAnchor.constraint(equalTo: trackView.leadingAnchor).isActive = true
minimumTrackView.trailingAnchor.constraint(equalTo: thumbView.centerXAnchor).isActive = true
minimumTrackView.centerYAnchor.constraint(equalTo:trackView.centerYAnchor).isActive = true
minimumTrackView.heightAnchor.constraint(equalToConstant: trackViewHeight).isActive = true
}
private func setUpMaximumTrackViewConstraints() {
maximumTrackView.translatesAutoresizingMaskIntoConstraints = false
maximumTrackView.leadingAnchor.constraint(equalTo: thumbView.centerXAnchor).isActive = true
maximumTrackView.trailingAnchor.constraint(equalTo: trackView.trailingAnchor).isActive = true
maximumTrackView.centerYAnchor.constraint(equalTo:trackView.centerYAnchor).isActive = true
maximumTrackView.heightAnchor.constraint(equalToConstant: trackViewHeight).isActive = true
}
private func setUpThumbViewConstraints() {
thumbView.translatesAutoresizingMaskIntoConstraints = false
thumbView.centerYAnchor.constraint(equalTo: centerYAnchor).isActive = true
thumbView.widthAnchor.constraint(equalToConstant: thumbSize / 3).isActive = true
thumbView.heightAnchor.constraint(equalToConstant: thumbSize).isActive = true
thumbViewCenterXConstraint = thumbView.centerXAnchor.constraint(equalTo: trackView.leadingAnchor, constant: CGFloat(value))
thumbViewCenterXConstraint.isActive = true
}
private func setUpGestures() {
panGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(panGestureWasTriggered(panGestureRecognizer:)))
addGestureRecognizer(panGestureRecognizer)
leftTapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(leftTapWasTriggered))
leftTapGestureRecognizer.allowedPressTypes = [NSNumber(value: UIPress.PressType.leftArrow.rawValue)]
leftTapGestureRecognizer.allowedTouchTypes = [NSNumber(value: UITouch.TouchType.indirect.rawValue)]
addGestureRecognizer(leftTapGestureRecognizer)
rightTapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(rightTapWasTriggered))
rightTapGestureRecognizer.allowedPressTypes = [NSNumber(value: UIPress.PressType.rightArrow.rawValue)]
rightTapGestureRecognizer.allowedTouchTypes = [NSNumber(value: UITouch.TouchType.indirect.rawValue)]
addGestureRecognizer(rightTapGestureRecognizer)
}
private func updateStateDependantViews() {
thumbView.image = thumbViewImages[state.rawValue] ?? thumbViewImages[UIControl.State.normal.rawValue]
if isFocused {
thumbView.transform = CGAffineTransform(scaleX: focusScaleFactor, y: focusScaleFactor)
}
else {
thumbView.transform = CGAffineTransform.identity
}
}
@objc private func controllerConnected(note: NSNotification) {
guard let controller = note.object as? GCController else { return }
guard let micro = controller.microGamepad else { return }
let threshold: Float = 0.7
micro.reportsAbsoluteDpadValues = true
micro.dpad.valueChangedHandler = {
[weak self] (pad, x, y) in
if x < -threshold {
self?.dPadState = .left
}
else if x > threshold {
self?.dPadState = .right
}
else {
self?.dPadState = .select
}
}
}
@objc
private func handleDeceleratingTimer(timer: Timer) {
let centerX = thumbViewCenterXConstraintConstant + deceleratingVelocity * 0.01
let percent = centerX / Float(trackView.frame.width)
value = minimumValue + ((maximumValue - minimumValue) * percent)
if isContinuous {
sendActions(for: .valueChanged)
}
thumbViewCenterXConstraintConstant = Float(thumbViewCenterXConstraint.constant)
deceleratingVelocity *= decelerationRate
if !isFocused || abs(deceleratingVelocity) < 1 {
stopDeceleratingTimer()
}
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

View File

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

View File

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

View File

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

View File

@ -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",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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" {

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,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)
}
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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() {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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