initial screens Refactoring

remove ContentView
add HomeView
add MainTabView
add SplashView
add ConnectToServerViewModel
add HomeViewModel
add LibraryListViewModel
add SplashViewModel
add ViewModel
This commit is contained in:
PangMo5 2021-06-15 22:44:03 +09:00
parent f1138b50f2
commit 94aa3bc4b4
26 changed files with 717 additions and 691 deletions

View File

@ -42,7 +42,6 @@
5364F455266CA0DC0026ECBA /* APIExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5364F454266CA0DC0026ECBA /* APIExtensions.swift */; };
5364F456266CA0DC0026ECBA /* APIExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5364F454266CA0DC0026ECBA /* APIExtensions.swift */; };
5377CBF5263B596A003A4E83 /* JellyfinPlayerApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5377CBF4263B596A003A4E83 /* JellyfinPlayerApp.swift */; };
5377CBF7263B596A003A4E83 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5377CBF6263B596A003A4E83 /* ContentView.swift */; };
5377CBF9263B596B003A4E83 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 5377CBF8263B596B003A4E83 /* Assets.xcassets */; };
5377CBFC263B596B003A4E83 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 5377CBFB263B596B003A4E83 /* Preview Assets.xcassets */; };
5377CBFE263B596B003A4E83 /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5377CBFD263B596B003A4E83 /* PersistenceController.swift */; };
@ -70,6 +69,15 @@
621C638026672A30004216EA /* NukeUI in Frameworks */ = {isa = PBXBuildFile; productRef = 621C637F26672A30004216EA /* NukeUI */; };
6225FCCB2663841E00E067F6 /* ParallaxHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6225FCCA2663841E00E067F6 /* ParallaxHeader.swift */; };
6228B1C22670EB010067FD35 /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5377CBFD263B596B003A4E83 /* PersistenceController.swift */; };
625CB5682678B6FB00530A6E /* SplashView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 625CB5672678B6FB00530A6E /* SplashView.swift */; };
625CB56A2678B71200530A6E /* SplashViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 625CB5692678B71200530A6E /* SplashViewModel.swift */; };
625CB56C2678C0FD00530A6E /* MainTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 625CB56B2678C0FD00530A6E /* MainTabView.swift */; };
625CB56F2678C23300530A6E /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 625CB56E2678C23300530A6E /* HomeView.swift */; };
625CB5732678C32A00530A6E /* HomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 625CB5722678C32A00530A6E /* HomeViewModel.swift */; };
625CB5752678C33500530A6E /* LibraryListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 625CB5742678C33500530A6E /* LibraryListViewModel.swift */; };
625CB5772678C34300530A6E /* ConnectToServerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 625CB5762678C34300530A6E /* ConnectToServerViewModel.swift */; };
625CB57A2678C4A400530A6E /* ActivityIndicator in Frameworks */ = {isa = PBXBuildFile; productRef = 625CB5792678C4A400530A6E /* ActivityIndicator */; };
625CB57C2678CE1000530A6E /* ViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 625CB57B2678CE1000530A6E /* ViewModel.swift */; };
6267B3D42671024A00A7371D /* APIExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5364F454266CA0DC0026ECBA /* APIExtensions.swift */; };
6267B3D626710B8900A7371D /* CollectionExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6267B3D526710B8900A7371D /* CollectionExtensions.swift */; };
6267B3D726710B9700A7371D /* CollectionExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6267B3D526710B8900A7371D /* CollectionExtensions.swift */; };
@ -177,7 +185,6 @@
5364F454266CA0DC0026ECBA /* APIExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIExtensions.swift; sourceTree = "<group>"; };
5377CBF1263B596A003A4E83 /* JellyfinPlayer iOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "JellyfinPlayer iOS.app"; sourceTree = BUILT_PRODUCTS_DIR; };
5377CBF4263B596A003A4E83 /* JellyfinPlayerApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JellyfinPlayerApp.swift; sourceTree = "<group>"; };
5377CBF6263B596A003A4E83 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
5377CBF8263B596B003A4E83 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
5377CBFB263B596B003A4E83 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = "<group>"; };
5377CBFD263B596B003A4E83 /* PersistenceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceController.swift; sourceTree = "<group>"; };
@ -206,6 +213,14 @@
621338922660107500A81A2A /* StringExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StringExtensions.swift; sourceTree = "<group>"; };
621338B22660A07800A81A2A /* LazyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LazyView.swift; sourceTree = "<group>"; };
6225FCCA2663841E00E067F6 /* ParallaxHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParallaxHeader.swift; sourceTree = "<group>"; };
625CB5672678B6FB00530A6E /* SplashView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplashView.swift; sourceTree = "<group>"; };
625CB5692678B71200530A6E /* SplashViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplashViewModel.swift; sourceTree = "<group>"; };
625CB56B2678C0FD00530A6E /* MainTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTabView.swift; sourceTree = "<group>"; };
625CB56E2678C23300530A6E /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = "<group>"; };
625CB5722678C32A00530A6E /* HomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewModel.swift; sourceTree = "<group>"; };
625CB5742678C33500530A6E /* LibraryListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryListViewModel.swift; sourceTree = "<group>"; };
625CB5762678C34300530A6E /* ConnectToServerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectToServerViewModel.swift; sourceTree = "<group>"; };
625CB57B2678CE1000530A6E /* ViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModel.swift; sourceTree = "<group>"; };
6267B3D526710B8900A7371D /* CollectionExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionExtensions.swift; sourceTree = "<group>"; };
6267B3D92671138200A7371D /* ImageExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageExtensions.swift; sourceTree = "<group>"; };
628B95202670CABD0091AF3B /* WidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = WidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
@ -242,6 +257,7 @@
5338F757263B7E2E0014BF09 /* KeychainSwift in Frameworks */,
53352571265EA0A0006CCA86 /* Introspect in Frameworks */,
621C638026672A30004216EA /* NukeUI in Frameworks */,
625CB57A2678C4A400530A6E /* ActivityIndicator in Frameworks */,
53A431BD266B0FF20016769F /* JellyfinAPI in Frameworks */,
62EC3527267665D8000E9F2D /* MobileVLCKit.xcframework in Frameworks */,
);
@ -266,6 +282,11 @@
isa = PBXGroup;
children = (
5321753A2671BCFC005491E6 /* SettingsViewModel.swift */,
625CB5692678B71200530A6E /* SplashViewModel.swift */,
625CB5722678C32A00530A6E /* HomeViewModel.swift */,
625CB5742678C33500530A6E /* LibraryListViewModel.swift */,
625CB5762678C34300530A6E /* ConnectToServerViewModel.swift */,
625CB57B2678CE1000530A6E /* ViewModel.swift */,
);
path = ViewModel;
sourceTree = "<group>";
@ -338,10 +359,10 @@
5377CBF3263B596A003A4E83 /* JellyfinPlayer */ = {
isa = PBXGroup;
children = (
625CB56D2678C1C400530A6E /* ViewModels */,
53AD124C2670278D0094A276 /* JellyfinPlayer.entitlements */,
5377CBF8263B596B003A4E83 /* Assets.xcassets */,
5338F74D263B61370014BF09 /* ConnectToServerView.swift */,
5377CBF6263B596A003A4E83 /* ContentView.swift */,
5389276D263C25100035E14B /* ContinueWatchingView.swift */,
53192D5C265AA78A008A4215 /* DeviceProfileBuilder.swift */,
53987CA72657424A00E7EA70 /* EpisodeItemView.swift */,
@ -366,6 +387,9 @@
53F8377C265FF67C00F456B3 /* VideoPlayerSettingsView.swift */,
53DE4BD1267098F300739748 /* SearchBarView.swift */,
531AC8BE26750DE20091C7EB /* ImageView.swift */,
625CB5672678B6FB00530A6E /* SplashView.swift */,
625CB56B2678C0FD00530A6E /* MainTabView.swift */,
625CB56E2678C23300530A6E /* HomeView.swift */,
);
path = JellyfinPlayer;
sourceTree = "<group>";
@ -406,6 +430,13 @@
path = Extensions;
sourceTree = "<group>";
};
625CB56D2678C1C400530A6E /* ViewModels */ = {
isa = PBXGroup;
children = (
);
path = ViewModels;
sourceTree = "<group>";
};
628B95252670CABD0091AF3B /* WidgetExtension */ = {
isa = PBXGroup;
children = (
@ -485,6 +516,7 @@
53352570265EA0A0006CCA86 /* Introspect */,
621C637F26672A30004216EA /* NukeUI */,
53A431BC266B0FF20016769F /* JellyfinAPI */,
625CB5792678C4A400530A6E /* ActivityIndicator */,
);
productName = JellyfinPlayer;
productReference = 5377CBF1263B596A003A4E83 /* JellyfinPlayer iOS.app */;
@ -549,6 +581,7 @@
5335256F265EA0A0006CCA86 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */,
621C637E26672A30004216EA /* XCRemoteSwiftPackageReference "NukeUI" */,
53A431BB266B0FF20016769F /* XCRemoteSwiftPackageReference "jellyfin-sdk-swift" */,
625CB5782678C4A400530A6E /* XCRemoteSwiftPackageReference "ActivityIndicator" */,
);
productRefGroup = 5377CBF2263B596A003A4E83 /* Products */;
projectDirPath = "";
@ -626,16 +659,20 @@
5364F455266CA0DC0026ECBA /* APIExtensions.swift in Sources */,
621338932660107500A81A2A /* StringExtensions.swift in Sources */,
53FF7F2A263CF3F500585C35 /* LatestMediaView.swift in Sources */,
625CB5732678C32A00530A6E /* HomeViewModel.swift in Sources */,
5377CBFE263B596B003A4E83 /* PersistenceController.swift in Sources */,
5389276E263C25100035E14B /* ContinueWatchingView.swift in Sources */,
53AD124E26702B8A0094A276 /* SeasonItemView.swift in Sources */,
535BAE9F2649E569005FA86D /* ItemView.swift in Sources */,
6225FCCB2663841E00E067F6 /* ParallaxHeader.swift in Sources */,
625CB5772678C34300530A6E /* ConnectToServerViewModel.swift in Sources */,
53DE4BD02670961400739748 /* EpisodeItemView.swift in Sources */,
53F8377D265FF67C00F456B3 /* VideoPlayerSettingsView.swift in Sources */,
53192D5D265AA78A008A4215 /* DeviceProfileBuilder.swift in Sources */,
62133890265F83A900A81A2A /* LibraryListView.swift in Sources */,
625CB56F2678C23300530A6E /* HomeView.swift in Sources */,
53892770263C25230035E14B /* NextUpView.swift in Sources */,
625CB5682678B6FB00530A6E /* SplashView.swift in Sources */,
535BAEA5264A151C005FA86D /* VideoPlayer.swift in Sources */,
5321753B2671BCFC005491E6 /* SettingsViewModel.swift in Sources */,
532175402671EE4F005491E6 /* LibraryFilterView.swift in Sources */,
@ -643,23 +680,26 @@
53DF641E263D9C0600A7CD1A /* LibraryView.swift in Sources */,
6267B3D626710B8900A7371D /* CollectionExtensions.swift in Sources */,
53A089D0264DA9DA00D57806 /* MovieItemView.swift in Sources */,
625CB56A2678B71200530A6E /* SplashViewModel.swift in Sources */,
53DE4BD2267098F300739748 /* SearchBarView.swift in Sources */,
53E4E649263F725B00F67C6B /* MultiSelectorView.swift in Sources */,
625CB57C2678CE1000530A6E /* ViewModel.swift in Sources */,
621338B32660A07800A81A2A /* LazyView.swift in Sources */,
531AC8BF26750DE20091C7EB /* ImageView.swift in Sources */,
62EC352F267666A5000E9F2D /* SessionManager.swift in Sources */,
535870AD2669D8DD00D05A09 /* Typings.swift in Sources */,
62EC352C26766675000E9F2D /* ServerEnvironment.swift in Sources */,
6267B3DA2671138200A7371D /* ImageExtensions.swift in Sources */,
5377CBF7263B596A003A4E83 /* ContentView.swift in Sources */,
62EC353426766B03000E9F2D /* DeviceRotationViewModifier.swift in Sources */,
5389277C263CC3DB0035E14B /* BlurHashDecode.swift in Sources */,
625CB56C2678C0FD00530A6E /* MainTabView.swift in Sources */,
539B2DA5263BA5B8007FF1A4 /* SettingsView.swift in Sources */,
5338F74E263B61370014BF09 /* ConnectToServerView.swift in Sources */,
53AD124D267029D60094A276 /* SeriesItemView.swift in Sources */,
5377CBF5263B596A003A4E83 /* JellyfinPlayerApp.swift in Sources */,
53EE24E6265060780068F029 /* LibrarySearchView.swift in Sources */,
53892772263C8C6F0035E14B /* LoadingView.swift in Sources */,
625CB5752678C33500530A6E /* LibraryListViewModel.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -1065,6 +1105,14 @@
version = 0.3.0;
};
};
625CB5782678C4A400530A6E /* XCRemoteSwiftPackageReference "ActivityIndicator" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/duyquang91/ActivityIndicator";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 1.1.0;
};
};
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
@ -1108,6 +1156,11 @@
package = 621C637E26672A30004216EA /* XCRemoteSwiftPackageReference "NukeUI" */;
productName = NukeUI;
};
625CB5792678C4A400530A6E /* ActivityIndicator */ = {
isa = XCSwiftPackageProductDependency;
package = 625CB5782678C4A400530A6E /* XCRemoteSwiftPackageReference "ActivityIndicator" */;
productName = ActivityIndicator;
};
628B95322670CAEA0091AF3B /* NukeUI */ = {
isa = XCSwiftPackageProductDependency;
package = 621C637E26672A30004216EA /* XCRemoteSwiftPackageReference "NukeUI" */;

View File

@ -1,6 +1,15 @@
{
"object": {
"pins": [
{
"package": "ActivityIndicator",
"repositoryURL": "https://github.com/duyquang91/ActivityIndicator",
"state": {
"branch": null,
"revision": "0101a02196f6a67cf26f6434b007d3db6bd07fee",
"version": "1.1.0"
}
},
{
"package": "AnyCodable",
"repositoryURL": "https://github.com/Flight-School/AnyCodable",

View File

@ -5,259 +5,46 @@
* Copyright 2021 Aiden Vigue & Jellyfin Contributors
*/
import SwiftUI
import CoreData
import KeychainSwift
import JellyfinAPI
import KeychainSwift
import SwiftUI
struct ConnectToServerView: View {
@Environment(\.managedObjectContext) private var viewContext
@EnvironmentObject var jsi: justSignedIn
@StateObject
var viewModel = ConnectToServerViewModel()
@State private var uri = ""
@State private var isWorking = false
@State private var isErrored = false
@State private var isDone = false
@State private var isSignInErrored = false
@State private var isConnected = false
@State private var serverName = ""
@State private var usernameDisabled: Bool = false
@State private var publicUsers: [UserDto] = []
@State private var lastPublicUsers: [UserDto] = []
@State private var username = ""
@State private var password = ""
@State private var server_id = ""
@State private var serverSkipped: Bool = false
@State private var serverSkippedAlert: Bool = false
@State private var skip_server_bool: Bool = false
@State private var skip_server_obj: Server!
@Binding var rootIsActive: Bool
private var reauthDeviceID: String = ""
private let userUUID = UUID()
init(skip_server: Bool, skip_server_prefill: Server, reauth_deviceId: String, isActive: Binding<Bool>) {
_rootIsActive = isActive
skip_server_bool = skip_server
skip_server_obj = skip_server_prefill
reauthDeviceID = reauth_deviceId
}
init(isActive: Binding<Bool>) {
_rootIsActive = isActive
}
func start() {
if skip_server_bool {
uri = skip_server_obj.baseURI!
UserAPI.getPublicUsers()
.sink(receiveCompletion: { completion in
switch completion {
case .finished:
break
case .failure:
skip_server_bool = false
skip_server_obj = Server()
break
}
}, receiveValue: { response in
publicUsers = response
serverSkipped = true
serverSkippedAlert = true
server_id = skip_server_obj.server_id!
serverName = skip_server_obj.name!
isConnected = true
})
.store(in: &globalData.pendingAPIRequests)
}
}
func doLogin() {
isWorking = true
let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
var deviceName = UIDevice.current.name
deviceName = deviceName.folding(options: .diacriticInsensitive, locale: .current)
deviceName = deviceName.removeRegexMatches(pattern: "[^\\w\\s]")
let authHeader = "MediaBrowser Client=\"SwiftFin\", Device=\"\(deviceName)\", DeviceId=\"\(serverSkipped ? reauthDeviceID : userUUID.uuidString)\", Version=\"\(appVersion ?? "0.0.1")\""
print(authHeader)
JellyfinAPI.customHeaders["X-Emby-Authorization"] = authHeader
let x: AuthenticateUserByName = AuthenticateUserByName(username: username, pw: password, password: nil)
UserAPI.authenticateUserByName(authenticateUserByName: x)
.sink(receiveCompletion: { completion in
switch completion {
case .finished:
break
case .failure(let error):
isWorking = false
if let err = error as? ErrorResponse {
switch err {
case .error(401, _, _, _):
isSignInErrored = true
case .error:
globalData.networkError = true
}
}
break
}
}, receiveValue: { response in
let fetchRequest: NSFetchRequest<NSFetchRequestResult> = NSFetchRequest(entityName: "Server")
let deleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest)
do {
try viewContext.execute(deleteRequest)
} catch _ as NSError {
}
let fetchRequest2: NSFetchRequest<NSFetchRequestResult> = NSFetchRequest(entityName: "SignedInUser")
let deleteRequest2 = NSBatchDeleteRequest(fetchRequest: fetchRequest2)
do {
try viewContext.execute(deleteRequest2)
} catch _ as NSError {
}
let newServer = Server(context: viewContext)
newServer.baseURI = uri
newServer.name = serverName
newServer.server_id = server_id
let newUser = SignedInUser(context: viewContext)
newUser.device_uuid = userUUID.uuidString
newUser.username = username
newUser.user_id = response.user!.id!
let keychain = KeychainSwift()
keychain.set(response.accessToken!, forKey: "AccessToken_\(newUser.user_id!)")
do {
try viewContext.save()
DispatchQueue.main.async { [self] in
globalData.authHeader = authHeader
_rootIsActive.wrappedValue = false
globalData.expiredCredentials = false
globalData.networkError = false
globalData.user = newUser
globalData.server = newServer
jsi.did = true
print("logged in")
}
} catch {
print("Couldn't store objects to CoreData")
}
})
.store(in: &globalData.pendingAPIRequests)
}
@Binding
var isLoggedIn: Bool
var body: some View {
Form {
if !isConnected {
Section(header: Text("Server Information")) {
TextField("Jellyfin Server URL", text: $uri)
.disableAutocorrection(true)
.autocapitalization(.none)
Button {
isWorking = true
if !uri.contains("http") {
uri = "https://" + uri
}
if uri.last == "/" {
uri = String(uri.dropLast())
ZStack {
Form {
if viewModel.isConnectedServer {
if viewModel.publicUsers.isEmpty {
Section(header: Text("Login to \(ServerEnvironment.current.server.name ?? "")")) {
TextField("Username", text: $viewModel.username)
.disableAutocorrection(true)
.autocapitalization(.none)
SecureField("Password", text: $viewModel.password)
.disableAutocorrection(true)
.autocapitalization(.none)
Button {
viewModel.login()
} label: {
HStack {
Text("Login")
Spacer()
if viewModel.isLoading {
ProgressView()
}
}
}.disabled(viewModel.isLoading || viewModel.username.isEmpty)
}
JellyfinAPI.basePath = uri
SystemAPI.getPublicSystemInfo()
.sink(receiveCompletion: { completion in
switch completion {
case .finished:
break
case .failure:
isErrored = true
isWorking = false
break
}
}, receiveValue: { response in
let server = response
serverName = server.serverName!
server_id = server.id!
if server.startupWizardCompleted ?? true {
isConnected = true
UserAPI.getPublicUsers()
.sink(receiveCompletion: { completion in
switch completion {
case .finished:
break
case .failure:
isErrored = true
isWorking = false
break
}
}, receiveValue: { response in
publicUsers = response
isWorking = false
})
.store(in: &globalData.pendingAPIRequests)
}
})
.store(in: &globalData.pendingAPIRequests)
} label: {
HStack {
Text("Connect")
Spacer()
if isWorking == true {
ProgressView()
}
}
}.disabled(isWorking || uri.isEmpty)
}.alert(isPresented: $isErrored) {
Alert(title: Text("Error"), message: Text("Couldn't connect to server"), dismissButton: .default(Text("Try again")))
}
} else {
if publicUsers.count == 0 {
Section(header: Text("\(serverSkipped ? "Reauthenticate" : "Login") to \(serverName)")) {
TextField("Username", text: $username)
.disableAutocorrection(true)
.autocapitalization(.none)
.disabled(usernameDisabled)
SecureField("Password", text: $password)
.disableAutocorrection(true)
.autocapitalization(.none)
Button {
doLogin()
} label: {
HStack {
Text("Login")
Spacer()
if isWorking {
ProgressView()
}
}
}.disabled(isWorking || username.isEmpty)
.alert(isPresented: $isSignInErrored) {
Alert(title: Text("Error"), message: Text("Invalid credentials"), dismissButton: .default(Text("Back")))
}
}
if serverSkipped {
Section {
Button {
serverSkippedAlert = false
server_id = ""
serverName = ""
isConnected = false
serverSkipped = false
viewModel.isConnectedServer = false
} label: {
HStack {
HStack {
@ -269,85 +56,85 @@ struct ConnectToServerView: View {
}
}
} else {
Section {
Button {
publicUsers = lastPublicUsers
usernameDisabled = false
} label: {
Section(header: Text("Login to \(ServerEnvironment.current.server.name ?? "")")) {
ForEach(viewModel.publicUsers, id: \.id) { publicUser in
HStack {
HStack {
Image(systemName: "chevron.left")
Text("Back")
}
Spacer()
}
}
}
}
} else {
Section(header: Text("\(serverSkipped ? "Reauthenticate" : "Login") to \(serverName)")) {
ForEach(publicUsers, id: \.id) { publicUser in
HStack {
Button() {
if publicUser.hasPassword ?? true {
lastPublicUsers = publicUsers
username = publicUser.name ?? ""
usernameDisabled = true
publicUsers = []
} else {
publicUsers = []
password = ""
username = publicUser.name ?? ""
doLogin()
}
} label: {
HStack {
Text(publicUser.name ?? "").font(.subheadline).fontWeight(.semibold)
Spacer()
if publicUser.primaryImageTag != nil {
ImageView(src: URL(string: "\(uri)/Users/\(publicUser.id ?? "")/Images/Primary?width=200&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)
Button(action: {
viewModel.username = publicUser.name ?? ""
viewModel.publicUsers.removeAll()
if !(publicUser.hasPassword ?? true) {
viewModel.password = ""
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=200&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() {
lastPublicUsers = publicUsers
publicUsers = []
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)
Section {
Button {
viewModel.publicUsers.removeAll()
viewModel.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("Server Information")) {
TextField("Jellyfin Server URL", text: $viewModel.uri)
.disableAutocorrection(true)
.autocapitalization(.none)
Button {
viewModel.connectToServer()
} label: {
HStack {
Text("Connect")
Spacer()
}
if viewModel.isLoading {
ProgressView()
}
}
.disabled(viewModel.isLoading || viewModel.uri.isEmpty)
}
}
}
}.navigationTitle("Connect to Server")
.alert(isPresented: $serverSkippedAlert) {
Alert(title: Text("Error"), message: Text("Credentials have expired"), dismissButton: .default(Text("Sign in again")))
}
.onAppear(perform: start)
.alert(item: $viewModel.errorMessage) { _ in
Alert(title: Text("Error"), message: Text("message"), dismissButton: .default(Text("Try again")))
}
.onReceive(viewModel.$isLoggedIn, perform: { flag in
isLoggedIn = flag
})
.navigationTitle("Connect to Server")
}
}

View File

@ -1,231 +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 KeychainSwift
import Nuke
import JellyfinAPI
import WidgetKit
struct ContentView: View {
@Environment(\.managedObjectContext) private var viewContext
@EnvironmentObject var jsi: justSignedIn
@State private var orientation = UIDeviceOrientation.unknown
@State private var needsToSelectServer = false
@State private var isLoading = false
@State private var tabSelection: String = "Home"
@State private var libraries: [String] = []
@State private var library_names: [String: String] = [:]
@State private var librariesShowRecentlyAdded: [String] = []
@State private var libraryPrefillID: String = ""
@State private var showSettingsPopover: Bool = false
@State private var viewDidLoad: Bool = false
@State private var loadState: Int = 2
@FetchRequest(entity: Server.entity(), sortDescriptors: [NSSortDescriptor(keyPath: \Server.name, ascending: true)])
var servers: FetchedResults<Server>
@FetchRequest(entity: SignedInUser.entity(), sortDescriptors: [NSSortDescriptor(keyPath: \SignedInUser.username, ascending: true)])
var savedUsers: FetchedResults<SignedInUser>
private var recentFilterSet: LibraryFilters = LibraryFilters(filters: [], sortOrder: [.descending], sortBy: ["DateCreated"])
func startup() {
if viewDidLoad == true {
return
}
ImageCache.shared.costLimit = 125 * 1024 * 1024 // 125MB memory
DataLoader.sharedUrlCache.diskCapacity = 1000 * 1024 * 1024 // 1000MB disk
if servers.isEmpty {
isLoading = false
needsToSelectServer = true
} else {
isLoading = true
let savedUser = savedUsers[0]
let keychain = KeychainSwift()
keychain.accessGroup = "9R8RREG67J.me.vigue.jellyfin.sharedKeychain"
if keychain.get("AccessToken_\(savedUser.user_id ?? "")") != nil {
globalData.authToken = keychain.get("AccessToken_\(savedUser.user_id ?? "")") ?? ""
globalData.server = servers[0]
globalData.user = savedUser
}
let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
var deviceName = UIDevice.current.name
deviceName = deviceName.folding(options: .diacriticInsensitive, locale: .current)
deviceName = deviceName.removeRegexMatches(pattern: "[^\\w\\s]")
var header = "MediaBrowser "
header.append("Client=\"SwiftFin\", ")
header.append("Device=\"\(deviceName)\", ")
header.append("DeviceId=\"\(globalData.user.device_uuid ?? "")\", ")
header.append("Version=\"\(appVersion ?? "0.0.1")\", ")
header.append("Token=\"\(globalData.authToken)\"")
globalData.authHeader = header
JellyfinAPI.basePath = ServerEnvironment.current.server.baseURI ?? ""
JellyfinAPI.customHeaders = ["X-Emby-Authorization": globalData.authHeader]
DispatchQueue.global(qos: .userInitiated).async {
UserAPI.getCurrentUser()
.sink(receiveCompletion: { completion in
print(completion)
loadState = loadState - 1
}, receiveValue: { response in
libraries = response.configuration?.orderedViews ?? []
librariesShowRecentlyAdded = libraries.filter { element in
return !(response.configuration?.latestItemsExcludes?.contains(element))!
}
if loadState == 1 {
isLoading = false
viewDidLoad = true
}
})
.store(in: &globalData.pendingAPIRequests)
UserViewsAPI.getUserViews(userId: SessionManager.current.userID ?? "")
.sink(receiveCompletion: { completion in
print(completion)
loadState = loadState - 1
}, receiveValue: { response in
response.items?.forEach({ item in
library_names[item.id ?? ""] = item.name
})
if loadState == 1 {
isLoading = false
viewDidLoad = true
}
})
.store(in: &globalData.pendingAPIRequests)
}
let defaults = UserDefaults.standard
if defaults.integer(forKey: "InNetworkBandwidth") == 0 {
defaults.setValue(40_000_000, forKey: "InNetworkBandwidth")
}
if defaults.integer(forKey: "OutOfNetworkBandwidth") == 0 {
defaults.setValue(40_000_000, forKey: "OutOfNetworkBandwidth")
}
}
WidgetCenter.shared.reloadAllTimelines()
}
var body: some View {
if needsToSelectServer == true || globalData.user == nil || globalData.server == nil {
NavigationView {
ConnectToServerView(isActive: $needsToSelectServer)
}
.navigationViewStyle(StackNavigationViewStyle())
.environmentObject(globalData)
.onAppear(perform: startup)
} else if globalData.expiredCredentials == true {
NavigationView {
ConnectToServerView(skip_server: true, skip_server_prefill: globalData.server,
reauth_deviceId: globalData.user.device_uuid!, isActive: $globalData.expiredCredentials)
}
.navigationViewStyle(StackNavigationViewStyle())
.environmentObject(globalData)
.onAppear(perform: startup)
} else {
if !jsi.did {
if isLoading || globalData.user == nil || SessionManager.current.userID == nil {
ProgressView()
.onAppear(perform: startup)
} else {
VStack {
TabView(selection: $tabSelection) {
NavigationView {
VStack(alignment: .leading) {
ScrollView {
Spacer().frame(height: orientation == .portrait ? 0 : 16)
ContinueWatchingView()
NextUpView()
ForEach(librariesShowRecentlyAdded, id: \.self) { library_id in
VStack(alignment: .leading) {
HStack {
Text("Latest \(library_names[library_id] ?? "")").font(.title2).fontWeight(.bold)
.padding(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 16))
Spacer()
NavigationLink(destination: LazyView {
LibraryView(usingParentID: library_id,
title: library_names[library_id] ?? "", usingFilters: recentFilterSet)
}) {
HStack {
Text("See All").font(.subheadline).fontWeight(.bold)
Image(systemName: "chevron.right").font(Font.subheadline.bold())
}
}
}.padding(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16))
LatestMediaView(usingParentID: library_id)
}.padding(EdgeInsets(top: 4, leading: 0, bottom: 0, trailing: 0))
}
Spacer().frame(height: UIDevice.current.userInterfaceIdiom == .phone ? 20 : 30)
}
.navigationTitle("Home")
.toolbar {
ToolbarItemGroup(placement: .navigationBarTrailing) {
Button {
showSettingsPopover = true
} label: {
Image(systemName: "gear")
}
}
}
.fullScreenCover(isPresented: $showSettingsPopover) {
SettingsView(viewModel: SettingsViewModel(), close: $showSettingsPopover)
}
}
}
.navigationViewStyle(StackNavigationViewStyle())
.tabItem {
Text("Home")
Image(systemName: "house")
}
.tag("Home")
NavigationView {
LibraryListView(libraries: library_names)
}
.navigationViewStyle(StackNavigationViewStyle())
.tabItem {
Text("All Media")
Image(systemName: "folder")
}
.tag("All Media")
}
}
.environmentObject(globalData)
.onAppear(perform: startup)
.alert(isPresented: $globalData.networkError) {
Alert(title: Text("Network Error"), message: Text("An error occured while performing a network request"), dismissButton: .default(Text("Ok")))
}
.onRotate { orientation in
self.orientation = orientation
}
}
} else {
Text("Please wait...")
.onAppear(perform: {
DispatchQueue.main.async { [self] in
_viewDidLoad.wrappedValue = false
sleep(1)
self.jsi.did = false
}
})
}
}
}
}

View File

@ -32,21 +32,7 @@ struct ProgressBar: Shape {
}
struct ContinueWatchingView: View {
@State private var items: [BaseItemDto] = []
func onAppear() {
var tempCancellables = Set<AnyCancellable>()
DispatchQueue.global(qos: .userInitiated).async {
ItemsAPI.getResumeItems(userId: SessionManager.current.userID!, limit: 12, fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people], mediaTypes: ["Video"], imageTypeLimit: 1, enableImageTypes: [.primary, .backdrop, .thumb])
.sink(receiveCompletion: { completion in
print(completion)
}, receiveValue: { response in
items = response.items ?? []
})
.store(in: &tempCancellables)
}
}
var items: [BaseItemDto]
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
@ -97,6 +83,6 @@ struct ContinueWatchingView: View {
} else {
EmptyView()
}
}.onAppear(perform: onAppear)
}
}
}

View File

@ -10,7 +10,11 @@ import JellyfinAPI
import Combine
struct EpisodeItemView: View {
@StateObject
var tempViewModel = ViewModel()
@State private var orientation = UIDeviceOrientation.unknown
@Environment(\.horizontalSizeClass) var hSizeClass
@Environment(\.verticalSizeClass) var vSizeClass
@EnvironmentObject private var playbackInfo: VideoPlayerItem
var item: BaseItemDto
@ -18,7 +22,6 @@ struct EpisodeItemView: View {
@State private var settingState: Bool = true
@State private var watched: Bool = false {
didSet {
var tempCancellables = Set<AnyCancellable>()
if !settingState {
if watched == true {
PlaystateAPI.markPlayedItem(userId: SessionManager.current.userID!, itemId: item.id!)
@ -26,14 +29,14 @@ struct EpisodeItemView: View {
print(completion)
}, receiveValue: { _ in
})
.store(in: &tempCancellables)
.store(in: &tempViewModel.cancellables)
} else {
PlaystateAPI.markUnplayedItem(userId: SessionManager.current.userID!, itemId: item.id!)
.sink(receiveCompletion: { completion in
print(completion)
}, receiveValue: { _ in
})
.store(in: &tempCancellables)
.store(in: &tempViewModel.cancellables)
}
}
}
@ -42,7 +45,6 @@ struct EpisodeItemView: View {
@State
private var favorite: Bool = false {
didSet {
var tempCancellables = Set<AnyCancellable>()
if !settingState {
if favorite == true {
UserLibraryAPI.markFavoriteItem(userId: SessionManager.current.userID!, itemId: item.id!)
@ -50,14 +52,14 @@ struct EpisodeItemView: View {
print(completion)
}, receiveValue: { _ in
})
.store(in: &tempCancellables)
.store(in: &tempViewModel.cancellables)
} else {
UserLibraryAPI.unmarkFavoriteItem(userId: SessionManager.current.userID!, itemId: item.id!)
.sink(receiveCompletion: { completion in
print(completion)
}, receiveValue: { _ in
})
.store(in: &tempCancellables)
.store(in: &tempViewModel.cancellables)
}
}
}
@ -152,7 +154,7 @@ struct EpisodeItemView: View {
var body: some View {
VStack(alignment: .leading) {
if orientation == .portrait {
if hSizeClass == .compact && vSizeClass == .regular {
ParallaxHeaderScrollView(header: portraitHeaderView, staticOverlayView: portraitHeaderOverlayView, overlayAlignment: .bottomLeading, headerHeight: UIDevice.current.userInterfaceIdiom == .pad ? 350 : UIScreen.main.bounds.width * 0.5625) {
VStack(alignment: .leading) {
Spacer()

View File

@ -0,0 +1,85 @@
//
/*
* 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 HomeView: View {
@StateObject
var viewModel = HomeViewModel()
@State
private var orientation = UIDevice.current.orientation
@Environment(\.horizontalSizeClass)
var hSizeClass
@Environment(\.verticalSizeClass)
var vSizeClass
@State
var showingSettings = false
var body: some View {
ZStack {
ScrollView {
LazyVStack(alignment: .leading) {
Spacer().frame(height: hSizeClass == .compact && vSizeClass == .regular ? 0 : 16)
if !viewModel.resumeItems.isEmpty {
ContinueWatchingView(items: viewModel.resumeItems)
}
if !viewModel.nextUpItems.isEmpty {
NextUpView(items: viewModel.nextUpItems)
}
if !viewModel.librariesShowRecentlyAddedIDs.isEmpty {
ForEach(viewModel.librariesShowRecentlyAddedIDs, id: \.self) { libraryID in
VStack(alignment: .leading) {
let library = viewModel.libraries.first(where: { $0.id == libraryID })
HStack {
Text("Latest \(library?.name ?? "")")
.font(.title2)
.fontWeight(.bold)
.padding(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 16))
Spacer()
NavigationLink(destination: LazyView {
LibraryView(usingParentID: libraryID,
title: library?.name ?? "", usingFilters: viewModel.recentFilterSet)
}) {
HStack {
Text("See All").font(.subheadline).fontWeight(.bold)
Image(systemName: "chevron.right").font(Font.subheadline.bold())
}
}
}.padding(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16))
LatestMediaView(usingParentID: libraryID)
}.padding(EdgeInsets(top: 4, leading: 0, bottom: 0, trailing: 0))
}
}
Spacer().frame(height: UIDevice.current.userInterfaceIdiom == .phone ? 20 : 30)
}
}
if viewModel.isLoading {
ProgressView()
}
}
.onRotate {
orientation = $0
}
.navigationTitle(MainTabView.Tab.home.localized)
.toolbar {
ToolbarItemGroup(placement: .navigationBarTrailing) {
Button {
showingSettings = true
} label: {
Image(systemName: "gear")
}
}
}
.fullScreenCover(isPresented: $showingSettings) {
SettingsView(viewModel: SettingsViewModel(), close: $showingSettings)
}
}
}

View File

@ -141,15 +141,13 @@ extension View {
@main
struct JellyfinPlayerApp: App {
let persistenceController = PersistenceController.shared
@StateObject private var jsi = justSignedIn()
var body: some Scene {
WindowGroup {
ContentView()
SplashView()
.environment(\.managedObjectContext, persistenceController.container.viewContext)
.environmentObject(jsi)
.withHostingWindow { window in
window?.rootViewController = PreferenceUIHostingController(wrappedView: ContentView().environment(\.managedObjectContext, persistenceController.container.viewContext).environmentObject(jsi))
window?.rootViewController = PreferenceUIHostingController(wrappedView: SplashView().environment(\.managedObjectContext, persistenceController.container.viewContext))
}
}
}

View File

@ -11,6 +11,8 @@ import Combine
struct LatestMediaView: View {
@StateObject
var tempViewModel = ViewModel()
@State var items: [BaseItemDto] = []
private var library_id: String = ""
@State private var viewDidLoad: Bool = false
@ -24,8 +26,6 @@ struct LatestMediaView: View {
return
}
viewDidLoad = true
var tempCancellables = Set<AnyCancellable>()
DispatchQueue.global(qos: .userInitiated).async {
UserLibraryAPI.getLatestMedia(userId: SessionManager.current.userID!, parentId: library_id, fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people], enableUserData: true, limit: 12)
@ -34,7 +34,7 @@ struct LatestMediaView: View {
}, receiveValue: { response in
items = response
})
.store(in: &tempCancellables)
.store(in: &tempViewModel.cancellables)
}
}

View File

@ -9,51 +9,33 @@ import Foundation
import SwiftUI
struct LibraryListView: View {
@State var library_ids: [String] = ["favorites", "genres"]
@State var library_names: [String: String] = ["favorites": "Favorites", "genres": "Genres"]
var libraries: [String: String] = [:] // input libraries
var withFavorites: LibraryFilters = LibraryFilters(filters: [.isFavorite], sortOrder: [], withGenres: [], sortBy: [])
init(libraries: [String: String]) {
self.libraries = libraries
}
func onAppear() {
if library_ids.count == 2 {
libraries.forEach { k, v in
print("\(k): \(v)")
_library_ids.wrappedValue.append(k)
_library_names.wrappedValue[k] = v
}
}
}
@StateObject
var viewModel = LibraryListViewModel()
var body: some View {
List(library_ids, id: \.self) { key in
switch key {
case "favorites":
NavigationLink(destination: LazyView {
LibraryView(usingParentID: "", title: library_names[key] ?? "", usingFilters: withFavorites)
}) {
Text(library_names[key] ?? "")
}
case "genres":
NavigationLink(destination: LazyView {
EmptyView()
}) {
Text(library_names[key] ?? "")
}
default:
NavigationLink(destination: LazyView {
LibraryView(usingParentID: key, title: library_names[key] ?? "")
}) {
Text(library_names[key] ?? "")
}
List(viewModel.libraries, id: \.self) { library in
switch library.id {
case "favorites":
NavigationLink(destination: LazyView {
LibraryView(usingParentID: "", title: library.name ?? "", usingFilters: viewModel.withFavorites)
}) {
Text(library.name ?? "")
}
case "genres":
NavigationLink(destination: LazyView {
EmptyView()
}) {
Text(library.name ?? "")
}
default:
NavigationLink(destination: LazyView {
LibraryView(usingParentID: library.id ?? "", title: library.name ?? "")
}) {
Text(library.name ?? "")
}
}
}
.navigationTitle("All Media")
.onAppear(perform: onAppear)
.toolbar {
ToolbarItemGroup(placement: .navigationBarTrailing) {
NavigationLink(destination: LazyView {

View File

@ -11,6 +11,8 @@ import Combine
struct LibrarySearchView: View {
@StateObject
var tempViewModel = ViewModel()
@State private var items: [BaseItemDto] = []
@State private var searchQuery: String = ""
@State private var isLoading: Bool = false
@ -28,7 +30,6 @@ struct LibrarySearchView: View {
func requestSearch(query: String) {
isLoading = true
var tempCancellables = Set<AnyCancellable>()
DispatchQueue.global(qos: .userInitiated).async {
ItemsAPI.getItemsByUserId(userId: SessionManager.current.userID!, limit: 60, recursive: true, searchTerm: query, sortOrder: [.ascending], parentId: (usingParentID != "" ? usingParentID : nil), fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people], includeItemTypes: ["Movie", "Series"], sortBy: ["SortName"], enableUserData: true, enableImages: true)
.sink(receiveCompletion: { completion in
@ -37,7 +38,7 @@ struct LibrarySearchView: View {
items = response.items ?? []
isLoading = false
})
.store(in: &tempCancellables)
.store(in: &tempViewModel.cancellables)
}
}

View File

@ -13,6 +13,8 @@ import Combine
struct LibraryView: View {
@StateObject
var tempViewModel = ViewModel()
@State private var items: [BaseItemDto] = []
@State private var isLoading: Bool = false
@ -66,8 +68,6 @@ struct LibraryView: View {
isLoading = true
items = []
var tempCancellables = Set<AnyCancellable>()
DispatchQueue.global(qos: .userInitiated).async {
ItemsAPI.getItemsByUserId(userId: SessionManager.current.userID!, startIndex: currentPage * 100, limit: 100, recursive: true, searchTerm: nil, sortOrder: filters.sortOrder, parentId: (usingParentID != "" ? usingParentID : nil), fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people], includeItemTypes: ["Movie", "Series"], filters: filters.filters, sortBy: filters.sortBy, enableUserData: true, personIds: (personId == "" ? nil : [personId]), studioIds: (studio == "" ? nil : [studio]), genreIds: (genre == "" ? nil : [genre]), enableImages: true)
@ -81,7 +81,7 @@ struct LibraryView: View {
isLoading = false
viewDidLoad = true
})
.store(in: &tempCancellables)
.store(in: &tempViewModel.cancellables)
}
}

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 Foundation
import SwiftUI
struct MainTabView: View {
@State private var tabSelection: Tab = .home
var body: some View {
TabView(selection: $tabSelection) {
NavigationView {
HomeView()
}
.navigationViewStyle(StackNavigationViewStyle())
.tabItem {
Text(Tab.home.localized)
Image(systemName: "house")
}
.tag(Tab.home)
NavigationView {
LibraryListView()
}
.navigationViewStyle(StackNavigationViewStyle())
.tabItem {
Text(Tab.allMedia.localized)
Image(systemName: "folder")
}
.tag(Tab.allMedia)
}
}
}
extension MainTabView {
enum Tab: String {
case home
case allMedia
var localized: String {
switch self {
case .home:
return "Home"
case .allMedia:
return "All Media"
}
}
}
}

View File

@ -5,21 +5,29 @@
* Copyright 2021 Aiden Vigue & Jellyfin Contributors
*/
import SwiftUI
import JellyfinAPI
import Combine
import JellyfinAPI
import SwiftUI
struct MovieItemView: View {
@State private var orientation = UIDeviceOrientation.unknown
@EnvironmentObject private var playbackInfo: VideoPlayerItem
@StateObject
var tempViewModel = ViewModel()
@State
private var orientation = UIDeviceOrientation.unknown
@Environment(\.horizontalSizeClass)
var hSizeClass
@Environment(\.verticalSizeClass)
var vSizeClass
@EnvironmentObject
private var playbackInfo: VideoPlayerItem
var item: BaseItemDto
@State private var settingState: Bool = true
@State private var watched: Bool = false {
@State
private var settingState: Bool = true
@State
private var watched: Bool = false {
didSet {
var tempCancellables = Set<AnyCancellable>()
if !settingState {
if watched == true {
PlaystateAPI.markPlayedItem(userId: SessionManager.current.userID!, itemId: item.id!)
@ -27,14 +35,14 @@ struct MovieItemView: View {
print(completion)
}, receiveValue: { _ in
})
.store(in: &tempCancellables)
.store(in: &tempViewModel.cancellables)
} else {
PlaystateAPI.markUnplayedItem(userId: SessionManager.current.userID!, itemId: item.id!)
.sink(receiveCompletion: { completion in
print(completion)
}, receiveValue: { _ in
})
.store(in: &tempCancellables)
.store(in: &tempViewModel.cancellables)
}
}
}
@ -43,8 +51,6 @@ struct MovieItemView: View {
@State
private var favorite: Bool = false {
didSet {
var tempCancellables = Set<AnyCancellable>()
if !settingState {
if favorite == true {
UserLibraryAPI.markFavoriteItem(userId: SessionManager.current.userID!, itemId: item.id!)
@ -52,21 +58,24 @@ struct MovieItemView: View {
print(completion)
}, receiveValue: { _ in
})
.store(in: &tempCancellables)
.store(in: &tempViewModel.cancellables)
} else {
UserLibraryAPI.unmarkFavoriteItem(userId: SessionManager.current.userID!, itemId: item.id!)
.sink(receiveCompletion: { completion in
print(completion)
}, receiveValue: { _ in
})
.store(in: &tempCancellables)
.store(in: &tempViewModel.cancellables)
}
}
}
}
var portraitHeaderView: some View {
ImageView(src: item.getBackdropImage(baseURL: ServerEnvironment.current.server.baseURI!, maxWidth: UIDevice.current.userInterfaceIdiom == .pad ? 622 : Int(UIScreen.main.bounds.width)), bh: item.getBackdropImageBlurHash())
ImageView(src: item
.getBackdropImage(baseURL: ServerEnvironment.current.server.baseURI!,
maxWidth: UIDevice.current.userInterfaceIdiom == .pad ? 622 : Int(UIScreen.main.bounds.width)),
bh: item.getBackdropImageBlurHash())
.opacity(0.4)
.blur(radius: 2.0)
}
@ -156,8 +165,11 @@ struct MovieItemView: View {
var body: some View {
VStack(alignment: .leading) {
if orientation == .portrait {
ParallaxHeaderScrollView(header: portraitHeaderView, staticOverlayView: portraitHeaderOverlayView, overlayAlignment: .bottomLeading, headerHeight: UIDevice.current.userInterfaceIdiom == .pad ? 350 : UIScreen.main.bounds.width * 0.5625) {
if hSizeClass == .compact && vSizeClass == .regular {
ParallaxHeaderScrollView(header: portraitHeaderView, staticOverlayView: portraitHeaderOverlayView,
overlayAlignment: .bottomLeading,
headerHeight: UIDevice.current.userInterfaceIdiom == .pad ? 350 : UIScreen.main.bounds
.width * 0.5625) {
VStack(alignment: .leading) {
Spacer()
.frame(height: UIDevice.current.userInterfaceIdiom == .pad ? 135 : 40)
@ -196,7 +208,9 @@ struct MovieItemView: View {
LibraryView(withPerson: person)
}) {
VStack {
ImageView(src: person.getImage(baseURL: ServerEnvironment.current.server.baseURI!, maxWidth: 100), bh: person.getBlurHash())
ImageView(src: person
.getImage(baseURL: ServerEnvironment.current.server.baseURI!, maxWidth: 100),
bh: person.getBlurHash())
.frame(width: 100, height: 100)
.cornerRadius(10)
Text(person.name ?? "").font(.footnote).fontWeight(.regular).lineLimit(1)
@ -235,7 +249,8 @@ struct MovieItemView: View {
} else {
GeometryReader { geometry in
ZStack {
ImageView(src: item.getBackdropImage(baseURL: ServerEnvironment.current.server.baseURI!, maxWidth: 200), bh: item.getBackdropImageBlurHash())
ImageView(src: item.getBackdropImage(baseURL: ServerEnvironment.current.server.baseURI!, maxWidth: 200),
bh: item.getBackdropImageBlurHash())
.opacity(0.3)
.frame(width: geometry.size.width + geometry.safeAreaInsets.leading + geometry.safeAreaInsets.trailing,
height: geometry.size.height + geometry.safeAreaInsets.top + geometry.safeAreaInsets.bottom)
@ -243,7 +258,8 @@ struct MovieItemView: View {
.blur(radius: 4)
HStack {
VStack {
ImageView(src: item.getPrimaryImage(baseURL: ServerEnvironment.current.server.baseURI!, maxWidth: 120), bh: item.getPrimaryImageBlurHash())
ImageView(src: item.getPrimaryImage(baseURL: ServerEnvironment.current.server.baseURI!, maxWidth: 120),
bh: item.getPrimaryImageBlurHash())
.frame(width: 120, height: 180)
.cornerRadius(10)
Spacer().frame(height: 15)
@ -369,10 +385,14 @@ struct MovieItemView: View {
LibraryView(withPerson: person)
}) {
VStack {
ImageView(src: person.getImage(baseURL: ServerEnvironment.current.server.baseURI!, maxWidth: 100), bh: person.getBlurHash())
ImageView(src: person
.getImage(baseURL: ServerEnvironment.current.server.baseURI!,
maxWidth: 100),
bh: person.getBlurHash())
.frame(width: 100, height: 100)
.cornerRadius(10)
Text(person.name ?? "").font(.footnote).fontWeight(.regular).lineLimit(1)
Text(person.name ?? "").font(.footnote).fontWeight(.regular)
.lineLimit(1)
.frame(width: 100).foregroundColor(Color.primary)
if person.role != "" {
Text(person.role!).font(.caption).fontWeight(.medium).lineLimit(1)

View File

@ -11,27 +11,7 @@ import JellyfinAPI
struct NextUpView: View {
@State private var items: [BaseItemDto] = []
@State private var viewDidLoad: Bool = false
func onAppear() {
if viewDidLoad == true {
return
}
viewDidLoad = true
var tempCancellables = Set<AnyCancellable>()
DispatchQueue.global(qos: .userInitiated).async {
TvShowsAPI.getNextUp(userId: SessionManager.current.userID!, limit: 12, fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people])
.sink(receiveCompletion: { result in
print(result)
}, receiveValue: { response in
items = response.items ?? []
})
.store(in: &tempCancellables)
}
}
var items: [BaseItemDto]
var body: some View {
VStack(alignment: .leading) {
@ -69,7 +49,6 @@ struct NextUpView: View {
.frame(height: 200)
}
}
.onAppear(perform: onAppear)
.padding(EdgeInsets(top: 4, leading: 0, bottom: 0, trailing: 0))
}
}

View File

@ -10,10 +10,15 @@ import Combine
import JellyfinAPI
struct SeasonItemView: View {
@StateObject
var tempViewModel = ViewModel()
@State private var orientation = UIDeviceOrientation.unknown
@Environment(\.horizontalSizeClass) var hSizeClass
@Environment(\.verticalSizeClass) var vSizeClass
var item: BaseItemDto = BaseItemDto()
@State private var episodes: [BaseItemDto] = []
@State private var isLoading: Bool = true
@State private var viewDidLoad: Bool = false
@ -26,7 +31,6 @@ struct SeasonItemView: View {
if viewDidLoad {
return
}
var tempCancellables = Set<AnyCancellable>()
DispatchQueue.global(qos: .userInitiated).async {
TvShowsAPI.getEpisodes(seriesId: item.seriesId ?? "", userId: SessionManager.current.userID!, fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people], seasonId: item.id ?? "")
@ -37,7 +41,7 @@ struct SeasonItemView: View {
viewDidLoad = true
episodes = response.items ?? []
})
.store(in: &tempCancellables)
.store(in: &tempViewModel.cancellables)
}
}
@ -76,7 +80,7 @@ struct SeasonItemView: View {
@ViewBuilder
var innerBody: some View {
if orientation == .portrait {
if hSizeClass == .compact && vSizeClass == .regular {
ParallaxHeaderScrollView(header: portraitHeaderView,
staticOverlayView: portraitHeaderOverlayView,
overlayAlignment: .bottomLeading,

View File

@ -10,6 +10,8 @@ import JellyfinAPI
import Combine
struct SeriesItemView: View {
@StateObject
var tempViewModel = ViewModel()
var item: BaseItemDto
@ -25,7 +27,6 @@ struct SeriesItemView: View {
isLoading = true
var tempCancellables = Set<AnyCancellable>()
DispatchQueue.global(qos: .userInitiated).async {
TvShowsAPI.getSeasons(seriesId: item.id ?? "", fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people])
@ -36,7 +37,7 @@ struct SeriesItemView: View {
viewDidLoad = true
seasons = response.items ?? []
})
.store(in: &tempCancellables)
.store(in: &tempViewModel.cancellables)
}
}

View File

@ -10,18 +10,16 @@ import SwiftUI
struct SettingsView: View {
@Environment(\.managedObjectContext) private var viewContext
@EnvironmentObject var jsi: justSignedIn
@ObservedObject var viewModel: SettingsViewModel
@Binding var close: Bool
@State private var inNetworkStreamBitrate: Int = 40_000_000
@State private var outOfNetworkStreamBitrate: Int = 40_000_000
@State private var autoSelectSubtitles: Bool = false
@State private var autoSelectSubtitlesLangcode: String = "none"
@State private var username: String = ""
func onAppear() {
let defaults = UserDefaults.standard
username = SessionManager.current.user.username!
@ -30,7 +28,7 @@ struct SettingsView: View {
autoSelectSubtitles = defaults.bool(forKey: "AutoSelectSubtitles")
autoSelectSubtitlesLangcode = defaults.string(forKey: "AutoSelectSubtitlesLangcode") ?? ""
}
var body: some View {
NavigationView {
Form {
@ -43,7 +41,7 @@ struct SettingsView: View {
let defaults = UserDefaults.standard
defaults.setValue(_inNetworkStreamBitrate.wrappedValue, forKey: "InNetworkBandwidth")
}
Picker("Default remote quality", selection: $outOfNetworkStreamBitrate) {
ForEach(self.viewModel.bitrates, id: \.self) { bitrate in
Text(bitrate.name).tag(bitrate.value)
@ -53,7 +51,7 @@ struct SettingsView: View {
defaults.setValue(_outOfNetworkStreamBitrate.wrappedValue, forKey: "OutOfNetworkBandwidth")
}
}
Section(header: Text("Accessibility")) {
Toggle("Automatically show subtitles", isOn: $autoSelectSubtitles).onChange(of: autoSelectSubtitles, perform: { _ in
let defaults = UserDefaults.standard
@ -61,7 +59,7 @@ struct SettingsView: View {
})
Picker("Language preferences", selection: $autoSelectSubtitlesLangcode) {}
}
Section {
HStack {
Text("Signed in as \(username)").foregroundColor(.primary)
@ -69,27 +67,28 @@ struct SettingsView: View {
Button {
let fetchRequest: NSFetchRequest<NSFetchRequestResult> = NSFetchRequest(entityName: "Server")
let deleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest)
do {
try viewContext.execute(deleteRequest)
} catch _ as NSError {
// TODO: handle the error
}
let fetchRequest2: NSFetchRequest<NSFetchRequestResult> = NSFetchRequest(entityName: "SignedInUser")
let deleteRequest2 = NSBatchDeleteRequest(fetchRequest: fetchRequest2)
do {
try viewContext.execute(deleteRequest2)
} catch _ as NSError {
// TODO: handle the error
}
globalData.server = Server()
globalData.user = SignedInUser()
globalData.authToken = ""
globalData.authHeader = ""
jsi.did = true
do {
try SessionManager.current.logout()
try ServerEnvironment.current.reset()
} catch {
print(error)
}
// TODO: This should redirect to the server selection screen
exit(-1)
} label: {

View File

@ -0,0 +1,26 @@
//
/*
* 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 {
if viewModel.isLoggedIn {
MainTabView()
} else {
NavigationView {
ConnectToServerView(isLoggedIn: $viewModel.isLoggedIn)
}
.navigationViewStyle(StackNavigationViewStyle())
}
}
}

View File

@ -21,7 +21,7 @@ final class SessionManager {
fileprivate(set) var authToken: String!
fileprivate(set) var deviceID: String
var userID: String? {
user.user_id
user?.user_id
}
init() {

View File

@ -22,7 +22,3 @@ public enum SortBy: String, Codable, CaseIterable {
case name = "SortName"
case dateAdded = "DateCreated"
}
class justSignedIn: ObservableObject {
@Published var did: Bool = false
}

View File

@ -0,0 +1,88 @@
//
/*
* SwiftFin is subject to the terms of the Mozilla Public
* License, v2.0. If a copy of the MPL was not distributed with this
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
*
* Copyright 2021 Aiden Vigue & Jellyfin Contributors
*/
import Combine
import Foundation
import JellyfinAPI
final class ConnectToServerViewModel: ViewModel {
@Published
var publicUsers = [UserDto]()
@Published
var isConnectedServer = false
@Published
var isLoggedIn = false
@Published
var uri = ""
@Published
var username = ""
@Published
var password = ""
override init() {
super.init()
refresh()
}
func refresh() {
if ServerEnvironment.current.server != nil {
UserAPI.getPublicUsers()
.sink(receiveCompletion: { completion in
switch completion {
case .finished:
break
case .failure:
self.isConnectedServer = false
}
}, receiveValue: { response in
self.publicUsers = response
self.isConnectedServer = true
})
.store(in: &cancellables)
}
}
func connectToServer() {
ServerEnvironment.current.setUp(with: uri)
.sink(receiveCompletion: { result in
switch result {
case let .failure(error):
self.errorMessage = error.localizedDescription
default:
break
}
}, receiveValue: { response in
guard response.server_id != nil else {
return
}
self.isConnectedServer = true
})
.store(in: &cancellables)
}
func login() {
SessionManager.current.login(username: username, password: password)
.sink(receiveCompletion: { result in
switch result {
case let .failure(error):
self.errorMessage = error.localizedDescription
default:
break
}
}, receiveValue: { response in
guard response.user_id != nil else {
return
}
self.isLoggedIn = true
})
.store(in: &cancellables)
}
}

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 ActivityIndicator
import Combine
import Foundation
import JellyfinAPI
final class HomeViewModel: ViewModel {
@Published
var librariesShowRecentlyAddedIDs = [String]()
@Published
var libraries = [BaseItemDto]()
@Published
var resumeItems = [BaseItemDto]()
@Published
var nextUpItems = [BaseItemDto]()
// temp
var recentFilterSet: LibraryFilters = LibraryFilters(filters: [], sortOrder: [.descending], sortBy: ["DateCreated"])
override init() {
super.init()
refresh()
}
func refresh() {
UserAPI.getCurrentUser()
.trackActivity(loading)
.sink(receiveCompletion: { completion in
print(completion)
}, receiveValue: { response in
let libraries = response.configuration?.orderedViews ?? []
self.librariesShowRecentlyAddedIDs = libraries.filter { element in
!(response.configuration?.latestItemsExcludes?.contains(element))!
}
})
.store(in: &cancellables)
UserViewsAPI.getUserViews(userId: SessionManager.current.userID ?? "")
.trackActivity(loading)
.sink(receiveCompletion: { completion in
print(completion)
}, receiveValue: { response in
self.libraries = response.items ?? []
})
.store(in: &cancellables)
ItemsAPI.getResumeItems(userId: SessionManager.current.userID!, limit: 12,
fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people],
mediaTypes: ["Video"], imageTypeLimit: 1, enableImageTypes: [.primary, .backdrop, .thumb])
.trackActivity(loading)
.sink(receiveCompletion: { completion in
print(completion)
}, receiveValue: { response in
self.resumeItems = response.items ?? []
})
.store(in: &cancellables)
TvShowsAPI.getNextUp(userId: SessionManager.current.userID!, limit: 12,
fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people])
.trackActivity(loading)
.sink(receiveCompletion: { result in
print(result)
}, receiveValue: { response in
self.nextUpItems = response.items ?? []
})
.store(in: &cancellables)
}
}

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 JellyfinAPI
final class LibraryListViewModel: ViewModel {
@Published
var libraries = [BaseItemDto]()
// temp
var withFavorites = LibraryFilters(filters: [.isFavorite], sortOrder: [], withGenres: [], sortBy: [])
override init() {
super.init()
libraries.append(.init(name: "Favorites", id: "favorites"))
libraries.append(.init(name: "Genres", id: "genres"))
refresh()
}
func refresh() {
UserViewsAPI.getUserViews(userId: SessionManager.current.userID ?? "")
.trackActivity(loading)
.sink(receiveCompletion: { completion in
print(completion)
}, receiveValue: { response in
self.libraries.append(contentsOf: response.items ?? [])
})
.store(in: &cancellables)
}
}

View File

@ -0,0 +1,37 @@
//
/*
* SwiftFin is subject to the terms of the Mozilla Public
* License, v2.0. If a copy of the MPL was not distributed with this
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
*
* Copyright 2021 Aiden Vigue & Jellyfin Contributors
*/
import Foundation
import Combine
import Nuke
import WidgetKit
final class SplashViewModel: ViewModel {
@Published
var isLoggedIn: Bool
override init() {
isLoggedIn = ServerEnvironment.current.server != nil && SessionManager.current.user != nil
super.init()
ImageCache.shared.costLimit = 125 * 1024 * 1024 // 125MB memory
DataLoader.sharedUrlCache.diskCapacity = 1000 * 1024 * 1024 // 1000MB disk
WidgetCenter.shared.reloadAllTimelines()
let defaults = UserDefaults.standard
if defaults.integer(forKey: "InNetworkBandwidth") == 0 {
defaults.setValue(40_000_000, forKey: "InNetworkBandwidth")
}
if defaults.integer(forKey: "OutOfNetworkBandwidth") == 0 {
defaults.setValue(40_000_000, forKey: "OutOfNetworkBandwidth")
}
}
}

View File

@ -0,0 +1,33 @@
//
/*
* SwiftFin is subject to the terms of the Mozilla Public
* License, v2.0. If a copy of the MPL was not distributed with this
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
*
* Copyright 2021 Aiden Vigue & Jellyfin Contributors
*/
import Combine
import Foundation
import ActivityIndicator
typealias ErrorMessage = String
extension ErrorMessage: Identifiable {
public var id: String {
self
}
}
class ViewModel: ObservableObject {
var cancellables = Set<AnyCancellable>()
@Published
var isLoading = true
let loading = ActivityIndicator()
@Published
var errorMessage: ErrorMessage?
init() {
loading.loading.assign(to: \.isLoading, on: self).store(in: &cancellables)
}
}