Merge pull request #181 from LePips/multi-server-user-login

Multi Server/User Support and More
This commit is contained in:
aiden 3 2021-10-17 15:22:35 -04:00 committed by GitHub
commit 5a7ec2463f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
140 changed files with 3284 additions and 2116 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

@ -1,3 +1,12 @@
//
/*
* 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 PlainNavigationLinkButtonStyle: ButtonStyle {

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

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

@ -91,15 +91,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,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,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": "6bde3b0063ba8e7537b43744948535ca7e9e0dad",
"version": "0.5.2"
"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": "8a6e4a96fd38504a05903d136c85634b65fd7c4d",
"version": "6.0.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,64 @@
/* 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 {
// TODO: Replace with a SplashView
Color(appAppearance.style == .light ? UIColor.white : UIColor.black)
.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

View File

@ -9,10 +9,6 @@
import SwiftUI
protocol PillStackable {
var title: String { get }
}
struct PillHStackView<ItemType: PillStackable>: View {
let title: String

View File

@ -9,14 +9,6 @@
import SwiftUI
public protocol PortraitImageStackable {
func imageURLContsructor(maxWidth: Int) -> URL
var title: String { get }
var description: String? { get }
var blurHash: String { get }
var failureInitials: String { get }
}
struct PortraitImageHStackView<TopBarView: View, ItemType: PortraitImageStackable>: View {
let items: [ItemType]

View File

@ -1,184 +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 Stinsen
struct ConnectToServerView: View {
@EnvironmentObject var mainRouter: MainCoordinator.Router
@StateObject var viewModel = ConnectToServerViewModel()
@State var username = ""
@State var password = ""
@State var uri = ""
var body: some View {
ZStack {
Form {
if viewModel.isConnectedServer {
if viewModel.publicUsers.isEmpty {
Section(header: Text("Login to \(ServerEnvironment.current.server.name ?? "")")) {
TextField(NSLocalizedString("Username", comment: ""), text: $username)
.disableAutocorrection(true)
.autocapitalization(.none)
SecureField(NSLocalizedString("Password", comment: ""), text: $password)
.disableAutocorrection(true)
.autocapitalization(.none)
Button {
viewModel.login()
} label: {
HStack {
Text("Login")
Spacer()
if viewModel.isLoading {
ProgressView()
}
}
}.disabled(viewModel.isLoading || username.isEmpty)
}
Section {
Button {
viewModel.isConnectedServer = false
} label: {
HStack {
HStack {
Image(systemName: "chevron.left")
Text("Change Server")
}
Spacer()
}
}
}
} else {
Section(header: Text("Login to \(ServerEnvironment.current.server.name ?? "")")) {
ForEach(viewModel.publicUsers, id: \.id) { publicUser in
HStack {
Button(action: {
if SessionManager.current.doesUserHaveSavedSession(userID: publicUser.id!) {
let user = SessionManager.current.getSavedSession(userID: publicUser.id!)
SessionManager.current.loginWithSavedSession(user: user)
mainRouter.root(\.mainTab)
} else {
username = publicUser.name ?? ""
viewModel.selectedPublicUser = publicUser
viewModel.hidePublicUsers()
if !(publicUser.hasPassword ?? true) {
password = ""
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
viewModel.login()
}
}
}
}) {
HStack {
Text(publicUser.name ?? "").font(.subheadline).fontWeight(.semibold)
Spacer()
if publicUser.primaryImageTag != nil {
ImageView(src: URL(string: "\(ServerEnvironment.current.server.baseURI ?? "")/Users/\(publicUser.id ?? "")/Images/Primary?width=60&quality=80&tag=\(publicUser.primaryImageTag!)")!)
.frame(width: 60, height: 60)
.cornerRadius(30.0)
} else {
Image(systemName: "person.fill")
.foregroundColor(Color(red: 1, green: 1, blue: 1).opacity(0.8))
.font(.system(size: 35))
.frame(width: 60, height: 60)
.background(Color(red: 98 / 255, green: 121 / 255, blue: 205 / 255))
.cornerRadius(30.0)
.shadow(radius: 6)
}
}
}
}
}
}
Section {
Button {
viewModel.publicUsers.removeAll()
username = ""
} label: {
HStack {
Text("Other User").font(.subheadline).fontWeight(.semibold)
Spacer()
Image(systemName: "person.fill.questionmark")
.foregroundColor(Color(red: 1, green: 1, blue: 1).opacity(0.8))
.font(.system(size: 35))
.frame(width: 60, height: 60)
.background(Color(red: 98 / 255, green: 121 / 255, blue: 205 / 255))
.cornerRadius(30.0)
.shadow(radius: 6)
}
}
}
}
} else {
Section(header: Text("Connect Manually")) {
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("Discovered Servers")) {
if self.viewModel.searching {
ProgressView()
}
ForEach(self.viewModel.servers, id: \.id) { server in
Button(action: {
viewModel.connectToServer(at: server.url)
}, label: {
HStack {
Text(server.name)
.font(.headline)
Text("\(server.host)")
.font(.subheadline)
.foregroundColor(.secondary)
Spacer()
if viewModel.isLoading {
ProgressView()
}
}
})
}
}
.onAppear(perform: self.viewModel.discoverServers)
}
}
}
.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)
}
.alert(item: $viewModel.errorMessage) { _ in
Alert(title: Text("\(viewModel.errorMessage?.code ?? -1)\n\(viewModel.errorMessage?.title ?? "Error")"),
message: Text(viewModel.errorMessage?.displayMessage ?? "Error"),
dismissButton: .cancel())
}
.navigationTitle(NSLocalizedString("Connect to Server", comment: ""))
.onAppear {
AppURLHandler.shared.appURLState = .allowedInLogin
}
}
}

View File

@ -1,73 +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 JellyfinAPI
import Stinsen
import SwiftUI
#if os(iOS)
final class ItemCoordinator: NavigationCoordinatable {
let stack = NavigationStack(initial: \ItemCoordinator.start)
@Root var start = makeStart
@Route(.push) var item = makeItem
@Route(.push) var library = makeLibrary
@Route(.fullScreen) var videoPlayer = makeVideoPlayer
let itemDto: BaseItemDto
init(item: BaseItemDto) {
self.itemDto = item
}
func makeLibrary(params: LibraryCoordinatorParams) -> LibraryCoordinator {
LibraryCoordinator(viewModel: params.viewModel, title: params.title)
}
func makeItem(item: BaseItemDto) -> ItemCoordinator {
ItemCoordinator(item: item)
}
func makeVideoPlayer(item: BaseItemDto) -> NavigationViewCoordinator<VideoPlayerCoordinator> {
NavigationViewCoordinator(VideoPlayerCoordinator(item: item))
}
@ViewBuilder func makeStart() -> some View {
ItemNavigationView(item: itemDto)
}
}
#elseif os(tvOS)
// temp for fixing build error
final class ItemCoordinator: NavigationCoordinatable {
let stack = NavigationStack<ItemCoordinator>(initial: \ItemCoordinator.start)
@Root var start = makeStart
@Route(.push) var item = makeItem
@Route(.push) var library = makeLibrary
@Route(.fullScreen) var videoPlayer = makeVideoPlayer
@ViewBuilder func makeStart() -> some View {
EmptyView()
}
@ViewBuilder func makeLibrary(params: (viewModel: LibraryViewModel, title: String)) -> some View {
EmptyView()
}
@ViewBuilder func makeItem(item: BaseItemDto) -> some View {
EmptyView()
}
@ViewBuilder func makeVideoPlayer(item: BaseItemDto) -> some View {
EmptyView()
}
}
#endif

View File

@ -1,88 +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 Nuke
import Stinsen
import SwiftUI
#if !os(tvOS)
import WidgetKit
#endif
#if os(iOS)
final class MainCoordinator: NavigationCoordinatable {
var stack: NavigationStack<MainCoordinator>
@Root var mainTab = makeMainTab
@Root var connectToServer = makeConnectToServer
init() {
if ServerEnvironment.current.server != nil, SessionManager.current.user != nil {
self.stack = NavigationStack(initial: \MainCoordinator.mainTab)
} else {
self.stack = NavigationStack(initial: \MainCoordinator.connectToServer)
}
ImageCache.shared.costLimit = 125 * 1024 * 1024 // 125MB memory
DataLoader.sharedUrlCache.diskCapacity = 1000 * 1024 * 1024 // 1000MB disk
#if !os(tvOS)
WidgetCenter.shared.reloadAllTimelines()
UIScrollView.appearance().keyboardDismissMode = .onDrag
#endif
let nc = NotificationCenter.default
nc.addObserver(self, selector: #selector(didLogIn), name: Notification.Name("didSignIn"), object: nil)
nc.addObserver(self, selector: #selector(didLogOut), name: Notification.Name("didSignOut"), object: nil)
nc.addObserver(self, selector: #selector(processDeepLink), name: Notification.Name("processDeepLink"), object: nil)
}
@objc func didLogIn() {
LogManager.shared.log.info("Received `didSignIn` from NSNotificationCenter.")
root(\.mainTab)
}
@objc func didLogOut() {
LogManager.shared.log.info("Received `didSignOut` from NSNotificationCenter.")
root(\.connectToServer)
}
@objc func processDeepLink(_ notification: Notification) {
guard let deepLink = notification.object as? DeepLink else { return }
if let coordinator = hasRoot(\.mainTab) {
switch deepLink {
case let .item(item):
coordinator.focusFirst(\.home)
.child
.popToRoot()
.route(to: \.item, item)
}
}
}
func makeMainTab() -> MainTabCoordinator {
MainTabCoordinator()
}
func makeConnectToServer() -> NavigationViewCoordinator<ConnectToServerCoodinator> {
NavigationViewCoordinator(ConnectToServerCoodinator())
}
}
#elseif os(tvOS)
// temp for fixing build error
final class MainCoordinator: NavigationCoordinatable {
var stack = NavigationStack<MainCoordinator>(initial: \MainCoordinator.mainTab)
@Root var mainTab = makeEmpty
@ViewBuilder func makeEmpty() -> some View {
EmptyView()
}
}
#endif

View File

@ -2,19 +2,9 @@
<!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.coremedia.hls.low-latency</key>
<true/>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.application-groups</key>
<array>
<string>group.me.vigue.jellyfin.mobileclient</string>
</array>
<key>com.apple.security.network.client</key>
<true/>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)me.vigue.jellyfin.sharedKeychain</string>
</array>
</dict>
</plist>

View File

@ -1,254 +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 Defaults
import MessageUI
import Stinsen
import SwiftUI
// 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)
}
}
}
// 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))
}
}
extension UIDevice {
var hasNotch: Bool {
let bottom = UIApplication.shared.windows.filter { $0.isKeyWindow }.first?.safeAreaInsets.bottom ?? 0
return bottom > 0
}
}
extension View {
func withHostingWindow(_ callback: @escaping (UIWindow?) -> Void) -> some View {
background(HostingWindowFinder(callback: callback))
}
}
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) {}
}
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())
}
}
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
}
}
}
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)
}
}
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
}
}
@main
struct JellyfinPlayerApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
@Default(.appAppearance) var appAppearance
let persistenceController = PersistenceController.shared
var body: some Scene {
WindowGroup {
EmptyView()
.environment(\.managedObjectContext, persistenceController.container.viewContext)
.onAppear(perform: {
setupAppearance()
})
.withHostingWindow { window in
window?
.rootViewController = PreferenceUIHostingController(wrappedView: MainCoordinator().view()
.environment(\.managedObjectContext, persistenceController.container.viewContext))
}
.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
}
}
class AppDelegate: NSObject, UIApplicationDelegate {
static var orientationLock = UIInterfaceOrientationMask.all
func application(_ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask {
AppDelegate.orientationLock
}
}

View File

@ -1,55 +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()
static var preview: PersistenceController = {
let result = PersistenceController(inMemory: true)
let viewContext = result.container.viewContext
do {
try viewContext.save()
} catch {
// 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.
let nsError = error as NSError
fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
}
return result
}()
let container: NSPersistentCloudKitContainer
init(inMemory: Bool = false) {
container = NSPersistentCloudKitContainer(name: "Model")
container.persistentStoreDescriptions = [NSPersistentStoreDescription(url: FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: "group.me.vigue.jellyfin.mobileclient")!.appendingPathComponent("\(container.name).sqlite"))]
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,27 +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
struct SplashView: View {
@EnvironmentObject var mainRouter: MainCoordinator.Router
@StateObject var viewModel = SplashViewModel()
var body: some View {
ProgressView()
.onReceive(viewModel.$isLoggedIn) { flag in
if flag {
mainRouter.root(\.mainTab)
} else {
mainRouter.root(\.connectToServer)
}
}
}
}

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 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")
}
})
.navigationBarTitle("Settings", displayMode: .inline)
.toolbar {
ToolbarItemGroup(placement: .navigationBarLeading) {
Button {
basicAppSettingsRouter.dismissCoordinator()
} label: {
Image(systemName: "xmark.circle.fill")
}
}
}
}
}

View File

@ -0,0 +1,108 @@
/*
* 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)
if viewModel.isLoading {
Button(role: .destructive) {
viewModel.cancelConnection()
} label: {
Text("Cancel")
}
} else {
Button {
viewModel.connectToServer(uri: uri)
} label: {
Text("Connect")
}
.disabled(uri.isEmpty)
}
} header: {
Text("Connect to a Jellyfin server")
}
Section {
if viewModel.searching {
HStack(alignment: .center, spacing: 5) {
Spacer()
// Oct. 15, 2021
// There is a bug where ProgressView() won't appear sometimes when searching,
// dots were used instead but ProgressView() is preferred
Text("Searching...")
.foregroundColor(.secondary)
Spacer()
}
} else {
if viewModel.discoveredServers.isEmpty {
HStack(alignment: .center) {
Spacer()
Text("No local servers found")
.font(.callout)
.foregroundColor(.secondary)
Spacer()
}
} else {
ForEach(viewModel.discoveredServers.sorted(by: { $0.name < $1.name }), id: \.id) { discoveredServer in
Button {
uri = discoveredServer.url.absoluteString
viewModel.connectToServer(uri: discoveredServer.url.absoluteString)
} label: {
VStack(alignment: .leading, spacing: 5) {
Text(discoveredServer.name)
.font(.title3)
Text(discoveredServer.host)
.font(.subheadline)
.foregroundColor(.secondary)
}
}
.disabled(viewModel.isLoading)
}
}
}
} header: {
HStack {
Text("Local Servers")
Spacer()
Button {
viewModel.discoverServers()
} label: {
Image(systemName: "arrow.clockwise.circle.fill")
}
.disabled(viewModel.searching || viewModel.isLoading)
}
}
.headerProminence(.increased)
}
.alert(item: $viewModel.errorMessage) { _ in
Alert(title: Text(viewModel.alertTitle),
message: Text(viewModel.errorMessage?.displayMessage ?? "Unknown Error"),
dismissButton: .cancel())
}
.navigationTitle("Connect")
.onAppear {
viewModel.discoverServers()
AppURLHandler.shared.appURLState = .allowedInLogin
}
.navigationBarBackButtonHidden(viewModel.isLoading)
}
}

View File

@ -11,17 +11,10 @@ import Foundation
import SwiftUI
struct HomeView: View {
@EnvironmentObject var homeRouter: HomeCoordinator.Router
@StateObject var viewModel = HomeViewModel()
init() {
let backButtonBackgroundImage = UIImage(systemName: "chevron.backward.circle.fill")
let barAppearance = UINavigationBar.appearance()
barAppearance.backIndicatorImage = backButtonBackgroundImage
barAppearance.backIndicatorTransitionMaskImage = backButtonBackgroundImage
barAppearance.tintColor = UIColor(Color.jellyfinPurple)
}
@ViewBuilder
var innerBody: some View {
if viewModel.isLoading {
@ -73,7 +66,7 @@ struct HomeView: View {
Button {
homeRouter.route(to: \.settings)
} label: {
Image(systemName: "gear")
Image(systemName: "gearshape.fill")
}
}
}

View File

@ -28,7 +28,7 @@ struct ItemNavigationView: View {
}
}
private struct ItemView: View {
fileprivate struct ItemView: View {
@EnvironmentObject var itemRouter: ItemCoordinator.Router
@State private var videoIsLoading: Bool = false // This variable is only changed by the underlying VLC view.
@ -66,7 +66,7 @@ private struct ItemView: View {
Label("Show Series", systemImage: "text.below.photo")
}
} label: {
Image(systemName: "ellipsis.circle")
Image(systemName: "ellipsis.circle.fill")
}
case .episode:
Menu {
@ -81,7 +81,7 @@ private struct ItemView: View {
Label("Show Season", systemImage: "square.fill.text.grid.1x2")
}
} label: {
Image(systemName: "ellipsis.circle")
Image(systemName: "ellipsis.circle.fill")
}
default:
EmptyView()

View File

@ -10,8 +10,8 @@ import Stinsen
import SwiftUI
struct LibraryFilterView: View {
@EnvironmentObject var filterRouter: FilterCoordinator.Router
@Environment(\.presentationMode) var presentationMode
@Binding var filters: LibraryFilters
var parentId: String = ""

View File

@ -15,32 +15,32 @@ struct ServerDetailView: View {
var body: some View {
Form {
Section(header: Text("")) {
Section(header: Text("Server Details")) {
HStack {
Text("Name")
Spacer()
Text(ServerEnvironment.current.server.name ?? "")
Text(SessionManager.main.currentLogin.server.name)
.foregroundColor(.secondary)
}
HStack {
Text("URI")
Spacer()
Text(ServerEnvironment.current.server.baseURI ?? "")
Text(SessionManager.main.currentLogin.server.uri)
.foregroundColor(.secondary)
}
HStack {
Text("Version")
Spacer()
Text(ServerEnvironment.current.server.version ?? "")
Text(SessionManager.main.currentLogin.server.version)
.foregroundColor(.secondary)
}
HStack {
Text("Operating System")
Spacer()
Text(ServerEnvironment.current.server.os ?? "")
Text(SessionManager.main.currentLogin.server.os)
.foregroundColor(.secondary)
}
}

View File

@ -0,0 +1,140 @@
//
/*
* 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
private var listView: some View {
ScrollView {
LazyVStack {
ForEach(viewModel.servers, id: \.id) { server in
Button {
serverListRouter.route(to: \.userList, server)
} label: {
ZStack(alignment: Alignment.leading) {
Rectangle()
.foregroundColor(Color(UIColor.secondarySystemFill))
.frame(height: 100)
.cornerRadius(10)
HStack(spacing: 10) {
Image(systemName: "server.rack")
.font(.system(size: 36))
.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)
}
}.padding([.leading])
}
.padding()
}
.contextMenu {
Button(role: .destructive) {
viewModel.remove(server: server)
} label: {
Label("Remove", systemImage: "trash")
}
}
}
}
}
}
private var noServerView: some View {
VStack {
Text("Connect to a Jellyfin server to get started")
.frame(minWidth: 50, maxWidth: 240)
.multilineTextAlignment(.center)
Button {
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)
Text("Connect")
.foregroundColor(Color.white)
.bold()
}
}
}
}
@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")
}
}
}
private var leadingToolbarContent: some View {
Button {
serverListRouter.route(to: \.basicAppSettings)
} label: {
Image(systemName: "gearshape.fill")
}
}
var body: some View {
innerBody
.navigationTitle("Servers")
.toolbar {
ToolbarItemGroup(placement: .navigationBarTrailing) {
trailingToolbarContent
}
}
.toolbar(content: {
ToolbarItemGroup(placement: .navigationBarLeading) {
leadingToolbarContent
}
})
.onAppear {
viewModel.fetchServers()
}
}
}

View File

@ -11,9 +11,8 @@ import Stinsen
import SwiftUI
struct SettingsView: View {
@EnvironmentObject var settingsRouter: SettingsCoordinator.Router
@Environment(\.managedObjectContext) private var viewContext
@ObservedObject var viewModel: SettingsViewModel
@Default(.inNetworkBandwidth) var inNetworkStreamBitrate
@ -28,38 +27,61 @@ struct SettingsView: View {
var body: some View {
Form {
Section(header: EmptyView()) {
HStack {
Text("User")
Spacer()
Text(SessionManager.current.user?.username ?? "")
.foregroundColor(.jellyfinPurple)
}
Button {
settingsRouter.route(to: \.serverDetail)
} label: {
// 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("Server")
Text("User")
Spacer()
Text(ServerEnvironment.current.server?.name ?? "")
Text("")
.foregroundColor(.jellyfinPurple)
}
Image(systemName: "chevron.right")
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()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
SessionManager.current.logout()
let nc = NotificationCenter.default
nc.post(name: Notification.Name("didSignOut"), object: nil)
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
@ -122,7 +144,7 @@ struct SettingsView: View {
Button {
settingsRouter.dismissCoordinator()
} label: {
Image(systemName: "xmark")
Image(systemName: "xmark.circle.fill")
}
}
}

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 SwiftUI
struct UserListView: View {
@EnvironmentObject var userListRouter: UserListCoordinator.Router
@ObservedObject var viewModel: UserListViewModel
private var listView: some View {
ScrollView {
LazyVStack {
ForEach(viewModel.users, id: \.id) { user in
Button {
viewModel.login(user: user)
} label: {
ZStack(alignment: Alignment.leading) {
Rectangle()
.foregroundColor(Color(UIColor.secondarySystemFill))
.frame(height: 50)
.cornerRadius(10)
HStack {
Text(user.username)
.font(.title2)
Spacer()
if viewModel.isLoading {
ProgressView()
}
}.padding(.leading)
}
.padding()
}
.contextMenu {
Button(role: .destructive) {
viewModel.remove(user: user)
} label: {
Label("Remove", systemImage: "trash")
}
}
}
}
}
}
private var noUserView: some View {
VStack {
Text("Sign in to get started")
.frame(minWidth: 50, maxWidth: 240)
.multilineTextAlignment(.center)
Button {
userListRouter.route(to: \.userSignIn, viewModel.server)
} label: {
ZStack {
Rectangle()
.foregroundColor(Color.jellyfinPurple)
.frame(maxWidth: 400, maxHeight: 50)
.frame(height: 50)
.cornerRadius(10)
.padding(.horizontal, 30)
.padding([.top, .bottom], 20)
Text("Sign in")
.foregroundColor(Color.white)
.bold()
}
}
}
}
@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,57 @@
//
/*
* 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)
if viewModel.isLoading {
Button(role: .destructive) {
viewModel.cancelSignIn()
} label: {
Text("Cancel")
}
} else {
Button {
viewModel.login(username: username, password: password)
} label: {
Text("Sign In")
}
.disabled(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")
.navigationBarBackButtonHidden(viewModel.isLoading)
}
}

View File

@ -518,13 +518,13 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe
let builder = DeviceProfileBuilder()
builder.setMaxBitrate(bitrate: maxBitrate)
let profile = builder.buildProfile()
let playbackInfo = PlaybackInfoDto(userId: SessionManager.current.user.user_id!, maxStreamingBitrate: Int(maxBitrate),
let playbackInfo = PlaybackInfoDto(userId: SessionManager.main.currentLogin.user.id, maxStreamingBitrate: Int(maxBitrate),
startTimeTicks: manifest.userData?.playbackPositionTicks ?? 0, deviceProfile: profile,
autoOpenLiveStream: true)
DispatchQueue.global(qos: .userInitiated).async { [self] in
delegate?.showLoadingView(self)
MediaInfoAPI.getPostedPlaybackInfo(itemId: manifest.id!, userId: SessionManager.current.user.user_id!,
MediaInfoAPI.getPostedPlaybackInfo(itemId: manifest.id!, userId: SessionManager.main.currentLogin.user.id,
maxStreamingBitrate: Int(maxBitrate),
startTimeTicks: manifest.userData?.playbackPositionTicks ?? 0, autoOpenLiveStream: true,
playbackInfoDto: playbackInfo)
@ -537,8 +537,7 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe
switch err {
case .error(401, _, _, _):
self.delegate?.exitPlayer(self)
SessionManager.current.logout()
main?.root(\.connectToServer)
SessionManager.main.logout()
case .error:
self.delegate?.exitPlayer(self)
}
@ -550,7 +549,7 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe
let mediaSource = response.mediaSources!.first.self!
if mediaSource.transcodingUrl != nil {
// Item is being transcoded by request of server
let streamURL = URL(string: "\(ServerEnvironment.current.server.baseURI!)\(mediaSource.transcodingUrl!)")
let streamURL = URL(string: "\(SessionManager.main.currentLogin.server.uri)\(mediaSource.transcodingUrl!)")
let item = PlaybackItem()
item.videoType = .transcode
item.videoUrl = streamURL!
@ -564,7 +563,7 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe
if stream.type == .subtitle {
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 ?? "")")!
} else {
deliveryUrl = nil
}
@ -596,9 +595,10 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe
self.sendPlayReport()
playbackItem = item
} else {
// TODO: todo
// Item will be directly played by the client.
let 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 ?? "")")!
let streamURL = URL(string: "\(SessionManager.main.currentLogin.server.uri)/Videos/\(manifest.id!)/stream?Static=true&mediaSourceId=\(manifest.id!)&Tag=\(mediaSource.eTag ?? "")")!
// 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 ?? "")")!
let item = PlaybackItem()
item.videoUrl = streamURL
@ -613,7 +613,7 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe
if stream.type == .subtitle {
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!)")!
} else {
deliveryUrl = nil
}
@ -771,7 +771,7 @@ class PlayerViewController: UIViewController, GCKDiscoveryManagerListener, GCKRe
}
func getNextEpisode() {
TvShowsAPI.getEpisodes(seriesId: manifest.seriesId!, userId: SessionManager.current.user.user_id!, startItemId: manifest.id,
TvShowsAPI.getEpisodes(seriesId: manifest.seriesId!, userId: SessionManager.main.currentLogin.user.id, startItemId: manifest.id,
limit: 2)
.sink(receiveCompletion: { completion in
print(completion)
@ -873,11 +873,11 @@ extension PlayerViewController: GCKGenericChannelDelegate {
let payload: [String: Any] = [
"options": options,
"command": command,
"userId": SessionManager.current.user.user_id!,
"deviceId": SessionManager.current.deviceID,
"accessToken": SessionManager.current.accessToken,
"serverAddress": ServerEnvironment.current.server.baseURI!,
"serverId": ServerEnvironment.current.server.server_id!,
"userId": SessionManager.main.currentLogin.user.id,
// "deviceId": SessionManager.main.currentLogin.de.deviceID,
"accessToken": SessionManager.main.currentLogin.user.accessToken,
"serverAddress": SessionManager.main.currentLogin.server.uri,
"serverId": SessionManager.main.currentLogin.server.id,
"serverVersion": "10.8.0",
"receiverName": castSessionManager.currentCastSession!.device.friendlyName!,
"subtitleBurnIn": false,
@ -931,7 +931,7 @@ extension PlayerViewController: GCKSessionManagerListener {
let playNowOptions: [String: Any] = [
"items": [[
"Id": manifest.id!,
"ServerId": ServerEnvironment.current.server.server_id!,
"ServerId": SessionManager.main.currentLogin.server.id,
"Name": manifest.name!,
"Type": manifest.type!,
"MediaType": manifest.mediaType!,

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
import Stinsen
import SwiftUI
final class BasicAppSettingsCoordinator: NavigationCoordinatable {
let stack = NavigationStack(initial: \BasicAppSettingsCoordinator.start)
@Root var start = makeStart
@ViewBuilder func makeStart() -> some View {
BasicAppSettingsView(viewModel: BasicAppSettingsViewModel())
}
}

View File

@ -12,11 +12,17 @@ import Stinsen
import SwiftUI
final class ConnectToServerCoodinator: NavigationCoordinatable {
let stack = NavigationStack(initial: \ConnectToServerCoodinator.start)
@Root var start = makeStart
@Route(.push) var userSignIn = makeUserSignIn
func makeUserSignIn(server: SwiftfinStore.State.Server) -> UserSignInCoordinator {
return UserSignInCoordinator(viewModel: .init(server: server))
}
@ViewBuilder func makeStart() -> some View {
ConnectToServerView()
ConnectToServerView(viewModel: ConnectToServerViewModel())
}
}

View File

@ -14,7 +14,9 @@ import SwiftUI
typealias FilterCoordinatorParams = (filters: Binding<LibraryFilters>, enabledFilterType: [FilterType], parentId: String)
final class FilterCoordinator: NavigationCoordinatable {
let stack = NavigationStack(initial: \FilterCoordinator.start)
@Root var start = makeStart
@Binding var filters: LibraryFilters

View File

@ -13,6 +13,7 @@ import Stinsen
import SwiftUI
final class HomeCoordinator: NavigationCoordinatable {
let stack = NavigationStack(initial: \HomeCoordinator.start)
@Root var start = makeStart

View File

@ -0,0 +1,45 @@
//
/*
* 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 JellyfinAPI
import Stinsen
import SwiftUI
final class ItemCoordinator: NavigationCoordinatable {
let stack = NavigationStack(initial: \ItemCoordinator.start)
@Root var start = makeStart
@Route(.push) var item = makeItem
@Route(.push) var library = makeLibrary
@Route(.fullScreen) var videoPlayer = makeVideoPlayer
let itemDto: BaseItemDto
init(item: BaseItemDto) {
self.itemDto = item
}
func makeLibrary(params: LibraryCoordinatorParams) -> LibraryCoordinator {
LibraryCoordinator(viewModel: params.viewModel, title: params.title)
}
func makeItem(item: BaseItemDto) -> ItemCoordinator {
ItemCoordinator(item: item)
}
func makeVideoPlayer(item: BaseItemDto) -> NavigationViewCoordinator<VideoPlayerCoordinator> {
NavigationViewCoordinator(VideoPlayerCoordinator(item: item))
}
@ViewBuilder func makeStart() -> some View {
ItemNavigationView(item: itemDto)
}
}

View File

@ -15,6 +15,7 @@ import SwiftUI
typealias LibraryCoordinatorParams = (viewModel: LibraryViewModel, title: String)
final class LibraryCoordinator: NavigationCoordinatable {
let stack = NavigationStack(initial: \LibraryCoordinator.start)
@Root var start = makeStart
@ -22,8 +23,8 @@ final class LibraryCoordinator: NavigationCoordinatable {
@Route(.modal) var filter = makeFilter
@Route(.push) var item = makeItem
var viewModel: LibraryViewModel
var title: String
let viewModel: LibraryViewModel
let title: String
init(viewModel: LibraryViewModel, title: String) {
self.viewModel = viewModel

View File

@ -12,6 +12,7 @@ import Stinsen
import SwiftUI
final class LibraryListCoordinator: NavigationCoordinatable {
let stack = NavigationStack(initial: \LibraryListCoordinator.start)
@Root var start = makeStart

View File

@ -0,0 +1,78 @@
//
/*
* 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 Nuke
import Stinsen
import SwiftUI
import WidgetKit
final class MainCoordinator: NavigationCoordinatable {
var stack: NavigationStack<MainCoordinator>
@Root var mainTab = makeMainTab
@Root var serverList = makeServerList
init() {
if SessionManager.main.currentLogin != nil {
self.stack = NavigationStack(initial: \MainCoordinator.mainTab)
} else {
self.stack = NavigationStack(initial: \MainCoordinator.serverList)
}
ImageCache.shared.costLimit = 125 * 1024 * 1024 // 125MB memory
DataLoader.sharedUrlCache.diskCapacity = 1000 * 1024 * 1024 // 1000MB disk
WidgetCenter.shared.reloadAllTimelines()
UIScrollView.appearance().keyboardDismissMode = .onDrag
// Back bar button item setup
let backButtonBackgroundImage = UIImage(systemName: "chevron.backward.circle.fill")
let barAppearance = UINavigationBar.appearance()
barAppearance.backIndicatorImage = backButtonBackgroundImage
barAppearance.backIndicatorTransitionMaskImage = backButtonBackgroundImage
barAppearance.tintColor = UIColor(Color.jellyfinPurple)
// Notification setup for state
let nc = SwiftfinNotificationCenter.main
nc.addObserver(self, selector: #selector(didLogIn), name: SwiftfinNotificationCenter.Keys.didSignIn, 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)
}
@objc func didLogIn() {
LogManager.shared.log.info("Received `didSignIn` from SwiftfinNotificationCenter.")
root(\.mainTab)
}
@objc func didLogOut() {
LogManager.shared.log.info("Received `didSignOut` from SwiftfinNotificationCenter.")
root(\.serverList)
}
@objc func processDeepLink(_ notification: Notification) {
guard let deepLink = notification.object as? DeepLink else { return }
if let coordinator = hasRoot(\.mainTab) {
switch deepLink {
case let .item(item):
coordinator.focusFirst(\.home)
.child
.popToRoot()
.route(to: \.item, item)
}
}
}
func makeMainTab() -> MainTabCoordinator {
MainTabCoordinator()
}
func makeServerList() -> NavigationViewCoordinator<ServerListCoordinator> {
NavigationViewCoordinator(ServerListCoordinator())
}
}

View File

@ -9,17 +9,16 @@
import Foundation
import SwiftUI
import Stinsen
final class MainTabCoordinator: TabCoordinatable {
var child = TabChild(startingItems: [
\MainTabCoordinator.home,
\MainTabCoordinator.allMedia,
\MainTabCoordinator.allMedia
])
@Route(tabItem: makeHomeTab) var home = makeHome
@Route(tabItem: makeTodosTab) var allMedia = makeTodos
@Route(tabItem: makeAllMediaTab) var allMedia = makeAllMedia
func makeHome() -> NavigationViewCoordinator<HomeCoordinator> {
return NavigationViewCoordinator(HomeCoordinator())
@ -30,11 +29,11 @@ final class MainTabCoordinator: TabCoordinatable {
Text("Home")
}
func makeTodos() -> NavigationViewCoordinator<LibraryListCoordinator> {
func makeAllMedia() -> NavigationViewCoordinator<LibraryListCoordinator> {
return NavigationViewCoordinator(LibraryListCoordinator())
}
@ViewBuilder func makeTodosTab(isActive: Bool) -> some View {
@ViewBuilder func makeAllMediaTab(isActive: Bool) -> some View {
Image(systemName: "folder")
Text("All Media")
}
@ -42,6 +41,7 @@ final class MainTabCoordinator: TabCoordinatable {
@ViewBuilder func customize(_ view: AnyView) -> some View {
view.onAppear {
AppURLHandler.shared.appURLState = .allowed
// TODO: todo
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
AppURLHandler.shared.processLaunchedURLIfNeeded()
}

View File

@ -0,0 +1,54 @@
//
/*
* 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 Nuke
import Stinsen
import SwiftUI
final class MainCoordinator: NavigationCoordinatable {
var stack = NavigationStack<MainCoordinator>(initial: \MainCoordinator.mainTab)
@Root var mainTab = makeMainTab
@Root var serverList = makeServerList
init() {
if SessionManager.main.currentLogin != nil {
self.stack = NavigationStack(initial: \MainCoordinator.mainTab)
} else {
self.stack = NavigationStack(initial: \MainCoordinator.serverList)
}
ImageCache.shared.costLimit = 125 * 1024 * 1024 // 125MB memory
DataLoader.sharedUrlCache.diskCapacity = 1000 * 1024 * 1024 // 1000MB disk
// Notification setup for state
let nc = SwiftfinNotificationCenter.main
nc.addObserver(self, selector: #selector(didLogIn), name: SwiftfinNotificationCenter.Keys.didSignIn, object: nil)
nc.addObserver(self, selector: #selector(didLogOut), name: SwiftfinNotificationCenter.Keys.didSignOut, object: nil)
}
@objc func didLogIn() {
LogManager.shared.log.info("Received `didSignIn` from NSNotificationCenter.")
root(\.mainTab)
}
@objc func didLogOut() {
LogManager.shared.log.info("Received `didSignOut` from NSNotificationCenter.")
root(\.serverList)
}
func makeMainTab() -> MainTabCoordinator {
MainTabCoordinator()
}
func makeServerList() -> NavigationViewCoordinator<ServerListCoordinator> {
NavigationViewCoordinator(ServerListCoordinator())
}
}

View File

@ -0,0 +1,57 @@
//
/*
* 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
import Stinsen
final class MainTabCoordinator: TabCoordinatable {
var child = TabChild(startingItems: [
\MainTabCoordinator.home,
\MainTabCoordinator.allMedia,
\MainTabCoordinator.settings
])
@Route(tabItem: makeHomeTab) var home = makeHome
@Route(tabItem: makeAllMediaTab) var allMedia = makeAllMedia
@Route(tabItem: makeSettingsTab) var settings = makeSettings
func makeHome() -> NavigationViewCoordinator<HomeCoordinator> {
return NavigationViewCoordinator(HomeCoordinator())
}
@ViewBuilder func makeHomeTab(isActive: Bool) -> some View {
HStack {
Image(systemName: "house")
Text("Home")
}
}
func makeAllMedia() -> NavigationViewCoordinator<LibraryListCoordinator> {
return NavigationViewCoordinator(LibraryListCoordinator())
}
@ViewBuilder func makeAllMediaTab(isActive: Bool) -> some View {
HStack {
Image(systemName: "folder")
Text("All Media")
}
}
func makeSettings() -> NavigationViewCoordinator<SettingsCoordinator> {
return NavigationViewCoordinator(SettingsCoordinator())
}
@ViewBuilder func makeSettingsTab(isActive: Bool) -> some View {
HStack {
Image(systemName: "gearshape.fill")
Text("Settings")
}
}
}

View File

@ -13,12 +13,13 @@ import SwiftUI
import JellyfinAPI
final class SearchCoordinator: NavigationCoordinatable {
let stack = NavigationStack(initial: \SearchCoordinator.start)
@Root var start = makeStart
@Route(.push) var item = makeItem
var viewModel: LibrarySearchViewModel
let viewModel: LibrarySearchViewModel
init(viewModel: LibrarySearchViewModel) {
self.viewModel = viewModel

View File

@ -0,0 +1,38 @@
//
/*
* 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 Stinsen
import SwiftUI
final class ServerListCoordinator: NavigationCoordinatable {
let stack = NavigationStack(initial: \ServerListCoordinator.start)
@Root var start = makeStart
@Route(.push) var connectToServer = makeConnectToServer
@Route(.push) var userList = makeUserList
@Route(.modal) var basicAppSettings = makeBasicAppSettings
func makeConnectToServer() -> ConnectToServerCoodinator {
ConnectToServerCoodinator()
}
func makeUserList(server: SwiftfinStore.State.Server) -> UserListCoordinator {
UserListCoordinator(viewModel: .init(server: server))
}
func makeBasicAppSettings() -> NavigationViewCoordinator<BasicAppSettingsCoordinator> {
NavigationViewCoordinator(BasicAppSettingsCoordinator())
}
@ViewBuilder func makeStart() -> some View {
ServerListView(viewModel: ServerListViewModel())
}
}

View File

@ -12,6 +12,7 @@ import Stinsen
import SwiftUI
final class SettingsCoordinator: NavigationCoordinatable {
let stack = NavigationStack(initial: \SettingsCoordinator.start)
@Root var start = makeStart

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 Foundation
import Stinsen
import SwiftUI
final class UserListCoordinator: NavigationCoordinatable {
let stack = NavigationStack(initial: \UserListCoordinator.start)
@Root var start = makeStart
@Route(.push) var userSignIn = makeUserSignIn
let viewModel: UserListViewModel
init(viewModel: UserListViewModel) {
self.viewModel = viewModel
}
func makeUserSignIn(server: SwiftfinStore.State.Server) -> UserSignInCoordinator {
return UserSignInCoordinator(viewModel: .init(server: server))
}
@ViewBuilder func makeStart() -> some View {
UserListView(viewModel: viewModel)
}
}

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 Foundation
import Stinsen
import SwiftUI
final class UserSignInCoordinator: NavigationCoordinatable {
let stack = NavigationStack(initial: \UserSignInCoordinator.start)
@Root var start = makeStart
let viewModel: UserSignInViewModel
init(viewModel: UserSignInViewModel) {
self.viewModel = viewModel
}
@ViewBuilder func makeStart() -> some View {
UserSignInView(viewModel: viewModel)
}
}

View File

@ -13,10 +13,12 @@ import Stinsen
import SwiftUI
final class VideoPlayerCoordinator: NavigationCoordinatable {
let stack = NavigationStack(initial: \VideoPlayerCoordinator.start)
@Root var start = makeStart
var item: BaseItemDto
let item: BaseItemDto
init(item: BaseItemDto) {
self.item = item

View File

@ -16,6 +16,10 @@ struct ErrorMessage: Identifiable {
let title: String
let displayMessage: String
let logConstructor: LogConstructor
// Chosen value such that if an error has this code, don't show the code to the UI
// This was chosen because of its unlikelyhood to ever be used
static let noShowErrorCode = -69420
var id: String {
return "\(code)\(title)\(logConstructor.message)"

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