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") } else { fatalError("Need to have new current login state server") }
guard SessionManager.main.currentLogin != nil else { return } guard SessionManager.main.currentLogin != nil else { return }
if newCurrentServerState.id == SessionManager.main.currentLogin.server.id { 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] = [] var viewModels: [VideoPlayerViewModel] = []
for currentMediaSource in mediaSources { for currentMediaSource in mediaSources {
let videoStream = currentMediaSource.mediaStreams?.filter { $0.type == .video }.first let videoStream = currentMediaSource.mediaStreams?.filter { $0.type == .video }.first
let audioStreams = currentMediaSource.mediaStreams?.filter { $0.type == .audio } ?? [] let audioStreams = currentMediaSource.mediaStreams?.filter { $0.type == .audio } ?? []
let subtitleStreams = currentMediaSource.mediaStreams?.filter { $0.type == .subtitle } ?? [] 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 let defaultSubtitleStream = subtitleStreams
.first(where: { $0.index! == currentMediaSource.defaultSubtitleStreamIndex ?? -1 }) .first(where: { $0.index! == currentMediaSource.defaultSubtitleStreamIndex ?? -1 })
// MARK: Build Streams // MARK: Build Streams
let directStreamURL: URL let directStreamURL: URL
let transcodedStreamURL: URLComponents? let transcodedStreamURL: URLComponents?
var hlsStreamURL: URL var hlsStreamURL: URL
let mediaSourceID: String let mediaSourceID: String
let streamType: ServerStreamType let streamType: ServerStreamType
if mediaSources.count > 1 { if mediaSources.count > 1 {
mediaSourceID = currentMediaSource.id! mediaSourceID = currentMediaSource.id!
} else { } else {
mediaSourceID = self.id! 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 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 return viewModels
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
} }
.eraseToAnyPublisher() .eraseToAnyPublisher()
} }
@ -211,137 +211,137 @@ extension BaseItemDto {
var viewModels: [VideoPlayerViewModel] = [] var viewModels: [VideoPlayerViewModel] = []
for currentMediaSource in mediaSources { for currentMediaSource in mediaSources {
let videoStream = currentMediaSource.mediaStreams?.filter { $0.type == .video }.first let videoStream = currentMediaSource.mediaStreams?.filter { $0.type == .video }.first
let audioStreams = currentMediaSource.mediaStreams?.filter { $0.type == .audio } ?? [] let audioStreams = currentMediaSource.mediaStreams?.filter { $0.type == .audio } ?? []
let subtitleStreams = currentMediaSource.mediaStreams?.filter { $0.type == .subtitle } ?? [] 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 let defaultSubtitleStream = subtitleStreams
.first(where: { $0.index! == currentMediaSource.defaultSubtitleStreamIndex ?? -1 }) .first(where: { $0.index! == currentMediaSource.defaultSubtitleStreamIndex ?? -1 })
// MARK: Build Streams // MARK: Build Streams
let directStreamURL: URL let directStreamURL: URL
let transcodedStreamURL: URLComponents? let transcodedStreamURL: URLComponents?
var hlsStreamURL: URL var hlsStreamURL: URL
let mediaSourceID: String let mediaSourceID: String
let streamType: ServerStreamType let streamType: ServerStreamType
if mediaSources.count > 1 { if mediaSources.count > 1 {
mediaSourceID = currentMediaSource.id! mediaSourceID = currentMediaSource.id!
} else { } else {
mediaSourceID = self.id! 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 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 return viewModels
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
} }
.eraseToAnyPublisher() .eraseToAnyPublisher()
} }

View File

@ -270,6 +270,14 @@ internal enum L10n {
internal static var quickConnectCode: String { return L10n.tr("Localizable", "quickConnectCode") } internal static var quickConnectCode: String { return L10n.tr("Localizable", "quickConnectCode") }
/// Invalid Quick Connect code /// Invalid Quick Connect code
internal static var quickConnectInvalidError: String { return L10n.tr("Localizable", "quickConnectInvalidError") } 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. /// Authorizing Quick Connect successful. Please continue on your other device.
internal static var quickConnectSuccessMessage: String { return L10n.tr("Localizable", "quickConnectSuccessMessage") } internal static var quickConnectSuccessMessage: String { return L10n.tr("Localizable", "quickConnectSuccessMessage") }
/// Rated /// Rated

View File

@ -22,7 +22,7 @@ final class SessionManager {
// MARK: currentLogin // MARK: currentLogin
private(set) var currentLogin: CurrentLogin! fileprivate(set) var currentLogin: CurrentLogin!
// MARK: main // MARK: main
@ -31,6 +31,8 @@ final class SessionManager {
// MARK: init // MARK: init
private init() { private init() {
setAuthHeader(with: "")
if let lastUserID = Defaults[.lastServerUserID], if let lastUserID = Defaults[.lastServerUserID],
let user = try? SwiftfinStore.dataStack.fetchOne( let user = try? SwiftfinStore.dataStack.fetchOne(
From<SwiftfinStore.Models.StoredUser>(), From<SwiftfinStore.Models.StoredUser>(),
@ -195,86 +197,31 @@ final class SessionManager {
.eraseToAnyPublisher() .eraseToAnyPublisher()
} }
// MARK: loginUser publisher // MARK: signInUser publisher
// Logs in a user with an associated server, storing if successful // Logs in a user with an associated server, storing if successful
func loginUser( func signInUser(
server: SwiftfinStore.State.Server, server: SwiftfinStore.State.Server,
username: String, username: String,
password: String password: String
) -> AnyPublisher<SwiftfinStore.State.User, Error> { ) -> AnyPublisher<SwiftfinStore.State.User, Error> {
setAuthHeader(with: "")
JellyfinAPIAPI.basePath = server.currentURI JellyfinAPIAPI.basePath = server.currentURI
return UserAPI.authenticateUserByName(authenticateUserByNameRequest: .init(username: username, pw: password)) return UserAPI.authenticateUserByName(authenticateUserByNameRequest: .init(username: username, pw: password))
.tryMap { response -> (SwiftfinStore.Models.StoredServer, SwiftfinStore.Models.StoredUser, UnsafeDataTransaction) in .processAuthenticationRequest(with: self, server: server)
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()
} }
// 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 JellyfinAPIAPI.basePath = server.currentURI
Defaults[.lastServerUserID] = user.id Defaults[.lastServerUserID] = user.id
setAuthHeader(with: user.accessToken) setAuthHeader(with: user.accessToken)
@ -347,7 +294,7 @@ final class SessionManager {
try? transaction.commitAndWait() try? transaction.commitAndWait()
} }
private func setAuthHeader(with accessToken: String) { fileprivate func setAuthHeader(with accessToken: String) {
let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
var deviceName = UIDevice.current.name var deviceName = UIDevice.current.name
deviceName = deviceName.folding(options: .diacriticInsensitive, locale: .current) deviceName = deviceName.folding(options: .diacriticInsensitive, locale: .current)
@ -370,3 +317,74 @@ final class SessionManager {
JellyfinAPIAPI.customHeaders["X-Emby-Authorization"] = header 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) self.users = SessionManager.main.fetchUsers(for: server)
} }
func login(user: SwiftfinStore.State.User) { func signIn(user: SwiftfinStore.State.User) {
self.isLoading = true self.isLoading = true
SessionManager.main.loginUser(server: server, user: user) SessionManager.main.signInUser(server: server, user: user)
} }
func remove(user: SwiftfinStore.State.User) { func remove(user: SwiftfinStore.State.User) {

View File

@ -15,14 +15,24 @@ final class UserSignInViewModel: ViewModel {
@RouterObject @RouterObject
var router: UserSignInCoordinator.Router? var router: UserSignInCoordinator.Router?
let server: SwiftfinStore.State.Server
@Published @Published
var publicUsers: [UserDto] = [] 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) { init(server: SwiftfinStore.State.Server) {
self.server = server self.server = server
super.init()
JellyfinAPIAPI.basePath = server.currentURI JellyfinAPIAPI.basePath = server.currentURI
checkQuickConnect()
} }
var alertTitle: String { var alertTitle: String {
@ -34,10 +44,10 @@ final class UserSignInViewModel: ViewModel {
return message 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") 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) .trackActivity(loading)
.sink { completion in .sink { completion in
self.handleAPIRequestError(displayMessage: L10n.unableToConnectServer, completion: completion) self.handleAPIRequestError(displayMessage: L10n.unableToConnectServer, completion: completion)
@ -79,4 +89,65 @@ final class UserSignInViewModel: ViewModel {
let urlString = ImageAPI.getSplashscreenWithRequestBuilder().URLString let urlString = ImageAPI.getSplashscreenWithRequestBuilder().URLString
return URL(string: 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 { LazyVStack {
ForEach(viewModel.users, id: \.id) { user in ForEach(viewModel.users, id: \.id) { user in
Button { Button {
viewModel.login(user: user) viewModel.signIn(user: user)
} label: { } label: {
HStack { HStack {
Text(user.username) Text(user.username)

View File

@ -27,41 +27,83 @@ struct UserSignInView: View {
.opacity(0.9) .opacity(0.9)
.ignoresSafeArea() .ignoresSafeArea()
Form { HStack(alignment: .top) {
Section { VStack(alignment: .leading) {
TextField(L10n.username, text: $username) Section {
.disableAutocorrection(true) TextField(L10n.username, text: $username)
.autocapitalization(.none) .disableAutocorrection(true)
.autocapitalization(.none)
SecureField(L10n.password, text: $password) SecureField(L10n.password, text: $password)
.disableAutocorrection(true) .disableAutocorrection(true)
.autocapitalization(.none) .autocapitalization(.none)
Button { Button {
viewModel.login(username: username, password: password) viewModel.signIn(username: username, password: password)
} label: { } label: {
HStack { HStack {
L10n.connect.text L10n.connect.text
Spacer()
if viewModel.isLoading { Spacer()
ProgressView()
if viewModel.isLoading {
ProgressView()
}
} }
} }
} .disabled(viewModel.isLoading || username.isEmpty)
.disabled(viewModel.isLoading || username.isEmpty)
} header: { } header: {
L10n.signInToServer(viewModel.server.name).text 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 { DisclosureGroup {
SecureField(L10n.password, text: $enteredPassword) SecureField(L10n.password, text: $enteredPassword)
Button { Button {
viewModel.login(username: user.name ?? "--", password: enteredPassword) viewModel.signIn(username: user.name ?? "--", password: enteredPassword)
} label: { } label: {
L10n.signIn.text L10n.signIn.text
} }

View File

@ -20,7 +20,7 @@ struct UserListView: View {
LazyVStack { LazyVStack {
ForEach(viewModel.users, id: \.id) { user in ForEach(viewModel.users, id: \.id) { user in
Button { Button {
viewModel.login(user: user) viewModel.signIn(user: user)
} label: { } label: {
ZStack(alignment: Alignment.leading) { ZStack(alignment: Alignment.leading) {
Rectangle() Rectangle()

View File

@ -37,7 +37,7 @@ struct UserSignInView: View {
} }
} else { } else {
Button { Button {
viewModel.login(username: username, password: password) viewModel.signIn(username: username, password: password)
} label: { } label: {
L10n.signIn.text L10n.signIn.text
} }