Add Quick Connect sign in to tvOS (v2) (#487)

This commit is contained in:
David Ullmer 2022-07-18 15:52:13 +02:00 committed by GitHub
parent 30cb980ed3
commit ed519744f4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 493 additions and 354 deletions

View File

@ -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)
}
}

View File

@ -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()
}

View File

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

View File

@ -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<SwiftfinStore.Models.StoredUser>(),
@ -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<SwiftfinStore.State.User, Error> {
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<SwiftfinStore.Models.StoredUser>())
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<SwiftfinStore.Models.StoredUser>(),
[Where<SwiftfinStore.Models.StoredUser>(
"id == %@",
newUser.id
)]
) {
throw SwiftfinStore.Error.existingUser(existingUser.state)
}
let newAccessToken = transaction.create(Into<SwiftfinStore.Models.StoredAccessToken>())
newAccessToken.value = accessToken
newUser.accessToken = newAccessToken
guard let userServer = try? SwiftfinStore.dataStack.fetchOne(
From<SwiftfinStore.Models.StoredServer>(),
[
Where<SwiftfinStore.Models.StoredServer>(
"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<SwiftfinStore.State.User, Error> {
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<SwiftfinStore.State.User, Error> {
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<SwiftfinStore.Models.StoredUser>())
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<SwiftfinStore.Models.StoredUser>(),
[Where<SwiftfinStore.Models.StoredUser>(
"id == %@",
newUser.id
)]
) {
throw SwiftfinStore.Error.existingUser(existingUser.state)
}
let newAccessToken = transaction.create(Into<SwiftfinStore.Models.StoredAccessToken>())
newAccessToken.value = accessToken
newUser.accessToken = newAccessToken
guard let userServer = try? SwiftfinStore.dataStack.fetchOne(
From<SwiftfinStore.Models.StoredServer>(),
[
Where<SwiftfinStore.Models.StoredServer>(
"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()
}
}

View File

@ -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) {

View File

@ -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)
}
}

View File

@ -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)

View File

@ -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))
}
}

View File

@ -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
}

View File

@ -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()

View File

@ -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
}