diff --git a/JellyfinPlayer tvOS/Components/LandscapeItemElement.swift b/JellyfinPlayer tvOS/Components/LandscapeItemElement.swift new file mode 100644 index 00000000..3b27e7ca --- /dev/null +++ b/JellyfinPlayer tvOS/Components/LandscapeItemElement.swift @@ -0,0 +1,110 @@ +// + /* + * SwiftFin is subject to the terms of the Mozilla Public + * License, v2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright 2021 Aiden Vigue & Jellyfin Contributors + */ + +import SwiftUI +import JellyfinAPI + +fileprivate struct CutOffShadow: Shape { + let radius = 6.0; + + func path(in rect: CGRect) -> Path { + var path = Path() + + let tl = CGPoint(x: rect.minX, y: rect.minY) + let tr = CGPoint(x: rect.maxX, y: rect.minY) + let brs = CGPoint(x: rect.maxX, y: rect.maxY - radius) + let brc = CGPoint(x: rect.maxX - radius, y: rect.maxY - radius) + let bls = CGPoint(x: rect.minX + radius, y: rect.maxY) + let blc = CGPoint(x: rect.minX + radius, y: rect.maxY - radius) + + path.move(to: tl) + path.addLine(to: tr) + path.addLine(to: brs) + path.addRelativeArc(center: brc, radius: radius, + startAngle: Angle.degrees(0), delta: Angle.degrees(90)) + path.addLine(to: bls) + path.addRelativeArc(center: blc, radius: radius, + startAngle: Angle.degrees(90), delta: Angle.degrees(90)) + + return path + } +} + +struct LandscapeItemElement: View { + @Environment(\.isFocused) var envFocused: Bool + @State var focused: Bool = false; + @State var backgroundURL: URL?; + + var item: BaseItemDto; + + var body: some View { + VStack() { + ImageView(src: item.getBackdropImage(baseURL: ServerEnvironment.current.server.baseURI!, maxWidth: 375), bh: item.getBackdropImageBlurHash()) + .frame(width: 375, height: 250) + .cornerRadius(10) + .overlay( + Group { + if(focused && item.userData?.playedPercentage != nil) { + ZStack(alignment: .leading) { + Rectangle() + .fill(LinearGradient(colors: [.black,.clear], startPoint: .bottom, endPoint: .top)) + .frame(width: 375, height: 90) + .mask(CutOffShadow()) + VStack(alignment: .leading) { + Text("CONTINUE • \(item.getItemProgressString())") + .font(.caption) + .fontWeight(.medium) + .offset(y: 5) + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 6) + .fill(Color.gray) + .opacity(0.4) + .frame(minWidth: 100, maxWidth: .infinity, minHeight: 12, maxHeight: 12) + RoundedRectangle(cornerRadius: 6) + .fill(Color(red: 172/255, green: 92/255, blue: 195/255)) + .frame(width: CGFloat(item.userData?.playedPercentage ?? 0 * 3.59), height: 12) + } + }.padding(8) + } + } else { + EmptyView() + } + }, alignment: .bottomLeading + ) + .shadow(radius: focused ? 10.0 : 0, y: focused ? 10.0 : 0) + .shadow(radius: focused ? 10.0 : 0, y: focused ? 10.0 : 0) + if(focused) { + Text(item.seriesName ?? item.name ?? "") + .font(.callout) + .fontWeight(.semibold) + .lineLimit(1) + .frame(width: 375) + } else { + Spacer().frame(height: 25) + } + } + .onChange(of: envFocused) { envFocus in + withAnimation(.linear(duration: 0.15)) { + self.focused = envFocus + } + + if(envFocus == true) { + backgroundURL = item.getBackdropImage(baseURL: ServerEnvironment.current.server.baseURI!, maxWidth: Int((UIScreen.main.currentMode?.size.width)!)) + BackgroundManager.current.setBackground(to: backgroundURL!, hash: item.getBackdropImageBlurHash()) + } else { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { + if(BackgroundManager.current.backgroundURL == backgroundURL) { + BackgroundManager.current.clearBackground() + } + } + } + } + .scaleEffect(focused ? 1.1 : 1) + } +} diff --git a/JellyfinPlayer tvOS/ConnectToServerView.swift b/JellyfinPlayer tvOS/ConnectToServerView.swift index 7d788136..de4d7a88 100644 --- a/JellyfinPlayer tvOS/ConnectToServerView.swift +++ b/JellyfinPlayer tvOS/ConnectToServerView.swift @@ -11,8 +11,6 @@ import SwiftUI struct ConnectToServerView: View { @StateObject var viewModel = ConnectToServerViewModel() - @Binding var isLoggedIn: Bool - var body: some View { VStack(alignment: .leading) { if viewModel.isConnectedServer { @@ -31,6 +29,7 @@ struct ConnectToServerView: View { Spacer() } } + SecureField("Password (optional)", text: $viewModel.password) .disableAutocorrection(true) .autocapitalization(.none) @@ -71,7 +70,10 @@ struct ConnectToServerView: View { HStack() { ForEach(viewModel.publicUsers, id: \.id) { publicUser in Button(action: { - if(!viewModel.userHasSavedCredentials(userID: publicUser.id!)) { + if(SessionManager.current.doesUserHaveSavedSession(userID: publicUser.id!)) { + let user = SessionManager.current.getSavedSession(userID: publicUser.id!) + SessionManager.current.loginWithSavedSession(user: user) + } else { viewModel.username = publicUser.name ?? "" viewModel.selectedPublicUser = publicUser viewModel.hidePublicUsers() @@ -79,8 +81,6 @@ struct ConnectToServerView: View { viewModel.password = "" viewModel.login() } - } else { - viewModel.loginWithSavedCredentials(user: publicUser) } }) { VStack { @@ -141,9 +141,6 @@ struct ConnectToServerView: View { .alert(item: $viewModel.errorMessage) { _ in Alert(title: Text("Error"), message: Text(viewModel.errorMessage ?? ""), dismissButton: .default(Text("Ok"))) } - .onReceive(viewModel.$isLoggedIn, perform: { flag in - isLoggedIn = flag - }) .navigationTitle(viewModel.isConnectedServer ? "Who's watching?" : "Connect to Jellyfin") } } diff --git a/JellyfinPlayer tvOS/ContinueWatching/ContinueWatchingItem.swift b/JellyfinPlayer tvOS/ContinueWatching/ContinueWatchingItem.swift deleted file mode 100644 index 74974eca..00000000 --- a/JellyfinPlayer tvOS/ContinueWatching/ContinueWatchingItem.swift +++ /dev/null @@ -1,69 +0,0 @@ -// - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ - -import SwiftUI -import JellyfinAPI - -fileprivate struct ProgressBar: Shape { - func path(in rect: CGRect) -> Path { - var path = Path() - - let tl = CGPoint(x: rect.minX, y: rect.minY) - let tr = CGPoint(x: rect.maxX, y: rect.minY) - let br = CGPoint(x: rect.maxX, y: rect.maxY) - let bls = CGPoint(x: rect.minX + 10, y: rect.maxY) - let blc = CGPoint(x: rect.minX + 10, y: rect.maxY - 10) - - path.move(to: tl) - path.addLine(to: tr) - path.addLine(to: br) - path.addLine(to: bls) - path.addRelativeArc(center: blc, radius: 10, - startAngle: Angle.degrees(90), delta: Angle.degrees(90)) - - return path - } -} - -struct ContinueWatchingItem: View { - @Environment(\.isFocused) var envFocused: Bool - @State var focused: Bool = false; - - var item: BaseItemDto; - - var body: some View { - VStack() { - ImageView(src: item.getBackdropImage(baseURL: ServerEnvironment.current.server.baseURI!, maxWidth: 375), bh: item.getBackdropImageBlurHash()) - .frame(width: 375, height: 250) - .cornerRadius(10) - .overlay( - Rectangle() - .fill(Color(red: 172/255, green: 92/255, blue: 195/255)) - .mask(ProgressBar()) - .frame(width: CGFloat(item.userData?.playedPercentage ?? 0 * 3.75), height: 12) - .padding(6), alignment: .bottomLeading - ) - if(focused) { - Text(item.seriesName ?? item.name ?? "") - .font(.callout) - .fontWeight(.semibold) - .lineLimit(1) - .frame(width: 375) - } else { - Spacer().frame(height: 25) - } - } - .onChange(of: envFocused) { envFocus in - withAnimation(.linear(duration: 0.15)) { - self.focused = envFocus - } - } - .scaleEffect(focused ? 1.1 : 1) - } -} diff --git a/JellyfinPlayer tvOS/ContinueWatching/ContinueWatchingView.swift b/JellyfinPlayer tvOS/ContinueWatchingView.swift similarity index 95% rename from JellyfinPlayer tvOS/ContinueWatching/ContinueWatchingView.swift rename to JellyfinPlayer tvOS/ContinueWatchingView.swift index 10f17b3c..ed5229dd 100644 --- a/JellyfinPlayer tvOS/ContinueWatching/ContinueWatchingView.swift +++ b/JellyfinPlayer tvOS/ContinueWatchingView.swift @@ -25,7 +25,7 @@ struct ContinueWatchingView: View { Spacer().frame(width: 90) ForEach(items, id: \.id) { item in NavigationLink(destination: Text("itemv")) { - ContinueWatchingItem(item: item) + LandscapeItemElement(item: item) }.buttonStyle(PlainNavigationLinkButtonStyle()) } Spacer().frame(width: 90) diff --git a/JellyfinPlayer tvOS/HomeView.swift b/JellyfinPlayer tvOS/HomeView.swift index f99e92ec..0dd3a24f 100644 --- a/JellyfinPlayer tvOS/HomeView.swift +++ b/JellyfinPlayer tvOS/HomeView.swift @@ -24,10 +24,10 @@ struct HomeView: View { 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) { diff --git a/JellyfinPlayer tvOS/MainTabView.swift b/JellyfinPlayer tvOS/MainTabView.swift index adb83b3d..a218fba0 100644 --- a/JellyfinPlayer tvOS/MainTabView.swift +++ b/JellyfinPlayer tvOS/MainTabView.swift @@ -12,24 +12,54 @@ import SwiftUI struct MainTabView: View { @State private var tabSelection: Tab = .home + @StateObject private var viewModel = MainTabViewModel() + @State private var backdropAnim: Bool = false + @State private var lastBackdropAnim: Bool = false var body: some View { - TabView(selection: $tabSelection) { - HomeView() - .navigationViewStyle(StackNavigationViewStyle()) - .tabItem { - Text(Tab.home.localized) - Image(systemName: "house") + ZStack() { + //please do not touch my magical crossfading. + if(viewModel.backgroundURL != nil) { + if(viewModel.lastBackgroundURL != nil) { + ImageView(src: viewModel.lastBackgroundURL!, bh: viewModel.backgroundBlurHash) + .frame(width: UIScreen.main.currentMode?.size.width, height: UIScreen.main.currentMode?.size.height) + .blur(radius: 2) + .opacity(lastBackdropAnim ? 0.4 : 0) + .onChange(of: viewModel.backgroundURL) { _ in + withAnimation(.linear(duration: 0.15)) { + lastBackdropAnim = false + } + } + } + ImageView(src: viewModel.backgroundURL!, bh: viewModel.backgroundBlurHash) + .frame(width: UIScreen.main.currentMode?.size.width, height: UIScreen.main.currentMode?.size.height) + .blur(radius: 2) + .opacity(backdropAnim ? 0.4 : 0) + .onChange(of: viewModel.backgroundURL) { _ in + lastBackdropAnim = true + backdropAnim = false + withAnimation(.linear(duration: 0.15)) { + backdropAnim = true + } + } } - .tag(Tab.home) - - Text("Library") - .navigationViewStyle(StackNavigationViewStyle()) - .tabItem { - Text(Tab.allMedia.localized) - Image(systemName: "folder") + TabView(selection: $tabSelection) { + HomeView() + .navigationViewStyle(StackNavigationViewStyle()) + .tabItem { + Text(Tab.home.localized) + Image(systemName: "house") + } + .tag(Tab.home) + + Text("Library") + .navigationViewStyle(StackNavigationViewStyle()) + .tabItem { + Text(Tab.allMedia.localized) + Image(systemName: "folder") + } + .tag(Tab.allMedia) } - .tag(Tab.allMedia) } } } diff --git a/JellyfinPlayer tvOS/NextUp/NextUpView.swift b/JellyfinPlayer tvOS/NextUp/NextUpView.swift deleted file mode 100644 index 4daaa8cd..00000000 --- a/JellyfinPlayer tvOS/NextUp/NextUpView.swift +++ /dev/null @@ -1,54 +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 Combine -import JellyfinAPI - -struct NextUpView: View { - - var items: [BaseItemDto] - - var body: some View { - VStack(alignment: .leading) { - if items.count != 0 { - Text("Next Up") - .font(.headline) - .fontWeight(.semibold) - .padding(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) - ScrollView(.horizontal, showsIndicators: false) { - LazyHStack { - Spacer().frame(width: 16) - ForEach(items, id: \.id) { item in - NavigationLink(destination: EmptyView()) { - VStack(alignment: .leading) { - ImageView(src: item.getSeriesPrimaryImage(baseURL: ServerEnvironment.current.server.baseURI!, maxWidth: 100), bh: item.getSeriesPrimaryImageBlurHash()) - .frame(width: 100, height: 150) - .cornerRadius(10) - Spacer().frame(height: 5) - Text(item.seriesName!) - .font(.caption) - .fontWeight(.semibold) - .foregroundColor(.primary) - .lineLimit(1) - Text("S\(item.parentIndexNumber ?? 0):E\(item.indexNumber ?? 0)") - .font(.caption) - .fontWeight(.semibold) - .foregroundColor(.secondary) - .lineLimit(1) - }.frame(width: 100) - Spacer().frame(width: 16) - } - } - } - } - .frame(height: 200) - } - } - .padding(EdgeInsets(top: 4, leading: 0, bottom: 0, trailing: 0)) - } -} diff --git a/JellyfinPlayer tvOS/NextUpView.swift b/JellyfinPlayer tvOS/NextUpView.swift new file mode 100644 index 00000000..a7936168 --- /dev/null +++ b/JellyfinPlayer tvOS/NextUpView.swift @@ -0,0 +1,40 @@ +/* + * 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 JellyfinAPI +import Combine + +struct NextUpView: View { + var items: [BaseItemDto] + + var body: some View { + VStack(alignment: .leading) { + if items.count > 0 { + Text("Next Up") + .font(.headline) + .fontWeight(.semibold) + .padding(.leading, 135) + ScrollView(.horizontal, showsIndicators: false) { + LazyHStack { + Spacer().frame(width: 90) + ForEach(items, id: \.id) { item in + NavigationLink(destination: Text("itemv")) { + LandscapeItemElement(item: item) + }.buttonStyle(PlainNavigationLinkButtonStyle()) + } + Spacer().frame(width: 90) + } + }.frame(height: 330) + .offset(y: -10) + } else { + EmptyView() + } + } + } +} diff --git a/JellyfinPlayer tvOS/SplashView.swift b/JellyfinPlayer tvOS/SplashView.swift index 9d43b740..d0c5fb18 100644 --- a/JellyfinPlayer tvOS/SplashView.swift +++ b/JellyfinPlayer tvOS/SplashView.swift @@ -11,7 +11,6 @@ import SwiftUI struct SplashView: View { @StateObject var viewModel = SplashViewModel() - @State var showingAlert: Bool = false var body: some View { Group { @@ -23,16 +22,10 @@ struct SplashView: View { .padding(.trailing, -60) } else { NavigationView { - ConnectToServerView(isLoggedIn: $viewModel.isLoggedIn) + ConnectToServerView() } .navigationViewStyle(StackNavigationViewStyle()) } } - .alert(isPresented: $showingAlert) { - Alert(title: Text("Important message"), message: Text("\(ServerEnvironment.current.errorMessage)"), dismissButton: .default(Text("Got it!"))) - } - .onChange(of: ServerEnvironment.current.hasErrorMessage) { hEM in - self.showingAlert = hEM - } } } diff --git a/JellyfinPlayer.xcodeproj/project.pbxproj b/JellyfinPlayer.xcodeproj/project.pbxproj index dddf2636..8503c02c 100644 --- a/JellyfinPlayer.xcodeproj/project.pbxproj +++ b/JellyfinPlayer.xcodeproj/project.pbxproj @@ -9,11 +9,9 @@ /* Begin PBXBuildFile section */ 531690E5267ABD5C005D8AB9 /* MainTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531690E4267ABD5C005D8AB9 /* MainTabView.swift */; }; 531690E7267ABD79005D8AB9 /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531690E6267ABD79005D8AB9 /* HomeView.swift */; }; - 531690EC267ABF46005D8AB9 /* ContinueWatchingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531690EB267ABF46005D8AB9 /* ContinueWatchingView.swift */; }; 531690ED267ABF46005D8AB9 /* ContinueWatchingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531690EB267ABF46005D8AB9 /* ContinueWatchingView.swift */; }; - 531690EF267ABF72005D8AB9 /* NextUpView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531690EE267ABF72005D8AB9 /* NextUpView.swift */; }; 531690F0267ABF72005D8AB9 /* NextUpView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531690EE267ABF72005D8AB9 /* NextUpView.swift */; }; - 531690F7267ACC00005D8AB9 /* ContinueWatchingItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531690F6267ACC00005D8AB9 /* ContinueWatchingItem.swift */; }; + 531690F7267ACC00005D8AB9 /* LandscapeItemElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531690F6267ACC00005D8AB9 /* LandscapeItemElement.swift */; }; 531690FA267AD6EC005D8AB9 /* PlainNavigationLinkButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531690F9267AD6EC005D8AB9 /* PlainNavigationLinkButton.swift */; }; 531690FD267AEDC5005D8AB9 /* HandleAPIRequestCompletion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531690FC267AEDC5005D8AB9 /* HandleAPIRequestCompletion.swift */; }; 531690FE267AEDC5005D8AB9 /* HandleAPIRequestCompletion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531690FC267AEDC5005D8AB9 /* HandleAPIRequestCompletion.swift */; }; @@ -47,6 +45,11 @@ 535BAEA5264A151C005FA86D /* VideoPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 535BAEA4264A151C005FA86D /* VideoPlayer.swift */; }; 5364F455266CA0DC0026ECBA /* APIExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5364F454266CA0DC0026ECBA /* APIExtensions.swift */; }; 5364F456266CA0DC0026ECBA /* APIExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5364F454266CA0DC0026ECBA /* APIExtensions.swift */; }; + 536D3D74267BA8170004248C /* BackgroundManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 536D3D73267BA8170004248C /* BackgroundManager.swift */; }; + 536D3D76267BA9BB0004248C /* MainTabViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 536D3D75267BA9BB0004248C /* MainTabViewModel.swift */; }; + 536D3D78267BD5C30004248C /* ViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 625CB57B2678CE1000530A6E /* ViewModel.swift */; }; + 536D3D79267BD5D00004248C /* ViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 625CB57B2678CE1000530A6E /* ViewModel.swift */; }; + 536D3D7D267BD5F90004248C /* ActivityIndicator in Frameworks */ = {isa = PBXBuildFile; productRef = 536D3D7C267BD5F90004248C /* ActivityIndicator */; }; 5377CBF5263B596A003A4E83 /* JellyfinPlayerApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5377CBF4263B596A003A4E83 /* JellyfinPlayerApp.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 */; }; @@ -94,7 +97,6 @@ 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 */; }; 625CB57E2678E81E00530A6E /* TVVLCKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 625CB57D2678E81E00530A6E /* TVVLCKit.xcframework */; }; 625CB57F2678E81E00530A6E /* TVVLCKit.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 625CB57D2678E81E00530A6E /* TVVLCKit.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 6267B3D42671024A00A7371D /* APIExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5364F454266CA0DC0026ECBA /* APIExtensions.swift */; }; @@ -186,7 +188,7 @@ 531690E6267ABD79005D8AB9 /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = ""; }; 531690EB267ABF46005D8AB9 /* ContinueWatchingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContinueWatchingView.swift; sourceTree = ""; }; 531690EE267ABF72005D8AB9 /* NextUpView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NextUpView.swift; sourceTree = ""; }; - 531690F6267ACC00005D8AB9 /* ContinueWatchingItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContinueWatchingItem.swift; sourceTree = ""; }; + 531690F6267ACC00005D8AB9 /* LandscapeItemElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LandscapeItemElement.swift; sourceTree = ""; }; 531690F8267AD135005D8AB9 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; 531690F9267AD6EC005D8AB9 /* PlainNavigationLinkButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlainNavigationLinkButton.swift; sourceTree = ""; }; 531690FC267AEDC5005D8AB9 /* HandleAPIRequestCompletion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HandleAPIRequestCompletion.swift; sourceTree = ""; }; @@ -205,6 +207,8 @@ 535BAE9E2649E569005FA86D /* ItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemView.swift; sourceTree = ""; }; 535BAEA4264A151C005FA86D /* VideoPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayer.swift; sourceTree = ""; }; 5364F454266CA0DC0026ECBA /* APIExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIExtensions.swift; sourceTree = ""; }; + 536D3D73267BA8170004248C /* BackgroundManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundManager.swift; sourceTree = ""; }; + 536D3D75267BA9BB0004248C /* MainTabViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTabViewModel.swift; sourceTree = ""; }; 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 = ""; }; 5377CBF8263B596B003A4E83 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; @@ -299,6 +303,7 @@ 628B95332670CAEA0091AF3B /* NukeUI in Frameworks */, 628B95242670CABD0091AF3B /* SwiftUI.framework in Frameworks */, 531ABF6C2671F5CC00C0FE20 /* WidgetKit.framework in Frameworks */, + 536D3D7D267BD5F90004248C /* ActivityIndicator in Frameworks */, 628B953A2670CE250091AF3B /* KeychainSwift in Frameworks */, 628B95352670CAEA0091AF3B /* JellyfinAPI in Frameworks */, ); @@ -307,23 +312,6 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ - 531690F5267ACBF2005D8AB9 /* ContinueWatching */ = { - isa = PBXGroup; - children = ( - 531690EB267ABF46005D8AB9 /* ContinueWatchingView.swift */, - 531690F6267ACC00005D8AB9 /* ContinueWatchingItem.swift */, - ); - path = ContinueWatching; - sourceTree = ""; - }; - 531690FB267AD7FA005D8AB9 /* NextUp */ = { - isa = PBXGroup; - children = ( - 531690EE267ABF72005D8AB9 /* NextUpView.swift */, - ); - path = NextUp; - sourceTree = ""; - }; 532175392671BCED005491E6 /* ViewModels */ = { isa = PBXGroup; children = ( @@ -333,6 +321,7 @@ 625CB5742678C33500530A6E /* LibraryListViewModel.swift */, 625CB5762678C34300530A6E /* ConnectToServerViewModel.swift */, 625CB57B2678CE1000530A6E /* ViewModel.swift */, + 536D3D75267BA9BB0004248C /* MainTabViewModel.swift */, ); path = ViewModels; sourceTree = ""; @@ -340,8 +329,7 @@ 535870612669D21600D05A09 /* JellyfinPlayer tvOS */ = { isa = PBXGroup; children = ( - 531690FB267AD7FA005D8AB9 /* NextUp */, - 531690F5267ACBF2005D8AB9 /* ContinueWatching */, + 536D3D77267BB9650004248C /* Components */, 53ABFDDA267972BF00886593 /* JellyfinPlayer tvOS.entitlements */, 531690F9267AD6EC005D8AB9 /* PlainNavigationLinkButton.swift */, 535870622669D21600D05A09 /* JellyfinPlayer_tvOSApp.swift */, @@ -350,6 +338,8 @@ 535870702669D21700D05A09 /* Info.plist */, 535870682669D21700D05A09 /* Preview Content */, 53ABFDDD267974E300886593 /* SplashView.swift */, + 531690EB267ABF46005D8AB9 /* ContinueWatchingView.swift */, + 531690EE267ABF72005D8AB9 /* NextUpView.swift */, 53ABFDEA2679753200886593 /* ConnectToServerView.swift */, 531690E4267ABD5C005D8AB9 /* MainTabView.swift */, 531690E6267ABD79005D8AB9 /* HomeView.swift */, @@ -386,6 +376,14 @@ path = Typings; sourceTree = ""; }; + 536D3D77267BB9650004248C /* Components */ = { + isa = PBXGroup; + children = ( + 531690F6267ACC00005D8AB9 /* LandscapeItemElement.swift */, + ); + path = Components; + sourceTree = ""; + }; 5377CBE8263B596A003A4E83 = { isa = PBXGroup; children = ( @@ -500,6 +498,7 @@ children = ( 62EC352B26766675000E9F2D /* ServerEnvironment.swift */, 62EC352E267666A5000E9F2D /* SessionManager.swift */, + 536D3D73267BA8170004248C /* BackgroundManager.swift */, ); path = Singleton; sourceTree = ""; @@ -586,6 +585,7 @@ 628B95322670CAEA0091AF3B /* NukeUI */, 628B95342670CAEA0091AF3B /* JellyfinAPI */, 628B95392670CE250091AF3B /* KeychainSwift */, + 536D3D7C267BD5F90004248C /* ActivityIndicator */, ); productName = WidgetExtensionExtension; productReference = 628B95202670CABD0091AF3B /* WidgetExtension.appex */; @@ -686,7 +686,7 @@ 53ABFDE8267974EF00886593 /* SplashViewModel.swift in Sources */, 531690ED267ABF46005D8AB9 /* ContinueWatchingView.swift in Sources */, 62EC3530267666A5000E9F2D /* SessionManager.swift in Sources */, - 531690F7267ACC00005D8AB9 /* ContinueWatchingItem.swift in Sources */, + 531690F7267ACC00005D8AB9 /* LandscapeItemElement.swift in Sources */, 535870A82669D8AE00D05A09 /* StringExtensions.swift in Sources */, 53ABFDE6267974EF00886593 /* SettingsViewModel.swift in Sources */, 6267B3D826710B9800A7371D /* CollectionExtensions.swift in Sources */, @@ -703,6 +703,8 @@ 535870A62669D8AE00D05A09 /* LazyView.swift in Sources */, 5321753E2671DE9C005491E6 /* Typings.swift in Sources */, 53ABFDEB2679753200886593 /* ConnectToServerView.swift in Sources */, + 536D3D76267BA9BB0004248C /* MainTabViewModel.swift in Sources */, + 536D3D74267BA8170004248C /* BackgroundManager.swift in Sources */, 535870632669D21600D05A09 /* JellyfinPlayer_tvOSApp.swift in Sources */, 53ABFDE4267974EF00886593 /* LibraryListViewModel.swift in Sources */, 5364F456266CA0DC0026ECBA /* APIExtensions.swift in Sources */, @@ -725,6 +727,7 @@ 535BAE9F2649E569005FA86D /* ItemView.swift in Sources */, 6225FCCB2663841E00E067F6 /* ParallaxHeader.swift in Sources */, 625CB5772678C34300530A6E /* ConnectToServerViewModel.swift in Sources */, + 536D3D78267BD5C30004248C /* ViewModel.swift in Sources */, 53DE4BD02670961400739748 /* EpisodeItemView.swift in Sources */, 53F8377D265FF67C00F456B3 /* VideoPlayerSettingsView.swift in Sources */, 53192D5D265AA78A008A4215 /* DeviceProfileBuilder.swift in Sources */, @@ -743,19 +746,16 @@ 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 */, - 531690EF267ABF72005D8AB9 /* NextUpView.swift in Sources */, 62EC352C26766675000E9F2D /* ServerEnvironment.swift in Sources */, 6267B3DA2671138200A7371D /* ImageExtensions.swift in Sources */, 62EC353426766B03000E9F2D /* DeviceRotationViewModifier.swift in Sources */, 5389277C263CC3DB0035E14B /* BlurHashDecode.swift in Sources */, 625CB56C2678C0FD00530A6E /* MainTabView.swift in Sources */, 539B2DA5263BA5B8007FF1A4 /* SettingsView.swift in Sources */, - 531690EC267ABF46005D8AB9 /* ContinueWatchingView.swift in Sources */, 5338F74E263B61370014BF09 /* ConnectToServerView.swift in Sources */, 53AD124D267029D60094A276 /* SeriesItemView.swift in Sources */, 5377CBF5263B596A003A4E83 /* JellyfinPlayerApp.swift in Sources */, @@ -779,6 +779,7 @@ 628B95272670CABD0091AF3B /* NextUpWidget.swift in Sources */, 628B95382670CDAB0091AF3B /* Model.xcdatamodeld in Sources */, 62EC353226766849000E9F2D /* SessionManager.swift in Sources */, + 536D3D79267BD5D00004248C /* ViewModel.swift in Sources */, 531690FF267AEDC5005D8AB9 /* HandleAPIRequestCompletion.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -1206,6 +1207,11 @@ package = 621C637E26672A30004216EA /* XCRemoteSwiftPackageReference "NukeUI" */; productName = NukeUI; }; + 536D3D7C267BD5F90004248C /* ActivityIndicator */ = { + isa = XCSwiftPackageProductDependency; + package = 625CB5782678C4A400530A6E /* XCRemoteSwiftPackageReference "ActivityIndicator" */; + productName = ActivityIndicator; + }; 53A431BC266B0FF20016769F /* JellyfinAPI */ = { isa = XCSwiftPackageProductDependency; package = 53A431BB266B0FF20016769F /* XCRemoteSwiftPackageReference "jellyfin-sdk-swift" */; diff --git a/JellyfinPlayer/ConnectToServerView.swift b/JellyfinPlayer/ConnectToServerView.swift index a6e826c5..34356475 100644 --- a/JellyfinPlayer/ConnectToServerView.swift +++ b/JellyfinPlayer/ConnectToServerView.swift @@ -11,11 +11,7 @@ import KeychainSwift import SwiftUI struct ConnectToServerView: View { - @StateObject - var viewModel = ConnectToServerViewModel() - - @Binding - var isLoggedIn: Bool + @StateObject var viewModel = ConnectToServerViewModel() var body: some View { ZStack { @@ -132,9 +128,6 @@ struct ConnectToServerView: View { .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") } } diff --git a/JellyfinPlayer/EpisodeItemView.swift b/JellyfinPlayer/EpisodeItemView.swift index cf9e4a36..593b8fd2 100644 --- a/JellyfinPlayer/EpisodeItemView.swift +++ b/JellyfinPlayer/EpisodeItemView.swift @@ -24,14 +24,14 @@ struct EpisodeItemView: View { didSet { if !settingState { if watched == true { - PlaystateAPI.markPlayedItem(userId: SessionManager.current.userID!, itemId: item.id!) + PlaystateAPI.markPlayedItem(userId: SessionManager.current.user.user_id!, itemId: item.id!) .sink(receiveCompletion: { completion in print(completion) }, receiveValue: { _ in }) .store(in: &tempViewModel.cancellables) } else { - PlaystateAPI.markUnplayedItem(userId: SessionManager.current.userID!, itemId: item.id!) + PlaystateAPI.markUnplayedItem(userId: SessionManager.current.user.user_id!, itemId: item.id!) .sink(receiveCompletion: { completion in print(completion) }, receiveValue: { _ in @@ -47,14 +47,14 @@ struct EpisodeItemView: View { didSet { if !settingState { if favorite == true { - UserLibraryAPI.markFavoriteItem(userId: SessionManager.current.userID!, itemId: item.id!) + UserLibraryAPI.markFavoriteItem(userId: SessionManager.current.user.user_id!, itemId: item.id!) .sink(receiveCompletion: { completion in print(completion) }, receiveValue: { _ in }) .store(in: &tempViewModel.cancellables) } else { - UserLibraryAPI.unmarkFavoriteItem(userId: SessionManager.current.userID!, itemId: item.id!) + UserLibraryAPI.unmarkFavoriteItem(userId: SessionManager.current.user.user_id!, itemId: item.id!) .sink(receiveCompletion: { completion in print(completion) }, receiveValue: { _ in diff --git a/JellyfinPlayer/LatestMediaView.swift b/JellyfinPlayer/LatestMediaView.swift index dd75a03f..540971f0 100644 --- a/JellyfinPlayer/LatestMediaView.swift +++ b/JellyfinPlayer/LatestMediaView.swift @@ -28,7 +28,7 @@ struct LatestMediaView: View { viewDidLoad = true 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) + UserLibraryAPI.getLatestMedia(userId: SessionManager.current.user.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 diff --git a/JellyfinPlayer/LibrarySearchView.swift b/JellyfinPlayer/LibrarySearchView.swift index 353211b7..a3f4b8fa 100644 --- a/JellyfinPlayer/LibrarySearchView.swift +++ b/JellyfinPlayer/LibrarySearchView.swift @@ -31,7 +31,7 @@ struct LibrarySearchView: View { func requestSearch(query: String) { isLoading = true 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) + ItemsAPI.getItemsByUserId(userId: SessionManager.current.user.user_id!, 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 print(completion) }, receiveValue: { response in diff --git a/JellyfinPlayer/LibraryView.swift b/JellyfinPlayer/LibraryView.swift index a6709f9a..52e8a2b6 100644 --- a/JellyfinPlayer/LibraryView.swift +++ b/JellyfinPlayer/LibraryView.swift @@ -70,7 +70,7 @@ struct LibraryView: View { items = [] 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) + ItemsAPI.getItemsByUserId(userId: SessionManager.current.user.user_id!, 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) .sink(receiveCompletion: { completion in print(completion) isLoading = false diff --git a/JellyfinPlayer/MovieItemView.swift b/JellyfinPlayer/MovieItemView.swift index 441065ac..2f75ad6d 100644 --- a/JellyfinPlayer/MovieItemView.swift +++ b/JellyfinPlayer/MovieItemView.swift @@ -30,14 +30,14 @@ struct MovieItemView: View { didSet { if !settingState { if watched == true { - PlaystateAPI.markPlayedItem(userId: SessionManager.current.userID!, itemId: item.id!) + PlaystateAPI.markPlayedItem(userId: SessionManager.current.user.user_id!, itemId: item.id!) .sink(receiveCompletion: { completion in print(completion) }, receiveValue: { _ in }) .store(in: &tempViewModel.cancellables) } else { - PlaystateAPI.markUnplayedItem(userId: SessionManager.current.userID!, itemId: item.id!) + PlaystateAPI.markUnplayedItem(userId: SessionManager.current.user.user_id!, itemId: item.id!) .sink(receiveCompletion: { completion in print(completion) }, receiveValue: { _ in @@ -53,14 +53,14 @@ struct MovieItemView: View { didSet { if !settingState { if favorite == true { - UserLibraryAPI.markFavoriteItem(userId: SessionManager.current.userID!, itemId: item.id!) + UserLibraryAPI.markFavoriteItem(userId: SessionManager.current.user.user_id!, itemId: item.id!) .sink(receiveCompletion: { completion in print(completion) }, receiveValue: { _ in }) .store(in: &tempViewModel.cancellables) } else { - UserLibraryAPI.unmarkFavoriteItem(userId: SessionManager.current.userID!, itemId: item.id!) + UserLibraryAPI.unmarkFavoriteItem(userId: SessionManager.current.user.user_id!, itemId: item.id!) .sink(receiveCompletion: { completion in print(completion) }, receiveValue: { _ in diff --git a/JellyfinPlayer/SeasonItemView.swift b/JellyfinPlayer/SeasonItemView.swift index 988dbb40..7cbde0a5 100644 --- a/JellyfinPlayer/SeasonItemView.swift +++ b/JellyfinPlayer/SeasonItemView.swift @@ -33,7 +33,7 @@ struct SeasonItemView: View { } DispatchQueue.global(qos: .userInitiated).async { - TvShowsAPI.getEpisodes(seriesId: item.seriesId ?? "", userId: SessionManager.current.userID!, fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people], seasonId: item.id ?? "") + TvShowsAPI.getEpisodes(seriesId: item.seriesId ?? "", userId: SessionManager.current.user.user_id!, fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people], seasonId: item.id ?? "") .sink(receiveCompletion: { completion in print(completion) isLoading = false diff --git a/JellyfinPlayer/SettingsView.swift b/JellyfinPlayer/SettingsView.swift index b42c0fd4..03d8307a 100644 --- a/JellyfinPlayer/SettingsView.swift +++ b/JellyfinPlayer/SettingsView.swift @@ -83,14 +83,8 @@ struct SettingsView: View { // TODO: handle the error } - do { - try SessionManager.current.logout() - try ServerEnvironment.current.reset() - } catch { - print(error) - } - // TODO: This should redirect to the server selection screen - exit(-1) + SessionManager.current.logout() + ServerEnvironment.current.reset() } label: { Text("Log out").font(.callout) } diff --git a/JellyfinPlayer/SplashView.swift b/JellyfinPlayer/SplashView.swift index 099a3a27..7c8256a6 100644 --- a/JellyfinPlayer/SplashView.swift +++ b/JellyfinPlayer/SplashView.swift @@ -18,7 +18,7 @@ struct SplashView: View { MainTabView() } else { NavigationView { - ConnectToServerView(isLoggedIn: $viewModel.isLoggedIn) + ConnectToServerView() } .navigationViewStyle(StackNavigationViewStyle()) } diff --git a/JellyfinPlayer/VideoPlayer.swift b/JellyfinPlayer/VideoPlayer.swift index 448fbe82..2c471ae7 100644 --- a/JellyfinPlayer/VideoPlayer.swift +++ b/JellyfinPlayer/VideoPlayer.swift @@ -290,11 +290,11 @@ class PlayerViewController: UIViewController, VLCMediaDelegate, VLCMediaPlayerDe builder.setMaxBitrate(bitrate: maxBitrate) let profile = builder.buildProfile() - let playbackInfo = PlaybackInfoDto(userId: SessionManager.current.userID!, maxStreamingBitrate: Int(maxBitrate), startTimeTicks: manifest.userData?.playbackPositionTicks ?? 0, deviceProfile: profile, autoOpenLiveStream: true) + let playbackInfo = PlaybackInfoDto(userId: SessionManager.current.user.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.userID!, maxStreamingBitrate: Int(maxBitrate), startTimeTicks: manifest.userData?.playbackPositionTicks ?? 0, autoOpenLiveStream: true, playbackInfoDto: playbackInfo) + MediaInfoAPI.getPostedPlaybackInfo(itemId: manifest.id!, userId: SessionManager.current.user.user_id!, maxStreamingBitrate: Int(maxBitrate), startTimeTicks: manifest.userData?.playbackPositionTicks ?? 0, autoOpenLiveStream: true, playbackInfoDto: playbackInfo) .sink(receiveCompletion: { result in print(result) }, receiveValue: { [self] response in @@ -348,7 +348,7 @@ class PlayerViewController: UIViewController, VLCMediaDelegate, VLCMediaPlayerDe playbackItem = item } else { // Item will be directly played by the client. - let streamURL: URL = URL(string: "\(ServerEnvironment.current.server.baseURI!)/Videos/\(manifest.id!)/stream?Static=true&mediaSourceId=\(manifest.id!)&deviceId=\(SessionManager.current.deviceID)&api_key=\(SessionManager.current.authToken)&Tag=\(mediaSource.eTag!)")! + let streamURL: URL = 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 item = PlaybackItem() item.videoUrl = streamURL diff --git a/Shared/Extensions/APIExtensions.swift b/Shared/Extensions/APIExtensions.swift index 13df0c9f..533df91a 100644 --- a/Shared/Extensions/APIExtensions.swift +++ b/Shared/Extensions/APIExtensions.swift @@ -126,7 +126,7 @@ extension BaseItemDto { let proghours = Int(remainingSecs / 3600) let progminutes = Int((Int(remainingSecs) - (proghours * 3600)) / 60) if proghours != 0 { - return "\(proghours):\(String(progminutes).leftPad(toWidth: 2, withString: "0"))" + return "\(proghours)h \(String(progminutes).leftPad(toWidth: 2, withString: "0"))m" } else { return "\(String(progminutes).leftPad(toWidth: 2, withString: "0"))m" } diff --git a/Shared/Extensions/HandleAPIRequestCompletion.swift b/Shared/Extensions/HandleAPIRequestCompletion.swift index f9db9805..0f7dd1a9 100644 --- a/Shared/Extensions/HandleAPIRequestCompletion.swift +++ b/Shared/Extensions/HandleAPIRequestCompletion.swift @@ -9,7 +9,7 @@ import Foundation import Combine import JellyfinAPI -func HandleAPIRequestCompletion(completion: Subscribers.Completion) { +func HandleAPIRequestCompletion(completion: Subscribers.Completion, vm: ViewModel) { switch completion { case .finished: break @@ -17,12 +17,10 @@ func HandleAPIRequestCompletion(completion: Subscribers.Completion) { if let err = error as? ErrorResponse { switch err { case .error(401, _, _, _): - ServerEnvironment.current.errorMessage = "User unauthorized." - ServerEnvironment.current.hasErrorMessage = true + vm.errorMessage = err.localizedDescription SessionManager.current.logout() case .error: - ServerEnvironment.current.errorMessage = err.localizedDescription - ServerEnvironment.current.hasErrorMessage = true + vm.errorMessage = err.localizedDescription } } break diff --git a/Shared/Resources/Model.xcdatamodeld/JellyfinPlayer.xcdatamodel/contents b/Shared/Resources/Model.xcdatamodeld/JellyfinPlayer.xcdatamodel/contents index e6d94afb..6e7215cb 100644 --- a/Shared/Resources/Model.xcdatamodeld/JellyfinPlayer.xcdatamodel/contents +++ b/Shared/Resources/Model.xcdatamodeld/JellyfinPlayer.xcdatamodel/contents @@ -7,12 +7,11 @@ - - + \ No newline at end of file diff --git a/Shared/Singleton/BackgroundManager.swift b/Shared/Singleton/BackgroundManager.swift new file mode 100644 index 00000000..bcad21dc --- /dev/null +++ b/Shared/Singleton/BackgroundManager.swift @@ -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 + +final class BackgroundManager { + static let current = BackgroundManager() + fileprivate(set) var backgroundURL: URL? + fileprivate(set) var blurhash: String = "001fC^" + + init() { + backgroundURL = nil + } + + func setBackground(to: URL, hash: String) { + self.backgroundURL = to + self.blurhash = hash + + let nc = NotificationCenter.default + nc.post(name: Notification.Name("backgroundDidChange"), object: nil) + } + + func clearBackground() { + self.backgroundURL = nil + self.blurhash = "001fC^" + + let nc = NotificationCenter.default + nc.post(name: Notification.Name("backgroundDidChange"), object: nil) + } +} + diff --git a/Shared/Singleton/ServerEnvironment.swift b/Shared/Singleton/ServerEnvironment.swift index 67bf4691..741b99fa 100644 --- a/Shared/Singleton/ServerEnvironment.swift +++ b/Shared/Singleton/ServerEnvironment.swift @@ -17,14 +17,16 @@ final class ServerEnvironment { fileprivate(set) var server: Server! init() { - let serverRequest = NSFetchRequest(entityName: "Server") - let servers = try? PersistenceController.shared.container.viewContext.fetch(serverRequest) as? [Server] - server = servers?.first - guard let baseURI = server?.baseURI else { return } - JellyfinAPI.basePath = baseURI + let serverRequest = Server.fetchRequest() + let servers = try? PersistenceController.shared.container.viewContext.fetch(serverRequest) + + if(servers?.count != 0) { + server = servers?.first + JellyfinAPI.basePath = server.baseURI! + } } - func setUp(with uri: String) -> AnyPublisher { + func create(with uri: String) -> AnyPublisher { var uri = uri if !uri.contains("http") { uri = "https://" + uri @@ -32,6 +34,7 @@ final class ServerEnvironment { if uri.last == "/" { uri = String(uri.dropLast()) } + JellyfinAPI.basePath = uri return SystemAPI.getPublicSystemInfo() .map { response in @@ -47,13 +50,14 @@ final class ServerEnvironment { }).eraseToAnyPublisher() } - func reset() throws { + func reset() { JellyfinAPI.basePath = "" server = nil - let serverRequest: NSFetchRequest = NSFetchRequest(entityName: "Server") + let serverRequest: NSFetchRequest = Server.fetchRequest() let deleteRequest = NSBatchDeleteRequest(fetchRequest: serverRequest) - - try PersistenceController.shared.container.viewContext.execute(deleteRequest) + + //coredata will theoretically never throw + _ = try? PersistenceController.shared.container.viewContext.execute(deleteRequest) } } diff --git a/Shared/Singleton/SessionManager.swift b/Shared/Singleton/SessionManager.swift index d18ff620..4c6db7e7 100644 --- a/Shared/Singleton/SessionManager.swift +++ b/Shared/Singleton/SessionManager.swift @@ -14,95 +14,147 @@ import JellyfinAPI import KeychainSwift import UIKit +#if os(tvOS) +import TVServices +#endif + final class SessionManager { static let current = SessionManager() fileprivate(set) var user: SignedInUser! - fileprivate(set) var authHeader: String! - fileprivate(set) var authToken: String! - fileprivate(set) var deviceID: String - var userID: String? { - user?.user_id - } + fileprivate(set) var deviceID: String = "" + fileprivate(set) var accessToken: String = "" + #if os(tvOS) + let tvUserManager = TVUserManager() + #endif + init() { - let savedUserRequest = NSFetchRequest(entityName: "SignedInUser") - let savedUsers = try? PersistenceController.shared.container.viewContext.fetch(savedUserRequest) as? [SignedInUser] + let savedUserRequest = SignedInUser.fetchRequest() + + let savedUsers = try? PersistenceController.shared.container.viewContext.fetch(savedUserRequest) + + #if os(tvOS) + savedUsers?.forEach() { savedUser in + if(savedUser.appletv_id == tvUserManager.currentUserIdentifier ?? "") { + self.user = savedUser + } + } + #else user = savedUsers?.first - - let keychain = KeychainSwift() - keychain.accessGroup = "9R8RREG67J.me.vigue.jellyfin.sharedKeychain" - if let deviceID = keychain.get("DeviceID") { - self.deviceID = deviceID - } else { - self.deviceID = UUID().uuidString - keychain.set(deviceID, forKey: "DeviceID") + #endif + + if(user != nil) { + let authToken = getAuthToken(userID: user.user_id!) + generateAuthHeader(with: authToken) } - - guard let authToken = keychain.get("AccessToken_\(user?.user_id ?? "")") else { - return - } - - updateHeader(with: authToken) } - fileprivate func updateHeader(with authToken: String?) { + fileprivate func generateAuthHeader(with authToken: String?) { 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\", ") + #if os(tvOS) + header.append("Client=\"SwiftFin tvOS\", ") + #else + header.append("Client=\"SwiftFin iOS\", ") + #endif header.append("Device=\"\(deviceName)\", ") - header.append("DeviceId=\"\(deviceID)\", ") + #if os(tvOS) + header.append("DeviceId=\"tvOS_\(UIDevice.current.identifierForVendor!.uuidString)_\(user?.user_id ?? "")\", ") + deviceID = "tvOS_\(UIDevice.current.identifierForVendor!.uuidString)_\(user?.user_id ?? "")" + #else + header.append("DeviceId=\"iOS_\(UIDevice.current.identifierForVendor!.uuidString)_\(user?.user_id ?? "")\", ") + deviceID = "iOS_\(UIDevice.current.identifierForVendor!.uuidString)_\(user?.user_id ?? "")" + #endif header.append("Version=\"\(appVersion ?? "0.0.1")\", ") - if let token = authToken { - self.authToken = token - header.append("Token=\"\(token)\"") + + if(authToken != nil) { + header.append("Token=\"\(authToken!)\"") + accessToken = authToken! } - authHeader = header - JellyfinAPI.customHeaders["X-Emby-Authorization"] = authHeader + JellyfinAPI.customHeaders["X-Emby-Authorization"] = header + } + + fileprivate func getAuthToken(userID: String) -> String? { + let keychain = KeychainSwift() + keychain.accessGroup = "9R8RREG67J.me.vigue.jellyfin.sharedKeychain" + return keychain.get("AccessToken_\(userID)") + } + + func doesUserHaveSavedSession(userID: String) -> Bool { + let savedUserRequest = SignedInUser.fetchRequest() + savedUserRequest.predicate = NSPredicate(format: "user_id == %@", userID) + let savedUsers = try? PersistenceController.shared.container.viewContext.fetch(savedUserRequest) + + if(savedUsers!.isEmpty) { + return false + } + + return true + } + + func getSavedSession(userID: String) -> SignedInUser { + let savedUserRequest = SignedInUser.fetchRequest() + savedUserRequest.predicate = NSPredicate(format: "user_id == %@", userID) + let savedUsers = try? PersistenceController.shared.container.viewContext.fetch(savedUserRequest) + return savedUsers!.first! + } + + func loginWithSavedSession(user: SignedInUser) { + let accessToken = getAuthToken(userID: user.user_id!) + + self.user = user + generateAuthHeader(with: accessToken) + + let nc = NotificationCenter.default + nc.post(name: Notification.Name("didSignIn"), object: nil) } func login(username: String, password: String) -> AnyPublisher { - updateHeader(with: nil) - return UserAPI.authenticateUserByName(authenticateUserByName: AuthenticateUserByName(username: username, pw: password)) - .map { [unowned self] response -> (SignedInUser, String?) in + .map { response -> (SignedInUser, String?) in let user = SignedInUser(context: PersistenceController.shared.container.viewContext) - user.device_uuid = deviceID user.username = response.user?.name user.user_id = response.user?.id + + #if os(tvOS) + //user.appletv_id = tvUserManager.currentUserIdentifier ?? "" + #endif + return (user, response.accessToken) } .handleEvents(receiveOutput: { [unowned self] response, accessToken in user = response _ = try? PersistenceController.shared.container.viewContext.save() - if let userID = user.user_id, - let token = accessToken - { - let keychain = KeychainSwift() - keychain.accessGroup = "9R8RREG67J.me.vigue.jellyfin.sharedKeychain" - keychain.set(token, forKey: "AccessToken_\(userID)") - } - updateHeader(with: accessToken) + + let keychain = KeychainSwift() + keychain.accessGroup = "9R8RREG67J.me.vigue.jellyfin.sharedKeychain" + keychain.set(accessToken!, forKey: "AccessToken_\(user.user_id!)") + + generateAuthHeader(with: accessToken) + + let nc = NotificationCenter.default + nc.post(name: Notification.Name("didSignIn"), object: nil) }) .map(\.0) .eraseToAnyPublisher() } - func logout() throws { + func logout() { let keychain = KeychainSwift() keychain.accessGroup = "9R8RREG67J.me.vigue.jellyfin.sharedKeychain" keychain.delete("AccessToken_\(user.user_id ?? "")") - JellyfinAPI.customHeaders["X-Emby-Authorization"] = nil + generateAuthHeader(with: nil) + + let deleteRequest = NSBatchDeleteRequest(objectIDs: [user.objectID]) user = nil - authHeader = nil - - let userRequest: NSFetchRequest = NSFetchRequest(entityName: "SignedInUser") - let deleteRequest = NSBatchDeleteRequest(fetchRequest: userRequest) - - try PersistenceController.shared.container.viewContext.execute(deleteRequest) + _ = try? PersistenceController.shared.container.viewContext.execute(deleteRequest) + + let nc = NotificationCenter.default + nc.post(name: Notification.Name("didSignOut"), object: nil) } } diff --git a/Shared/ViewModels/ConnectToServerViewModel.swift b/Shared/ViewModels/ConnectToServerViewModel.swift index 2933994a..14c5f346 100644 --- a/Shared/ViewModels/ConnectToServerViewModel.swift +++ b/Shared/ViewModels/ConnectToServerViewModel.swift @@ -12,38 +12,32 @@ 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 = "" + @Published var lastPublicUsers = [UserDto]() - + @Published + var publicUsers = [UserDto]() + @Published + var selectedPublicUser = UserDto() override init() { super.init() - - refresh() + getPublicUsers() } - func refresh() { + func getPublicUsers() { if ServerEnvironment.current.server != nil { UserAPI.getPublicUsers() .sink(receiveCompletion: { completion in - switch completion { - case .finished: - break - case .failure: - self.isConnectedServer = false - } + HandleAPIRequestCompletion(completion: completion, vm: self) }, receiveValue: { response in self.publicUsers = response self.isConnectedServer = true @@ -63,37 +57,26 @@ final class ConnectToServerViewModel: ViewModel { } func connectToServer() { - ServerEnvironment.current.setUp(with: uri) + ServerEnvironment.current.create(with: uri) .sink(receiveCompletion: { result in switch result { - case let .failure(error): - self.errorMessage = error.localizedDescription - default: - break + case let .failure(error): + self.errorMessage = error.localizedDescription + default: + break } }, receiveValue: { response in - guard response.server_id != nil else { - return - } - self.refresh() + self.getPublicUsers() }) .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 + .sink(receiveCompletion: { completion in + HandleAPIRequestCompletion(completion: completion, vm: self) + }, receiveValue: { _ in + }) .store(in: &cancellables) } diff --git a/Shared/ViewModels/HomeViewModel.swift b/Shared/ViewModels/HomeViewModel.swift index c93bf8a6..0170a81f 100644 --- a/Shared/ViewModels/HomeViewModel.swift +++ b/Shared/ViewModels/HomeViewModel.swift @@ -45,7 +45,7 @@ final class HomeViewModel: ViewModel { }) .store(in: &cancellables) - UserViewsAPI.getUserViews(userId: SessionManager.current.userID ?? "") + UserViewsAPI.getUserViews(userId: SessionManager.current.user.user_id!) .trackActivity(loading) .sink(receiveCompletion: { completion in print(completion) @@ -54,7 +54,7 @@ final class HomeViewModel: ViewModel { }) .store(in: &cancellables) - ItemsAPI.getResumeItems(userId: SessionManager.current.userID!, limit: 12, + ItemsAPI.getResumeItems(userId: SessionManager.current.user.user_id!, limit: 12, fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people], mediaTypes: ["Video"], imageTypeLimit: 1, enableImageTypes: [.primary, .backdrop, .thumb]) .trackActivity(loading) @@ -65,7 +65,7 @@ final class HomeViewModel: ViewModel { }) .store(in: &cancellables) - TvShowsAPI.getNextUp(userId: SessionManager.current.userID!, limit: 12, + TvShowsAPI.getNextUp(userId: SessionManager.current.user.user_id!, limit: 12, fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people]) .trackActivity(loading) .sink(receiveCompletion: { result in diff --git a/Shared/ViewModels/LibraryListViewModel.swift b/Shared/ViewModels/LibraryListViewModel.swift index 0c6aa0aa..ac1ffd68 100644 --- a/Shared/ViewModels/LibraryListViewModel.swift +++ b/Shared/ViewModels/LibraryListViewModel.swift @@ -26,7 +26,7 @@ final class LibraryListViewModel: ViewModel { } func refresh() { - UserViewsAPI.getUserViews(userId: SessionManager.current.userID ?? "") + UserViewsAPI.getUserViews(userId: SessionManager.current.user.user_id!) .trackActivity(loading) .sink(receiveCompletion: { completion in print(completion) diff --git a/Shared/ViewModels/MainTabViewModel.swift b/Shared/ViewModels/MainTabViewModel.swift new file mode 100644 index 00000000..d01b51bf --- /dev/null +++ b/Shared/ViewModels/MainTabViewModel.swift @@ -0,0 +1,30 @@ +// +/* + * 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 MainTabViewModel: ViewModel { + @Published var backgroundURL: URL? + @Published var lastBackgroundURL: URL? + @Published var backgroundBlurHash: String = "001fC^" + + override init() { + super.init() + + let nc = NotificationCenter.default + nc.addObserver(self, selector: #selector(backgroundDidChange), name: Notification.Name("backgroundDidChange"), object: nil) + } + + @objc func backgroundDidChange() { + self.lastBackgroundURL = self.backgroundURL + self.backgroundURL = BackgroundManager.current.backgroundURL + self.backgroundBlurHash = BackgroundManager.current.blurhash + } +} diff --git a/Shared/ViewModels/SplashViewModel.swift b/Shared/ViewModels/SplashViewModel.swift index 7b9d76ce..6ce10794 100644 --- a/Shared/ViewModels/SplashViewModel.swift +++ b/Shared/ViewModels/SplashViewModel.swift @@ -17,8 +17,7 @@ import WidgetKit final class SplashViewModel: ViewModel { - @Published - var isLoggedIn: Bool + @Published var isLoggedIn: Bool = false override init() { isLoggedIn = ServerEnvironment.current.server != nil && SessionManager.current.user != nil @@ -38,5 +37,19 @@ final class SplashViewModel: ViewModel { if defaults.integer(forKey: "OutOfNetworkBandwidth") == 0 { defaults.setValue(40_000_000, forKey: "OutOfNetworkBandwidth") } + + 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) + } + + @objc func didLogIn() { + print("didLogIn") + isLoggedIn = true + } + + @objc func didLogOut() { + print("didLogOut") + isLoggedIn = false } } diff --git a/WidgetExtension/NextUpWidget.swift b/WidgetExtension/NextUpWidget.swift index 87107b5b..2d35128a 100644 --- a/WidgetExtension/NextUpWidget.swift +++ b/WidgetExtension/NextUpWidget.swift @@ -25,24 +25,11 @@ struct NextUpWidgetProvider: TimelineProvider { func getSnapshot(in context: Context, completion: @escaping (NextUpEntry) -> Void) { let currentDate = Date() - guard let server = ServerEnvironment.current.server else { return - DispatchQueue.main.async { - completion(NextUpEntry(date: currentDate, items: [], error: WidgetError.emptyServer)) - } - } - guard let savedUser = SessionManager.current.user else { return - DispatchQueue.main.async { - completion(NextUpEntry(date: currentDate, items: [], error: WidgetError.emptyUser)) - } - } - guard let header = SessionManager.current.authHeader else { return - DispatchQueue.main.async { - completion(NextUpEntry(date: currentDate, items: [], error: WidgetError.emptyHeader)) - } - } + let server = ServerEnvironment.current.server! + let savedUser = SessionManager.current.user! var tempCancellables = Set() + JellyfinAPI.basePath = server.baseURI ?? "" - JellyfinAPI.customHeaders = ["X-Emby-Authorization": header] TvShowsAPI.getNextUp(userId: savedUser.user_id, limit: 3, fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people], imageTypeLimit: 1, enableImageTypes: [.primary, .backdrop, .thumb]) @@ -80,27 +67,11 @@ struct NextUpWidgetProvider: TimelineProvider { func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { let currentDate = Date() let entryDate = Calendar.current.date(byAdding: .hour, value: 1, to: currentDate)! - guard let server = ServerEnvironment.current.server else { return - DispatchQueue.main.async { - completion(Timeline(entries: [NextUpEntry(date: currentDate, items: [], error: WidgetError.emptyServer)], - policy: .after(entryDate))) - } - } - guard let savedUser = SessionManager.current.user else { return - DispatchQueue.main.async { - completion(Timeline(entries: [NextUpEntry(date: currentDate, items: [], error: WidgetError.emptyUser)], - policy: .after(entryDate))) - } - } - guard let header = SessionManager.current.authHeader else { return - DispatchQueue.main.async { - completion(Timeline(entries: [NextUpEntry(date: currentDate, items: [], error: WidgetError.emptyHeader)], - policy: .after(entryDate))) - } - } + let server = ServerEnvironment.current.server! + let savedUser = SessionManager.current.user! + var tempCancellables = Set() JellyfinAPI.basePath = server.baseURI ?? "" - JellyfinAPI.customHeaders = ["X-Emby-Authorization": header] TvShowsAPI.getNextUp(userId: savedUser.user_id, limit: 3, fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people], imageTypeLimit: 1, enableImageTypes: [.primary, .backdrop, .thumb])