Add Quick Connect sign in to tvOS (v2) (#487)
This commit is contained in:
parent
30cb980ed3
commit
ed519744f4
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
Binary file not shown.
Loading…
Reference in New Issue