diff --git a/Shared/Coordinators/MainCoordinator/iOSMainCoordinator.swift b/Shared/Coordinators/MainCoordinator/iOSMainCoordinator.swift index f360f983..3e6725be 100644 --- a/Shared/Coordinators/MainCoordinator/iOSMainCoordinator.swift +++ b/Shared/Coordinators/MainCoordinator/iOSMainCoordinator.swift @@ -89,7 +89,7 @@ final class MainCoordinator: NavigationCoordinatable { else { fatalError("Need to have new current login state server") } guard SessionManager.main.currentLogin != nil else { return } if newCurrentServerState.id == SessionManager.main.currentLogin.server.id { - SessionManager.main.loginUser(server: newCurrentServerState, user: SessionManager.main.currentLogin.user) + SessionManager.main.signInUser(server: newCurrentServerState, user: SessionManager.main.currentLogin.user) } } diff --git a/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+VideoPlayerViewModel.swift b/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+VideoPlayerViewModel.swift index 833f3bbd..a6ccd682 100644 --- a/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+VideoPlayerViewModel.swift +++ b/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+VideoPlayerViewModel.swift @@ -44,137 +44,137 @@ extension BaseItemDto { var viewModels: [VideoPlayerViewModel] = [] for currentMediaSource in mediaSources { - let videoStream = currentMediaSource.mediaStreams?.filter { $0.type == .video }.first - let audioStreams = currentMediaSource.mediaStreams?.filter { $0.type == .audio } ?? [] - let subtitleStreams = currentMediaSource.mediaStreams?.filter { $0.type == .subtitle } ?? [] + let videoStream = currentMediaSource.mediaStreams?.filter { $0.type == .video }.first + let audioStreams = currentMediaSource.mediaStreams?.filter { $0.type == .audio } ?? [] + let subtitleStreams = currentMediaSource.mediaStreams?.filter { $0.type == .subtitle } ?? [] - let defaultAudioStream = audioStreams.first(where: { $0.index! == currentMediaSource.defaultAudioStreamIndex! }) + let defaultAudioStream = audioStreams.first(where: { $0.index! == currentMediaSource.defaultAudioStreamIndex! }) - let defaultSubtitleStream = subtitleStreams - .first(where: { $0.index! == currentMediaSource.defaultSubtitleStreamIndex ?? -1 }) + let defaultSubtitleStream = subtitleStreams + .first(where: { $0.index! == currentMediaSource.defaultSubtitleStreamIndex ?? -1 }) - // MARK: Build Streams + // MARK: Build Streams - let directStreamURL: URL - let transcodedStreamURL: URLComponents? - var hlsStreamURL: URL - let mediaSourceID: String - let streamType: ServerStreamType + let directStreamURL: URL + let transcodedStreamURL: URLComponents? + var hlsStreamURL: URL + let mediaSourceID: String + let streamType: ServerStreamType - if mediaSources.count > 1 { - mediaSourceID = currentMediaSource.id! - } else { - mediaSourceID = self.id! - } - - let directStreamBuilder = VideosAPI.getVideoStreamWithRequestBuilder( - itemId: self.id!, - _static: true, - tag: self.etag, - playSessionId: response.playSessionId, - minSegments: 6, - mediaSourceId: mediaSourceID - ) - directStreamURL = URL(string: directStreamBuilder.URLString)! - - if let transcodeURL = currentMediaSource.transcodingUrl { - streamType = .transcode - transcodedStreamURL = URLComponents( - string: SessionManager.main.currentLogin.server.currentURI - .appending(transcodeURL) - )! - } else { - streamType = .direct - transcodedStreamURL = nil - } - - let hlsStreamBuilder = DynamicHlsAPI.getMasterHlsVideoPlaylistWithRequestBuilder( - itemId: id ?? "", - mediaSourceId: id ?? "", - _static: true, - tag: currentMediaSource.eTag, - deviceProfileId: nil, - playSessionId: response.playSessionId, - segmentContainer: "ts", - segmentLength: nil, - minSegments: 2, - deviceId: UIDevice.vendorUUIDString, - audioCodec: audioStreams - .compactMap(\.codec) - .joined(separator: ","), - breakOnNonKeyFrames: true, - requireAvc: true, - transcodingMaxAudioChannels: 6, - videoCodec: videoStream?.codec, - videoStreamIndex: videoStream?.index, - enableAdaptiveBitrateStreaming: true - ) - - var hlsStreamComponents = URLComponents(string: hlsStreamBuilder.URLString)! - hlsStreamComponents.addQueryItem(name: "api_key", value: SessionManager.main.currentLogin.user.accessToken) - - hlsStreamURL = hlsStreamComponents.url! - - // MARK: VidoPlayerViewModel Creation - - var subtitle: String? - - // MARK: Attach media content to self - - var modifiedSelfItem = self - modifiedSelfItem.mediaStreams = currentMediaSource.mediaStreams - - // TODO: other forms of media subtitle - if self.itemType == .episode { - if let seriesName = self.seriesName, let episodeLocator = self.getEpisodeLocator() { - subtitle = "\(seriesName) - \(episodeLocator)" + if mediaSources.count > 1 { + mediaSourceID = currentMediaSource.id! + } else { + mediaSourceID = self.id! } + + let directStreamBuilder = VideosAPI.getVideoStreamWithRequestBuilder( + itemId: self.id!, + _static: true, + tag: self.etag, + playSessionId: response.playSessionId, + minSegments: 6, + mediaSourceId: mediaSourceID + ) + directStreamURL = URL(string: directStreamBuilder.URLString)! + + if let transcodeURL = currentMediaSource.transcodingUrl { + streamType = .transcode + transcodedStreamURL = URLComponents( + string: SessionManager.main.currentLogin.server.currentURI + .appending(transcodeURL) + )! + } else { + streamType = .direct + transcodedStreamURL = nil + } + + let hlsStreamBuilder = DynamicHlsAPI.getMasterHlsVideoPlaylistWithRequestBuilder( + itemId: id ?? "", + mediaSourceId: id ?? "", + _static: true, + tag: currentMediaSource.eTag, + deviceProfileId: nil, + playSessionId: response.playSessionId, + segmentContainer: "ts", + segmentLength: nil, + minSegments: 2, + deviceId: UIDevice.vendorUUIDString, + audioCodec: audioStreams + .compactMap(\.codec) + .joined(separator: ","), + breakOnNonKeyFrames: true, + requireAvc: true, + transcodingMaxAudioChannels: 6, + videoCodec: videoStream?.codec, + videoStreamIndex: videoStream?.index, + enableAdaptiveBitrateStreaming: true + ) + + var hlsStreamComponents = URLComponents(string: hlsStreamBuilder.URLString)! + hlsStreamComponents.addQueryItem(name: "api_key", value: SessionManager.main.currentLogin.user.accessToken) + + hlsStreamURL = hlsStreamComponents.url! + + // MARK: VidoPlayerViewModel Creation + + var subtitle: String? + + // MARK: Attach media content to self + + var modifiedSelfItem = self + modifiedSelfItem.mediaStreams = currentMediaSource.mediaStreams + + // TODO: other forms of media subtitle + if self.itemType == .episode { + if let seriesName = self.seriesName, let episodeLocator = self.getEpisodeLocator() { + subtitle = "\(seriesName) - \(episodeLocator)" + } + } + + let subtitlesEnabled = defaultSubtitleStream != nil + + let shouldShowAutoPlay = Defaults[.shouldShowAutoPlay] && itemType == .episode + let autoplayEnabled = Defaults[.autoplayEnabled] && shouldShowAutoPlay + + let overlayType = Defaults[.overlayType] + + let shouldShowPlayPreviousItem = Defaults[.shouldShowPlayPreviousItem] && itemType == .episode + let shouldShowPlayNextItem = Defaults[.shouldShowPlayNextItem] && itemType == .episode + + var fileName: String? + if let lastInPath = currentMediaSource.path?.split(separator: "/").last { + fileName = String(lastInPath) + } + + let videoPlayerViewModel = VideoPlayerViewModel( + item: modifiedSelfItem, + title: modifiedSelfItem.name ?? "", + subtitle: subtitle, + directStreamURL: directStreamURL, + transcodedStreamURL: transcodedStreamURL?.url, + hlsStreamURL: hlsStreamURL, + streamType: streamType, + response: response, + audioStreams: audioStreams, + subtitleStreams: subtitleStreams, + chapters: modifiedSelfItem.chapters ?? [], + selectedAudioStreamIndex: defaultAudioStream?.index ?? -1, + selectedSubtitleStreamIndex: defaultSubtitleStream?.index ?? -1, + subtitlesEnabled: subtitlesEnabled, + autoplayEnabled: autoplayEnabled, + overlayType: overlayType, + shouldShowPlayPreviousItem: shouldShowPlayPreviousItem, + shouldShowPlayNextItem: shouldShowPlayNextItem, + shouldShowAutoPlay: shouldShowAutoPlay, + container: currentMediaSource.container ?? "", + filename: fileName, + versionName: currentMediaSource.name + ) + + viewModels.append(videoPlayerViewModel) } - let subtitlesEnabled = defaultSubtitleStream != nil - - let shouldShowAutoPlay = Defaults[.shouldShowAutoPlay] && itemType == .episode - let autoplayEnabled = Defaults[.autoplayEnabled] && shouldShowAutoPlay - - let overlayType = Defaults[.overlayType] - - let shouldShowPlayPreviousItem = Defaults[.shouldShowPlayPreviousItem] && itemType == .episode - let shouldShowPlayNextItem = Defaults[.shouldShowPlayNextItem] && itemType == .episode - - var fileName: String? - if let lastInPath = currentMediaSource.path?.split(separator: "/").last { - fileName = String(lastInPath) - } - - let videoPlayerViewModel = VideoPlayerViewModel( - item: modifiedSelfItem, - title: modifiedSelfItem.name ?? "", - subtitle: subtitle, - directStreamURL: directStreamURL, - transcodedStreamURL: transcodedStreamURL?.url, - hlsStreamURL: hlsStreamURL, - streamType: streamType, - response: response, - audioStreams: audioStreams, - subtitleStreams: subtitleStreams, - chapters: modifiedSelfItem.chapters ?? [], - selectedAudioStreamIndex: defaultAudioStream?.index ?? -1, - selectedSubtitleStreamIndex: defaultSubtitleStream?.index ?? -1, - subtitlesEnabled: subtitlesEnabled, - autoplayEnabled: autoplayEnabled, - overlayType: overlayType, - shouldShowPlayPreviousItem: shouldShowPlayPreviousItem, - shouldShowPlayNextItem: shouldShowPlayNextItem, - shouldShowAutoPlay: shouldShowAutoPlay, - container: currentMediaSource.container ?? "", - filename: fileName, - versionName: currentMediaSource.name - ) - - viewModels.append(videoPlayerViewModel) - } - - return viewModels + return viewModels } .eraseToAnyPublisher() } @@ -211,137 +211,137 @@ extension BaseItemDto { var viewModels: [VideoPlayerViewModel] = [] for currentMediaSource in mediaSources { - let videoStream = currentMediaSource.mediaStreams?.filter { $0.type == .video }.first - let audioStreams = currentMediaSource.mediaStreams?.filter { $0.type == .audio } ?? [] - let subtitleStreams = currentMediaSource.mediaStreams?.filter { $0.type == .subtitle } ?? [] + let videoStream = currentMediaSource.mediaStreams?.filter { $0.type == .video }.first + let audioStreams = currentMediaSource.mediaStreams?.filter { $0.type == .audio } ?? [] + let subtitleStreams = currentMediaSource.mediaStreams?.filter { $0.type == .subtitle } ?? [] - let defaultAudioStream = audioStreams.first(where: { $0.index! == currentMediaSource.defaultAudioStreamIndex! }) + let defaultAudioStream = audioStreams.first(where: { $0.index! == currentMediaSource.defaultAudioStreamIndex! }) - let defaultSubtitleStream = subtitleStreams - .first(where: { $0.index! == currentMediaSource.defaultSubtitleStreamIndex ?? -1 }) + let defaultSubtitleStream = subtitleStreams + .first(where: { $0.index! == currentMediaSource.defaultSubtitleStreamIndex ?? -1 }) - // MARK: Build Streams + // MARK: Build Streams - let directStreamURL: URL - let transcodedStreamURL: URLComponents? - var hlsStreamURL: URL - let mediaSourceID: String - let streamType: ServerStreamType + let directStreamURL: URL + let transcodedStreamURL: URLComponents? + var hlsStreamURL: URL + let mediaSourceID: String + let streamType: ServerStreamType - if mediaSources.count > 1 { - mediaSourceID = currentMediaSource.id! - } else { - mediaSourceID = self.id! - } - - let directStreamBuilder = VideosAPI.getVideoStreamWithRequestBuilder( - itemId: self.id!, - _static: true, - tag: self.etag, - playSessionId: response.playSessionId, - minSegments: 6, - mediaSourceId: mediaSourceID - ) - directStreamURL = URL(string: directStreamBuilder.URLString)! - - if let transcodeURL = currentMediaSource.transcodingUrl, !Defaults[.Experimental.liveTVForceDirectPlay] { - streamType = .transcode - transcodedStreamURL = URLComponents( - string: SessionManager.main.currentLogin.server.currentURI - .appending(transcodeURL) - )! - } else { - streamType = .direct - transcodedStreamURL = nil - } - - let hlsStreamBuilder = DynamicHlsAPI.getMasterHlsVideoPlaylistWithRequestBuilder( - itemId: id ?? "", - mediaSourceId: id ?? "", - _static: true, - tag: currentMediaSource.eTag, - deviceProfileId: nil, - playSessionId: response.playSessionId, - segmentContainer: "ts", - segmentLength: nil, - minSegments: 2, - deviceId: UIDevice.vendorUUIDString, - audioCodec: audioStreams - .compactMap(\.codec) - .joined(separator: ","), - breakOnNonKeyFrames: true, - requireAvc: true, - transcodingMaxAudioChannels: 6, - videoCodec: videoStream?.codec, - videoStreamIndex: videoStream?.index, - enableAdaptiveBitrateStreaming: true - ) - - var hlsStreamComponents = URLComponents(string: hlsStreamBuilder.URLString)! - hlsStreamComponents.addQueryItem(name: "api_key", value: SessionManager.main.currentLogin.user.accessToken) - - hlsStreamURL = hlsStreamComponents.url! - - // MARK: VidoPlayerViewModel Creation - - var subtitle: String? - - // MARK: Attach media content to self - - var modifiedSelfItem = self - modifiedSelfItem.mediaStreams = currentMediaSource.mediaStreams - - // TODO: other forms of media subtitle - if self.itemType == .episode { - if let seriesName = self.seriesName, let episodeLocator = self.getEpisodeLocator() { - subtitle = "\(seriesName) - \(episodeLocator)" + if mediaSources.count > 1 { + mediaSourceID = currentMediaSource.id! + } else { + mediaSourceID = self.id! } + + let directStreamBuilder = VideosAPI.getVideoStreamWithRequestBuilder( + itemId: self.id!, + _static: true, + tag: self.etag, + playSessionId: response.playSessionId, + minSegments: 6, + mediaSourceId: mediaSourceID + ) + directStreamURL = URL(string: directStreamBuilder.URLString)! + + if let transcodeURL = currentMediaSource.transcodingUrl, !Defaults[.Experimental.liveTVForceDirectPlay] { + streamType = .transcode + transcodedStreamURL = URLComponents( + string: SessionManager.main.currentLogin.server.currentURI + .appending(transcodeURL) + )! + } else { + streamType = .direct + transcodedStreamURL = nil + } + + let hlsStreamBuilder = DynamicHlsAPI.getMasterHlsVideoPlaylistWithRequestBuilder( + itemId: id ?? "", + mediaSourceId: id ?? "", + _static: true, + tag: currentMediaSource.eTag, + deviceProfileId: nil, + playSessionId: response.playSessionId, + segmentContainer: "ts", + segmentLength: nil, + minSegments: 2, + deviceId: UIDevice.vendorUUIDString, + audioCodec: audioStreams + .compactMap(\.codec) + .joined(separator: ","), + breakOnNonKeyFrames: true, + requireAvc: true, + transcodingMaxAudioChannels: 6, + videoCodec: videoStream?.codec, + videoStreamIndex: videoStream?.index, + enableAdaptiveBitrateStreaming: true + ) + + var hlsStreamComponents = URLComponents(string: hlsStreamBuilder.URLString)! + hlsStreamComponents.addQueryItem(name: "api_key", value: SessionManager.main.currentLogin.user.accessToken) + + hlsStreamURL = hlsStreamComponents.url! + + // MARK: VidoPlayerViewModel Creation + + var subtitle: String? + + // MARK: Attach media content to self + + var modifiedSelfItem = self + modifiedSelfItem.mediaStreams = currentMediaSource.mediaStreams + + // TODO: other forms of media subtitle + if self.itemType == .episode { + if let seriesName = self.seriesName, let episodeLocator = self.getEpisodeLocator() { + subtitle = "\(seriesName) - \(episodeLocator)" + } + } + + let subtitlesEnabled = defaultSubtitleStream != nil + + let shouldShowAutoPlay = Defaults[.shouldShowAutoPlay] && itemType == .episode + let autoplayEnabled = Defaults[.autoplayEnabled] && shouldShowAutoPlay + + let overlayType = Defaults[.overlayType] + + let shouldShowPlayPreviousItem = Defaults[.shouldShowPlayPreviousItem] && itemType == .episode + let shouldShowPlayNextItem = Defaults[.shouldShowPlayNextItem] && itemType == .episode + + var fileName: String? + if let lastInPath = currentMediaSource.path?.split(separator: "/").last { + fileName = String(lastInPath) + } + + let videoPlayerViewModel = VideoPlayerViewModel( + item: modifiedSelfItem, + title: modifiedSelfItem.name ?? "", + subtitle: subtitle, + directStreamURL: directStreamURL, + transcodedStreamURL: transcodedStreamURL?.url, + hlsStreamURL: hlsStreamURL, + streamType: streamType, + response: response, + audioStreams: audioStreams, + subtitleStreams: subtitleStreams, + chapters: modifiedSelfItem.chapters ?? [], + selectedAudioStreamIndex: defaultAudioStream?.index ?? -1, + selectedSubtitleStreamIndex: defaultSubtitleStream?.index ?? -1, + subtitlesEnabled: subtitlesEnabled, + autoplayEnabled: autoplayEnabled, + overlayType: overlayType, + shouldShowPlayPreviousItem: shouldShowPlayPreviousItem, + shouldShowPlayNextItem: shouldShowPlayNextItem, + shouldShowAutoPlay: shouldShowAutoPlay, + container: currentMediaSource.container ?? "", + filename: fileName, + versionName: currentMediaSource.name + ) + + viewModels.append(videoPlayerViewModel) } - let subtitlesEnabled = defaultSubtitleStream != nil - - let shouldShowAutoPlay = Defaults[.shouldShowAutoPlay] && itemType == .episode - let autoplayEnabled = Defaults[.autoplayEnabled] && shouldShowAutoPlay - - let overlayType = Defaults[.overlayType] - - let shouldShowPlayPreviousItem = Defaults[.shouldShowPlayPreviousItem] && itemType == .episode - let shouldShowPlayNextItem = Defaults[.shouldShowPlayNextItem] && itemType == .episode - - var fileName: String? - if let lastInPath = currentMediaSource.path?.split(separator: "/").last { - fileName = String(lastInPath) - } - - let videoPlayerViewModel = VideoPlayerViewModel( - item: modifiedSelfItem, - title: modifiedSelfItem.name ?? "", - subtitle: subtitle, - directStreamURL: directStreamURL, - transcodedStreamURL: transcodedStreamURL?.url, - hlsStreamURL: hlsStreamURL, - streamType: streamType, - response: response, - audioStreams: audioStreams, - subtitleStreams: subtitleStreams, - chapters: modifiedSelfItem.chapters ?? [], - selectedAudioStreamIndex: defaultAudioStream?.index ?? -1, - selectedSubtitleStreamIndex: defaultSubtitleStream?.index ?? -1, - subtitlesEnabled: subtitlesEnabled, - autoplayEnabled: autoplayEnabled, - overlayType: overlayType, - shouldShowPlayPreviousItem: shouldShowPlayPreviousItem, - shouldShowPlayNextItem: shouldShowPlayNextItem, - shouldShowAutoPlay: shouldShowAutoPlay, - container: currentMediaSource.container ?? "", - filename: fileName, - versionName: currentMediaSource.name - ) - - viewModels.append(videoPlayerViewModel) - } - - return viewModels + return viewModels } .eraseToAnyPublisher() } diff --git a/Shared/Generated/Strings.swift b/Shared/Generated/Strings.swift index a794cc91..d85399b3 100644 --- a/Shared/Generated/Strings.swift +++ b/Shared/Generated/Strings.swift @@ -270,6 +270,14 @@ internal enum L10n { internal static var quickConnectCode: String { return L10n.tr("Localizable", "quickConnectCode") } /// Invalid Quick Connect code internal static var quickConnectInvalidError: String { return L10n.tr("Localizable", "quickConnectInvalidError") } + /// Note: Quick Connect not enabled + internal static var quickConnectNotEnabled: String { return L10n.tr("Localizable", "quickConnectNotEnabled") } + /// 1. Open the Jellyfin app on your phone or web browser and sign in with your account + internal static var quickConnectStep1: String { return L10n.tr("Localizable", "quickConnectStep1") } + /// 2. Open the user menu and go to the Quick Connect page + internal static var quickConnectStep2: String { return L10n.tr("Localizable", "quickConnectStep2") } + /// 3. Enter the following code: + internal static var quickConnectStep3: String { return L10n.tr("Localizable", "quickConnectStep3") } /// Authorizing Quick Connect successful. Please continue on your other device. internal static var quickConnectSuccessMessage: String { return L10n.tr("Localizable", "quickConnectSuccessMessage") } /// Rated diff --git a/Shared/Singleton/SessionManager.swift b/Shared/Singleton/SessionManager.swift index e4e8e063..7ff479fd 100644 --- a/Shared/Singleton/SessionManager.swift +++ b/Shared/Singleton/SessionManager.swift @@ -22,7 +22,7 @@ final class SessionManager { // MARK: currentLogin - private(set) var currentLogin: CurrentLogin! + fileprivate(set) var currentLogin: CurrentLogin! // MARK: main @@ -31,6 +31,8 @@ final class SessionManager { // MARK: init private init() { + setAuthHeader(with: "") + if let lastUserID = Defaults[.lastServerUserID], let user = try? SwiftfinStore.dataStack.fetchOne( From(), @@ -195,86 +197,31 @@ final class SessionManager { .eraseToAnyPublisher() } - // MARK: loginUser publisher + // MARK: signInUser publisher // Logs in a user with an associated server, storing if successful - func loginUser( + func signInUser( server: SwiftfinStore.State.Server, username: String, password: String ) -> AnyPublisher { - setAuthHeader(with: "") - JellyfinAPIAPI.basePath = server.currentURI return UserAPI.authenticateUserByName(authenticateUserByNameRequest: .init(username: username, pw: password)) - .tryMap { response -> (SwiftfinStore.Models.StoredServer, SwiftfinStore.Models.StoredUser, UnsafeDataTransaction) in - - guard let accessToken = response.accessToken else { throw JellyfinAPIError("Access token missing from network call") } - - let transaction = SwiftfinStore.dataStack.beginUnsafe() - let newUser = transaction.create(Into()) - - guard let username = response.user?.name, - let id = response.user?.id else { throw JellyfinAPIError("Missing user data from network call") } - - newUser.username = username - newUser.id = id - newUser.appleTVID = "" - - // Check for existing user on device - if let existingUser = try? SwiftfinStore.dataStack.fetchOne( - From(), - [Where( - "id == %@", - newUser.id - )] - ) { - throw SwiftfinStore.Error.existingUser(existingUser.state) - } - - let newAccessToken = transaction.create(Into()) - newAccessToken.value = accessToken - newUser.accessToken = newAccessToken - - guard let userServer = try? SwiftfinStore.dataStack.fetchOne( - From(), - [ - Where( - "id == %@", - server.id - ), - ] - ) - else { fatalError("No stored server associated with given state server?") } - - guard let editUserServer = transaction.edit(userServer) else { fatalError("Can't get proxy for existing object?") } - editUserServer.users.insert(newUser) - - return (editUserServer, newUser, transaction) - } - .handleEvents(receiveOutput: { [unowned self] server, user, transaction in - setAuthHeader(with: user.accessToken?.value ?? "") - try? transaction.commitAndWait() - - // Fetch for the right queue - let currentServer = SwiftfinStore.dataStack.fetchExisting(server)! - let currentUser = SwiftfinStore.dataStack.fetchExisting(user)! - - Defaults[.lastServerUserID] = user.id - - currentLogin = (server: currentServer.state, user: currentUser.state) - Notifications[.didSignIn].post() - }) - .map { _, user, _ in - user.state - } - .eraseToAnyPublisher() + .processAuthenticationRequest(with: self, server: server) } - // MARK: loginUser + // Logs in a user with an associated server, storing if successful + func signInUser(server: SwiftfinStore.State.Server, quickConnectSecret: String) -> AnyPublisher { + JellyfinAPIAPI.basePath = server.currentURI - func loginUser(server: SwiftfinStore.State.Server, user: SwiftfinStore.State.User) { + return UserAPI.authenticateWithQuickConnect(authenticateWithQuickConnectRequest: .init(secret: quickConnectSecret)) + .processAuthenticationRequest(with: self, server: server) + } + + // MARK: signInUser + + func signInUser(server: SwiftfinStore.State.Server, user: SwiftfinStore.State.User) { JellyfinAPIAPI.basePath = server.currentURI Defaults[.lastServerUserID] = user.id setAuthHeader(with: user.accessToken) @@ -347,7 +294,7 @@ final class SessionManager { try? transaction.commitAndWait() } - private func setAuthHeader(with accessToken: String) { + fileprivate func setAuthHeader(with accessToken: String) { let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String var deviceName = UIDevice.current.name deviceName = deviceName.folding(options: .diacriticInsensitive, locale: .current) @@ -370,3 +317,74 @@ final class SessionManager { JellyfinAPIAPI.customHeaders["X-Emby-Authorization"] = header } } + +extension AnyPublisher where Output == AuthenticationResult { + func processAuthenticationRequest( + with sessionManager: SessionManager, + server: SwiftfinStore.State.Server + ) -> AnyPublisher { + self + .tryMap { response -> (SwiftfinStore.Models.StoredServer, SwiftfinStore.Models.StoredUser, UnsafeDataTransaction) in + + guard let accessToken = response.accessToken else { throw JellyfinAPIError("Access token missing from network call") } + + let transaction = SwiftfinStore.dataStack.beginUnsafe() + let newUser = transaction.create(Into()) + + guard let username = response.user?.name, + let id = response.user?.id else { throw JellyfinAPIError("Missing user data from network call") } + + newUser.username = username + newUser.id = id + newUser.appleTVID = "" + + // Check for existing user on device + if let existingUser = try? SwiftfinStore.dataStack.fetchOne( + From(), + [Where( + "id == %@", + newUser.id + )] + ) { + throw SwiftfinStore.Error.existingUser(existingUser.state) + } + + let newAccessToken = transaction.create(Into()) + newAccessToken.value = accessToken + newUser.accessToken = newAccessToken + + guard let userServer = try? SwiftfinStore.dataStack.fetchOne( + From(), + [ + Where( + "id == %@", + server.id + ), + ] + ) + else { fatalError("No stored server associated with given state server?") } + + guard let editUserServer = transaction.edit(userServer) else { fatalError("Can't get proxy for existing object?") } + editUserServer.users.insert(newUser) + + return (editUserServer, newUser, transaction) + } + .handleEvents(receiveOutput: { server, user, transaction in + sessionManager.setAuthHeader(with: user.accessToken?.value ?? "") + try? transaction.commitAndWait() + + // Fetch for the right queue + let currentServer = SwiftfinStore.dataStack.fetchExisting(server)! + let currentUser = SwiftfinStore.dataStack.fetchExisting(user)! + + Defaults[.lastServerUserID] = user.id + + sessionManager.currentLogin = (server: currentServer.state, user: currentUser.state) + Notifications[.didSignIn].post() + }) + .map { _, user, _ in + user.state + } + .eraseToAnyPublisher() + } +} diff --git a/Shared/ViewModels/UserListViewModel.swift b/Shared/ViewModels/UserListViewModel.swift index 1e32a812..d60da2aa 100644 --- a/Shared/ViewModels/UserListViewModel.swift +++ b/Shared/ViewModels/UserListViewModel.swift @@ -34,9 +34,9 @@ class UserListViewModel: ViewModel { self.users = SessionManager.main.fetchUsers(for: server) } - func login(user: SwiftfinStore.State.User) { + func signIn(user: SwiftfinStore.State.User) { self.isLoading = true - SessionManager.main.loginUser(server: server, user: user) + SessionManager.main.signInUser(server: server, user: user) } func remove(user: SwiftfinStore.State.User) { diff --git a/Shared/ViewModels/UserSignInViewModel.swift b/Shared/ViewModels/UserSignInViewModel.swift index ce39f84d..8ff1bfd7 100644 --- a/Shared/ViewModels/UserSignInViewModel.swift +++ b/Shared/ViewModels/UserSignInViewModel.swift @@ -15,14 +15,24 @@ final class UserSignInViewModel: ViewModel { @RouterObject var router: UserSignInCoordinator.Router? - let server: SwiftfinStore.State.Server @Published var publicUsers: [UserDto] = [] + @Published + var quickConnectCode: String? + @Published + var quickConnectEnabled = false + + let server: SwiftfinStore.State.Server + private var quickConnectTimer: Timer? + private var quickConnectSecret: String? init(server: SwiftfinStore.State.Server) { self.server = server + super.init() + JellyfinAPIAPI.basePath = server.currentURI + checkQuickConnect() } var alertTitle: String { @@ -34,10 +44,10 @@ final class UserSignInViewModel: ViewModel { return message } - func login(username: String, password: String) { + func signIn(username: String, password: String) { LogManager.log.debug("Attempting to login to server at \"\(server.currentURI)\"", tag: "login") - SessionManager.main.loginUser(server: server, username: username, password: password) + SessionManager.main.signInUser(server: server, username: username, password: password) .trackActivity(loading) .sink { completion in self.handleAPIRequestError(displayMessage: L10n.unableToConnectServer, completion: completion) @@ -79,4 +89,65 @@ final class UserSignInViewModel: ViewModel { let urlString = ImageAPI.getSplashscreenWithRequestBuilder().URLString return URL(string: urlString) } + + func checkQuickConnect() { + QuickConnectAPI.getEnabled() + .sink(receiveCompletion: { completion in + self.handleAPIRequestError(completion: completion) + }, receiveValue: { enabled in + self.quickConnectEnabled = enabled + if enabled { + self.startQuickConnect() + } + }) + .store(in: &cancellables) + } + + private func startQuickConnect() { + QuickConnectAPI.initiate() + .sink(receiveCompletion: { completion in + self.handleAPIRequestError(completion: completion) + }, receiveValue: { response in + + self.quickConnectSecret = response.secret + self.quickConnectCode = response.code + LogManager.log.debug("QuickConnect code: \(response.code ?? "--")") + + self.quickConnectTimer = Timer.scheduledTimer( + timeInterval: 5, + target: self, + selector: #selector(self.checkAuthStatus), + userInfo: nil, + repeats: true + ) + }) + .store(in: &cancellables) + } + + @objc + private func checkAuthStatus() { + guard let quickConnectSecret = quickConnectSecret else { return } + + QuickConnectAPI.connect(secret: quickConnectSecret) + .sink(receiveCompletion: { _ in + // Prefer not to handle error handling like normal as + // this is a repeated call + }, receiveValue: { value in + guard let authenticated = value.authenticated, authenticated else { + LogManager.log.debug("QuickConnect not authenticated yet") + return + } + + self.quickConnectTimer?.invalidate() + + SessionManager.main.signInUser(server: self.server, quickConnectSecret: quickConnectSecret) + .trackActivity(self.loading) + .sink { completion in + self.handleAPIRequestError(displayMessage: L10n.unableToConnectServer, completion: completion) + } receiveValue: { _ in + } + .store(in: &self.cancellables) + }) + .store(in: &cancellables) + } } diff --git a/Swiftfin tvOS/Views/UserListView.swift b/Swiftfin tvOS/Views/UserListView.swift index 5f7f1e0e..4e42bd7f 100644 --- a/Swiftfin tvOS/Views/UserListView.swift +++ b/Swiftfin tvOS/Views/UserListView.swift @@ -21,7 +21,7 @@ struct UserListView: View { LazyVStack { ForEach(viewModel.users, id: \.id) { user in Button { - viewModel.login(user: user) + viewModel.signIn(user: user) } label: { HStack { Text(user.username) diff --git a/Swiftfin tvOS/Views/UserSignInView.swift b/Swiftfin tvOS/Views/UserSignInView.swift index 8eff7d09..29987a4b 100644 --- a/Swiftfin tvOS/Views/UserSignInView.swift +++ b/Swiftfin tvOS/Views/UserSignInView.swift @@ -27,41 +27,83 @@ struct UserSignInView: View { .opacity(0.9) .ignoresSafeArea() - Form { - Section { - TextField(L10n.username, text: $username) - .disableAutocorrection(true) - .autocapitalization(.none) + HStack(alignment: .top) { + VStack(alignment: .leading) { + Section { + TextField(L10n.username, text: $username) + .disableAutocorrection(true) + .autocapitalization(.none) - SecureField(L10n.password, text: $password) - .disableAutocorrection(true) - .autocapitalization(.none) + SecureField(L10n.password, text: $password) + .disableAutocorrection(true) + .autocapitalization(.none) - Button { - viewModel.login(username: username, password: password) - } label: { - HStack { - L10n.connect.text - Spacer() - if viewModel.isLoading { - ProgressView() + Button { + viewModel.signIn(username: username, password: password) + } label: { + HStack { + L10n.connect.text + + Spacer() + + if viewModel.isLoading { + ProgressView() + } } } - } - .disabled(viewModel.isLoading || username.isEmpty) + .disabled(viewModel.isLoading || username.isEmpty) - } header: { - L10n.signInToServer(viewModel.server.name).text + } header: { + L10n.signInToServer(viewModel.server.name).text + .foregroundColor(.secondary) + } + + Spacer() + + if !viewModel.quickConnectEnabled { + L10n.quickConnectNotEnabled.text + } + } + .frame(maxWidth: .infinity) + .alert(item: $viewModel.errorMessage) { _ in + Alert( + title: Text(viewModel.alertTitle), + message: Text(viewModel.errorMessage?.message ?? L10n.unknownError), + dismissButton: .cancel() + ) + } + .navigationTitle(L10n.signIn) + + if viewModel.quickConnectEnabled { + VStack(alignment: .center) { + L10n.quickConnect.text + .font(.title3) + .fontWeight(.semibold) + + VStack(alignment: .leading, spacing: 20) { + L10n.quickConnectStep1.text + + L10n.quickConnectStep2.text + + L10n.quickConnectStep3.text + } + .padding(.vertical) + + Text(viewModel.quickConnectCode ?? "------") + .tracking(10) + .font(.title) + .monospacedDigit() + .frame(maxWidth: .infinity) + } + .frame(maxWidth: .infinity) } } - .alert(item: $viewModel.errorMessage) { _ in - Alert( - title: Text(viewModel.alertTitle), - message: Text(viewModel.errorMessage?.message ?? L10n.unknownError), - dismissButton: .cancel() - ) - } - .navigationTitle(L10n.signIn) } } } + +struct UserSignInView_Preivews: PreviewProvider { + static var previews: some View { + UserSignInView(viewModel: .init(server: .sample)) + } +} diff --git a/Swiftfin/Views/PublicUserSignInCellView.swift b/Swiftfin/Views/PublicUserSignInCellView.swift index cd462305..c82b696a 100644 --- a/Swiftfin/Views/PublicUserSignInCellView.swift +++ b/Swiftfin/Views/PublicUserSignInCellView.swift @@ -23,7 +23,7 @@ struct UserLoginCellView: View { DisclosureGroup { SecureField(L10n.password, text: $enteredPassword) Button { - viewModel.login(username: user.name ?? "--", password: enteredPassword) + viewModel.signIn(username: user.name ?? "--", password: enteredPassword) } label: { L10n.signIn.text } diff --git a/Swiftfin/Views/UserListView.swift b/Swiftfin/Views/UserListView.swift index 6c210b4e..1f585c73 100644 --- a/Swiftfin/Views/UserListView.swift +++ b/Swiftfin/Views/UserListView.swift @@ -20,7 +20,7 @@ struct UserListView: View { LazyVStack { ForEach(viewModel.users, id: \.id) { user in Button { - viewModel.login(user: user) + viewModel.signIn(user: user) } label: { ZStack(alignment: Alignment.leading) { Rectangle() diff --git a/Swiftfin/Views/UserSignInView.swift b/Swiftfin/Views/UserSignInView.swift index 7d5998ec..7e37dbde 100644 --- a/Swiftfin/Views/UserSignInView.swift +++ b/Swiftfin/Views/UserSignInView.swift @@ -37,7 +37,7 @@ struct UserSignInView: View { } } else { Button { - viewModel.login(username: username, password: password) + viewModel.signIn(username: username, password: password) } label: { L10n.signIn.text } diff --git a/Translations/en.lproj/Localizable.strings b/Translations/en.lproj/Localizable.strings index e6c374d0..be2f9868 100644 Binary files a/Translations/en.lproj/Localizable.strings and b/Translations/en.lproj/Localizable.strings differ