Merge branch 'main' into R.swift

# Conflicts:
#	JellyfinPlayer.xcodeproj/project.pbxproj
#	JellyfinPlayer.xcworkspace/xcshareddata/swiftpm/Package.resolved
#	Translations/en.lproj/Localizable.strings
This commit is contained in:
PangMo5 2021-11-08 00:51:54 +09:00
commit 646467b8e7
221 changed files with 4499 additions and 2399 deletions

View File

@ -7,15 +7,13 @@
import SwiftUI
import UIKit
@main
struct JellyfinPlayer_tvOSApp: App {
let persistenceController = PersistenceController.shared
var body: some Scene {
WindowGroup {
SplashView()
.environment(\.managedObjectContext, persistenceController.container.viewContext)
.ignoresSafeArea(.all, edges: .all)
MainCoordinator().view()
}
}
}

View File

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "1280x768-back.png",
"idiom" : "tv"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,14 @@
{
"info" : {
"author" : "xcode",
"version" : 1
},
"layers" : [
{
"filename" : "Front.imagestacklayer"
},
{
"filename" : "Back.imagestacklayer"
}
]
}

View File

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "512.png",
"idiom" : "tv"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,18 @@
{
"images" : [
{
"filename" : "400x240-back.png",
"idiom" : "tv",
"scale" : "1x"
},
{
"filename" : "Webp.net-resizeimage.png",
"idiom" : "tv",
"scale" : "2x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,14 @@
{
"info" : {
"author" : "xcode",
"version" : 1
},
"layers" : [
{
"filename" : "Front.imagestacklayer"
},
{
"filename" : "Back.imagestacklayer"
}
]
}

View File

@ -0,0 +1,18 @@
{
"images" : [
{
"filename" : "216.png",
"idiom" : "tv",
"scale" : "1x"
},
{
"filename" : "Webp.net-resizeimage-2.png",
"idiom" : "tv",
"scale" : "2x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,32 @@
{
"assets" : [
{
"filename" : "App Icon - App Store.imagestack",
"idiom" : "tv",
"role" : "primary-app-icon",
"size" : "1280x768"
},
{
"filename" : "App Icon.imagestack",
"idiom" : "tv",
"role" : "primary-app-icon",
"size" : "400x240"
},
{
"filename" : "Top Shelf Image Wide.imageset",
"idiom" : "tv",
"role" : "top-shelf-image-wide",
"size" : "2320x720"
},
{
"filename" : "Top Shelf Image.imageset",
"idiom" : "tv",
"role" : "top-shelf-image",
"size" : "1920x720"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,28 @@
{
"images" : [
{
"filename" : "top shelf.png",
"idiom" : "tv",
"scale" : "1x"
},
{
"filename" : "Untitled-1.png",
"idiom" : "tv",
"scale" : "2x"
},
{
"filename" : "top shelf-1.png",
"idiom" : "tv-marketing",
"scale" : "1x"
},
{
"filename" : "Untitled-2.png",
"idiom" : "tv-marketing",
"scale" : "2x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 355 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 355 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

View File

@ -0,0 +1,28 @@
{
"images" : [
{
"filename" : "top shelf.png",
"idiom" : "tv",
"scale" : "1x"
},
{
"filename" : "Untitled-2.png",
"idiom" : "tv",
"scale" : "2x"
},
{
"filename" : "top shelf-1.png",
"idiom" : "tv-marketing",
"scale" : "1x"
},
{
"filename" : "Untitled-1.png",
"idiom" : "tv-marketing",
"scale" : "2x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

View File

@ -57,6 +57,22 @@ struct PortraitItemElement: View {
.opacity(1), alignment: .topTrailing).opacity(1)
Text(item.title)
.frame(width: 200, height: 30, alignment: .center)
if item.type == "Movie" || item.type == "Series" {
Text("\(String(item.productionYear ?? 0))\(item.officialRating ?? "N/A")")
.foregroundColor(.secondary)
.font(.caption)
.fontWeight(.medium)
} else if item.type == "Season" {
Text("\(item.name ?? "")\(String(item.productionYear ?? 0))")
.foregroundColor(.secondary)
.font(.caption)
.fontWeight(.medium)
} else {
Text("S\(String(item.parentIndexNumber ?? 0)):E\(String(item.indexNumber ?? 0))")
.foregroundColor(.secondary)
.font(.caption)
.fontWeight(.medium)
}
}
.onChange(of: envFocused) { envFocus in
withAnimation(.linear(duration: 0.15)) {

View File

@ -19,7 +19,7 @@ struct PublicUserButton: View {
var body: some View {
VStack {
if publicUser.primaryImageTag != nil {
ImageView(src: URL(string: "\(ServerEnvironment.current.server.baseURI ?? "")/Users/\(publicUser.id ?? "")/Images/Primary?width=500&quality=80&tag=\(publicUser.primaryImageTag!)")!)
ImageView(src: URL(string: "\(SessionManager.main.currentLogin.server.uri)/Users/\(publicUser.id ?? "")/Images/Primary?width=500&quality=80&tag=\(publicUser.primaryImageTag!)")!)
.frame(width: 250, height: 250)
.cornerRadius(125.0)
} else {

View File

@ -1,176 +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 JellyfinAPI
import SwiftUI
struct ConnectToServerView: View {
@StateObject var viewModel = ConnectToServerViewModel()
@State var username = ""
@State var password = ""
@State var uri = ""
var body: some View {
VStack(alignment: .leading) {
if viewModel.isConnectedServer {
if viewModel.publicUsers.isEmpty {
Section(header: Text(viewModel.lastPublicUsers.isEmpty || username == "" ? "Login to \(ServerEnvironment.current.server.name ?? "")": "")) {
if viewModel.lastPublicUsers.isEmpty || username == "" {
TextField(NSLocalizedString("Username", comment: ""), text: $username)
.disableAutocorrection(true)
.autocapitalization(.none)
} else {
HStack {
Spacer()
ImageView(src: URL(string: "\(ServerEnvironment.current.server.baseURI ?? "")/Users/\(viewModel.selectedPublicUser.id ?? "")/Images/Primary?width=500&quality=80&tag=\(viewModel.selectedPublicUser.primaryImageTag ?? "")")!)
.frame(width: 250, height: 250)
.cornerRadius(125.0)
Spacer()
}
}
SecureField(NSLocalizedString("Password", comment: ""), text: $password)
.disableAutocorrection(true)
.autocapitalization(.none)
}
Section {
HStack {
Button {
if !viewModel.lastPublicUsers.isEmpty {
username = ""
viewModel.showPublicUsers()
} else {
viewModel.isConnectedServer = false
}
} label: {
Spacer()
HStack {
Text("Back")
}
Spacer()
}
Button {
viewModel.login()
} label: {
Spacer()
if viewModel.isLoading {
ProgressView()
} else {
Text("Login")
}
Spacer()
}.disabled(viewModel.isLoading || username.isEmpty)
}
}
} else {
VStack {
HStack {
ForEach(viewModel.publicUsers, id: \.id) { publicUser in
Button(action: {
if SessionManager.current.doesUserHaveSavedSession(userID: publicUser.id!) {
let user = SessionManager.current.getSavedSession(userID: publicUser.id!)
SessionManager.current.loginWithSavedSession(user: user)
} else {
username = publicUser.name ?? ""
viewModel.selectedPublicUser = publicUser
viewModel.hidePublicUsers()
if !(publicUser.hasPassword ?? true) {
password = ""
viewModel.login()
}
}
}) {
PublicUserButton(publicUser: publicUser)
}
.buttonStyle(PlainNavigationLinkButtonStyle())
}
}.padding(.bottom, 20)
HStack {
Spacer()
Button {
viewModel.hidePublicUsers()
username = ""
} label: {
Text("Other User").font(.headline).fontWeight(.semibold)
}
Spacer()
}.padding(.top, 12)
}
}
} else {
if !viewModel.isLoading {
Form {
Section(header: Text("Server Information")) {
TextField(NSLocalizedString("Server URL", comment: ""), text: $uri)
.disableAutocorrection(true)
.autocapitalization(.none)
.keyboardType(.URL)
Button {
viewModel.connectToServer()
} label: {
HStack {
Text("Connect")
Spacer()
}
if viewModel.isLoading {
ProgressView()
}
}
.disabled(viewModel.isLoading || uri.isEmpty)
}
Section(header: Text("Local Servers")) {
if self.viewModel.searching {
ProgressView()
}
ForEach(self.viewModel.servers, id: \.id) { server in
Button(action: {
print(server.url)
viewModel.connectToServer(at: server.url)
}, label: {
HStack {
VStack(alignment: .leading) {
Text(server.name)
.font(.headline)
Text(server.host)
.font(.subheadline)
}
Spacer()
Image(systemName: "chevron.forward")
.padding()
}
})
.disabled(viewModel.isLoading)
}
}
.onAppear(perform: self.viewModel.discoverServers)
}
} else {
ProgressView()
}
}
}
.padding(.leading, 90)
.padding(.trailing, 90)
.alert(item: $viewModel.errorMessage) { _ in
Alert(title: Text("Error"), message: Text(viewModel.errorMessage as? String ?? ""), dismissButton: .default(Text("Ok")))
}
.onChange(of: uri) { uri in
viewModel.uriSubject.send(uri)
}
.onChange(of: username) { username in
viewModel.usernameSubject.send(username)
}
.onChange(of: password) { password in
viewModel.passwordSubject.send(password)
}
.navigationTitle(viewModel.isConnectedServer ? NSLocalizedString("Who's watching?", comment: "") : NSLocalizedString("Connect to Jellyfin", comment: ""))
}
}

View File

@ -1,15 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.user-management</key>
<array>
<string>get-current-user</string>
<string>runs-as-current-user</string>
</array>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)me.vigue.jellyfin.sharedKeychain</string>
</array>
</dict>
</plist>

View File

@ -1,5 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict/>
</plist>

View File

@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="1" systemVersion="11A491" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithCloudKit="false" userDefinedModelVersionIdentifier="">
<entity name="Item" representedClassName="Item" syncable="YES" codeGenerationType="class">
<attribute name="timestamp" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
</entity>
<elements>
<element name="Item" positionX="-63" positionY="-18" width="128" height="44"/>
</elements>
</model>

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 SwiftUI
import SwiftUICollection
import JellyfinAPI
struct LibraryView: View {
@StateObject var viewModel: LibraryViewModel
var title: String
// MARK: tracks for grid
var defaultFilters = LibraryFilters(filters: [], sortOrder: [.ascending], withGenres: [], tags: [], sortBy: [.name])
@State var isShowingSearchView = false
@State var isShowingFilterView = false
var body: some View {
if viewModel.isLoading == true {
ProgressView()
} else if !viewModel.items.isEmpty {
CollectionView(rows: viewModel.rows) { _, _ in
let itemSize = NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1),
heightDimension: .fractionalHeight(1)
)
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let groupSize = NSCollectionLayoutSize(
widthDimension: .absolute(200),
heightDimension: .absolute(300)
)
let group = NSCollectionLayoutGroup.horizontal(
layoutSize: groupSize,
subitems: [item]
)
let header =
NSCollectionLayoutBoundarySupplementaryItem(
layoutSize: NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1),
heightDimension: .absolute(44)
),
elementKind: UICollectionView.elementKindSectionHeader,
alignment: .topLeading
)
let section = NSCollectionLayoutSection(group: group)
section.contentInsets = NSDirectionalEdgeInsets(top: 30, leading: 0, bottom: 80, trailing: 80)
section.interGroupSpacing = 48
section.orthogonalScrollingBehavior = .continuous
section.boundarySupplementaryItems = [header]
return section
} cell: { _, cell in
GeometryReader { _ in
if let item = cell.item {
if item.type != "Folder" {
NavigationLink(destination: LazyView { ItemView(item: item) }) {
PortraitItemElement(item: item)
}
.buttonStyle(PlainNavigationLinkButtonStyle())
.onAppear {
if item == viewModel.items.last && viewModel.hasNextPage {
viewModel.requestNextPageAsync()
}
}
}
} else if cell.loadingCell {
ProgressView()
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity, alignment: .center)
}
}
} supplementaryView: { _, indexPath in
HStack {
Spacer()
}.accessibilityIdentifier("\(indexPath.section).\(indexPath.row)")
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.ignoresSafeArea(.all)
} else {
Text("No results.")
}
}
}
// stream BM^S by nicki!
//

View File

@ -1,79 +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 Foundation
import SwiftUI
struct MainTabView: View {
@State private var tabSelection: Tab = .home
@StateObject private var viewModel = MainTabViewModel()
@State private var backdropAnim: Bool = true
@State private var lastBackdropAnim: Bool = false
var body: some View {
ZStack {
// please do not touch my magical crossfading. i will wave my magical github wand and cry
if viewModel.lastBackgroundURL != nil {
ImageView(src: viewModel.lastBackgroundURL!, bh: viewModel.backgroundBlurHash)
.frame(minWidth: 100, maxWidth: .infinity, minHeight: 100, maxHeight: .infinity)
.opacity(lastBackdropAnim ? 0.4 : 0)
.ignoresSafeArea()
}
if viewModel.backgroundURL != nil {
ImageView(src: viewModel.backgroundURL!, bh: viewModel.backgroundBlurHash)
.frame(minWidth: 100, maxWidth: .infinity, minHeight: 100, maxHeight: .infinity)
.opacity(backdropAnim ? 0.4 : 0)
.onChange(of: viewModel.backgroundURL) { _ in
lastBackdropAnim = true
backdropAnim = false
withAnimation(.linear(duration: 0.33)) {
lastBackdropAnim = false
backdropAnim = true
}
}
.ignoresSafeArea()
}
TabView(selection: $tabSelection) {
HomeView()
.offset(y: -1) // don't remove this. it breaks tabview on 4K displays.
.tabItem {
Text("Home")
Image(systemName: "house")
}
.tag(Tab.home)
LibraryListView()
.tabItem {
Text("All Media")
Image(systemName: "folder")
}
.tag(Tab.allMedia)
SettingsView(viewModel: SettingsViewModel())
.offset(y: -1) // don't remove this. it breaks tabview on 4K displays.
.tabItem {
Text("Settings")
Image(systemName: "gear")
}
.tag(Tab.settings)
}
}
}
}
extension MainTabView {
enum Tab: String {
case home
case allMedia
case settings
}
}
// stream ancient dreams in a modern land by MARINA!

View File

@ -1,37 +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
struct PersistenceController {
static let shared = PersistenceController()
let container: NSPersistentContainer
init(inMemory: Bool = false) {
container = NSPersistentContainer(name: "Model")
if inMemory {
container.persistentStoreDescriptions.first!.url = URL(fileURLWithPath: "/dev/null")
}
container.loadPersistentStores(completionHandler: { (_, error) in
if let error = error as NSError? {
// Replace this implementation with code to handle the error appropriately.
// fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
/*
Typical reasons for an error here include:
* The parent directory does not exist, cannot be created, or disallows writing.
* The persistent store is not accessible, due to permissions or data protection when the device is locked.
* The device is out of space.
* The store could not be migrated to the current model version.
Check the error message to determine what the actual problem was.
*/
fatalError("Unresolved error \(error), \(error.userInfo)")
}
})
}
}

View File

@ -1,4 +0,0 @@
# Design Notes
tvos is dumb and how I got around the ScrollViews clipping requires ALL interface elements to have a leading and trailing padding of _~~135~~ something else but i forgot_ pt to align with the original "safe area bounds"

View File

@ -1,29 +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
struct SplashView: View {
@StateObject var viewModel = SplashViewModel()
var body: some View {
Group {
if viewModel.isLoggedIn {
NavigationView {
MainTabView()
}.padding(.all, -1)
} else {
NavigationView {
ConnectToServerView()
}
.navigationViewStyle(StackNavigationViewStyle())
}
}
}
}

View File

@ -0,0 +1,52 @@
//
/*
* 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 Stinsen
import SwiftUI
struct BasicAppSettingsView: View {
@EnvironmentObject var basicAppSettingsRouter: BasicAppSettingsCoordinator.Router
@ObservedObject var viewModel: BasicAppSettingsViewModel
@State var resetTapped: Bool = false
@Default(.appAppearance) var appAppearance
var body: some View {
Form {
Section {
Picker(NSLocalizedString("Appearance", comment: ""), 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
})
} header: {
Text("Accessibility")
}
Button {
resetTapped = true
} label: {
Text("Reset")
}
}
.alert("Reset", isPresented: $resetTapped, actions: {
Button(role: .destructive) {
viewModel.reset()
basicAppSettingsRouter.dismissCoordinator()
} label: {
Text("Reset")
}
})
.navigationTitle("Settings")
}
}

View File

@ -0,0 +1,73 @@
/*
* 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
import Stinsen
struct ConnectToServerView: View {
@StateObject var viewModel = ConnectToServerViewModel()
@State var uri = ""
var body: some View {
List {
Section {
TextField(NSLocalizedString("Server URL", comment: ""), text: $uri)
.disableAutocorrection(true)
.autocapitalization(.none)
.keyboardType(.URL)
Button {
viewModel.connectToServer(uri: uri)
} label: {
HStack {
Text("Connect")
Spacer()
if viewModel.isLoading {
ProgressView()
}
}
}
.disabled(viewModel.isLoading || uri.isEmpty)
} header: {
Text("Connect to a Jellyfin server")
}
Section(header: Text("Local Servers")) {
if viewModel.searching {
ProgressView()
}
ForEach(viewModel.discoveredServers.sorted(by: { $0.name < $1.name }), id: \.id) { discoveredServer in
Button(action: {
viewModel.connectToServer(uri: discoveredServer.url.absoluteString)
}, label: {
HStack {
Text(discoveredServer.name)
.font(.headline)
Text("\(discoveredServer.host)")
.font(.subheadline)
.foregroundColor(.secondary)
Spacer()
if viewModel.isLoading {
ProgressView()
}
}
})
}
}
.onAppear(perform: self.viewModel.discoverServers)
.headerProminence(.increased)
}
.alert(item: $viewModel.errorMessage) { _ in
Alert(title: Text(viewModel.alertTitle),
message: Text(viewModel.errorMessage?.displayMessage ?? "Unknown Error"),
dismissButton: .cancel())
}
.navigationTitle("Connect")
}
}

View File

@ -9,11 +9,14 @@
import SwiftUI
import JellyfinAPI
import Combine
import Stinsen
struct ContinueWatchingView: View {
var items: [BaseItemDto]
@Namespace private var namespace
var homeRouter: HomeCoordinator.Router? = RouterStore.shared.retrieve()
var body: some View {
VStack(alignment: .leading) {
if items.count > 0 {
@ -25,14 +28,16 @@ struct ContinueWatchingView: View {
LazyHStack {
Spacer().frame(width: 45)
ForEach(items, id: \.id) { item in
NavigationLink(destination: LazyView { ItemView(item: item) }) {
Button {
self.homeRouter?.route(to: \.modalItem, item)
} label: {
LandscapeItemElement(item: item)
}
.buttonStyle(PlainNavigationLinkButtonStyle())
}
Spacer().frame(width: 45)
}
}.frame(height: 330)
}.frame(height: 350)
} else {
EmptyView()
}

View File

@ -11,6 +11,7 @@ import Foundation
import SwiftUI
struct HomeView: View {
@EnvironmentObject var homeRouter: HomeCoordinator.Router
@StateObject var viewModel = HomeViewModel()
@State var showingSettings = false
@ -33,9 +34,9 @@ struct HomeView: View {
VStack(alignment: .leading) {
let library = viewModel.libraries.first(where: { $0.id == libraryID })
NavigationLink(destination: LazyView {
LibraryView(viewModel: .init(parentID: libraryID, filters: viewModel.recentFilterSet), title: library?.name ?? "")
}) {
Button {
self.homeRouter.route(to: \.modalLibrary, (.init(parentID: libraryID, filters: viewModel.recentFilterSet), title: library?.name ?? ""))
} label: {
HStack {
Text("Latest \(library?.name ?? "")")
.font(.headline)

View File

@ -9,6 +9,19 @@ import SwiftUI
import Introspect
import JellyfinAPI
// Useless view necessary in tvOS because of iOS's implementation
struct ItemNavigationView: View {
private let item: BaseItemDto
init(item: BaseItemDto) {
self.item = item
}
var body: some View {
ItemView(item: item)
}
}
struct ItemView: View {
private var item: BaseItemDto

View File

@ -27,7 +27,7 @@ struct LatestMediaView: View {
viewDidLoad = true
DispatchQueue.global(qos: .userInitiated).async {
UserLibraryAPI.getLatestMedia(userId: SessionManager.current.user.user_id!, parentId: library_id, fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people], 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
print(completion)
}, receiveValue: { response in
@ -48,7 +48,7 @@ struct LatestMediaView: View {
}
Spacer().frame(width: 45)
}
}.frame(height: 396)
}.frame(height: 480)
.onAppear(perform: onAppear)
}
}

View File

@ -0,0 +1,94 @@
/* 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 JellyfinAPI
import Stinsen
import SwiftUI
struct LibraryFilterView: View {
@EnvironmentObject var filterRouter: FilterCoordinator.Router
@Binding var filters: LibraryFilters
var parentId: String = ""
@StateObject var viewModel: LibraryFilterViewModel
init(filters: Binding<LibraryFilters>, enabledFilterType: [FilterType], parentId: String) {
_filters = filters
self.parentId = parentId
_viewModel =
StateObject(wrappedValue: .init(filters: filters.wrappedValue, enabledFilterType: enabledFilterType, parentId: parentId))
}
var body: some View {
VStack {
if viewModel.isLoading {
ProgressView()
} else {
Form {
if viewModel.enabledFilterType.contains(.genre) {
MultiSelector(label: NSLocalizedString("Genres", comment: ""),
options: viewModel.possibleGenres,
optionToString: { $0.name ?? "" },
selected: $viewModel.modifiedFilters.withGenres)
}
if viewModel.enabledFilterType.contains(.filter) {
MultiSelector(label: NSLocalizedString("Filters", comment: ""),
options: viewModel.possibleItemFilters,
optionToString: { $0.localized },
selected: $viewModel.modifiedFilters.filters)
}
if viewModel.enabledFilterType.contains(.tag) {
MultiSelector(label: NSLocalizedString("Tags", comment: ""),
options: viewModel.possibleTags,
optionToString: { $0 },
selected: $viewModel.modifiedFilters.tags)
}
if viewModel.enabledFilterType.contains(.sortBy) {
Picker(selection: $viewModel.selectedSortBy, label: Text("Sort by")) {
ForEach(viewModel.possibleSortBys, id: \.self) { so in
Text(so.localized).tag(so)
}
}
}
if viewModel.enabledFilterType.contains(.sortOrder) {
Picker(selection: $viewModel.selectedSortOrder, label: Text("Display order")) {
ForEach(viewModel.possibleSortOrders, id: \.self) { so in
Text(so.rawValue).tag(so)
}
}
}
}
Button {
viewModel.resetFilters()
self.filters = viewModel.modifiedFilters
filterRouter.dismissCoordinator()
} label: {
Text("Reset")
}
}
}
.toolbar {
ToolbarItemGroup(placement: .navigationBarLeading) {
Button {
filterRouter.dismissCoordinator()
} label: {
Image(systemName: "xmark")
}
}
ToolbarItemGroup(placement: .navigationBarTrailing) {
Button {
viewModel.updateModifiedFilter()
self.filters = viewModel.modifiedFilters
filterRouter.dismissCoordinator()
} label: {
Text("Apply")
}
}
}
}
}

View File

@ -16,47 +16,11 @@ struct LibraryListView: View {
var body: some View {
ScrollView {
LazyVStack {
NavigationLink(destination: LazyView {
LibraryView(viewModel: .init(filters: viewModel.withFavorites), title: "Favorites")
}) {
ZStack {
HStack {
Spacer()
Text("Your Favorites")
.font(.subheadline)
.fontWeight(.semibold)
Spacer()
}
}
.padding(16)
.frame(minWidth: 100, maxWidth: .infinity)
}
.cornerRadius(10)
.shadow(radius: 5)
.padding(.bottom, 5)
NavigationLink(destination: LazyView {
Text("WIP")
}) {
ZStack {
HStack {
Spacer()
Text("All Genres")
.font(.subheadline)
.fontWeight(.semibold)
Spacer()
}
}
.padding(16)
.frame(minWidth: 100, maxWidth: .infinity)
}
.cornerRadius(10)
.shadow(radius: 5)
.padding(.bottom, 15)
if !viewModel.isLoading {
ForEach(viewModel.libraries, id: \.id) { library in
if library.collectionType ?? "" == "movies" || library.collectionType ?? "" == "tvshows" {
if library.collectionType ?? "" == "movies" || library.collectionType ?? "" == "tvshows" || library.collectionType ?? "" == "music" {
EmptyView()
} else {
NavigationLink(destination: LazyView {
LibraryView(viewModel: .init(parentID: library.id), title: library.name ?? "")
}) {
@ -80,8 +44,6 @@ struct LibraryListView: View {
.cornerRadius(10)
.shadow(radius: 5)
.padding(.bottom, 5)
} else {
EmptyView()
}
}
} else {
@ -91,15 +53,5 @@ struct LibraryListView: View {
.padding(.trailing, 16)
.padding(.top, 8)
}
.navigationTitle(NSLocalizedString("All Media", comment: ""))
.toolbar {
ToolbarItemGroup(placement: .navigationBarTrailing) {
NavigationLink(destination: LazyView {
LibrarySearchView(viewModel: .init(parentID: nil))
}) {
Image(systemName: "magnifyingglass")
}
}
}
}
}

View File

@ -0,0 +1,101 @@
/*
* 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
import SwiftUICollection
import JellyfinAPI
struct LibraryView: View {
@EnvironmentObject var libraryRouter: LibraryCoordinator.Router
@StateObject var viewModel: LibraryViewModel
var title: String
// MARK: tracks for grid
var defaultFilters = LibraryFilters(filters: [], sortOrder: [.ascending], withGenres: [], tags: [], sortBy: [.name])
@State var isShowingSearchView = false
@State var isShowingFilterView = false
var body: some View {
if viewModel.isLoading == true {
ProgressView()
} else if !viewModel.rows.isEmpty {
CollectionView(rows: viewModel.rows) { _, _ in
let itemSize = NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1),
heightDimension: .fractionalHeight(1)
)
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let groupSize = NSCollectionLayoutSize(
widthDimension: .absolute(200),
heightDimension: .absolute(300)
)
let group = NSCollectionLayoutGroup.horizontal(
layoutSize: groupSize,
subitems: [item]
)
let header =
NSCollectionLayoutBoundarySupplementaryItem(
layoutSize: NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1),
heightDimension: .absolute(44)
),
elementKind: UICollectionView.elementKindSectionHeader,
alignment: .topLeading
)
let section = NSCollectionLayoutSection(group: group)
section.contentInsets = NSDirectionalEdgeInsets(top: 30, leading: 0, bottom: 80, trailing: 80)
section.interGroupSpacing = 48
section.orthogonalScrollingBehavior = .continuous
section.boundarySupplementaryItems = [header]
return section
} cell: { _, cell in
GeometryReader { _ in
if let item = cell.item {
if item.type != "Folder" {
Button {
libraryRouter.route(to: \.modalItem, item)
} label: {
PortraitItemElement(item: item)
}
.buttonStyle(PlainNavigationLinkButtonStyle())
.onAppear {
if item == viewModel.items.last && viewModel.hasNextPage {
viewModel.requestNextPageAsync()
}
}
}
} else if cell.loadingCell {
ProgressView()
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity, alignment: .center)
}
}
} supplementaryView: { _, indexPath in
HStack {
Spacer()
}.accessibilityIdentifier("\(indexPath.section).\(indexPath.row)")
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.ignoresSafeArea(.all)
} else {
VStack {
Text("No results.")
Button { } label: {
Text("Reload")
}
}
}
}
}
// stream BM^S by nicki!
//

View File

@ -0,0 +1,89 @@
/*
* 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
import SwiftUICollection
import JellyfinAPI
struct MovieLibrariesView: View {
@EnvironmentObject var movieLibrariesRouter: MovieLibrariesCoordinator.Router
@StateObject var viewModel: MovieLibrariesViewModel
var title: String
var body: some View {
if viewModel.isLoading == true {
ProgressView()
} else if !viewModel.rows.isEmpty {
CollectionView(rows: viewModel.rows) { _, _ in
let itemSize = NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1),
heightDimension: .fractionalHeight(1)
)
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let groupSize = NSCollectionLayoutSize(
widthDimension: .absolute(200),
heightDimension: .absolute(300)
)
let group = NSCollectionLayoutGroup.horizontal(
layoutSize: groupSize,
subitems: [item]
)
let header =
NSCollectionLayoutBoundarySupplementaryItem(
layoutSize: NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1),
heightDimension: .absolute(44)
),
elementKind: UICollectionView.elementKindSectionHeader,
alignment: .topLeading
)
let section = NSCollectionLayoutSection(group: group)
section.contentInsets = NSDirectionalEdgeInsets(top: 30, leading: 0, bottom: 80, trailing: 80)
section.interGroupSpacing = 48
section.orthogonalScrollingBehavior = .continuous
section.boundarySupplementaryItems = [header]
return section
} cell: { _, cell in
GeometryReader { _ in
if let item = cell.item {
if item.type != "Folder" {
Button {
self.movieLibrariesRouter.route(to: \.library, item)
} label: {
PortraitItemElement(item: item)
}
.buttonStyle(PlainNavigationLinkButtonStyle())
}
} else if cell.loadingCell {
ProgressView()
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity, alignment: .center)
}
}
} supplementaryView: { _, indexPath in
HStack {
Spacer()
}.accessibilityIdentifier("\(indexPath.section).\(indexPath.row)")
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.ignoresSafeArea(.all)
} else {
VStack {
Text("No results.")
Button {
print("movieLibraries reload")
} label: {
Text("Reload")
}
}
}
}
}

View File

@ -9,9 +9,12 @@
import SwiftUI
import JellyfinAPI
import Combine
import Stinsen
struct NextUpView: View {
var items: [BaseItemDto]
var homeRouter: HomeCoordinator.Router? = RouterStore.shared.retrieve()
var body: some View {
VStack(alignment: .leading) {
@ -24,13 +27,15 @@ struct NextUpView: View {
LazyHStack {
Spacer().frame(width: 45)
ForEach(items, id: \.id) { item in
NavigationLink(destination: LazyView { ItemView(item: item) }) {
Button {
self.homeRouter?.route(to: \.modalItem, item)
} label: {
LandscapeItemElement(item: item)
}.buttonStyle(PlainNavigationLinkButtonStyle())
}
Spacer().frame(width: 45)
}
}.frame(height: 330)
}.frame(height: 350)
.offset(y: -10)
} else {
EmptyView()

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
struct ServerDetailView: View {
@ObservedObject var viewModel = ServerDetailViewModel()
var body: some View {
Form {
Section(header: Text("Server Details")) {
HStack {
Text("Name")
Spacer()
Text(SessionManager.main.currentLogin.server.name)
.foregroundColor(.secondary)
}
HStack {
Text("URI")
Spacer()
Text(SessionManager.main.currentLogin.server.uri)
.foregroundColor(.secondary)
}
HStack {
Text("Version")
Spacer()
Text(SessionManager.main.currentLogin.server.version)
.foregroundColor(.secondary)
}
HStack {
Text("Operating System")
Spacer()
Text(SessionManager.main.currentLogin.server.os)
.foregroundColor(.secondary)
}
}
Button(action: {
viewModel.refreshServerLibrary()
}, label: {
HStack {
Text("Refresh Library")
.font(.callout)
Spacer()
if viewModel.isLoading {
ProgressView()
}
}
}).disabled(viewModel.isLoading)
}
}
}

View File

@ -0,0 +1,125 @@
//
/*
* 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 CoreStore
import SwiftUI
struct ServerListView: View {
@EnvironmentObject var serverListRouter: ServerListCoordinator.Router
@ObservedObject var viewModel: ServerListViewModel
@ViewBuilder
private var listView: some View {
ScrollView {
LazyVStack {
ForEach(viewModel.servers, id: \.id) { server in
Button {
serverListRouter.route(to: \.userList, server)
} label: {
HStack {
Image(systemName: "server.rack")
.font(.system(size: 72))
.foregroundColor(.primary)
VStack(alignment: .leading, spacing: 5) {
Text(server.name)
.font(.title2)
.foregroundColor(.primary)
Text(server.uri)
.font(.footnote)
.disabled(true)
.foregroundColor(.secondary)
Text(viewModel.userTextFor(server: server))
.font(.footnote)
.foregroundColor(.primary)
}
Spacer()
}
}
.padding(.horizontal, 100)
.contextMenu {
Button(role: .destructive) {
viewModel.remove(server: server)
} label: {
Label("Remove", systemImage: "trash")
}
}
}
}
.padding(.top, 50)
}
.padding(.top, 50)
}
@ViewBuilder
private var noServerView: some View {
VStack {
Text("Connect to a Jellyfin server to get started")
.frame(minWidth: 50, maxWidth: 500)
.multilineTextAlignment(.center)
.font(.callout)
Button {
serverListRouter.route(to: \.connectToServer)
} label: {
Text("Connect")
.bold()
.font(.callout)
}
.padding(.top, 40)
}
}
@ViewBuilder
private var innerBody: some View {
if viewModel.servers.isEmpty {
noServerView
.offset(y: -50)
} else {
listView
}
}
@ViewBuilder
private var trailingToolbarContent: some View {
if viewModel.servers.isEmpty {
EmptyView()
} else {
Button {
serverListRouter.route(to: \.connectToServer)
} label: {
Image(systemName: "plus.circle.fill")
}
.contextMenu {
Button {
serverListRouter.route(to: \.basicAppSettings)
} label: {
Text("Settings")
}
}
}
}
var body: some View {
innerBody
.navigationTitle("Servers")
.toolbar {
ToolbarItemGroup(placement: .navigationBarTrailing) {
trailingToolbarContent
}
}
.onAppear {
viewModel.fetchServers()
}
}
}

View File

@ -8,10 +8,10 @@
import CoreData
import SwiftUI
import Defaults
import JellyfinAPI
struct SettingsView: View {
@Environment(\.managedObjectContext) private var viewContext
@ObservedObject var viewModel: SettingsViewModel
@Default(.inNetworkBandwidth) var inNetworkStreamBitrate
@ -19,11 +19,6 @@ struct SettingsView: View {
@Default(.isAutoSelectSubtitles) var isAutoSelectSubtitles
@Default(.autoSelectSubtitlesLangCode) var autoSelectSubtitlesLangcode
@Default(.autoSelectAudioLangCode) var autoSelectAudioLangcode
@State private var username: String = ""
func onAppear() {
username = SessionManager.current.user?.username ?? ""
}
var body: some View {
Form {
@ -61,30 +56,23 @@ struct SettingsView: View {
)
}
Section(header: Text(ServerEnvironment.current.server.name ?? "")) {
Section(header: Text(SessionManager.main.currentLogin.server.name)) {
HStack {
Text("Signed in as \(username)").foregroundColor(.primary)
Text("Signed in as \(SessionManager.main.currentLogin.user.username)").foregroundColor(.primary)
Spacer()
Button {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
let nc = NotificationCenter.default
nc.post(name: Notification.Name("didSignOut"), object: nil)
}
SwiftfinNotificationCenter.main.post(name: SwiftfinNotificationCenter.Keys.didSignOut, object: nil)
} label: {
Text("Switch user").font(.callout)
}
}
Button {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
SessionManager.current.logout()
let nc = NotificationCenter.default
nc.post(name: Notification.Name("didSignOut"), object: nil)
}
SessionManager.main.logout()
} label: {
Text("Sign out").font(.callout)
}
}
}.onAppear(perform: onAppear)
}
.padding(.leading, 90)
.padding(.trailing, 90)
}

View File

@ -0,0 +1,89 @@
/*
* 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
import SwiftUICollection
import JellyfinAPI
struct TVLibrariesView: View {
@EnvironmentObject var tvLibrariesRouter: TVLibrariesCoordinator.Router
@StateObject var viewModel: TVLibrariesViewModel
var title: String
var body: some View {
if viewModel.isLoading == true {
ProgressView()
} else if !viewModel.rows.isEmpty {
CollectionView(rows: viewModel.rows) { _, _ in
let itemSize = NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1),
heightDimension: .fractionalHeight(1)
)
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let groupSize = NSCollectionLayoutSize(
widthDimension: .absolute(200),
heightDimension: .absolute(300)
)
let group = NSCollectionLayoutGroup.horizontal(
layoutSize: groupSize,
subitems: [item]
)
let header =
NSCollectionLayoutBoundarySupplementaryItem(
layoutSize: NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1),
heightDimension: .absolute(44)
),
elementKind: UICollectionView.elementKindSectionHeader,
alignment: .topLeading
)
let section = NSCollectionLayoutSection(group: group)
section.contentInsets = NSDirectionalEdgeInsets(top: 30, leading: 0, bottom: 80, trailing: 80)
section.interGroupSpacing = 48
section.orthogonalScrollingBehavior = .continuous
section.boundarySupplementaryItems = [header]
return section
} cell: { _, cell in
GeometryReader { _ in
if let item = cell.item {
if item.type != "Folder" {
Button {
self.tvLibrariesRouter.route(to: \.library, item)
} label: {
PortraitItemElement(item: item)
}
.buttonStyle(PlainNavigationLinkButtonStyle())
}
} else if cell.loadingCell {
ProgressView()
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity, alignment: .center)
}
}
} supplementaryView: { _, indexPath in
HStack {
Spacer()
}.accessibilityIdentifier("\(indexPath.section).\(indexPath.row)")
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.ignoresSafeArea(.all)
} else {
VStack {
Text("No results.")
Button {
print("tvLibraries reload")
} label: {
Text("Reload")
}
}
}
}
}

View File

@ -0,0 +1,107 @@
//
/*
* 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 UserListView: View {
@EnvironmentObject var userListRouter: UserListCoordinator.Router
@ObservedObject var viewModel: UserListViewModel
@ViewBuilder
private var listView: some View {
ScrollView {
LazyVStack {
ForEach(viewModel.users, id: \.id) { user in
Button {
viewModel.login(user: user)
} label: {
HStack {
Text(user.username)
.font(.title2)
Spacer()
if viewModel.isLoading {
ProgressView()
}
}
}
.padding(.horizontal, 100)
.contextMenu {
Button(role: .destructive) {
viewModel.remove(user: user)
} label: {
Label("Remove", systemImage: "trash")
}
}
}
}
.padding(.top, 50)
}
.padding(.top, 50)
}
@ViewBuilder
private var noUserView: some View {
VStack {
Text("Sign in to get started")
.frame(minWidth: 50, maxWidth: 500)
.multilineTextAlignment(.center)
.font(.callout)
Button {
userListRouter.route(to: \.userSignIn, viewModel.server)
} label: {
Text("Sign in")
.bold()
.font(.callout)
}
.padding(.top, 40)
}
}
@ViewBuilder
private var innerBody: some View {
if viewModel.users.isEmpty {
noUserView
.offset(y: -50)
} else {
listView
}
}
@ViewBuilder
private var toolbarContent: some View {
if viewModel.users.isEmpty {
EmptyView()
} else {
HStack {
Button {
userListRouter.route(to: \.userSignIn, viewModel.server)
} label: {
Image(systemName: "person.crop.circle.fill.badge.plus")
}
}
}
}
var body: some View {
innerBody
.navigationTitle(viewModel.server.name)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
toolbarContent
}
}
.onAppear {
viewModel.fetchUsers()
}
}
}

View File

@ -0,0 +1,55 @@
//
/*
* 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 Stinsen
struct UserSignInView: View {
@ObservedObject var viewModel: UserSignInViewModel
@State private var username: String = ""
@State private var password: String = ""
var body: some View {
Form {
Section {
TextField("Username", text: $username)
.disableAutocorrection(true)
.autocapitalization(.none)
SecureField("Password", text: $password)
.disableAutocorrection(true)
.autocapitalization(.none)
Button {
viewModel.login(username: username, password: password)
} label: {
HStack {
Text("Connect")
Spacer()
if viewModel.isLoading {
ProgressView()
}
}
}
.disabled(viewModel.isLoading || username.isEmpty)
} header: {
Text("Sign In to \(viewModel.server.name)")
}
}
.alert(item: $viewModel.errorMessage) { _ in
Alert(title: Text(viewModel.alertTitle),
message: Text(viewModel.errorMessage?.displayMessage ?? "Unknown Error"),
dismissButton: .cancel())
}
.navigationTitle("Sign In")
}
}

View File

@ -138,15 +138,13 @@ class VideoPlayerViewController: UIViewController, VideoPlayerSettingsDelegate,
let builder = DeviceProfileBuilder()
builder.setMaxBitrate(bitrate: maxBitrate)
let profile = builder.buildProfile()
let currentUser = SessionManager.main.currentLogin.user
guard let currentUser = SessionManager.current.user else {
return
}
let playbackInfo = PlaybackInfoDto(userId: currentUser.user_id ?? "", maxStreamingBitrate: Int(maxBitrate), startTimeTicks: manifest.userData?.playbackPositionTicks ?? 0, deviceProfile: profile, autoOpenLiveStream: true)
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.user_id ?? "", maxStreamingBitrate: Int(maxBitrate), startTimeTicks: manifest.userData?.playbackPositionTicks ?? 0, autoOpenLiveStream: true, playbackInfoDto: playbackInfo)
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
@ -166,12 +164,13 @@ class VideoPlayerViewController: UIViewController, VideoPlayerSettingsDelegate,
// Item is being transcoded by request of server
if let transcodiungUrl = mediaSource.transcodingUrl {
item.videoType = .transcode
streamURL = URL(string: "\(ServerEnvironment.current.server.baseURI!)\(transcodiungUrl)")!
streamURL = URL(string: "\(SessionManager.main.currentLogin.server.uri)\(transcodiungUrl)")!
}
// Item will be directly played by the client
else {
item.videoType = .directPlay
streamURL = URL(string: "\(ServerEnvironment.current.server.baseURI!)/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.uri)/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.uri)/Videos/\(manifest.id!)/stream?Static=true&mediaSourceId=\(manifest.id!)&Tag=\(mediaSource.eTag ?? "")")!
}
item.videoUrl = streamURL
@ -186,7 +185,7 @@ class VideoPlayerViewController: UIViewController, VideoPlayerSettingsDelegate,
var deliveryUrl: URL?
if stream.deliveryMethod == .external {
deliveryUrl = URL(string: "\(ServerEnvironment.current.server.baseURI!)\(stream.deliveryUrl!)")!
deliveryUrl = URL(string: "\(SessionManager.main.currentLogin.server.uri)\(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 ?? "")

File diff suppressed because it is too large Load Diff

View File

@ -20,39 +20,30 @@
}
},
{
"package": "combine-schedulers",
"repositoryURL": "https://github.com/pointfreeco/combine-schedulers",
"package": "CombineExt",
"repositoryURL": "https://github.com/CombineCommunity/CombineExt",
"state": {
"branch": null,
"revision": "4cf088c29a20f52be0f2ca54992b492c54e0076b",
"version": "0.5.3"
"revision": "0880829102152185190064fd17847a7c681d2127",
"version": "1.5.1"
}
},
{
"package": "CombineExt",
"repositoryURL": "https://github.com/acvigue/CombineExt",
"package": "CoreStore",
"repositoryURL": "https://github.com/JohnEstropia/CoreStore.git",
"state": {
"branch": "main",
"revision": "f629c5b052d1cb5d03e10890deccc50e4c649e68",
"version": null
"branch": null,
"revision": "496145761ab30e8cf1c44220c0882b95e6b41077",
"version": "8.1.0"
}
},
{
"package": "Defaults",
"repositoryURL": "https://github.com/acvigue/Defaults",
"state": {
"branch": "main",
"revision": "a4153b523ab3df9f5e3f70e9cfe9c54bed98c7e3",
"version": null
}
},
{
"package": "Gifu",
"repositoryURL": "https://github.com/kaishin/Gifu",
"repositoryURL": "https://github.com/sindresorhus/Defaults",
"state": {
"branch": null,
"revision": "51f2eab32903e336f590c013267cfa4d7f8b06c4",
"version": "3.3.1"
"revision": "55f3302c3ab30a8760f10042d0ebc0a6907f865a",
"version": "6.1.0"
}
},
{
@ -64,31 +55,13 @@
"version": null
}
},
{
"package": "KeychainSwift",
"repositoryURL": "https://github.com/evgenyneu/keychain-swift",
"state": {
"branch": null,
"revision": "96fb84f45a96630e7583903bd7e08cf095c7a7ef",
"version": "19.0.0"
}
},
{
"package": "Nuke",
"repositoryURL": "https://github.com/kean/Nuke.git",
"repositoryURL": "https://github.com/kean/Nuke",
"state": {
"branch": null,
"revision": "0db18dd34998cca18e9a28bcee136f84518007a0",
"version": "10.4.1"
}
},
{
"package": "NukeUI",
"repositoryURL": "https://github.com/kean/NukeUI",
"state": {
"branch": null,
"revision": "d2580b8d22b29c6244418d8e4b568f3162191460",
"version": "0.3.0"
"revision": "7f73ceaeacd5df75a7994cd82e165ad9ff1815db",
"version": "9.6.1"
}
},
{
@ -145,31 +118,13 @@
"version": null
}
},
{
"package": "SwiftUIFocusGuide",
"repositoryURL": "https://github.com/rmnblm/SwiftUIFocusGuide",
"state": {
"branch": null,
"revision": "fb8eefaccb2954efedc19a5539241f370baa4a10",
"version": "0.1.0"
}
},
{
"package": "SwiftyJSON",
"repositoryURL": "https://github.com/SwiftyJSON/SwiftyJSON",
"state": {
"branch": "master",
"revision": "b3dcd7dbd0d488e1a7077cb33b00f2083e382f07",
"version": null
}
},
{
"package": "xctest-dynamic-overlay",
"repositoryURL": "https://github.com/pointfreeco/xctest-dynamic-overlay",
"state": {
"branch": null,
"revision": "50a70a9d3583fe228ce672e8923010c8df2deddd",
"version": "0.2.1"
"revision": "b3dcd7dbd0d488e1a7077cb33b00f2083e382f07",
"version": "5.0.1"
}
}
]

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 SwiftUI
import UIKit
class AppDelegate: NSObject, UIApplicationDelegate {
static var orientationLock = UIInterfaceOrientationMask.all
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
// Lazily initialize datastack
let _ = SwiftfinStore.dataStack
return true
}
func application(_ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask {
AppDelegate.orientationLock
}
}

View File

@ -0,0 +1,82 @@
//
/*
* 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

@ -0,0 +1,63 @@
/* 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 Defaults
import MessageUI
import Stinsen
import SwiftUI
// MARK: JellyfinPlayerApp
@main
struct JellyfinPlayerApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
@Default(.appAppearance) var appAppearance
var body: some Scene {
WindowGroup {
EmptyView()
.ignoresSafeArea()
.onAppear {
setupAppearance()
}
.withHostingWindow { window in
window?.rootViewController = PreferenceUIHostingController(wrappedView: MainCoordinator().view())
}
.onShake {
EmailHelper.shared.sendLogs(logURL: LogManager.shared.logFileURL())
}
.onOpenURL { url in
AppURLHandler.shared.processDeepLink(url: url)
}
}
}
private func setupAppearance() {
UIApplication.shared.windows.first?.overrideUserInterfaceStyle = appAppearance.style
}
}
// MARK: Hosting Window
struct HostingWindowFinder: UIViewRepresentable {
var callback: (UIWindow?) -> Void
func makeUIView(context: Context) -> UIView {
let view = UIView()
DispatchQueue.main.async { [weak view] in
callback(view?.window)
}
return view
}
func updateUIView(_ uiView: UIView, context: Context) {}
}
extension View {
func withHostingWindow(_ callback: @escaping (UIWindow?) -> Void) -> some View {
background(HostingWindowFinder(callback: callback))
}
}

View File

@ -0,0 +1,118 @@
//
/*
* 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
// MARK: PreferenceUIHostingController
class PreferenceUIHostingController: UIHostingController<AnyView> {
init<V: View>(wrappedView: V) {
let box = Box()
super.init(rootView: AnyView(wrappedView
.onPreferenceChange(PrefersHomeIndicatorAutoHiddenPreferenceKey.self) {
box.value?._prefersHomeIndicatorAutoHidden = $0
}.onPreferenceChange(SupportedOrientationsPreferenceKey.self) {
box.value?._orientations = $0
}.onPreferenceChange(ViewPreferenceKey.self) {
box.value?._viewPreference = $0
}))
box.value = self
}
@objc dynamic required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
super.modalPresentationStyle = .fullScreen
}
private class Box {
weak var value: PreferenceUIHostingController?
init() {}
}
// MARK: Prefers Home Indicator Auto Hidden
public var _prefersHomeIndicatorAutoHidden = false {
didSet { setNeedsUpdateOfHomeIndicatorAutoHidden() }
}
override var prefersHomeIndicatorAutoHidden: Bool {
_prefersHomeIndicatorAutoHidden
}
// MARK: Lock orientation
public var _orientations: UIInterfaceOrientationMask = .allButUpsideDown {
didSet {
if _orientations == .landscape {
let value = UIInterfaceOrientation.landscapeRight.rawValue
UIDevice.current.setValue(value, forKey: "orientation")
UIViewController.attemptRotationToDeviceOrientation()
}
}
}
override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
_orientations
}
public var _viewPreference: UIUserInterfaceStyle = .unspecified {
didSet {
overrideUserInterfaceStyle = _viewPreference
}
}
}
// MARK: Preference Keys
struct PrefersHomeIndicatorAutoHiddenPreferenceKey: PreferenceKey {
typealias Value = Bool
static var defaultValue: Value = false
static func reduce(value: inout Value, nextValue: () -> Value) {
value = nextValue() || value
}
}
struct ViewPreferenceKey: PreferenceKey {
typealias Value = UIUserInterfaceStyle
static var defaultValue: UIUserInterfaceStyle = .unspecified
static func reduce(value: inout UIUserInterfaceStyle, nextValue: () -> UIUserInterfaceStyle) {
value = nextValue()
}
}
struct SupportedOrientationsPreferenceKey: PreferenceKey {
typealias Value = UIInterfaceOrientationMask
static var defaultValue: UIInterfaceOrientationMask = .allButUpsideDown
static func reduce(value: inout UIInterfaceOrientationMask, nextValue: () -> UIInterfaceOrientationMask) {
// use the most restrictive set from the stack
value.formIntersection(nextValue())
}
}
// MARK: Preference Key View Extension
extension View {
// Controls the application's preferred home indicator auto-hiding when this view is shown.
func prefersHomeIndicatorAutoHidden(_ value: Bool) -> some View {
preference(key: PrefersHomeIndicatorAutoHiddenPreferenceKey.self, value: value)
}
func supportedOrientations(_ supportedOrientations: UIInterfaceOrientationMask) -> some View {
// When rendered, export the requested orientations upward to Root
preference(key: SupportedOrientationsPreferenceKey.self, value: supportedOrientations)
}
func overrideViewPreference(_ viewPreference: UIUserInterfaceStyle) -> some View {
// When rendered, export the requested orientations upward to Root
preference(key: ViewPreferenceKey.self, value: viewPreference)
}
}

View File

@ -82,7 +82,7 @@ extension AppURLHandler {
// It would be nice if the ItemViewModel could be initialized to id later.
getItem(userID: userID, itemID: itemID) { item in
guard let item = item else { return }
NotificationCenter.default.post(name: Notification.Name("processDeepLink"), object: DeepLink.item(item))
SwiftfinNotificationCenter.main.post(name: SwiftfinNotificationCenter.Keys.processDeepLink, object: DeepLink.item(item))
}
return true

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 893 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

View File

@ -0,0 +1 @@
{"images":[{"size":"60x60","expected-size":"180","filename":"180.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"40x40","expected-size":"80","filename":"80.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"40x40","expected-size":"120","filename":"120.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"60x60","expected-size":"120","filename":"120.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"57x57","expected-size":"57","filename":"57.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"1x"},{"size":"29x29","expected-size":"58","filename":"58.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"29x29","expected-size":"29","filename":"29.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"1x"},{"size":"29x29","expected-size":"87","filename":"87.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"57x57","expected-size":"114","filename":"114.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"20x20","expected-size":"40","filename":"40.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"20x20","expected-size":"60","filename":"60.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"1024x1024","filename":"1024.png","expected-size":"1024","idiom":"ios-marketing","folder":"Assets.xcassets/AppIcon.appiconset/","scale":"1x"},{"size":"40x40","expected-size":"80","filename":"80.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"72x72","expected-size":"72","filename":"72.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"76x76","expected-size":"152","filename":"152.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"50x50","expected-size":"100","filename":"100.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"29x29","expected-size":"58","filename":"58.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"76x76","expected-size":"76","filename":"76.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"29x29","expected-size":"29","filename":"29.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"50x50","expected-size":"50","filename":"50.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"72x72","expected-size":"144","filename":"144.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"40x40","expected-size":"40","filename":"40.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"83.5x83.5","expected-size":"167","filename":"167.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"20x20","expected-size":"20","filename":"20.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"20x20","expected-size":"40","filename":"40.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"}]}

View File

@ -0,0 +1,38 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "1.000",
"green" : "1.000",
"red" : "1.000"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.000",
"green" : "0.000",
"red" : "0.000"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,23 @@
{
"images" : [
{
"filename" : "swiftfin-logo.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "swiftfin-logo-1.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "swiftfin-logo-2.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Some files were not shown because too many files have changed in this diff Show More