No Tab Characters and Before First for Argument and Parameter Wrapping (#482)

This commit is contained in:
Ethan Pippin 2022-07-16 07:46:25 -06:00 committed by GitHub
parent 88f350b71e
commit cfb3aa1faa
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
253 changed files with 19200 additions and 18370 deletions

View File

@ -7,7 +7,7 @@ on:
jobs: jobs:
build: build:
name: "Lint 🧹" name: "Lint 🧹"
runs-on: macos-latest runs-on: macos-12
steps: steps:
- name: Checkout - name: Checkout

View File

@ -1,16 +1,15 @@
# version: 0.47.5 # version: 0.49.11
--swiftversion 5.5 --swiftversion 5.5
--indent tab
--tabwidth 4 --tabwidth 4
--xcodeindentation enabled --xcodeindentation enabled
--semicolons never --semicolons never
--stripunusedargs closure-only --stripunusedargs closure-only
--maxwidth 140 --maxwidth 140
--assetliterals visual-width --assetliterals visual-width
--wraparguments after-first --wraparguments before-first
--wrapparameters after-first --wrapparameters before-first
--wrapcollections before-first --wrapcollections before-first
--wrapconditions after-first --wrapconditions after-first
--funcattributes prev-line --funcattributes prev-line
@ -44,7 +43,6 @@
redundantClosure, \ redundantClosure, \
redundantType redundantType
--exclude Pods
--exclude Shared/Generated/Strings.swift --exclude Shared/Generated/Strings.swift
--header "\nSwiftfin is subject to the terms of the Mozilla Public\nLicense, v2.0. If a copy of the MPL was not distributed with this\nfile, you can obtain one at https://mozilla.org/MPL/2.0/.\n\nCopyright (c) {year} Jellyfin & Jellyfin Contributors\n" --header "\nSwiftfin is subject to the terms of the Mozilla Public\nLicense, v2.0. If a copy of the MPL was not distributed with this\nfile, you can obtain one at https://mozilla.org/MPL/2.0/.\n\nCopyright (c) {year} Jellyfin & Jellyfin Contributors\n"

View File

@ -12,20 +12,20 @@ import SwiftUI
final class BasicAppSettingsCoordinator: NavigationCoordinatable { final class BasicAppSettingsCoordinator: NavigationCoordinatable {
let stack = NavigationStack(initial: \BasicAppSettingsCoordinator.start) let stack = NavigationStack(initial: \BasicAppSettingsCoordinator.start)
@Root @Root
var start = makeStart var start = makeStart
@Route(.push) @Route(.push)
var about = makeAbout var about = makeAbout
@ViewBuilder @ViewBuilder
func makeAbout() -> some View { func makeAbout() -> some View {
AboutView() AboutView()
} }
@ViewBuilder @ViewBuilder
func makeStart() -> some View { func makeStart() -> some View {
BasicAppSettingsView(viewModel: BasicAppSettingsViewModel()) BasicAppSettingsView(viewModel: BasicAppSettingsViewModel())
} }
} }

View File

@ -12,19 +12,19 @@ import SwiftUI
final class ConnectToServerCoodinator: NavigationCoordinatable { final class ConnectToServerCoodinator: NavigationCoordinatable {
let stack = NavigationStack(initial: \ConnectToServerCoodinator.start) let stack = NavigationStack(initial: \ConnectToServerCoodinator.start)
@Root @Root
var start = makeStart var start = makeStart
@Route(.push) @Route(.push)
var userSignIn = makeUserSignIn var userSignIn = makeUserSignIn
func makeUserSignIn(server: SwiftfinStore.State.Server) -> UserSignInCoordinator { func makeUserSignIn(server: SwiftfinStore.State.Server) -> UserSignInCoordinator {
UserSignInCoordinator(viewModel: .init(server: server)) UserSignInCoordinator(viewModel: .init(server: server))
} }
@ViewBuilder @ViewBuilder
func makeStart() -> some View { func makeStart() -> some View {
ConnectToServerView(viewModel: ConnectToServerViewModel()) ConnectToServerView(viewModel: ConnectToServerViewModel())
} }
} }

View File

@ -14,24 +14,24 @@ typealias FilterCoordinatorParams = (filters: Binding<LibraryFilters>, enabledFi
final class FilterCoordinator: NavigationCoordinatable { final class FilterCoordinator: NavigationCoordinatable {
let stack = NavigationStack(initial: \FilterCoordinator.start) let stack = NavigationStack(initial: \FilterCoordinator.start)
@Root @Root
var start = makeStart var start = makeStart
@Binding @Binding
var filters: LibraryFilters var filters: LibraryFilters
var enabledFilterType: [FilterType] var enabledFilterType: [FilterType]
var parentId: String = "" var parentId: String = ""
init(filters: Binding<LibraryFilters>, enabledFilterType: [FilterType], parentId: String) { init(filters: Binding<LibraryFilters>, enabledFilterType: [FilterType], parentId: String) {
_filters = filters _filters = filters
self.enabledFilterType = enabledFilterType self.enabledFilterType = enabledFilterType
self.parentId = parentId self.parentId = parentId
} }
@ViewBuilder @ViewBuilder
func makeStart() -> some View { func makeStart() -> some View {
LibraryFilterView(filters: $filters, enabledFilterType: enabledFilterType, parentId: parentId) LibraryFilterView(filters: $filters, enabledFilterType: enabledFilterType, parentId: parentId)
} }
} }

View File

@ -13,43 +13,43 @@ import SwiftUI
final class HomeCoordinator: NavigationCoordinatable { final class HomeCoordinator: NavigationCoordinatable {
let stack = NavigationStack(initial: \HomeCoordinator.start) let stack = NavigationStack(initial: \HomeCoordinator.start)
@Root @Root
var start = makeStart var start = makeStart
@Route(.modal) @Route(.modal)
var settings = makeSettings var settings = makeSettings
@Route(.push) @Route(.push)
var library = makeLibrary var library = makeLibrary
@Route(.push) @Route(.push)
var item = makeItem var item = makeItem
@Route(.modal) @Route(.modal)
var modalItem = makeModalItem var modalItem = makeModalItem
@Route(.modal) @Route(.modal)
var modalLibrary = makeModalLibrary var modalLibrary = makeModalLibrary
func makeSettings() -> NavigationViewCoordinator<SettingsCoordinator> { func makeSettings() -> NavigationViewCoordinator<SettingsCoordinator> {
NavigationViewCoordinator(SettingsCoordinator()) NavigationViewCoordinator(SettingsCoordinator())
} }
func makeLibrary(params: LibraryCoordinatorParams) -> LibraryCoordinator { func makeLibrary(params: LibraryCoordinatorParams) -> LibraryCoordinator {
LibraryCoordinator(viewModel: params.viewModel, title: params.title) LibraryCoordinator(viewModel: params.viewModel, title: params.title)
} }
func makeItem(item: BaseItemDto) -> ItemCoordinator { func makeItem(item: BaseItemDto) -> ItemCoordinator {
ItemCoordinator(item: item) ItemCoordinator(item: item)
} }
func makeModalItem(item: BaseItemDto) -> NavigationViewCoordinator<ItemCoordinator> { func makeModalItem(item: BaseItemDto) -> NavigationViewCoordinator<ItemCoordinator> {
NavigationViewCoordinator(ItemCoordinator(item: item)) NavigationViewCoordinator(ItemCoordinator(item: item))
} }
func makeModalLibrary(params: LibraryCoordinatorParams) -> NavigationViewCoordinator<LibraryCoordinator> { func makeModalLibrary(params: LibraryCoordinatorParams) -> NavigationViewCoordinator<LibraryCoordinator> {
NavigationViewCoordinator(LibraryCoordinator(viewModel: params.viewModel, title: params.title)) NavigationViewCoordinator(LibraryCoordinator(viewModel: params.viewModel, title: params.title))
} }
@ViewBuilder @ViewBuilder
func makeStart() -> some View { func makeStart() -> some View {
HomeView() HomeView()
} }
} }

View File

@ -13,43 +13,43 @@ import SwiftUI
final class ItemCoordinator: NavigationCoordinatable { final class ItemCoordinator: NavigationCoordinatable {
let stack = NavigationStack(initial: \ItemCoordinator.start) let stack = NavigationStack(initial: \ItemCoordinator.start)
@Root @Root
var start = makeStart var start = makeStart
@Route(.push) @Route(.push)
var item = makeItem var item = makeItem
@Route(.push) @Route(.push)
var library = makeLibrary var library = makeLibrary
@Route(.modal) @Route(.modal)
var itemOverview = makeItemOverview var itemOverview = makeItemOverview
@Route(.fullScreen) @Route(.fullScreen)
var videoPlayer = makeVideoPlayer var videoPlayer = makeVideoPlayer
let itemDto: BaseItemDto let itemDto: BaseItemDto
init(item: BaseItemDto) { init(item: BaseItemDto) {
self.itemDto = item self.itemDto = item
} }
func makeLibrary(params: LibraryCoordinatorParams) -> LibraryCoordinator { func makeLibrary(params: LibraryCoordinatorParams) -> LibraryCoordinator {
LibraryCoordinator(viewModel: params.viewModel, title: params.title) LibraryCoordinator(viewModel: params.viewModel, title: params.title)
} }
func makeItem(item: BaseItemDto) -> ItemCoordinator { func makeItem(item: BaseItemDto) -> ItemCoordinator {
ItemCoordinator(item: item) ItemCoordinator(item: item)
} }
func makeItemOverview(item: BaseItemDto) -> NavigationViewCoordinator<ItemOverviewCoordinator> { func makeItemOverview(item: BaseItemDto) -> NavigationViewCoordinator<ItemOverviewCoordinator> {
NavigationViewCoordinator(ItemOverviewCoordinator(item: itemDto)) NavigationViewCoordinator(ItemOverviewCoordinator(item: itemDto))
} }
func makeVideoPlayer(viewModel: VideoPlayerViewModel) -> NavigationViewCoordinator<VideoPlayerCoordinator> { func makeVideoPlayer(viewModel: VideoPlayerViewModel) -> NavigationViewCoordinator<VideoPlayerCoordinator> {
NavigationViewCoordinator(VideoPlayerCoordinator(viewModel: viewModel)) NavigationViewCoordinator(VideoPlayerCoordinator(viewModel: viewModel))
} }
@ViewBuilder @ViewBuilder
func makeStart() -> some View { func makeStart() -> some View {
ItemNavigationView(item: itemDto) ItemNavigationView(item: itemDto)
} }
} }

View File

@ -12,23 +12,23 @@ import SwiftUI
final class ItemOverviewCoordinator: NavigationCoordinatable { final class ItemOverviewCoordinator: NavigationCoordinatable {
let stack = NavigationStack(initial: \ItemOverviewCoordinator.start) let stack = NavigationStack(initial: \ItemOverviewCoordinator.start)
@Root @Root
var start = makeStart var start = makeStart
let item: BaseItemDto let item: BaseItemDto
init(item: BaseItemDto) { init(item: BaseItemDto) {
self.item = item self.item = item
} }
@ViewBuilder @ViewBuilder
func makeStart() -> some View { func makeStart() -> some View {
#if os(tvOS) #if os(tvOS)
EmptyView() EmptyView()
#else #else
ItemOverviewView(item: item) ItemOverviewView(item: item)
#endif #endif
} }
} }

View File

@ -15,47 +15,49 @@ typealias LibraryCoordinatorParams = (viewModel: LibraryViewModel, title: String
final class LibraryCoordinator: NavigationCoordinatable { final class LibraryCoordinator: NavigationCoordinatable {
let stack = NavigationStack(initial: \LibraryCoordinator.start) let stack = NavigationStack(initial: \LibraryCoordinator.start)
@Root @Root
var start = makeStart var start = makeStart
@Route(.push) @Route(.push)
var search = makeSearch var search = makeSearch
@Route(.modal) @Route(.modal)
var filter = makeFilter var filter = makeFilter
@Route(.push) @Route(.push)
var item = makeItem var item = makeItem
@Route(.modal) @Route(.modal)
var modalItem = makeModalItem var modalItem = makeModalItem
let viewModel: LibraryViewModel let viewModel: LibraryViewModel
let title: String let title: String
init(viewModel: LibraryViewModel, title: String) { init(viewModel: LibraryViewModel, title: String) {
self.viewModel = viewModel self.viewModel = viewModel
self.title = title self.title = title
} }
@ViewBuilder @ViewBuilder
func makeStart() -> some View { func makeStart() -> some View {
LibraryView(viewModel: self.viewModel, title: title) LibraryView(viewModel: self.viewModel, title: title)
} }
func makeSearch(viewModel: LibrarySearchViewModel) -> SearchCoordinator { func makeSearch(viewModel: LibrarySearchViewModel) -> SearchCoordinator {
SearchCoordinator(viewModel: viewModel) SearchCoordinator(viewModel: viewModel)
} }
func makeFilter(params: FilterCoordinatorParams) -> NavigationViewCoordinator<FilterCoordinator> { func makeFilter(params: FilterCoordinatorParams) -> NavigationViewCoordinator<FilterCoordinator> {
NavigationViewCoordinator(FilterCoordinator(filters: params.filters, NavigationViewCoordinator(FilterCoordinator(
enabledFilterType: params.enabledFilterType, filters: params.filters,
parentId: params.parentId)) enabledFilterType: params.enabledFilterType,
} parentId: params.parentId
))
}
func makeItem(item: BaseItemDto) -> ItemCoordinator { func makeItem(item: BaseItemDto) -> ItemCoordinator {
ItemCoordinator(item: item) ItemCoordinator(item: item)
} }
func makeModalItem(item: BaseItemDto) -> NavigationViewCoordinator<ItemCoordinator> { func makeModalItem(item: BaseItemDto) -> NavigationViewCoordinator<ItemCoordinator> {
NavigationViewCoordinator(ItemCoordinator(item: item)) NavigationViewCoordinator(ItemCoordinator(item: item))
} }
} }

View File

@ -12,41 +12,41 @@ import SwiftUI
final class LibraryListCoordinator: NavigationCoordinatable { final class LibraryListCoordinator: NavigationCoordinatable {
let stack = NavigationStack(initial: \LibraryListCoordinator.start) let stack = NavigationStack(initial: \LibraryListCoordinator.start)
@Root @Root
var start = makeStart var start = makeStart
@Route(.push) @Route(.push)
var search = makeSearch var search = makeSearch
@Route(.push) @Route(.push)
var library = makeLibrary var library = makeLibrary
#if os(iOS) #if os(iOS)
@Route(.push) @Route(.push)
var liveTV = makeLiveTV var liveTV = makeLiveTV
#endif #endif
let viewModel: LibraryListViewModel let viewModel: LibraryListViewModel
init(viewModel: LibraryListViewModel) { init(viewModel: LibraryListViewModel) {
self.viewModel = viewModel self.viewModel = viewModel
} }
func makeLibrary(params: LibraryCoordinatorParams) -> LibraryCoordinator { func makeLibrary(params: LibraryCoordinatorParams) -> LibraryCoordinator {
LibraryCoordinator(viewModel: params.viewModel, title: params.title) LibraryCoordinator(viewModel: params.viewModel, title: params.title)
} }
func makeSearch(viewModel: LibrarySearchViewModel) -> SearchCoordinator { func makeSearch(viewModel: LibrarySearchViewModel) -> SearchCoordinator {
SearchCoordinator(viewModel: viewModel) SearchCoordinator(viewModel: viewModel)
} }
#if os(iOS) #if os(iOS)
func makeLiveTV() -> LiveTVCoordinator { func makeLiveTV() -> LiveTVCoordinator {
LiveTVCoordinator() LiveTVCoordinator()
} }
#endif #endif
@ViewBuilder @ViewBuilder
func makeStart() -> some View { func makeStart() -> some View {
LibraryListView(viewModel: self.viewModel) LibraryListView(viewModel: self.viewModel)
} }
} }

View File

@ -12,37 +12,37 @@ import Stinsen
import SwiftUI import SwiftUI
final class LiveTVChannelsCoordinator: NavigationCoordinatable { final class LiveTVChannelsCoordinator: NavigationCoordinatable {
let stack = NavigationStack(initial: \LiveTVChannelsCoordinator.start) let stack = NavigationStack(initial: \LiveTVChannelsCoordinator.start)
@Root @Root
var start = makeStart var start = makeStart
@Route(.modal) @Route(.modal)
var modalItem = makeModalItem var modalItem = makeModalItem
@Route(.fullScreen) @Route(.fullScreen)
var videoPlayer = makeVideoPlayer var videoPlayer = makeVideoPlayer
func makeModalItem(item: BaseItemDto) -> NavigationViewCoordinator<ItemCoordinator> { func makeModalItem(item: BaseItemDto) -> NavigationViewCoordinator<ItemCoordinator> {
NavigationViewCoordinator(ItemCoordinator(item: item)) NavigationViewCoordinator(ItemCoordinator(item: item))
} }
func makeVideoPlayer(viewModel: VideoPlayerViewModel) -> NavigationViewCoordinator<LiveTVVideoPlayerCoordinator> { func makeVideoPlayer(viewModel: VideoPlayerViewModel) -> NavigationViewCoordinator<LiveTVVideoPlayerCoordinator> {
NavigationViewCoordinator(LiveTVVideoPlayerCoordinator(viewModel: viewModel)) NavigationViewCoordinator(LiveTVVideoPlayerCoordinator(viewModel: viewModel))
} }
@ViewBuilder @ViewBuilder
func makeStart() -> some View { func makeStart() -> some View {
LiveTVChannelsView() LiveTVChannelsView()
} }
} }
final class EmptyViewCoordinator: NavigationCoordinatable { final class EmptyViewCoordinator: NavigationCoordinatable {
let stack = NavigationStack(initial: \EmptyViewCoordinator.start) let stack = NavigationStack(initial: \EmptyViewCoordinator.start)
@Root @Root
var start = makeStart var start = makeStart
@ViewBuilder @ViewBuilder
func makeStart() -> some View { func makeStart() -> some View {
Text("Empty") Text("Empty")
} }
} }

View File

@ -12,19 +12,19 @@ import Stinsen
import SwiftUI import SwiftUI
final class LiveTVCoordinator: NavigationCoordinatable { final class LiveTVCoordinator: NavigationCoordinatable {
let stack = NavigationStack(initial: \LiveTVCoordinator.start) let stack = NavigationStack(initial: \LiveTVCoordinator.start)
@Root @Root
var start = makeStart var start = makeStart
@Route(.fullScreen) @Route(.fullScreen)
var videoPlayer = makeVideoPlayer var videoPlayer = makeVideoPlayer
@ViewBuilder @ViewBuilder
func makeStart() -> some View { func makeStart() -> some View {
LiveTVChannelsView() LiveTVChannelsView()
} }
func makeVideoPlayer(viewModel: VideoPlayerViewModel) -> NavigationViewCoordinator<LiveTVVideoPlayerCoordinator> { func makeVideoPlayer(viewModel: VideoPlayerViewModel) -> NavigationViewCoordinator<LiveTVVideoPlayerCoordinator> {
NavigationViewCoordinator(LiveTVVideoPlayerCoordinator(viewModel: viewModel)) NavigationViewCoordinator(LiveTVVideoPlayerCoordinator(viewModel: viewModel))
} }
} }

View File

@ -13,19 +13,19 @@ import SwiftUI
final class LiveTVProgramsCoordinator: NavigationCoordinatable { final class LiveTVProgramsCoordinator: NavigationCoordinatable {
let stack = NavigationStack(initial: \LiveTVProgramsCoordinator.start) let stack = NavigationStack(initial: \LiveTVProgramsCoordinator.start)
@Root @Root
var start = makeStart var start = makeStart
@Route(.fullScreen) @Route(.fullScreen)
var videoPlayer = makeVideoPlayer var videoPlayer = makeVideoPlayer
func makeVideoPlayer(viewModel: VideoPlayerViewModel) -> NavigationViewCoordinator<LiveTVVideoPlayerCoordinator> { func makeVideoPlayer(viewModel: VideoPlayerViewModel) -> NavigationViewCoordinator<LiveTVVideoPlayerCoordinator> {
NavigationViewCoordinator(LiveTVVideoPlayerCoordinator(viewModel: viewModel)) NavigationViewCoordinator(LiveTVVideoPlayerCoordinator(viewModel: viewModel))
} }
@ViewBuilder @ViewBuilder
func makeStart() -> some View { func makeStart() -> some View {
LiveTVProgramsView() LiveTVProgramsView()
} }
} }

View File

@ -11,52 +11,52 @@ import Stinsen
import SwiftUI import SwiftUI
final class LiveTVTabCoordinator: TabCoordinatable { final class LiveTVTabCoordinator: TabCoordinatable {
var child = TabChild(startingItems: [ var child = TabChild(startingItems: [
\LiveTVTabCoordinator.programs, \LiveTVTabCoordinator.programs,
\LiveTVTabCoordinator.channels, \LiveTVTabCoordinator.channels,
\LiveTVTabCoordinator.home, \LiveTVTabCoordinator.home,
]) ])
@Route(tabItem: makeProgramsTab) @Route(tabItem: makeProgramsTab)
var programs = makePrograms var programs = makePrograms
@Route(tabItem: makeChannelsTab) @Route(tabItem: makeChannelsTab)
var channels = makeChannels var channels = makeChannels
@Route(tabItem: makeHomeTab) @Route(tabItem: makeHomeTab)
var home = makeHome var home = makeHome
func makePrograms() -> NavigationViewCoordinator<LiveTVProgramsCoordinator> { func makePrograms() -> NavigationViewCoordinator<LiveTVProgramsCoordinator> {
NavigationViewCoordinator(LiveTVProgramsCoordinator()) NavigationViewCoordinator(LiveTVProgramsCoordinator())
} }
@ViewBuilder @ViewBuilder
func makeProgramsTab(isActive: Bool) -> some View { func makeProgramsTab(isActive: Bool) -> some View {
HStack { HStack {
Image(systemName: "tv") Image(systemName: "tv")
L10n.programs.text L10n.programs.text
} }
} }
func makeChannels() -> NavigationViewCoordinator<LiveTVChannelsCoordinator> { func makeChannels() -> NavigationViewCoordinator<LiveTVChannelsCoordinator> {
NavigationViewCoordinator(LiveTVChannelsCoordinator()) NavigationViewCoordinator(LiveTVChannelsCoordinator())
} }
@ViewBuilder @ViewBuilder
func makeChannelsTab(isActive: Bool) -> some View { func makeChannelsTab(isActive: Bool) -> some View {
HStack { HStack {
Image(systemName: "square.grid.3x3") Image(systemName: "square.grid.3x3")
L10n.channels.text L10n.channels.text
} }
} }
func makeHome() -> LiveTVHomeView { func makeHome() -> LiveTVHomeView {
LiveTVHomeView() LiveTVHomeView()
} }
@ViewBuilder @ViewBuilder
func makeHomeTab(isActive: Bool) -> some View { func makeHomeTab(isActive: Bool) -> some View {
HStack { HStack {
Image(systemName: "house") Image(systemName: "house")
L10n.home.text L10n.home.text
} }
} }
} }

View File

@ -15,89 +15,89 @@ import SwiftUI
import WidgetKit import WidgetKit
final class MainCoordinator: NavigationCoordinatable { final class MainCoordinator: NavigationCoordinatable {
var stack: NavigationStack<MainCoordinator> var stack: NavigationStack<MainCoordinator>
@Root @Root
var mainTab = makeMainTab var mainTab = makeMainTab
@Root @Root
var serverList = makeServerList var serverList = makeServerList
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
init() { init() {
if SessionManager.main.currentLogin != nil { if SessionManager.main.currentLogin != nil {
self.stack = NavigationStack(initial: \MainCoordinator.mainTab) self.stack = NavigationStack(initial: \MainCoordinator.mainTab)
} else { } else {
self.stack = NavigationStack(initial: \MainCoordinator.serverList) self.stack = NavigationStack(initial: \MainCoordinator.serverList)
} }
ImageCache.shared.costLimit = 125 * 1024 * 1024 // 125MB memory ImageCache.shared.costLimit = 125 * 1024 * 1024 // 125MB memory
DataLoader.sharedUrlCache.diskCapacity = 1000 * 1024 * 1024 // 1000MB disk DataLoader.sharedUrlCache.diskCapacity = 1000 * 1024 * 1024 // 1000MB disk
WidgetCenter.shared.reloadAllTimelines() WidgetCenter.shared.reloadAllTimelines()
UIScrollView.appearance().keyboardDismissMode = .onDrag UIScrollView.appearance().keyboardDismissMode = .onDrag
// Back bar button item setup // Back bar button item setup
let backButtonBackgroundImage = UIImage(systemName: "chevron.backward.circle.fill") let backButtonBackgroundImage = UIImage(systemName: "chevron.backward.circle.fill")
let barAppearance = UINavigationBar.appearance() let barAppearance = UINavigationBar.appearance()
barAppearance.backIndicatorImage = backButtonBackgroundImage barAppearance.backIndicatorImage = backButtonBackgroundImage
barAppearance.backIndicatorTransitionMaskImage = backButtonBackgroundImage barAppearance.backIndicatorTransitionMaskImage = backButtonBackgroundImage
barAppearance.tintColor = UIColor(Color.jellyfinPurple) barAppearance.tintColor = UIColor(Color.jellyfinPurple)
// Notification setup for state // Notification setup for state
Notifications[.didSignIn].subscribe(self, selector: #selector(didSignIn)) Notifications[.didSignIn].subscribe(self, selector: #selector(didSignIn))
Notifications[.didSignOut].subscribe(self, selector: #selector(didSignOut)) Notifications[.didSignOut].subscribe(self, selector: #selector(didSignOut))
Notifications[.processDeepLink].subscribe(self, selector: #selector(processDeepLink(_:))) Notifications[.processDeepLink].subscribe(self, selector: #selector(processDeepLink(_:)))
Notifications[.didChangeServerCurrentURI].subscribe(self, selector: #selector(didChangeServerCurrentURI(_:))) Notifications[.didChangeServerCurrentURI].subscribe(self, selector: #selector(didChangeServerCurrentURI(_:)))
Defaults.publisher(.appAppearance) Defaults.publisher(.appAppearance)
.sink { _ in .sink { _ in
JellyfinPlayerApp.setupAppearance() JellyfinPlayerApp.setupAppearance()
} }
.store(in: &cancellables) .store(in: &cancellables)
} }
@objc @objc
func didSignIn() { func didSignIn() {
LogManager.log.info("Received `didSignIn` from SwiftfinNotificationCenter.") LogManager.log.info("Received `didSignIn` from SwiftfinNotificationCenter.")
root(\.mainTab) root(\.mainTab)
} }
@objc @objc
func didSignOut() { func didSignOut() {
LogManager.log.info("Received `didSignOut` from SwiftfinNotificationCenter.") LogManager.log.info("Received `didSignOut` from SwiftfinNotificationCenter.")
root(\.serverList) root(\.serverList)
} }
@objc @objc
func processDeepLink(_ notification: Notification) { func processDeepLink(_ notification: Notification) {
guard let deepLink = notification.object as? DeepLink else { return } guard let deepLink = notification.object as? DeepLink else { return }
if let coordinator = hasRoot(\.mainTab) { if let coordinator = hasRoot(\.mainTab) {
switch deepLink { switch deepLink {
case let .item(item): case let .item(item):
coordinator.focusFirst(\.home) coordinator.focusFirst(\.home)
.child .child
.popToRoot() .popToRoot()
.route(to: \.item, item) .route(to: \.item, item)
} }
} }
} }
@objc @objc
func didChangeServerCurrentURI(_ notification: Notification) { func didChangeServerCurrentURI(_ notification: Notification) {
guard let newCurrentServerState = notification.object as? SwiftfinStore.State.Server guard let newCurrentServerState = notification.object as? SwiftfinStore.State.Server
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.loginUser(server: newCurrentServerState, user: SessionManager.main.currentLogin.user)
} }
} }
func makeMainTab() -> MainTabCoordinator { func makeMainTab() -> MainTabCoordinator {
MainTabCoordinator() MainTabCoordinator()
} }
func makeServerList() -> NavigationViewCoordinator<ServerListCoordinator> { func makeServerList() -> NavigationViewCoordinator<ServerListCoordinator> {
NavigationViewCoordinator(ServerListCoordinator()) NavigationViewCoordinator(ServerListCoordinator())
} }
} }

View File

@ -11,56 +11,56 @@ import Stinsen
import SwiftUI import SwiftUI
final class MainTabCoordinator: TabCoordinatable { final class MainTabCoordinator: TabCoordinatable {
var child = TabChild(startingItems: [ var child = TabChild(startingItems: [
\MainTabCoordinator.home, \MainTabCoordinator.home,
\MainTabCoordinator.allMedia, \MainTabCoordinator.allMedia,
]) ])
@Route(tabItem: makeHomeTab, onTapped: onHomeTapped) @Route(tabItem: makeHomeTab, onTapped: onHomeTapped)
var home = makeHome var home = makeHome
@Route(tabItem: makeAllMediaTab, onTapped: onMediaTapped) @Route(tabItem: makeAllMediaTab, onTapped: onMediaTapped)
var allMedia = makeAllMedia var allMedia = makeAllMedia
func makeHome() -> NavigationViewCoordinator<HomeCoordinator> { func makeHome() -> NavigationViewCoordinator<HomeCoordinator> {
NavigationViewCoordinator(HomeCoordinator()) NavigationViewCoordinator(HomeCoordinator())
} }
func onHomeTapped(isRepeat: Bool, coordinator: NavigationViewCoordinator<HomeCoordinator>) { func onHomeTapped(isRepeat: Bool, coordinator: NavigationViewCoordinator<HomeCoordinator>) {
if isRepeat { if isRepeat {
coordinator.child.popToRoot() coordinator.child.popToRoot()
} }
} }
@ViewBuilder @ViewBuilder
func makeHomeTab(isActive: Bool) -> some View { func makeHomeTab(isActive: Bool) -> some View {
Image(systemName: "house") Image(systemName: "house")
L10n.home.text L10n.home.text
} }
func makeAllMedia() -> NavigationViewCoordinator<LibraryListCoordinator> { func makeAllMedia() -> NavigationViewCoordinator<LibraryListCoordinator> {
NavigationViewCoordinator(LibraryListCoordinator(viewModel: LibraryListViewModel())) NavigationViewCoordinator(LibraryListCoordinator(viewModel: LibraryListViewModel()))
} }
func onMediaTapped(isRepeat: Bool, coordinator: NavigationViewCoordinator<LibraryListCoordinator>) { func onMediaTapped(isRepeat: Bool, coordinator: NavigationViewCoordinator<LibraryListCoordinator>) {
if isRepeat { if isRepeat {
coordinator.child.popToRoot() coordinator.child.popToRoot()
} }
} }
@ViewBuilder @ViewBuilder
func makeAllMediaTab(isActive: Bool) -> some View { func makeAllMediaTab(isActive: Bool) -> some View {
Image(systemName: "folder") Image(systemName: "folder")
L10n.allMedia.text L10n.allMedia.text
} }
@ViewBuilder @ViewBuilder
func customize(_ view: AnyView) -> some View { func customize(_ view: AnyView) -> some View {
view.onAppear { view.onAppear {
AppURLHandler.shared.appURLState = .allowed AppURLHandler.shared.appURLState = .allowed
// TODO: todo // TODO: todo
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
AppURLHandler.shared.processLaunchedURLIfNeeded() AppURLHandler.shared.processLaunchedURLIfNeeded()
} }
} }
} }
} }

View File

@ -12,59 +12,59 @@ import Stinsen
import SwiftUI import SwiftUI
final class MainCoordinator: NavigationCoordinatable { final class MainCoordinator: NavigationCoordinatable {
var stack = NavigationStack<MainCoordinator>(initial: \MainCoordinator.mainTab) var stack = NavigationStack<MainCoordinator>(initial: \MainCoordinator.mainTab)
@Root @Root
var mainTab = makeMainTab var mainTab = makeMainTab
@Root @Root
var serverList = makeServerList var serverList = makeServerList
@Root @Root
var liveTV = makeLiveTV var liveTV = makeLiveTV
@ViewBuilder @ViewBuilder
func customize(_ view: AnyView) -> some View { func customize(_ view: AnyView) -> some View {
view.background { view.background {
Color.black Color.black
.ignoresSafeArea() .ignoresSafeArea()
} }
} }
init() { init() {
if SessionManager.main.currentLogin != nil { if SessionManager.main.currentLogin != nil {
self.stack = NavigationStack(initial: \MainCoordinator.mainTab) self.stack = NavigationStack(initial: \MainCoordinator.mainTab)
} else { } else {
self.stack = NavigationStack(initial: \MainCoordinator.serverList) self.stack = NavigationStack(initial: \MainCoordinator.serverList)
} }
ImageCache.shared.costLimit = 125 * 1024 * 1024 // 125MB memory ImageCache.shared.costLimit = 125 * 1024 * 1024 // 125MB memory
DataLoader.sharedUrlCache.diskCapacity = 1000 * 1024 * 1024 // 1000MB disk DataLoader.sharedUrlCache.diskCapacity = 1000 * 1024 * 1024 // 1000MB disk
// Notification setup for state // Notification setup for state
Notifications[.didSignIn].subscribe(self, selector: #selector(didSignIn)) Notifications[.didSignIn].subscribe(self, selector: #selector(didSignIn))
Notifications[.didSignOut].subscribe(self, selector: #selector(didSignOut)) Notifications[.didSignOut].subscribe(self, selector: #selector(didSignOut))
} }
@objc @objc
func didSignIn() { func didSignIn() {
LogManager.log.info("Received `didSignIn` from NSNotificationCenter.") LogManager.log.info("Received `didSignIn` from NSNotificationCenter.")
root(\.mainTab) root(\.mainTab)
} }
@objc @objc
func didSignOut() { func didSignOut() {
LogManager.log.info("Received `didSignOut` from NSNotificationCenter.") LogManager.log.info("Received `didSignOut` from NSNotificationCenter.")
root(\.serverList) root(\.serverList)
} }
func makeMainTab() -> MainTabCoordinator { func makeMainTab() -> MainTabCoordinator {
MainTabCoordinator() MainTabCoordinator()
} }
func makeServerList() -> NavigationViewCoordinator<ServerListCoordinator> { func makeServerList() -> NavigationViewCoordinator<ServerListCoordinator> {
NavigationViewCoordinator(ServerListCoordinator()) NavigationViewCoordinator(ServerListCoordinator())
} }
func makeLiveTV() -> LiveTVTabCoordinator { func makeLiveTV() -> LiveTVTabCoordinator {
LiveTVTabCoordinator() LiveTVTabCoordinator()
} }
} }

View File

@ -11,80 +11,80 @@ import Stinsen
import SwiftUI import SwiftUI
final class MainTabCoordinator: TabCoordinatable { final class MainTabCoordinator: TabCoordinatable {
var child = TabChild(startingItems: [ var child = TabChild(startingItems: [
\MainTabCoordinator.home, \MainTabCoordinator.home,
\MainTabCoordinator.tv, \MainTabCoordinator.tv,
\MainTabCoordinator.movies, \MainTabCoordinator.movies,
\MainTabCoordinator.other, \MainTabCoordinator.other,
\MainTabCoordinator.settings, \MainTabCoordinator.settings,
]) ])
@Route(tabItem: makeHomeTab) @Route(tabItem: makeHomeTab)
var home = makeHome var home = makeHome
@Route(tabItem: makeTvTab) @Route(tabItem: makeTvTab)
var tv = makeTv var tv = makeTv
@Route(tabItem: makeMoviesTab) @Route(tabItem: makeMoviesTab)
var movies = makeMovies var movies = makeMovies
@Route(tabItem: makeOtherTab) @Route(tabItem: makeOtherTab)
var other = makeOther var other = makeOther
@Route(tabItem: makeSettingsTab) @Route(tabItem: makeSettingsTab)
var settings = makeSettings var settings = makeSettings
func makeHome() -> NavigationViewCoordinator<HomeCoordinator> { func makeHome() -> NavigationViewCoordinator<HomeCoordinator> {
NavigationViewCoordinator(HomeCoordinator()) NavigationViewCoordinator(HomeCoordinator())
} }
@ViewBuilder @ViewBuilder
func makeHomeTab(isActive: Bool) -> some View { func makeHomeTab(isActive: Bool) -> some View {
HStack { HStack {
Image(systemName: "house") Image(systemName: "house")
L10n.home.text L10n.home.text
} }
} }
func makeTv() -> NavigationViewCoordinator<TVLibrariesCoordinator> { func makeTv() -> NavigationViewCoordinator<TVLibrariesCoordinator> {
NavigationViewCoordinator(TVLibrariesCoordinator(viewModel: TVLibrariesViewModel(), title: L10n.tvShows)) NavigationViewCoordinator(TVLibrariesCoordinator(viewModel: TVLibrariesViewModel(), title: L10n.tvShows))
} }
@ViewBuilder @ViewBuilder
func makeTvTab(isActive: Bool) -> some View { func makeTvTab(isActive: Bool) -> some View {
HStack { HStack {
Image(systemName: "tv") Image(systemName: "tv")
L10n.tvShows.text L10n.tvShows.text
} }
} }
func makeMovies() -> NavigationViewCoordinator<MovieLibrariesCoordinator> { func makeMovies() -> NavigationViewCoordinator<MovieLibrariesCoordinator> {
NavigationViewCoordinator(MovieLibrariesCoordinator(viewModel: MovieLibrariesViewModel(), title: L10n.movies)) NavigationViewCoordinator(MovieLibrariesCoordinator(viewModel: MovieLibrariesViewModel(), title: L10n.movies))
} }
@ViewBuilder @ViewBuilder
func makeMoviesTab(isActive: Bool) -> some View { func makeMoviesTab(isActive: Bool) -> some View {
HStack { HStack {
Image(systemName: "film") Image(systemName: "film")
L10n.movies.text L10n.movies.text
} }
} }
func makeOther() -> NavigationViewCoordinator<LibraryListCoordinator> { func makeOther() -> NavigationViewCoordinator<LibraryListCoordinator> {
NavigationViewCoordinator(LibraryListCoordinator(viewModel: LibraryListViewModel())) NavigationViewCoordinator(LibraryListCoordinator(viewModel: LibraryListViewModel()))
} }
@ViewBuilder @ViewBuilder
func makeOtherTab(isActive: Bool) -> some View { func makeOtherTab(isActive: Bool) -> some View {
HStack { HStack {
Image(systemName: "folder") Image(systemName: "folder")
L10n.other.text L10n.other.text
} }
} }
func makeSettings() -> NavigationViewCoordinator<SettingsCoordinator> { func makeSettings() -> NavigationViewCoordinator<SettingsCoordinator> {
NavigationViewCoordinator(SettingsCoordinator()) NavigationViewCoordinator(SettingsCoordinator())
} }
@ViewBuilder @ViewBuilder
func makeSettingsTab(isActive: Bool) -> some View { func makeSettingsTab(isActive: Bool) -> some View {
Image(systemName: "gearshape.fill") Image(systemName: "gearshape.fill")
.accessibilityLabel(L10n.settings) .accessibilityLabel(L10n.settings)
} }
} }

View File

@ -13,33 +13,33 @@ import SwiftUI
final class MovieLibrariesCoordinator: NavigationCoordinatable { final class MovieLibrariesCoordinator: NavigationCoordinatable {
let stack = NavigationStack(initial: \MovieLibrariesCoordinator.start) let stack = NavigationStack(initial: \MovieLibrariesCoordinator.start)
@Root @Root
var start = makeStart var start = makeStart
@Root @Root
var rootLibrary = makeRootLibrary var rootLibrary = makeRootLibrary
@Route(.push) @Route(.push)
var library = makeLibrary var library = makeLibrary
let viewModel: MovieLibrariesViewModel let viewModel: MovieLibrariesViewModel
let title: String let title: String
init(viewModel: MovieLibrariesViewModel, title: String) { init(viewModel: MovieLibrariesViewModel, title: String) {
self.viewModel = viewModel self.viewModel = viewModel
self.title = title self.title = title
} }
@ViewBuilder @ViewBuilder
func makeStart() -> some View { func makeStart() -> some View {
MovieLibrariesView(viewModel: self.viewModel, title: title) MovieLibrariesView(viewModel: self.viewModel, title: title)
} }
func makeLibrary(library: BaseItemDto) -> LibraryCoordinator { func makeLibrary(library: BaseItemDto) -> LibraryCoordinator {
LibraryCoordinator(viewModel: LibraryViewModel(parentID: library.id), title: library.title) LibraryCoordinator(viewModel: LibraryViewModel(parentID: library.id), title: library.title)
} }
func makeRootLibrary(library: BaseItemDto) -> LibraryCoordinator { func makeRootLibrary(library: BaseItemDto) -> LibraryCoordinator {
LibraryCoordinator(viewModel: LibraryViewModel(parentID: library.id), title: library.title) LibraryCoordinator(viewModel: LibraryViewModel(parentID: library.id), title: library.title)
} }
} }

View File

@ -13,25 +13,25 @@ import SwiftUI
final class SearchCoordinator: NavigationCoordinatable { final class SearchCoordinator: NavigationCoordinatable {
let stack = NavigationStack(initial: \SearchCoordinator.start) let stack = NavigationStack(initial: \SearchCoordinator.start)
@Root @Root
var start = makeStart var start = makeStart
@Route(.push) @Route(.push)
var item = makeItem var item = makeItem
let viewModel: LibrarySearchViewModel let viewModel: LibrarySearchViewModel
init(viewModel: LibrarySearchViewModel) { init(viewModel: LibrarySearchViewModel) {
self.viewModel = viewModel self.viewModel = viewModel
} }
func makeItem(item: BaseItemDto) -> ItemCoordinator { func makeItem(item: BaseItemDto) -> ItemCoordinator {
ItemCoordinator(item: item) ItemCoordinator(item: item)
} }
@ViewBuilder @ViewBuilder
func makeStart() -> some View { func makeStart() -> some View {
LibrarySearchView(viewModel: self.viewModel) LibrarySearchView(viewModel: self.viewModel)
} }
} }

View File

@ -12,19 +12,19 @@ import SwiftUI
final class ServerDetailCoordinator: NavigationCoordinatable { final class ServerDetailCoordinator: NavigationCoordinatable {
let stack = NavigationStack(initial: \ServerDetailCoordinator.start) let stack = NavigationStack(initial: \ServerDetailCoordinator.start)
@Root @Root
var start = makeStart var start = makeStart
let viewModel: ServerDetailViewModel let viewModel: ServerDetailViewModel
init(viewModel: ServerDetailViewModel) { init(viewModel: ServerDetailViewModel) {
self.viewModel = viewModel self.viewModel = viewModel
} }
@ViewBuilder @ViewBuilder
func makeStart() -> some View { func makeStart() -> some View {
ServerDetailView(viewModel: viewModel) ServerDetailView(viewModel: viewModel)
} }
} }

View File

@ -12,31 +12,31 @@ import SwiftUI
final class ServerListCoordinator: NavigationCoordinatable { final class ServerListCoordinator: NavigationCoordinatable {
let stack = NavigationStack(initial: \ServerListCoordinator.start) let stack = NavigationStack(initial: \ServerListCoordinator.start)
@Root @Root
var start = makeStart var start = makeStart
@Route(.push) @Route(.push)
var connectToServer = makeConnectToServer var connectToServer = makeConnectToServer
@Route(.push) @Route(.push)
var userList = makeUserList var userList = makeUserList
@Route(.modal) @Route(.modal)
var basicAppSettings = makeBasicAppSettings var basicAppSettings = makeBasicAppSettings
func makeConnectToServer() -> ConnectToServerCoodinator { func makeConnectToServer() -> ConnectToServerCoodinator {
ConnectToServerCoodinator() ConnectToServerCoodinator()
} }
func makeUserList(server: SwiftfinStore.State.Server) -> UserListCoordinator { func makeUserList(server: SwiftfinStore.State.Server) -> UserListCoordinator {
UserListCoordinator(viewModel: .init(server: server)) UserListCoordinator(viewModel: .init(server: server))
} }
func makeBasicAppSettings() -> NavigationViewCoordinator<BasicAppSettingsCoordinator> { func makeBasicAppSettings() -> NavigationViewCoordinator<BasicAppSettingsCoordinator> {
NavigationViewCoordinator(BasicAppSettingsCoordinator()) NavigationViewCoordinator(BasicAppSettingsCoordinator())
} }
@ViewBuilder @ViewBuilder
func makeStart() -> some View { func makeStart() -> some View {
ServerListView(viewModel: ServerListViewModel()) ServerListView(viewModel: ServerListViewModel())
} }
} }

View File

@ -12,70 +12,70 @@ import SwiftUI
final class SettingsCoordinator: NavigationCoordinatable { final class SettingsCoordinator: NavigationCoordinatable {
let stack = NavigationStack(initial: \SettingsCoordinator.start) let stack = NavigationStack(initial: \SettingsCoordinator.start)
@Root @Root
var start = makeStart var start = makeStart
@Route(.push) @Route(.push)
var serverDetail = makeServerDetail var serverDetail = makeServerDetail
@Route(.push) @Route(.push)
var overlaySettings = makeOverlaySettings var overlaySettings = makeOverlaySettings
@Route(.push) @Route(.push)
var experimentalSettings = makeExperimentalSettings var experimentalSettings = makeExperimentalSettings
@Route(.push) @Route(.push)
var customizeViewsSettings = makeCustomizeViewsSettings var customizeViewsSettings = makeCustomizeViewsSettings
@Route(.push) @Route(.push)
var missingSettings = makeMissingSettings var missingSettings = makeMissingSettings
@Route(.push) @Route(.push)
var about = makeAbout var about = makeAbout
#if !os(tvOS) #if !os(tvOS)
@Route(.push) @Route(.push)
var quickConnect = makeQuickConnectSettings var quickConnect = makeQuickConnectSettings
#endif #endif
@ViewBuilder @ViewBuilder
func makeServerDetail() -> some View { func makeServerDetail() -> some View {
let viewModel = ServerDetailViewModel(server: SessionManager.main.currentLogin.server) let viewModel = ServerDetailViewModel(server: SessionManager.main.currentLogin.server)
ServerDetailView(viewModel: viewModel) ServerDetailView(viewModel: viewModel)
} }
@ViewBuilder @ViewBuilder
func makeOverlaySettings() -> some View { func makeOverlaySettings() -> some View {
OverlaySettingsView() OverlaySettingsView()
} }
@ViewBuilder @ViewBuilder
func makeExperimentalSettings() -> some View { func makeExperimentalSettings() -> some View {
ExperimentalSettingsView() ExperimentalSettingsView()
} }
@ViewBuilder @ViewBuilder
func makeCustomizeViewsSettings() -> some View { func makeCustomizeViewsSettings() -> some View {
CustomizeViewsSettings() CustomizeViewsSettings()
} }
@ViewBuilder @ViewBuilder
func makeMissingSettings() -> some View { func makeMissingSettings() -> some View {
MissingItemsSettingsView() MissingItemsSettingsView()
} }
@ViewBuilder @ViewBuilder
func makeAbout() -> some View { func makeAbout() -> some View {
AboutView() AboutView()
} }
#if !os(tvOS) #if !os(tvOS)
@ViewBuilder @ViewBuilder
func makeQuickConnectSettings() -> some View { func makeQuickConnectSettings() -> some View {
let viewModel = QuickConnectSettingsViewModel() let viewModel = QuickConnectSettingsViewModel()
QuickConnectSettingsView(viewModel: viewModel) QuickConnectSettingsView(viewModel: viewModel)
} }
#endif #endif
@ViewBuilder @ViewBuilder
func makeStart() -> some View { func makeStart() -> some View {
let viewModel = SettingsViewModel(server: SessionManager.main.currentLogin.server, user: SessionManager.main.currentLogin.user) let viewModel = SettingsViewModel(server: SessionManager.main.currentLogin.server, user: SessionManager.main.currentLogin.user)
SettingsView(viewModel: viewModel) SettingsView(viewModel: viewModel)
} }
} }

View File

@ -13,33 +13,33 @@ import SwiftUI
final class TVLibrariesCoordinator: NavigationCoordinatable { final class TVLibrariesCoordinator: NavigationCoordinatable {
let stack = NavigationStack(initial: \TVLibrariesCoordinator.start) let stack = NavigationStack(initial: \TVLibrariesCoordinator.start)
@Root @Root
var start = makeStart var start = makeStart
@Root @Root
var rootLibrary = makeRootLibrary var rootLibrary = makeRootLibrary
@Route(.push) @Route(.push)
var library = makeLibrary var library = makeLibrary
let viewModel: TVLibrariesViewModel let viewModel: TVLibrariesViewModel
let title: String let title: String
init(viewModel: TVLibrariesViewModel, title: String) { init(viewModel: TVLibrariesViewModel, title: String) {
self.viewModel = viewModel self.viewModel = viewModel
self.title = title self.title = title
} }
@ViewBuilder @ViewBuilder
func makeStart() -> some View { func makeStart() -> some View {
TVLibrariesView(viewModel: self.viewModel, title: title) TVLibrariesView(viewModel: self.viewModel, title: title)
} }
func makeLibrary(library: BaseItemDto) -> LibraryCoordinator { func makeLibrary(library: BaseItemDto) -> LibraryCoordinator {
LibraryCoordinator(viewModel: LibraryViewModel(parentID: library.id), title: library.title) LibraryCoordinator(viewModel: LibraryViewModel(parentID: library.id), title: library.title)
} }
func makeRootLibrary(library: BaseItemDto) -> LibraryCoordinator { func makeRootLibrary(library: BaseItemDto) -> LibraryCoordinator {
LibraryCoordinator(viewModel: LibraryViewModel(parentID: library.id), title: library.title) LibraryCoordinator(viewModel: LibraryViewModel(parentID: library.id), title: library.title)
} }
} }

View File

@ -12,31 +12,31 @@ import SwiftUI
final class UserListCoordinator: NavigationCoordinatable { final class UserListCoordinator: NavigationCoordinatable {
let stack = NavigationStack(initial: \UserListCoordinator.start) let stack = NavigationStack(initial: \UserListCoordinator.start)
@Root @Root
var start = makeStart var start = makeStart
@Route(.push) @Route(.push)
var userSignIn = makeUserSignIn var userSignIn = makeUserSignIn
@Route(.push) @Route(.push)
var serverDetail = makeServerDetail var serverDetail = makeServerDetail
let viewModel: UserListViewModel let viewModel: UserListViewModel
init(viewModel: UserListViewModel) { init(viewModel: UserListViewModel) {
self.viewModel = viewModel self.viewModel = viewModel
} }
func makeUserSignIn(server: SwiftfinStore.State.Server) -> UserSignInCoordinator { func makeUserSignIn(server: SwiftfinStore.State.Server) -> UserSignInCoordinator {
UserSignInCoordinator(viewModel: .init(server: server)) UserSignInCoordinator(viewModel: .init(server: server))
} }
func makeServerDetail(server: SwiftfinStore.State.Server) -> ServerDetailCoordinator { func makeServerDetail(server: SwiftfinStore.State.Server) -> ServerDetailCoordinator {
ServerDetailCoordinator(viewModel: .init(server: server)) ServerDetailCoordinator(viewModel: .init(server: server))
} }
@ViewBuilder @ViewBuilder
func makeStart() -> some View { func makeStart() -> some View {
UserListView(viewModel: viewModel) UserListView(viewModel: viewModel)
} }
} }

View File

@ -12,19 +12,19 @@ import SwiftUI
final class UserSignInCoordinator: NavigationCoordinatable { final class UserSignInCoordinator: NavigationCoordinatable {
let stack = NavigationStack(initial: \UserSignInCoordinator.start) let stack = NavigationStack(initial: \UserSignInCoordinator.start)
@Root @Root
var start = makeStart var start = makeStart
let viewModel: UserSignInViewModel let viewModel: UserSignInViewModel
init(viewModel: UserSignInViewModel) { init(viewModel: UserSignInViewModel) {
self.viewModel = viewModel self.viewModel = viewModel
} }
@ViewBuilder @ViewBuilder
func makeStart() -> some View { func makeStart() -> some View {
UserSignInView(viewModel: viewModel) UserSignInView(viewModel: viewModel)
} }
} }

View File

@ -14,27 +14,27 @@ import SwiftUI
final class LiveTVVideoPlayerCoordinator: NavigationCoordinatable { final class LiveTVVideoPlayerCoordinator: NavigationCoordinatable {
let stack = NavigationStack(initial: \LiveTVVideoPlayerCoordinator.start) let stack = NavigationStack(initial: \LiveTVVideoPlayerCoordinator.start)
@Root @Root
var start = makeStart var start = makeStart
let viewModel: VideoPlayerViewModel let viewModel: VideoPlayerViewModel
init(viewModel: VideoPlayerViewModel) { init(viewModel: VideoPlayerViewModel) {
self.viewModel = viewModel self.viewModel = viewModel
} }
@ViewBuilder @ViewBuilder
func makeStart() -> some View { func makeStart() -> some View {
if Defaults[.Experimental.liveTVNativePlayer] { if Defaults[.Experimental.liveTVNativePlayer] {
LiveTVNativePlayerView(viewModel: viewModel) LiveTVNativePlayerView(viewModel: viewModel)
.navigationBarHidden(true) .navigationBarHidden(true)
.ignoresSafeArea() .ignoresSafeArea()
} else { } else {
LiveTVPlayerView(viewModel: viewModel) LiveTVPlayerView(viewModel: viewModel)
.navigationBarHidden(true) .navigationBarHidden(true)
.ignoresSafeArea() .ignoresSafeArea()
} }
} }
} }

View File

@ -14,35 +14,35 @@ import SwiftUI
final class VideoPlayerCoordinator: NavigationCoordinatable { final class VideoPlayerCoordinator: NavigationCoordinatable {
let stack = NavigationStack(initial: \VideoPlayerCoordinator.start) let stack = NavigationStack(initial: \VideoPlayerCoordinator.start)
@Root @Root
var start = makeStart var start = makeStart
let viewModel: VideoPlayerViewModel let viewModel: VideoPlayerViewModel
init(viewModel: VideoPlayerViewModel) { init(viewModel: VideoPlayerViewModel) {
self.viewModel = viewModel self.viewModel = viewModel
} }
@ViewBuilder @ViewBuilder
func makeStart() -> some View { func makeStart() -> some View {
PreferenceUIHostingControllerView { PreferenceUIHostingControllerView {
if Defaults[.Experimental.nativePlayer] { if Defaults[.Experimental.nativePlayer] {
NativePlayerView(viewModel: self.viewModel) NativePlayerView(viewModel: self.viewModel)
.navigationBarHidden(true) .navigationBarHidden(true)
.statusBar(hidden: true) .statusBar(hidden: true)
.ignoresSafeArea() .ignoresSafeArea()
.prefersHomeIndicatorAutoHidden(true) .prefersHomeIndicatorAutoHidden(true)
.supportedOrientations(UIDevice.current.userInterfaceIdiom == .pad ? .all : .landscape) .supportedOrientations(UIDevice.current.userInterfaceIdiom == .pad ? .all : .landscape)
} else { } else {
VLCPlayerView(viewModel: self.viewModel) VLCPlayerView(viewModel: self.viewModel)
.navigationBarHidden(true) .navigationBarHidden(true)
.statusBar(hidden: true) .statusBar(hidden: true)
.ignoresSafeArea() .ignoresSafeArea()
.prefersHomeIndicatorAutoHidden(true) .prefersHomeIndicatorAutoHidden(true)
.supportedOrientations(UIDevice.current.userInterfaceIdiom == .pad ? .all : .landscape) .supportedOrientations(UIDevice.current.userInterfaceIdiom == .pad ? .all : .landscape)
} }
}.ignoresSafeArea() }.ignoresSafeArea()
} }
} }

View File

@ -14,27 +14,27 @@ import SwiftUI
final class LiveTVVideoPlayerCoordinator: NavigationCoordinatable { final class LiveTVVideoPlayerCoordinator: NavigationCoordinatable {
let stack = NavigationStack(initial: \LiveTVVideoPlayerCoordinator.start) let stack = NavigationStack(initial: \LiveTVVideoPlayerCoordinator.start)
@Root @Root
var start = makeStart var start = makeStart
let viewModel: VideoPlayerViewModel let viewModel: VideoPlayerViewModel
init(viewModel: VideoPlayerViewModel) { init(viewModel: VideoPlayerViewModel) {
self.viewModel = viewModel self.viewModel = viewModel
} }
@ViewBuilder @ViewBuilder
func makeStart() -> some View { func makeStart() -> some View {
if Defaults[.Experimental.liveTVNativePlayer] { if Defaults[.Experimental.liveTVNativePlayer] {
LiveTVNativeVideoPlayerView(viewModel: viewModel) LiveTVNativeVideoPlayerView(viewModel: viewModel)
.navigationBarHidden(true) .navigationBarHidden(true)
.ignoresSafeArea() .ignoresSafeArea()
} else { } else {
LiveTVVideoPlayerView(viewModel: viewModel) LiveTVVideoPlayerView(viewModel: viewModel)
.navigationBarHidden(true) .navigationBarHidden(true)
.ignoresSafeArea() .ignoresSafeArea()
} }
} }
} }

View File

@ -14,27 +14,27 @@ import SwiftUI
final class VideoPlayerCoordinator: NavigationCoordinatable { final class VideoPlayerCoordinator: NavigationCoordinatable {
let stack = NavigationStack(initial: \VideoPlayerCoordinator.start) let stack = NavigationStack(initial: \VideoPlayerCoordinator.start)
@Root @Root
var start = makeStart var start = makeStart
let viewModel: VideoPlayerViewModel let viewModel: VideoPlayerViewModel
init(viewModel: VideoPlayerViewModel) { init(viewModel: VideoPlayerViewModel) {
self.viewModel = viewModel self.viewModel = viewModel
} }
@ViewBuilder @ViewBuilder
func makeStart() -> some View { func makeStart() -> some View {
if Defaults[.Experimental.nativePlayer] { if Defaults[.Experimental.nativePlayer] {
NativePlayerView(viewModel: viewModel) NativePlayerView(viewModel: viewModel)
.navigationBarHidden(true) .navigationBarHidden(true)
.ignoresSafeArea() .ignoresSafeArea()
} else { } else {
VLCPlayerView(viewModel: viewModel) VLCPlayerView(viewModel: viewModel)
.navigationBarHidden(true) .navigationBarHidden(true)
.ignoresSafeArea() .ignoresSafeArea()
} }
} }
} }

View File

@ -11,21 +11,21 @@ import JellyfinAPI
struct ErrorMessage: Identifiable { struct ErrorMessage: Identifiable {
let code: Int let code: Int
let title: String let title: String
let message: String let message: String
// Chosen value such that if an error has this code, don't show the code to the UI // Chosen value such that if an error has this code, don't show the code to the UI
// This was chosen because of its unlikelyhood to ever be used // This was chosen because of its unlikelyhood to ever be used
static let noShowErrorCode = -69420 static let noShowErrorCode = -69420
var id: String { var id: String {
"\(code)\(title)\(message)" "\(code)\(title)\(message)"
} }
init(code: Int, title: String, message: String) { init(code: Int, title: String, message: String) {
self.code = code self.code = code
self.title = title self.title = title
self.message = message self.message = message
} }
} }

View File

@ -11,90 +11,104 @@ import JellyfinAPI
enum NetworkError: Error { enum NetworkError: Error {
/// For the case that the ErrorResponse object has a code of -1 /// For the case that the ErrorResponse object has a code of -1
case URLError(response: ErrorResponse, displayMessage: String?) case URLError(response: ErrorResponse, displayMessage: String?)
/// For the case that the ErrorRespones object has a code of -2 /// For the case that the ErrorRespones object has a code of -2
case HTTPURLError(response: ErrorResponse, displayMessage: String?) case HTTPURLError(response: ErrorResponse, displayMessage: String?)
/// For the case that the ErrorResponse object has a positive code /// For the case that the ErrorResponse object has a positive code
case JellyfinError(response: ErrorResponse, displayMessage: String?) case JellyfinError(response: ErrorResponse, displayMessage: String?)
var errorMessage: ErrorMessage { var errorMessage: ErrorMessage {
switch self { switch self {
case let .URLError(response, displayMessage): case let .URLError(response, displayMessage):
return NetworkError.parseURLError(from: response, displayMessage: displayMessage) return NetworkError.parseURLError(from: response, displayMessage: displayMessage)
case let .HTTPURLError(response, displayMessage): case let .HTTPURLError(response, displayMessage):
return NetworkError.parseHTTPURLError(from: response, displayMessage: displayMessage) return NetworkError.parseHTTPURLError(from: response, displayMessage: displayMessage)
case let .JellyfinError(response, displayMessage): case let .JellyfinError(response, displayMessage):
return NetworkError.parseJellyfinError(from: response, displayMessage: displayMessage) return NetworkError.parseJellyfinError(from: response, displayMessage: displayMessage)
} }
} }
private static func parseURLError(from response: ErrorResponse, displayMessage: String?) -> ErrorMessage { private static func parseURLError(from response: ErrorResponse, displayMessage: String?) -> ErrorMessage {
let errorMessage: ErrorMessage let errorMessage: ErrorMessage
switch response { switch response {
case let .error(_, _, _, err): case let .error(_, _, _, err):
// Code references: // Code references:
// https://developer.apple.com/documentation/foundation/1508628-url_loading_system_error_codes // https://developer.apple.com/documentation/foundation/1508628-url_loading_system_error_codes
switch err._code { switch err._code {
case -1001: case -1001:
errorMessage = ErrorMessage(code: err._code, errorMessage = ErrorMessage(
title: L10n.error, code: err._code,
message: L10n.networkTimedOut) title: L10n.error,
case -1003: message: L10n.networkTimedOut
errorMessage = ErrorMessage(code: err._code, )
title: L10n.error, case -1003:
message: L10n.unableToFindHost) errorMessage = ErrorMessage(
case -1004: code: err._code,
errorMessage = ErrorMessage(code: err._code, title: L10n.error,
title: L10n.error, message: L10n.unableToFindHost
message: L10n.cannotConnectToHost) )
default: case -1004:
errorMessage = ErrorMessage(code: err._code, errorMessage = ErrorMessage(
title: L10n.error, code: err._code,
message: L10n.unknownError) title: L10n.error,
} message: L10n.cannotConnectToHost
} )
default:
errorMessage = ErrorMessage(
code: err._code,
title: L10n.error,
message: L10n.unknownError
)
}
}
return errorMessage return errorMessage
} }
private static func parseHTTPURLError(from response: ErrorResponse, displayMessage: String?) -> ErrorMessage { private static func parseHTTPURLError(from response: ErrorResponse, displayMessage: String?) -> ErrorMessage {
let errorMessage: ErrorMessage let errorMessage: ErrorMessage
// Not implemented as has not run into one of these errors as time of writing // Not implemented as has not run into one of these errors as time of writing
switch response { switch response {
case .error: case .error:
errorMessage = ErrorMessage(code: 0, errorMessage = ErrorMessage(
title: L10n.error, code: 0,
message: "An HTTP URL error has occurred") title: L10n.error,
} message: "An HTTP URL error has occurred"
)
}
return errorMessage return errorMessage
} }
private static func parseJellyfinError(from response: ErrorResponse, displayMessage: String?) -> ErrorMessage { private static func parseJellyfinError(from response: ErrorResponse, displayMessage: String?) -> ErrorMessage {
let errorMessage: ErrorMessage let errorMessage: ErrorMessage
switch response { switch response {
case let .error(code, _, _, _): case let .error(code, _, _, _):
// Generic HTTP status codes // Generic HTTP status codes
switch code { switch code {
case 401: case 401:
errorMessage = ErrorMessage(code: code, errorMessage = ErrorMessage(
title: L10n.unauthorized, code: code,
message: L10n.unauthorizedUser) title: L10n.unauthorized,
default: message: L10n.unauthorizedUser
errorMessage = ErrorMessage(code: code, )
title: L10n.error, default:
message: displayMessage ?? L10n.unknownError) errorMessage = ErrorMessage(
} code: code,
} title: L10n.error,
message: displayMessage ?? L10n.unknownError
)
}
}
return errorMessage return errorMessage
} }
} }

View File

@ -11,143 +11,155 @@ import UIKit
// https://github.com/woltapp/blurhash/tree/master/Swift // https://github.com/woltapp/blurhash/tree/master/Swift
public extension UIImage { public extension UIImage {
convenience init?(blurHash: String, size: CGSize, punch: Float = 1) { convenience init?(blurHash: String, size: CGSize, punch: Float = 1) {
guard blurHash.count >= 6 else { return nil } guard blurHash.count >= 6 else { return nil }
let sizeFlag = String(blurHash[0]).decode83() let sizeFlag = String(blurHash[0]).decode83()
let numY = (sizeFlag / 9) + 1 let numY = (sizeFlag / 9) + 1
let numX = (sizeFlag % 9) + 1 let numX = (sizeFlag % 9) + 1
let quantisedMaximumValue = String(blurHash[1]).decode83() let quantisedMaximumValue = String(blurHash[1]).decode83()
let maximumValue = Float(quantisedMaximumValue + 1) / 166 let maximumValue = Float(quantisedMaximumValue + 1) / 166
guard blurHash.count == 4 + 2 * numX * numY else { return nil } guard blurHash.count == 4 + 2 * numX * numY else { return nil }
let colours: [(Float, Float, Float)] = (0 ..< numX * numY).map { i in let colours: [(Float, Float, Float)] = (0 ..< numX * numY).map { i in
if i == 0 { if i == 0 {
let value = String(blurHash[2 ..< 6]).decode83() let value = String(blurHash[2 ..< 6]).decode83()
return decodeDC(value) return decodeDC(value)
} else { } else {
let value = String(blurHash[4 + i * 2 ..< 4 + i * 2 + 2]).decode83() let value = String(blurHash[4 + i * 2 ..< 4 + i * 2 + 2]).decode83()
return decodeAC(value, maximumValue: maximumValue * punch) return decodeAC(value, maximumValue: maximumValue * punch)
} }
} }
let width = Int(size.width) let width = Int(size.width)
let height = Int(size.height) let height = Int(size.height)
let bytesPerRow = width * 3 let bytesPerRow = width * 3
guard let data = CFDataCreateMutable(kCFAllocatorDefault, bytesPerRow * height) else { return nil } guard let data = CFDataCreateMutable(kCFAllocatorDefault, bytesPerRow * height) else { return nil }
CFDataSetLength(data, bytesPerRow * height) CFDataSetLength(data, bytesPerRow * height)
guard let pixels = CFDataGetMutableBytePtr(data) else { return nil } guard let pixels = CFDataGetMutableBytePtr(data) else { return nil }
for y in 0 ..< height { for y in 0 ..< height {
for x in 0 ..< width { for x in 0 ..< width {
var r: Float = 0 var r: Float = 0
var g: Float = 0 var g: Float = 0
var b: Float = 0 var b: Float = 0
for j in 0 ..< numY { for j in 0 ..< numY {
for i in 0 ..< numX { for i in 0 ..< numX {
let basis = cos(Float.pi * Float(x) * Float(i) / Float(width)) * cos(Float.pi * Float(y) * Float(j) / Float(height)) let basis = cos(Float.pi * Float(x) * Float(i) / Float(width)) * cos(Float.pi * Float(y) * Float(j) / Float(height))
let colour = colours[i + j * numX] let colour = colours[i + j * numX]
r += colour.0 * basis r += colour.0 * basis
g += colour.1 * basis g += colour.1 * basis
b += colour.2 * basis b += colour.2 * basis
} }
} }
let intR = UInt8(linearTosRGB(r)) let intR = UInt8(linearTosRGB(r))
let intG = UInt8(linearTosRGB(g)) let intG = UInt8(linearTosRGB(g))
let intB = UInt8(linearTosRGB(b)) let intB = UInt8(linearTosRGB(b))
pixels[3 * x + 0 + y * bytesPerRow] = intR pixels[3 * x + 0 + y * bytesPerRow] = intR
pixels[3 * x + 1 + y * bytesPerRow] = intG pixels[3 * x + 1 + y * bytesPerRow] = intG
pixels[3 * x + 2 + y * bytesPerRow] = intB pixels[3 * x + 2 + y * bytesPerRow] = intB
} }
} }
let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.none.rawValue) let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.none.rawValue)
guard let provider = CGDataProvider(data: data) else { return nil } guard let provider = CGDataProvider(data: data) else { return nil }
guard let cgImage = CGImage(width: width, height: height, bitsPerComponent: 8, bitsPerPixel: 24, bytesPerRow: bytesPerRow, guard let cgImage = CGImage(
space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: bitmapInfo, provider: provider, decode: nil, width: width,
shouldInterpolate: true, intent: .defaultIntent) else { return nil } height: height,
bitsPerComponent: 8,
bitsPerPixel: 24,
bytesPerRow: bytesPerRow,
space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: bitmapInfo,
provider: provider,
decode: nil,
shouldInterpolate: true,
intent: .defaultIntent
) else { return nil }
self.init(cgImage: cgImage) self.init(cgImage: cgImage)
} }
} }
private func decodeDC(_ value: Int) -> (Float, Float, Float) { private func decodeDC(_ value: Int) -> (Float, Float, Float) {
let intR = value >> 16 let intR = value >> 16
let intG = (value >> 8) & 255 let intG = (value >> 8) & 255
let intB = value & 255 let intB = value & 255
return (sRGBToLinear(intR), sRGBToLinear(intG), sRGBToLinear(intB)) return (sRGBToLinear(intR), sRGBToLinear(intG), sRGBToLinear(intB))
} }
private func decodeAC(_ value: Int, maximumValue: Float) -> (Float, Float, Float) { private func decodeAC(_ value: Int, maximumValue: Float) -> (Float, Float, Float) {
let quantR = value / (19 * 19) let quantR = value / (19 * 19)
let quantG = (value / 19) % 19 let quantG = (value / 19) % 19
let quantB = value % 19 let quantB = value % 19
let rgb = (signPow((Float(quantR) - 9) / 9, 2) * maximumValue, let rgb = (
signPow((Float(quantG) - 9) / 9, 2) * maximumValue, signPow((Float(quantR) - 9) / 9, 2) * maximumValue,
signPow((Float(quantB) - 9) / 9, 2) * maximumValue) signPow((Float(quantG) - 9) / 9, 2) * maximumValue,
signPow((Float(quantB) - 9) / 9, 2) * maximumValue
)
return rgb return rgb
} }
private func signPow(_ value: Float, _ exp: Float) -> Float { private func signPow(_ value: Float, _ exp: Float) -> Float {
copysign(pow(abs(value), exp), value) copysign(pow(abs(value), exp), value)
} }
private func linearTosRGB(_ value: Float) -> Int { private func linearTosRGB(_ value: Float) -> Int {
let v = max(0, min(1, value)) let v = max(0, min(1, value))
if v <= 0.0031308 { return Int(v * 12.92 * 255 + 0.5) } else { return Int((1.055 * pow(v, 1 / 2.4) - 0.055) * 255 + 0.5) } if v <= 0.0031308 { return Int(v * 12.92 * 255 + 0.5) } else { return Int((1.055 * pow(v, 1 / 2.4) - 0.055) * 255 + 0.5) }
} }
private func sRGBToLinear<Type: BinaryInteger>(_ value: Type) -> Float { private func sRGBToLinear<Type: BinaryInteger>(_ value: Type) -> Float {
let v = Float(Int64(value)) / 255 let v = Float(Int64(value)) / 255
if v <= 0.04045 { return v / 12.92 } else { return pow((v + 0.055) / 1.055, 2.4) } if v <= 0.04045 { return v / 12.92 } else { return pow((v + 0.055) / 1.055, 2.4) }
} }
private let encodeCharacters: [String] = { private let encodeCharacters: [String] = {
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~".map { String($0) } "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~".map { String($0) }
}() }()
private let decodeCharacters: [String: Int] = { private let decodeCharacters: [String: Int] = {
var dict: [String: Int] = [:] var dict: [String: Int] = [:]
for (index, character) in encodeCharacters.enumerated() { for (index, character) in encodeCharacters.enumerated() {
dict[character] = index dict[character] = index
} }
return dict return dict
}() }()
extension String { extension String {
func decode83() -> Int { func decode83() -> Int {
var value: Int = 0 var value: Int = 0
for character in self { for character in self {
if let digit = decodeCharacters[String(character)] { if let digit = decodeCharacters[String(character)] {
value = value * 83 + digit value = value * 83 + digit
} }
} }
return value return value
} }
} }
private extension String { private extension String {
subscript(offset: Int) -> Character { subscript(offset: Int) -> Character {
self[index(startIndex, offsetBy: offset)] self[index(startIndex, offsetBy: offset)]
} }
subscript(bounds: CountableClosedRange<Int>) -> Substring { subscript(bounds: CountableClosedRange<Int>) -> Substring {
let start = index(startIndex, offsetBy: bounds.lowerBound) let start = index(startIndex, offsetBy: bounds.lowerBound)
let end = index(startIndex, offsetBy: bounds.upperBound) let end = index(startIndex, offsetBy: bounds.upperBound)
return self[start ... end] return self[start ... end]
} }
subscript(bounds: CountableRange<Int>) -> Substring { subscript(bounds: CountableRange<Int>) -> Substring {
let start = index(startIndex, offsetBy: bounds.lowerBound) let start = index(startIndex, offsetBy: bounds.lowerBound)
let end = index(startIndex, offsetBy: bounds.upperBound) let end = index(startIndex, offsetBy: bounds.upperBound)
return self[start ..< end] return self[start ..< end]
} }
} }

View File

@ -9,12 +9,12 @@
import Foundation import Foundation
extension Bundle { extension Bundle {
var iconFileName: String? { var iconFileName: String? {
guard let icons = infoDictionary?["CFBundleIcons"] as? [String: Any], guard let icons = infoDictionary?["CFBundleIcons"] as? [String: Any],
let primaryIcon = icons["CFBundlePrimaryIcon"] as? [String: Any], let primaryIcon = icons["CFBundlePrimaryIcon"] as? [String: Any],
let iconFiles = primaryIcon["CFBundleIconFiles"] as? [String], let iconFiles = primaryIcon["CFBundleIconFiles"] as? [String],
let iconFileName = iconFiles.last let iconFileName = iconFiles.last
else { return nil } else { return nil }
return iconFileName return iconFileName
} }
} }

View File

@ -10,22 +10,22 @@ import UIKit
extension CGSize { extension CGSize {
static func Circle(radius: CGFloat) -> CGSize { static func Circle(radius: CGFloat) -> CGSize {
CGSize(width: radius, height: radius) CGSize(width: radius, height: radius)
} }
// From https://gist.github.com/jkosoy/c835fea2c03e76720c77 // From https://gist.github.com/jkosoy/c835fea2c03e76720c77
static func aspectFill(aspectRatio: CGSize, minimumSize: CGSize) -> CGSize { static func aspectFill(aspectRatio: CGSize, minimumSize: CGSize) -> CGSize {
var minimumSize = minimumSize var minimumSize = minimumSize
let mW = minimumSize.width / aspectRatio.width let mW = minimumSize.width / aspectRatio.width
let mH = minimumSize.height / aspectRatio.height let mH = minimumSize.height / aspectRatio.height
if mH > mW { if mH > mW {
minimumSize.width = minimumSize.height / aspectRatio.height * aspectRatio.width minimumSize.width = minimumSize.height / aspectRatio.height * aspectRatio.width
} else if mW > mH { } else if mW > mH {
minimumSize.height = minimumSize.width / aspectRatio.width * aspectRatio.height minimumSize.height = minimumSize.width / aspectRatio.width * aspectRatio.height
} }
return minimumSize return minimumSize
} }
} }

View File

@ -10,14 +10,14 @@ import Foundation
public extension Collection { public extension Collection {
/// SwifterSwift: Safe protects the array from out of bounds by use of optional. /// SwifterSwift: Safe protects the array from out of bounds by use of optional.
/// ///
/// let arr = [1, 2, 3, 4, 5] /// let arr = [1, 2, 3, 4, 5]
/// arr[safe: 1] -> 2 /// arr[safe: 1] -> 2
/// arr[safe: 10] -> nil /// arr[safe: 10] -> nil
/// ///
/// - Parameter index: index of element to access element. /// - Parameter index: index of element to access element.
subscript(safe index: Index) -> Element? { subscript(safe index: Index) -> Element? {
indices.contains(index) ? self[index] : nil indices.contains(index) ? self[index] : nil
} }
} }

View File

@ -10,21 +10,21 @@ import SwiftUI
public extension Color { public extension Color {
internal static let jellyfinPurple = Color(uiColor: .jellyfinPurple) internal static let jellyfinPurple = Color(uiColor: .jellyfinPurple)
#if os(tvOS) // tvOS doesn't have these #if os(tvOS) // tvOS doesn't have these
static let systemFill = Color(UIColor.white) static let systemFill = Color(UIColor.white)
static let secondarySystemFill = Color(UIColor.gray) static let secondarySystemFill = Color(UIColor.gray)
static let tertiarySystemFill = Color(UIColor.black) static let tertiarySystemFill = Color(UIColor.black)
static let lightGray = Color(UIColor.lightGray) static let lightGray = Color(UIColor.lightGray)
#else #else
static let systemFill = Color(UIColor.systemFill) static let systemFill = Color(UIColor.systemFill)
static let systemBackground = Color(UIColor.systemBackground) static let systemBackground = Color(UIColor.systemBackground)
static let secondarySystemFill = Color(UIColor.secondarySystemBackground) static let secondarySystemFill = Color(UIColor.secondarySystemBackground)
static let tertiarySystemFill = Color(UIColor.tertiarySystemBackground) static let tertiarySystemFill = Color(UIColor.tertiarySystemBackground)
#endif #endif
} }
extension UIColor { extension UIColor {
static let jellyfinPurple = UIColor(red: 172 / 255, green: 92 / 255, blue: 195 / 255, alpha: 1) static let jellyfinPurple = UIColor(red: 172 / 255, green: 92 / 255, blue: 195 / 255, alpha: 1)
} }

View File

@ -10,37 +10,37 @@ import Defaults
import Foundation import Foundation
public extension Defaults.Serializable where Self: Codable { public extension Defaults.Serializable where Self: Codable {
static var bridge: Defaults.TopLevelCodableBridge<Self> { Defaults.TopLevelCodableBridge() } static var bridge: Defaults.TopLevelCodableBridge<Self> { Defaults.TopLevelCodableBridge() }
} }
public extension Defaults.Serializable where Self: Codable & NSSecureCoding { public extension Defaults.Serializable where Self: Codable & NSSecureCoding {
static var bridge: Defaults.CodableNSSecureCodingBridge<Self> { Defaults.CodableNSSecureCodingBridge() } static var bridge: Defaults.CodableNSSecureCodingBridge<Self> { Defaults.CodableNSSecureCodingBridge() }
} }
public extension Defaults.Serializable where Self: Codable & NSSecureCoding & Defaults.PreferNSSecureCoding { public extension Defaults.Serializable where Self: Codable & NSSecureCoding & Defaults.PreferNSSecureCoding {
static var bridge: Defaults.NSSecureCodingBridge<Self> { Defaults.NSSecureCodingBridge() } static var bridge: Defaults.NSSecureCodingBridge<Self> { Defaults.NSSecureCodingBridge() }
} }
public extension Defaults.Serializable where Self: Codable & RawRepresentable { public extension Defaults.Serializable where Self: Codable & RawRepresentable {
static var bridge: Defaults.RawRepresentableCodableBridge<Self> { Defaults.RawRepresentableCodableBridge() } static var bridge: Defaults.RawRepresentableCodableBridge<Self> { Defaults.RawRepresentableCodableBridge() }
} }
public extension Defaults.Serializable where Self: Codable & RawRepresentable & Defaults.PreferRawRepresentable { public extension Defaults.Serializable where Self: Codable & RawRepresentable & Defaults.PreferRawRepresentable {
static var bridge: Defaults.RawRepresentableBridge<Self> { Defaults.RawRepresentableBridge() } static var bridge: Defaults.RawRepresentableBridge<Self> { Defaults.RawRepresentableBridge() }
} }
public extension Defaults.Serializable where Self: RawRepresentable { public extension Defaults.Serializable where Self: RawRepresentable {
static var bridge: Defaults.RawRepresentableBridge<Self> { Defaults.RawRepresentableBridge() } static var bridge: Defaults.RawRepresentableBridge<Self> { Defaults.RawRepresentableBridge() }
} }
public extension Defaults.Serializable where Self: NSSecureCoding { public extension Defaults.Serializable where Self: NSSecureCoding {
static var bridge: Defaults.NSSecureCodingBridge<Self> { Defaults.NSSecureCodingBridge() } static var bridge: Defaults.NSSecureCodingBridge<Self> { Defaults.NSSecureCodingBridge() }
} }
public extension Defaults.CollectionSerializable where Element: Defaults.Serializable { public extension Defaults.CollectionSerializable where Element: Defaults.Serializable {
static var bridge: Defaults.CollectionBridge<Self> { Defaults.CollectionBridge() } static var bridge: Defaults.CollectionBridge<Self> { Defaults.CollectionBridge() }
} }
public extension Defaults.SetAlgebraSerializable where Element: Defaults.Serializable & Hashable { public extension Defaults.SetAlgebraSerializable where Element: Defaults.Serializable & Hashable {
static var bridge: Defaults.SetAlgebraBridge<Self> { Defaults.SetAlgebraBridge() } static var bridge: Defaults.SetAlgebraBridge<Self> { Defaults.SetAlgebraBridge() }
} }

View File

@ -10,13 +10,13 @@ import Foundation
extension Double { extension Double {
func subtract(_ other: Double, floor: Double) -> Double { func subtract(_ other: Double, floor: Double) -> Double {
var v = self - other var v = self - other
if v < floor { if v < floor {
v += abs(floor - v) v += abs(floor - v)
} }
return v return v
} }
} }

View File

@ -10,13 +10,13 @@ import Foundation
import SwiftUI import SwiftUI
extension Image { extension Image {
func centerCropped() -> some View { func centerCropped() -> some View {
GeometryReader { geo in GeometryReader { geo in
self self
.resizable() .resizable()
.scaledToFill() .scaledToFill()
.frame(width: geo.size.width, height: geo.size.height) .frame(width: geo.size.width, height: geo.size.height)
.clipped() .clipped()
} }
} }
} }

View File

@ -13,53 +13,53 @@ import JellyfinAPI
// MARK: PortraitImageStackable // MARK: PortraitImageStackable
extension BaseItemDto: PortraitImageStackable { extension BaseItemDto: PortraitImageStackable {
public var portraitImageID: String { public var portraitImageID: String {
id ?? "no id" id ?? "no id"
} }
public func imageURLConstructor(maxWidth: Int) -> URL { public func imageURLConstructor(maxWidth: Int) -> URL {
switch self.itemType { switch self.itemType {
case .episode: case .episode:
return getSeriesPrimaryImage(maxWidth: maxWidth) return getSeriesPrimaryImage(maxWidth: maxWidth)
default: default:
return self.getPrimaryImage(maxWidth: maxWidth) return self.getPrimaryImage(maxWidth: maxWidth)
} }
} }
public var title: String { public var title: String {
switch self.itemType { switch self.itemType {
case .episode: case .episode:
return self.seriesName ?? self.name ?? "" return self.seriesName ?? self.name ?? ""
default: default:
return self.name ?? "" return self.name ?? ""
} }
} }
public var subtitle: String? { public var subtitle: String? {
switch self.itemType { switch self.itemType {
case .episode: case .episode:
return getEpisodeLocator() return getEpisodeLocator()
default: default:
return nil return nil
} }
} }
public var blurHash: String { public var blurHash: String {
self.getPrimaryImageBlurHash() self.getPrimaryImageBlurHash()
} }
public var failureInitials: String { public var failureInitials: String {
guard let name = self.name else { return "" } guard let name = self.name else { return "" }
let initials = name.split(separator: " ").compactMap { String($0).first } let initials = name.split(separator: " ").compactMap { String($0).first }
return String(initials) return String(initials)
} }
public var showTitle: Bool { public var showTitle: Bool {
switch self.itemType { switch self.itemType {
case .episode, .series, .movie, .boxset: case .episode, .series, .movie, .boxset:
return Defaults[.showPosterLabels] return Defaults[.showPosterLabels]
default: default:
return true return true
} }
} }
} }

View File

@ -12,313 +12,337 @@ import JellyfinAPI
import UIKit import UIKit
extension BaseItemDto { extension BaseItemDto {
func createVideoPlayerViewModel() -> AnyPublisher<[VideoPlayerViewModel], Error> { func createVideoPlayerViewModel() -> AnyPublisher<[VideoPlayerViewModel], Error> {
LogManager.log.debug("Creating video player view model for item: \(id ?? "")") LogManager.log.debug("Creating video player view model for item: \(id ?? "")")
let builder = DeviceProfileBuilder() let builder = DeviceProfileBuilder()
// TODO: fix bitrate settings // TODO: fix bitrate settings
let tempOverkillBitrate = 360_000_000 let tempOverkillBitrate = 360_000_000
builder.setMaxBitrate(bitrate: tempOverkillBitrate) builder.setMaxBitrate(bitrate: tempOverkillBitrate)
let profile = builder.buildProfile() let profile = builder.buildProfile()
let getPostedPlaybackInfoRequest = GetPostedPlaybackInfoRequest(userId: SessionManager.main.currentLogin.user.id, let getPostedPlaybackInfoRequest = GetPostedPlaybackInfoRequest(
maxStreamingBitrate: tempOverkillBitrate, userId: SessionManager.main.currentLogin.user.id,
startTimeTicks: self.userData?.playbackPositionTicks ?? 0, maxStreamingBitrate: tempOverkillBitrate,
deviceProfile: profile, startTimeTicks: self.userData?.playbackPositionTicks ?? 0,
autoOpenLiveStream: true) deviceProfile: profile,
autoOpenLiveStream: true
)
return MediaInfoAPI.getPostedPlaybackInfo(itemId: self.id!, return MediaInfoAPI.getPostedPlaybackInfo(
userId: SessionManager.main.currentLogin.user.id, itemId: self.id!,
maxStreamingBitrate: tempOverkillBitrate, userId: SessionManager.main.currentLogin.user.id,
startTimeTicks: self.userData?.playbackPositionTicks ?? 0, maxStreamingBitrate: tempOverkillBitrate,
autoOpenLiveStream: true, startTimeTicks: self.userData?.playbackPositionTicks ?? 0,
getPostedPlaybackInfoRequest: getPostedPlaybackInfoRequest) autoOpenLiveStream: true,
.map { response -> [VideoPlayerViewModel] in getPostedPlaybackInfoRequest: getPostedPlaybackInfoRequest
let mediaSources = response.mediaSources! )
.map { response -> [VideoPlayerViewModel] in
let mediaSources = response.mediaSources!
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!, let directStreamBuilder = VideosAPI.getVideoStreamWithRequestBuilder(
_static: true, itemId: self.id!,
tag: self.etag, _static: true,
playSessionId: response.playSessionId, tag: self.etag,
minSegments: 6, playSessionId: response.playSessionId,
mediaSourceId: mediaSourceID) minSegments: 6,
directStreamURL = URL(string: directStreamBuilder.URLString)! mediaSourceId: mediaSourceID
)
directStreamURL = URL(string: directStreamBuilder.URLString)!
if let transcodeURL = currentMediaSource.transcodingUrl { if let transcodeURL = currentMediaSource.transcodingUrl {
streamType = .transcode streamType = .transcode
transcodedStreamURL = URLComponents(string: SessionManager.main.currentLogin.server.currentURI transcodedStreamURL = URLComponents(
.appending(transcodeURL))! string: SessionManager.main.currentLogin.server.currentURI
} else { .appending(transcodeURL)
streamType = .direct )!
transcodedStreamURL = nil } else {
} streamType = .direct
transcodedStreamURL = nil
}
let hlsStreamBuilder = DynamicHlsAPI.getMasterHlsVideoPlaylistWithRequestBuilder(itemId: id ?? "", let hlsStreamBuilder = DynamicHlsAPI.getMasterHlsVideoPlaylistWithRequestBuilder(
mediaSourceId: id ?? "", itemId: id ?? "",
_static: true, mediaSourceId: id ?? "",
tag: currentMediaSource.eTag, _static: true,
deviceProfileId: nil, tag: currentMediaSource.eTag,
playSessionId: response.playSessionId, deviceProfileId: nil,
segmentContainer: "ts", playSessionId: response.playSessionId,
segmentLength: nil, segmentContainer: "ts",
minSegments: 2, segmentLength: nil,
deviceId: UIDevice.vendorUUIDString, minSegments: 2,
audioCodec: audioStreams deviceId: UIDevice.vendorUUIDString,
.compactMap(\.codec) audioCodec: audioStreams
.joined(separator: ","), .compactMap(\.codec)
breakOnNonKeyFrames: true, .joined(separator: ","),
requireAvc: true, breakOnNonKeyFrames: true,
transcodingMaxAudioChannels: 6, requireAvc: true,
videoCodec: videoStream?.codec, transcodingMaxAudioChannels: 6,
videoStreamIndex: videoStream?.index, videoCodec: videoStream?.codec,
enableAdaptiveBitrateStreaming: true) videoStreamIndex: videoStream?.index,
enableAdaptiveBitrateStreaming: true
)
var hlsStreamComponents = URLComponents(string: hlsStreamBuilder.URLString)! var hlsStreamComponents = URLComponents(string: hlsStreamBuilder.URLString)!
hlsStreamComponents.addQueryItem(name: "api_key", value: SessionManager.main.currentLogin.user.accessToken) hlsStreamComponents.addQueryItem(name: "api_key", value: SessionManager.main.currentLogin.user.accessToken)
hlsStreamURL = hlsStreamComponents.url! hlsStreamURL = hlsStreamComponents.url!
// MARK: VidoPlayerViewModel Creation // MARK: VidoPlayerViewModel Creation
var subtitle: String? var subtitle: String?
// MARK: Attach media content to self // MARK: Attach media content to self
var modifiedSelfItem = self var modifiedSelfItem = self
modifiedSelfItem.mediaStreams = currentMediaSource.mediaStreams modifiedSelfItem.mediaStreams = currentMediaSource.mediaStreams
// TODO: other forms of media subtitle // TODO: other forms of media subtitle
if self.itemType == .episode { if self.itemType == .episode {
if let seriesName = self.seriesName, let episodeLocator = self.getEpisodeLocator() { if let seriesName = self.seriesName, let episodeLocator = self.getEpisodeLocator() {
subtitle = "\(seriesName) - \(episodeLocator)" subtitle = "\(seriesName) - \(episodeLocator)"
} }
} }
let subtitlesEnabled = defaultSubtitleStream != nil let subtitlesEnabled = defaultSubtitleStream != nil
let shouldShowAutoPlay = Defaults[.shouldShowAutoPlay] && itemType == .episode let shouldShowAutoPlay = Defaults[.shouldShowAutoPlay] && itemType == .episode
let autoplayEnabled = Defaults[.autoplayEnabled] && shouldShowAutoPlay let autoplayEnabled = Defaults[.autoplayEnabled] && shouldShowAutoPlay
let overlayType = Defaults[.overlayType] let overlayType = Defaults[.overlayType]
let shouldShowPlayPreviousItem = Defaults[.shouldShowPlayPreviousItem] && itemType == .episode let shouldShowPlayPreviousItem = Defaults[.shouldShowPlayPreviousItem] && itemType == .episode
let shouldShowPlayNextItem = Defaults[.shouldShowPlayNextItem] && itemType == .episode let shouldShowPlayNextItem = Defaults[.shouldShowPlayNextItem] && itemType == .episode
var fileName: String? var fileName: String?
if let lastInPath = currentMediaSource.path?.split(separator: "/").last { if let lastInPath = currentMediaSource.path?.split(separator: "/").last {
fileName = String(lastInPath) fileName = String(lastInPath)
} }
let videoPlayerViewModel = VideoPlayerViewModel(item: modifiedSelfItem, let videoPlayerViewModel = VideoPlayerViewModel(
title: modifiedSelfItem.name ?? "", item: modifiedSelfItem,
subtitle: subtitle, title: modifiedSelfItem.name ?? "",
directStreamURL: directStreamURL, subtitle: subtitle,
transcodedStreamURL: transcodedStreamURL?.url, directStreamURL: directStreamURL,
hlsStreamURL: hlsStreamURL, transcodedStreamURL: transcodedStreamURL?.url,
streamType: streamType, hlsStreamURL: hlsStreamURL,
response: response, streamType: streamType,
audioStreams: audioStreams, response: response,
subtitleStreams: subtitleStreams, audioStreams: audioStreams,
chapters: modifiedSelfItem.chapters ?? [], subtitleStreams: subtitleStreams,
selectedAudioStreamIndex: defaultAudioStream?.index ?? -1, chapters: modifiedSelfItem.chapters ?? [],
selectedSubtitleStreamIndex: defaultSubtitleStream?.index ?? -1, selectedAudioStreamIndex: defaultAudioStream?.index ?? -1,
subtitlesEnabled: subtitlesEnabled, selectedSubtitleStreamIndex: defaultSubtitleStream?.index ?? -1,
autoplayEnabled: autoplayEnabled, subtitlesEnabled: subtitlesEnabled,
overlayType: overlayType, autoplayEnabled: autoplayEnabled,
shouldShowPlayPreviousItem: shouldShowPlayPreviousItem, overlayType: overlayType,
shouldShowPlayNextItem: shouldShowPlayNextItem, shouldShowPlayPreviousItem: shouldShowPlayPreviousItem,
shouldShowAutoPlay: shouldShowAutoPlay, shouldShowPlayNextItem: shouldShowPlayNextItem,
container: currentMediaSource.container ?? "", shouldShowAutoPlay: shouldShowAutoPlay,
filename: fileName, container: currentMediaSource.container ?? "",
versionName: currentMediaSource.name) filename: fileName,
versionName: currentMediaSource.name
)
viewModels.append(videoPlayerViewModel) viewModels.append(videoPlayerViewModel)
} }
return viewModels return viewModels
} }
.eraseToAnyPublisher() .eraseToAnyPublisher()
} }
func createLiveTVVideoPlayerViewModel() -> AnyPublisher<[VideoPlayerViewModel], Error> { func createLiveTVVideoPlayerViewModel() -> AnyPublisher<[VideoPlayerViewModel], Error> {
LogManager.log.debug("Creating liveTV video player view model for item: \(id ?? "")") LogManager.log.debug("Creating liveTV video player view model for item: \(id ?? "")")
let builder = DeviceProfileBuilder() let builder = DeviceProfileBuilder()
// TODO: fix bitrate settings // TODO: fix bitrate settings
let tempOverkillBitrate = 360_000_000 let tempOverkillBitrate = 360_000_000
builder.setMaxBitrate(bitrate: tempOverkillBitrate) builder.setMaxBitrate(bitrate: tempOverkillBitrate)
let profile = builder.buildProfile() let profile = builder.buildProfile()
let getPostedPlaybackInfoRequest = GetPostedPlaybackInfoRequest(userId: SessionManager.main.currentLogin.user.id, let getPostedPlaybackInfoRequest = GetPostedPlaybackInfoRequest(
maxStreamingBitrate: tempOverkillBitrate, userId: SessionManager.main.currentLogin.user.id,
startTimeTicks: self.userData?.playbackPositionTicks ?? 0, maxStreamingBitrate: tempOverkillBitrate,
deviceProfile: profile, startTimeTicks: self.userData?.playbackPositionTicks ?? 0,
autoOpenLiveStream: true) deviceProfile: profile,
autoOpenLiveStream: true
)
return MediaInfoAPI.getPostedPlaybackInfo(itemId: self.id!, return MediaInfoAPI.getPostedPlaybackInfo(
userId: SessionManager.main.currentLogin.user.id, itemId: self.id!,
maxStreamingBitrate: tempOverkillBitrate, userId: SessionManager.main.currentLogin.user.id,
startTimeTicks: self.userData?.playbackPositionTicks ?? 0, maxStreamingBitrate: tempOverkillBitrate,
autoOpenLiveStream: true, startTimeTicks: self.userData?.playbackPositionTicks ?? 0,
getPostedPlaybackInfoRequest: getPostedPlaybackInfoRequest) autoOpenLiveStream: true,
.map { response -> [VideoPlayerViewModel] in getPostedPlaybackInfoRequest: getPostedPlaybackInfoRequest
let mediaSources = response.mediaSources! )
.map { response -> [VideoPlayerViewModel] in
let mediaSources = response.mediaSources!
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!, let directStreamBuilder = VideosAPI.getVideoStreamWithRequestBuilder(
_static: true, itemId: self.id!,
tag: self.etag, _static: true,
playSessionId: response.playSessionId, tag: self.etag,
minSegments: 6, playSessionId: response.playSessionId,
mediaSourceId: mediaSourceID) minSegments: 6,
directStreamURL = URL(string: directStreamBuilder.URLString)! mediaSourceId: mediaSourceID
)
directStreamURL = URL(string: directStreamBuilder.URLString)!
if let transcodeURL = currentMediaSource.transcodingUrl, !Defaults[.Experimental.liveTVForceDirectPlay] { if let transcodeURL = currentMediaSource.transcodingUrl, !Defaults[.Experimental.liveTVForceDirectPlay] {
streamType = .transcode streamType = .transcode
transcodedStreamURL = URLComponents(string: SessionManager.main.currentLogin.server.currentURI transcodedStreamURL = URLComponents(
.appending(transcodeURL))! string: SessionManager.main.currentLogin.server.currentURI
} else { .appending(transcodeURL)
streamType = .direct )!
transcodedStreamURL = nil } else {
} streamType = .direct
transcodedStreamURL = nil
}
let hlsStreamBuilder = DynamicHlsAPI.getMasterHlsVideoPlaylistWithRequestBuilder(itemId: id ?? "", let hlsStreamBuilder = DynamicHlsAPI.getMasterHlsVideoPlaylistWithRequestBuilder(
mediaSourceId: id ?? "", itemId: id ?? "",
_static: true, mediaSourceId: id ?? "",
tag: currentMediaSource.eTag, _static: true,
deviceProfileId: nil, tag: currentMediaSource.eTag,
playSessionId: response.playSessionId, deviceProfileId: nil,
segmentContainer: "ts", playSessionId: response.playSessionId,
segmentLength: nil, segmentContainer: "ts",
minSegments: 2, segmentLength: nil,
deviceId: UIDevice.vendorUUIDString, minSegments: 2,
audioCodec: audioStreams deviceId: UIDevice.vendorUUIDString,
.compactMap(\.codec) audioCodec: audioStreams
.joined(separator: ","), .compactMap(\.codec)
breakOnNonKeyFrames: true, .joined(separator: ","),
requireAvc: true, breakOnNonKeyFrames: true,
transcodingMaxAudioChannels: 6, requireAvc: true,
videoCodec: videoStream?.codec, transcodingMaxAudioChannels: 6,
videoStreamIndex: videoStream?.index, videoCodec: videoStream?.codec,
enableAdaptiveBitrateStreaming: true) videoStreamIndex: videoStream?.index,
enableAdaptiveBitrateStreaming: true
)
var hlsStreamComponents = URLComponents(string: hlsStreamBuilder.URLString)! var hlsStreamComponents = URLComponents(string: hlsStreamBuilder.URLString)!
hlsStreamComponents.addQueryItem(name: "api_key", value: SessionManager.main.currentLogin.user.accessToken) hlsStreamComponents.addQueryItem(name: "api_key", value: SessionManager.main.currentLogin.user.accessToken)
hlsStreamURL = hlsStreamComponents.url! hlsStreamURL = hlsStreamComponents.url!
// MARK: VidoPlayerViewModel Creation // MARK: VidoPlayerViewModel Creation
var subtitle: String? var subtitle: String?
// MARK: Attach media content to self // MARK: Attach media content to self
var modifiedSelfItem = self var modifiedSelfItem = self
modifiedSelfItem.mediaStreams = currentMediaSource.mediaStreams modifiedSelfItem.mediaStreams = currentMediaSource.mediaStreams
// TODO: other forms of media subtitle // TODO: other forms of media subtitle
if self.itemType == .episode { if self.itemType == .episode {
if let seriesName = self.seriesName, let episodeLocator = self.getEpisodeLocator() { if let seriesName = self.seriesName, let episodeLocator = self.getEpisodeLocator() {
subtitle = "\(seriesName) - \(episodeLocator)" subtitle = "\(seriesName) - \(episodeLocator)"
} }
} }
let subtitlesEnabled = defaultSubtitleStream != nil let subtitlesEnabled = defaultSubtitleStream != nil
let shouldShowAutoPlay = Defaults[.shouldShowAutoPlay] && itemType == .episode let shouldShowAutoPlay = Defaults[.shouldShowAutoPlay] && itemType == .episode
let autoplayEnabled = Defaults[.autoplayEnabled] && shouldShowAutoPlay let autoplayEnabled = Defaults[.autoplayEnabled] && shouldShowAutoPlay
let overlayType = Defaults[.overlayType] let overlayType = Defaults[.overlayType]
let shouldShowPlayPreviousItem = Defaults[.shouldShowPlayPreviousItem] && itemType == .episode let shouldShowPlayPreviousItem = Defaults[.shouldShowPlayPreviousItem] && itemType == .episode
let shouldShowPlayNextItem = Defaults[.shouldShowPlayNextItem] && itemType == .episode let shouldShowPlayNextItem = Defaults[.shouldShowPlayNextItem] && itemType == .episode
var fileName: String? var fileName: String?
if let lastInPath = currentMediaSource.path?.split(separator: "/").last { if let lastInPath = currentMediaSource.path?.split(separator: "/").last {
fileName = String(lastInPath) fileName = String(lastInPath)
} }
let videoPlayerViewModel = VideoPlayerViewModel(item: modifiedSelfItem, let videoPlayerViewModel = VideoPlayerViewModel(
title: modifiedSelfItem.name ?? "", item: modifiedSelfItem,
subtitle: subtitle, title: modifiedSelfItem.name ?? "",
directStreamURL: directStreamURL, subtitle: subtitle,
transcodedStreamURL: transcodedStreamURL?.url, directStreamURL: directStreamURL,
hlsStreamURL: hlsStreamURL, transcodedStreamURL: transcodedStreamURL?.url,
streamType: streamType, hlsStreamURL: hlsStreamURL,
response: response, streamType: streamType,
audioStreams: audioStreams, response: response,
subtitleStreams: subtitleStreams, audioStreams: audioStreams,
chapters: modifiedSelfItem.chapters ?? [], subtitleStreams: subtitleStreams,
selectedAudioStreamIndex: defaultAudioStream?.index ?? -1, chapters: modifiedSelfItem.chapters ?? [],
selectedSubtitleStreamIndex: defaultSubtitleStream?.index ?? -1, selectedAudioStreamIndex: defaultAudioStream?.index ?? -1,
subtitlesEnabled: subtitlesEnabled, selectedSubtitleStreamIndex: defaultSubtitleStream?.index ?? -1,
autoplayEnabled: autoplayEnabled, subtitlesEnabled: subtitlesEnabled,
overlayType: overlayType, autoplayEnabled: autoplayEnabled,
shouldShowPlayPreviousItem: shouldShowPlayPreviousItem, overlayType: overlayType,
shouldShowPlayNextItem: shouldShowPlayNextItem, shouldShowPlayPreviousItem: shouldShowPlayPreviousItem,
shouldShowAutoPlay: shouldShowAutoPlay, shouldShowPlayNextItem: shouldShowPlayNextItem,
container: currentMediaSource.container ?? "", shouldShowAutoPlay: shouldShowAutoPlay,
filename: fileName, container: currentMediaSource.container ?? "",
versionName: currentMediaSource.name) filename: fileName,
versionName: currentMediaSource.name
)
viewModels.append(videoPlayerViewModel) viewModels.append(videoPlayerViewModel)
} }
return viewModels return viewModels
} }
.eraseToAnyPublisher() .eraseToAnyPublisher()
} }
} }

View File

@ -13,366 +13,380 @@ import UIKit
// 001fC^ = dark grey plain blurhash // 001fC^ = dark grey plain blurhash
public extension BaseItemDto { public extension BaseItemDto {
// MARK: Images // MARK: Images
func getSeriesBackdropImageBlurHash() -> String { func getSeriesBackdropImageBlurHash() -> String {
let imgURL = getSeriesBackdropImage(maxWidth: 1) let imgURL = getSeriesBackdropImage(maxWidth: 1)
guard let imgTag = imgURL.queryParameters?["tag"], guard let imgTag = imgURL.queryParameters?["tag"],
let hash = imageBlurHashes?.backdrop?[imgTag] let hash = imageBlurHashes?.backdrop?[imgTag]
else { else {
return "001fC^" return "001fC^"
} }
return hash return hash
} }
func getSeriesPrimaryImageBlurHash() -> String { func getSeriesPrimaryImageBlurHash() -> String {
let imgURL = getSeriesPrimaryImage(maxWidth: 1) let imgURL = getSeriesPrimaryImage(maxWidth: 1)
guard let imgTag = imgURL.queryParameters?["tag"], guard let imgTag = imgURL.queryParameters?["tag"],
let hash = imageBlurHashes?.primary?[imgTag] let hash = imageBlurHashes?.primary?[imgTag]
else { else {
return "001fC^" return "001fC^"
} }
return hash return hash
} }
func getPrimaryImageBlurHash() -> String { func getPrimaryImageBlurHash() -> String {
let imgURL = getPrimaryImage(maxWidth: 1) let imgURL = getPrimaryImage(maxWidth: 1)
guard let imgTag = imgURL.queryParameters?["tag"], guard let imgTag = imgURL.queryParameters?["tag"],
let hash = imageBlurHashes?.primary?[imgTag] let hash = imageBlurHashes?.primary?[imgTag]
else { else {
return "001fC^" return "001fC^"
} }
return hash return hash
} }
func getBackdropImageBlurHash() -> String { func getBackdropImageBlurHash() -> String {
let imgURL = getBackdropImage(maxWidth: 1) let imgURL = getBackdropImage(maxWidth: 1)
guard let imgTag = imgURL.queryParameters?["tag"] else { guard let imgTag = imgURL.queryParameters?["tag"] else {
return "001fC^" return "001fC^"
} }
if imgURL.queryParameters?[ImageType.backdrop.rawValue] == nil { if imgURL.queryParameters?[ImageType.backdrop.rawValue] == nil {
if itemType == .episode { if itemType == .episode {
return imageBlurHashes?.backdrop?.values.first ?? "001fC^" return imageBlurHashes?.backdrop?.values.first ?? "001fC^"
} else { } else {
return imageBlurHashes?.backdrop?[imgTag] ?? "001fC^" return imageBlurHashes?.backdrop?[imgTag] ?? "001fC^"
} }
} else { } else {
return imageBlurHashes?.primary?[imgTag] ?? "001fC^" return imageBlurHashes?.primary?[imgTag] ?? "001fC^"
} }
} }
func getBackdropImage(maxWidth: Int) -> URL { func getBackdropImage(maxWidth: Int) -> URL {
var imageType = ImageType.backdrop var imageType = ImageType.backdrop
var imageTag: String? var imageTag: String?
var imageItemId = id ?? "" var imageItemId = id ?? ""
if primaryImageAspectRatio ?? 0.0 < 1.0 { if primaryImageAspectRatio ?? 0.0 < 1.0 {
if !(backdropImageTags?.isEmpty ?? true) { if !(backdropImageTags?.isEmpty ?? true) {
imageTag = backdropImageTags?.first imageTag = backdropImageTags?.first
} }
} else { } else {
imageType = .primary imageType = .primary
imageTag = imageTags?[ImageType.primary.rawValue] ?? "" imageTag = imageTags?[ImageType.primary.rawValue] ?? ""
} }
if imageTag == nil || imageItemId.isEmpty { if imageTag == nil || imageItemId.isEmpty {
if !(parentBackdropImageTags?.isEmpty ?? true) { if !(parentBackdropImageTags?.isEmpty ?? true) {
imageTag = parentBackdropImageTags?.first imageTag = parentBackdropImageTags?.first
imageItemId = parentBackdropItemId ?? "" imageItemId = parentBackdropItemId ?? ""
} }
} }
let x = UIScreen.main.nativeScale * CGFloat(maxWidth) let x = UIScreen.main.nativeScale * CGFloat(maxWidth)
let urlString = ImageAPI.getItemImageWithRequestBuilder(itemId: imageItemId, let urlString = ImageAPI.getItemImageWithRequestBuilder(
imageType: imageType, itemId: imageItemId,
maxWidth: Int(x), imageType: imageType,
quality: 96, maxWidth: Int(x),
tag: imageTag).URLString quality: 96,
return URL(string: urlString)! tag: imageTag
} ).URLString
return URL(string: urlString)!
}
func getThumbImage(maxWidth: Int) -> URL { func getThumbImage(maxWidth: Int) -> URL {
let x = UIScreen.main.nativeScale * CGFloat(maxWidth) let x = UIScreen.main.nativeScale * CGFloat(maxWidth)
let urlString = ImageAPI.getItemImageWithRequestBuilder(itemId: id ?? "", let urlString = ImageAPI.getItemImageWithRequestBuilder(
imageType: .thumb, itemId: id ?? "",
maxWidth: Int(x), imageType: .thumb,
quality: 96).URLString maxWidth: Int(x),
return URL(string: urlString)! quality: 96
} ).URLString
return URL(string: urlString)!
}
func getEpisodeLocator() -> String? { func getEpisodeLocator() -> String? {
if let seasonNo = parentIndexNumber, let episodeNo = indexNumber { if let seasonNo = parentIndexNumber, let episodeNo = indexNumber {
return L10n.seasonAndEpisode(String(seasonNo), String(episodeNo)) return L10n.seasonAndEpisode(String(seasonNo), String(episodeNo))
} }
return nil return nil
} }
func getSeriesBackdropImage(maxWidth: Int) -> URL { func getSeriesBackdropImage(maxWidth: Int) -> URL {
let x = UIScreen.main.nativeScale * CGFloat(maxWidth) let x = UIScreen.main.nativeScale * CGFloat(maxWidth)
let urlString = ImageAPI.getItemImageWithRequestBuilder(itemId: parentBackdropItemId ?? "", let urlString = ImageAPI.getItemImageWithRequestBuilder(
imageType: .backdrop, itemId: parentBackdropItemId ?? "",
maxWidth: Int(x), imageType: .backdrop,
quality: 96, maxWidth: Int(x),
tag: parentBackdropImageTags?.first).URLString quality: 96,
return URL(string: urlString)! tag: parentBackdropImageTags?.first
} ).URLString
return URL(string: urlString)!
}
func getSeriesPrimaryImage(maxWidth: Int) -> URL { func getSeriesPrimaryImage(maxWidth: Int) -> URL {
guard let seriesId = seriesId else { guard let seriesId = seriesId else {
return getPrimaryImage(maxWidth: maxWidth) return getPrimaryImage(maxWidth: maxWidth)
} }
let x = UIScreen.main.nativeScale * CGFloat(maxWidth) let x = UIScreen.main.nativeScale * CGFloat(maxWidth)
let urlString = ImageAPI.getItemImageWithRequestBuilder(itemId: seriesId, let urlString = ImageAPI.getItemImageWithRequestBuilder(
imageType: .primary, itemId: seriesId,
maxWidth: Int(x), imageType: .primary,
quality: 96, maxWidth: Int(x),
tag: seriesPrimaryImageTag).URLString quality: 96,
return URL(string: urlString)! tag: seriesPrimaryImageTag
} ).URLString
return URL(string: urlString)!
}
func getSeriesThumbImage(maxWidth: Int) -> URL { func getSeriesThumbImage(maxWidth: Int) -> URL {
let x = UIScreen.main.nativeScale * CGFloat(maxWidth) let x = UIScreen.main.nativeScale * CGFloat(maxWidth)
let urlString = ImageAPI.getItemImageWithRequestBuilder(itemId: seriesId ?? "", let urlString = ImageAPI.getItemImageWithRequestBuilder(
imageType: .thumb, itemId: seriesId ?? "",
maxWidth: Int(x), imageType: .thumb,
quality: 96, maxWidth: Int(x),
tag: seriesPrimaryImageTag).URLString quality: 96,
return URL(string: urlString)! tag: seriesPrimaryImageTag
} ).URLString
return URL(string: urlString)!
}
func getPrimaryImage(maxWidth: Int) -> URL { func getPrimaryImage(maxWidth: Int) -> URL {
let imageType = ImageType.primary let imageType = ImageType.primary
var imageTag = imageTags?[ImageType.primary.rawValue] ?? "" var imageTag = imageTags?[ImageType.primary.rawValue] ?? ""
var imageItemId = id ?? "" var imageItemId = id ?? ""
if imageTag.isEmpty || imageItemId.isEmpty { if imageTag.isEmpty || imageItemId.isEmpty {
imageTag = seriesPrimaryImageTag ?? "" imageTag = seriesPrimaryImageTag ?? ""
imageItemId = seriesId ?? "" imageItemId = seriesId ?? ""
} }
let x = UIScreen.main.nativeScale * CGFloat(maxWidth) let x = UIScreen.main.nativeScale * CGFloat(maxWidth)
let urlString = ImageAPI.getItemImageWithRequestBuilder(itemId: imageItemId, let urlString = ImageAPI.getItemImageWithRequestBuilder(
imageType: imageType, itemId: imageItemId,
maxWidth: Int(x), imageType: imageType,
quality: 96, maxWidth: Int(x),
tag: imageTag).URLString quality: 96,
return URL(string: urlString)! tag: imageTag
} ).URLString
return URL(string: urlString)!
}
// MARK: Calculations // MARK: Calculations
func getItemRuntime() -> String? { func getItemRuntime() -> String? {
let timeHMSFormatter: DateComponentsFormatter = { let timeHMSFormatter: DateComponentsFormatter = {
let formatter = DateComponentsFormatter() let formatter = DateComponentsFormatter()
formatter.unitsStyle = .abbreviated formatter.unitsStyle = .abbreviated
formatter.allowedUnits = [.hour, .minute] formatter.allowedUnits = [.hour, .minute]
return formatter return formatter
}() }()
guard let runTimeTicks = runTimeTicks, guard let runTimeTicks = runTimeTicks,
let text = timeHMSFormatter.string(from: Double(runTimeTicks / 10_000_000)) else { return nil } let text = timeHMSFormatter.string(from: Double(runTimeTicks / 10_000_000)) else { return nil }
return text return text
} }
func getItemProgressString() -> String? { func getItemProgressString() -> String? {
if userData?.playbackPositionTicks == nil || userData?.playbackPositionTicks == 0 { if userData?.playbackPositionTicks == nil || userData?.playbackPositionTicks == 0 {
return nil return nil
} }
let remainingSecs = ((runTimeTicks ?? 0) - (userData?.playbackPositionTicks ?? 0)) / 10_000_000 let remainingSecs = ((runTimeTicks ?? 0) - (userData?.playbackPositionTicks ?? 0)) / 10_000_000
let proghours = Int(remainingSecs / 3600) let proghours = Int(remainingSecs / 3600)
let progminutes = Int((Int(remainingSecs) - (proghours * 3600)) / 60) let progminutes = Int((Int(remainingSecs) - (proghours * 3600)) / 60)
if proghours != 0 { if proghours != 0 {
return "\(proghours)h \(String(progminutes).leftPad(toWidth: 2, withString: "0"))m" return "\(proghours)h \(String(progminutes).leftPad(toWidth: 2, withString: "0"))m"
} else { } else {
return "\(String(progminutes))m" return "\(String(progminutes))m"
} }
} }
func getLiveStartTimeString(formatter: DateFormatter) -> String { func getLiveStartTimeString(formatter: DateFormatter) -> String {
if let startDate = self.startDate { if let startDate = self.startDate {
return formatter.string(from: startDate) return formatter.string(from: startDate)
} }
return " " return " "
} }
func getLiveEndTimeString(formatter: DateFormatter) -> String { func getLiveEndTimeString(formatter: DateFormatter) -> String {
if let endDate = self.endDate { if let endDate = self.endDate {
return formatter.string(from: endDate) return formatter.string(from: endDate)
} }
return " " return " "
} }
func getLiveProgressPercentage() -> Double { func getLiveProgressPercentage() -> Double {
if let startDate = self.startDate, if let startDate = self.startDate,
let endDate = self.endDate let endDate = self.endDate
{ {
let start = startDate.timeIntervalSinceReferenceDate let start = startDate.timeIntervalSinceReferenceDate
let end = endDate.timeIntervalSinceReferenceDate let end = endDate.timeIntervalSinceReferenceDate
let now = Date().timeIntervalSinceReferenceDate let now = Date().timeIntervalSinceReferenceDate
let length = end - start let length = end - start
let progress = now - start let progress = now - start
return progress / length return progress / length
} }
return 0 return 0
} }
// MARK: ItemType // MARK: ItemType
enum ItemType: String { enum ItemType: String {
case movie = "Movie" case movie = "Movie"
case season = "Season" case season = "Season"
case episode = "Episode" case episode = "Episode"
case series = "Series" case series = "Series"
case boxset = "BoxSet" case boxset = "BoxSet"
case collectionFolder = "CollectionFolder" case collectionFolder = "CollectionFolder"
case folder = "Folder" case folder = "Folder"
case liveTV = "LiveTV" case liveTV = "LiveTV"
case unknown case unknown
var showDetails: Bool { var showDetails: Bool {
switch self { switch self {
case .season, .series: case .season, .series:
return false return false
default: default:
return true return true
} }
} }
public init?(rawValue: String) { public init?(rawValue: String) {
let lowerCase = rawValue.lowercased() let lowerCase = rawValue.lowercased()
switch lowerCase { switch lowerCase {
case "movie": self = .movie case "movie": self = .movie
case "season": self = .season case "season": self = .season
case "episode": self = .episode case "episode": self = .episode
case "series": self = .series case "series": self = .series
case "boxset": self = .boxset case "boxset": self = .boxset
case "collectionfolder": self = .collectionFolder case "collectionfolder": self = .collectionFolder
case "folder": self = .folder case "folder": self = .folder
case "livetv": self = .liveTV case "livetv": self = .liveTV
default: self = .unknown default: self = .unknown
} }
} }
} }
var itemType: ItemType { var itemType: ItemType {
guard let originalType = type, let knownType = ItemType(rawValue: originalType.rawValue) else { return .unknown } guard let originalType = type, let knownType = ItemType(rawValue: originalType.rawValue) else { return .unknown }
return knownType return knownType
} }
// MARK: PortraitHeaderViewURL // MARK: PortraitHeaderViewURL
func portraitHeaderViewURL(maxWidth: Int) -> URL { func portraitHeaderViewURL(maxWidth: Int) -> URL {
switch itemType { switch itemType {
case .movie, .season, .series, .boxset, .collectionFolder, .folder, .liveTV: case .movie, .season, .series, .boxset, .collectionFolder, .folder, .liveTV:
return getPrimaryImage(maxWidth: maxWidth) return getPrimaryImage(maxWidth: maxWidth)
case .episode: case .episode:
return getSeriesPrimaryImage(maxWidth: maxWidth) return getSeriesPrimaryImage(maxWidth: maxWidth)
case .unknown: case .unknown:
return getPrimaryImage(maxWidth: maxWidth) return getPrimaryImage(maxWidth: maxWidth)
} }
} }
// MARK: ItemDetail // MARK: ItemDetail
struct ItemDetail { struct ItemDetail {
let title: String let title: String
let content: String let content: String
} }
func createInformationItems() -> [ItemDetail] { func createInformationItems() -> [ItemDetail] {
var informationItems: [ItemDetail] = [] var informationItems: [ItemDetail] = []
if let productionYear = productionYear { if let productionYear = productionYear {
informationItems.append(ItemDetail(title: L10n.released, content: "\(productionYear)")) informationItems.append(ItemDetail(title: L10n.released, content: "\(productionYear)"))
} }
if let rating = officialRating { if let rating = officialRating {
informationItems.append(ItemDetail(title: L10n.rated, content: "\(rating)")) informationItems.append(ItemDetail(title: L10n.rated, content: "\(rating)"))
} }
if let runtime = getItemRuntime() { if let runtime = getItemRuntime() {
informationItems.append(ItemDetail(title: L10n.runtime, content: runtime)) informationItems.append(ItemDetail(title: L10n.runtime, content: runtime))
} }
return informationItems return informationItems
} }
func createMediaItems() -> [ItemDetail] { func createMediaItems() -> [ItemDetail] {
var mediaItems: [ItemDetail] = [] var mediaItems: [ItemDetail] = []
if let mediaStreams = mediaStreams { if let mediaStreams = mediaStreams {
let audioStreams = mediaStreams.filter { $0.type == .audio } let audioStreams = mediaStreams.filter { $0.type == .audio }
let subtitleStreams = mediaStreams.filter { $0.type == .subtitle } let subtitleStreams = mediaStreams.filter { $0.type == .subtitle }
if !audioStreams.isEmpty { if !audioStreams.isEmpty {
let audioList = audioStreams.compactMap { "\($0.displayTitle ?? L10n.noTitle) (\($0.codec ?? L10n.noCodec))" } let audioList = audioStreams.compactMap { "\($0.displayTitle ?? L10n.noTitle) (\($0.codec ?? L10n.noCodec))" }
.joined(separator: ", ") .joined(separator: ", ")
mediaItems.append(ItemDetail(title: L10n.audio, content: audioList)) mediaItems.append(ItemDetail(title: L10n.audio, content: audioList))
} }
if !subtitleStreams.isEmpty { if !subtitleStreams.isEmpty {
let subtitleList = subtitleStreams.compactMap { "\($0.displayTitle ?? L10n.noTitle) (\($0.codec ?? L10n.noCodec))" } let subtitleList = subtitleStreams.compactMap { "\($0.displayTitle ?? L10n.noTitle) (\($0.codec ?? L10n.noCodec))" }
.joined(separator: ", ") .joined(separator: ", ")
mediaItems.append(ItemDetail(title: L10n.subtitles, content: subtitleList)) mediaItems.append(ItemDetail(title: L10n.subtitles, content: subtitleList))
} }
} }
return mediaItems return mediaItems
} }
// MARK: Missing and Unaired // MARK: Missing and Unaired
var missing: Bool { var missing: Bool {
locationType == .virtual locationType == .virtual
} }
var unaired: Bool { var unaired: Bool {
if let premierDate = premiereDate { if let premierDate = premiereDate {
return premierDate > Date() return premierDate > Date()
} else { } else {
return false return false
} }
} }
var airDateLabel: String? { var airDateLabel: String? {
guard let premiereDateFormatted = premiereDateFormatted else { return nil } guard let premiereDateFormatted = premiereDateFormatted else { return nil }
return L10n.airWithDate(premiereDateFormatted) return L10n.airWithDate(premiereDateFormatted)
} }
var premiereDateFormatted: String? { var premiereDateFormatted: String? {
guard let premiereDate = premiereDate else { return nil } guard let premiereDate = premiereDate else { return nil }
let dateFormatter = DateFormatter() let dateFormatter = DateFormatter()
dateFormatter.dateStyle = .medium dateFormatter.dateStyle = .medium
return dateFormatter.string(from: premiereDate) return dateFormatter.string(from: premiereDate)
} }
// MARK: Chapter Images // MARK: Chapter Images
func getChapterImage(maxWidth: Int) -> [URL] { func getChapterImage(maxWidth: Int) -> [URL] {
guard let chapters = chapters, !chapters.isEmpty else { return [] } guard let chapters = chapters, !chapters.isEmpty else { return [] }
var chapterImageURLs: [URL] = [] var chapterImageURLs: [URL] = []
for chapterIndex in 0 ..< chapters.count { for chapterIndex in 0 ..< chapters.count {
let urlString = ImageAPI.getItemImageWithRequestBuilder(itemId: id ?? "", let urlString = ImageAPI.getItemImageWithRequestBuilder(
imageType: .chapter, itemId: id ?? "",
maxWidth: maxWidth, imageType: .chapter,
imageIndex: chapterIndex).URLString maxWidth: maxWidth,
chapterImageURLs.append(URL(string: urlString)!) imageIndex: chapterIndex
} ).URLString
chapterImageURLs.append(URL(string: urlString)!)
}
return chapterImageURLs return chapterImageURLs
} }
} }

View File

@ -12,103 +12,105 @@ import UIKit
extension BaseItemPerson { extension BaseItemPerson {
// MARK: Get Image // MARK: Get Image
func getImage(baseURL: String, maxWidth: Int) -> URL { func getImage(baseURL: String, maxWidth: Int) -> URL {
let x = UIScreen.main.nativeScale * CGFloat(maxWidth) let x = UIScreen.main.nativeScale * CGFloat(maxWidth)
let urlString = ImageAPI.getItemImageWithRequestBuilder(itemId: id ?? "", let urlString = ImageAPI.getItemImageWithRequestBuilder(
imageType: .primary, itemId: id ?? "",
maxWidth: Int(x), imageType: .primary,
quality: 96, maxWidth: Int(x),
tag: primaryImageTag).URLString quality: 96,
return URL(string: urlString)! tag: primaryImageTag
} ).URLString
return URL(string: urlString)!
}
func getBlurHash() -> String { func getBlurHash() -> String {
let imgURL = getImage(baseURL: "", maxWidth: 1) let imgURL = getImage(baseURL: "", maxWidth: 1)
guard let imgTag = imgURL.queryParameters?["tag"], guard let imgTag = imgURL.queryParameters?["tag"],
let hash = imageBlurHashes?.primary?[imgTag] let hash = imageBlurHashes?.primary?[imgTag]
else { else {
return "001fC^" return "001fC^"
} }
return hash return hash
} }
// MARK: First Role // MARK: First Role
// Jellyfin will grab all roles the person played in the show which makes the role // Jellyfin will grab all roles the person played in the show which makes the role
// text too long. This will grab the first role which: // text too long. This will grab the first role which:
// - assumes that the most important role is the first // - assumes that the most important role is the first
// - will also grab the last "(<text>)" instance, like "(voice)" // - will also grab the last "(<text>)" instance, like "(voice)"
func firstRole() -> String? { func firstRole() -> String? {
guard let role = self.role else { return nil } guard let role = self.role else { return nil }
let split = role.split(separator: "/") let split = role.split(separator: "/")
guard split.count > 1 else { return role } guard split.count > 1 else { return role }
guard let firstRole = split.first?.trimmingCharacters(in: CharacterSet(charactersIn: " ")), guard let firstRole = split.first?.trimmingCharacters(in: CharacterSet(charactersIn: " ")),
let lastRole = split.last?.trimmingCharacters(in: CharacterSet(charactersIn: " ")) else { return role } let lastRole = split.last?.trimmingCharacters(in: CharacterSet(charactersIn: " ")) else { return role }
var final = firstRole var final = firstRole
if let lastOpenIndex = lastRole.lastIndex(of: "("), let lastClosingIndex = lastRole.lastIndex(of: ")") { if let lastOpenIndex = lastRole.lastIndex(of: "("), let lastClosingIndex = lastRole.lastIndex(of: ")") {
let roleText = lastRole[lastOpenIndex ... lastClosingIndex] let roleText = lastRole[lastOpenIndex ... lastClosingIndex]
final.append(" \(roleText)") final.append(" \(roleText)")
} }
return final return final
} }
} }
// MARK: PortraitImageStackable // MARK: PortraitImageStackable
extension BaseItemPerson: PortraitImageStackable { extension BaseItemPerson: PortraitImageStackable {
public var portraitImageID: String { public var portraitImageID: String {
(id ?? "noid") + title + (subtitle ?? "nodescription") + blurHash + failureInitials (id ?? "noid") + title + (subtitle ?? "nodescription") + blurHash + failureInitials
} }
public func imageURLConstructor(maxWidth: Int) -> URL { public func imageURLConstructor(maxWidth: Int) -> URL {
self.getImage(baseURL: SessionManager.main.currentLogin.server.currentURI, maxWidth: maxWidth) self.getImage(baseURL: SessionManager.main.currentLogin.server.currentURI, maxWidth: maxWidth)
} }
public var title: String { public var title: String {
self.name ?? "" self.name ?? ""
} }
public var subtitle: String? { public var subtitle: String? {
self.firstRole() self.firstRole()
} }
public var blurHash: String { public var blurHash: String {
self.getBlurHash() self.getBlurHash()
} }
public var failureInitials: String { public var failureInitials: String {
guard let name = self.name else { return "" } guard let name = self.name else { return "" }
let initials = name.split(separator: " ").compactMap { String($0).first } let initials = name.split(separator: " ").compactMap { String($0).first }
return String(initials) return String(initials)
} }
public var showTitle: Bool { public var showTitle: Bool {
true true
} }
} }
// MARK: DiplayedType // MARK: DiplayedType
extension BaseItemPerson { extension BaseItemPerson {
// Only displayed person types. // Only displayed person types.
// Will ignore people like "GuestStar" // Will ignore people like "GuestStar"
enum DisplayedType: String, CaseIterable { enum DisplayedType: String, CaseIterable {
case actor = "Actor" case actor = "Actor"
case director = "Director" case director = "Director"
case writer = "Writer" case writer = "Writer"
case producer = "Producer" case producer = "Producer"
static var allCasesRaw: [String] { static var allCasesRaw: [String] {
self.allCases.map(\.rawValue) self.allCases.map(\.rawValue)
} }
} }
} }

View File

@ -11,34 +11,34 @@ import JellyfinAPI
extension ChapterInfo { extension ChapterInfo {
var timestampLabel: String { var timestampLabel: String {
let seconds = (startPositionTicks ?? 0) / 10_000_000 let seconds = (startPositionTicks ?? 0) / 10_000_000
return seconds.toReadableString() return seconds.toReadableString()
} }
} }
extension Int64 { extension Int64 {
func toReadableString() -> String { func toReadableString() -> String {
let s = Int(self) % 60 let s = Int(self) % 60
let mn = (Int(self) / 60) % 60 let mn = (Int(self) / 60) % 60
let hr = (Int(self) / 3600) let hr = (Int(self) / 3600)
var final = "" var final = ""
if hr != 0 { if hr != 0 {
final += "\(hr):" final += "\(hr):"
} }
if mn != 0 { if mn != 0 {
final += String(format: "%0.2d:", mn) final += String(format: "%0.2d:", mn)
} else { } else {
final += "00:" final += "00:"
} }
final += String(format: "%0.2d", s) final += String(format: "%0.2d", s)
return final return final
} }
} }

View File

@ -10,13 +10,13 @@ import Foundation
struct JellyfinAPIError: Error { struct JellyfinAPIError: Error {
private let message: String private let message: String
init(_ message: String) { init(_ message: String) {
self.message = message self.message = message
} }
var localizedDescription: String { var localizedDescription: String {
message message
} }
} }

View File

@ -10,12 +10,12 @@ import Foundation
import JellyfinAPI import JellyfinAPI
extension MediaStream { extension MediaStream {
func externalURL(base: String) -> URL? { func externalURL(base: String) -> URL? {
var base = base var base = base
while base.last == Character("/") { while base.last == Character("/") {
base.removeLast() base.removeLast()
} }
guard let deliveryURL = deliveryUrl else { return nil } guard let deliveryURL = deliveryUrl else { return nil }
return URL(string: base + deliveryURL) return URL(string: base + deliveryURL)
} }
} }

View File

@ -10,7 +10,7 @@ import Foundation
import JellyfinAPI import JellyfinAPI
extension NameGuidPair: PillStackable { extension NameGuidPair: PillStackable {
var title: String { var title: String {
self.name ?? "" self.name ?? ""
} }
} }

View File

@ -10,31 +10,31 @@ import Foundation
import SwiftUI import SwiftUI
extension String { extension String {
func removeRegexMatches(pattern: String, replaceWith: String = "") -> String { func removeRegexMatches(pattern: String, replaceWith: String = "") -> String {
do { do {
let regex = try NSRegularExpression(pattern: pattern, options: .caseInsensitive) let regex = try NSRegularExpression(pattern: pattern, options: .caseInsensitive)
let range = NSRange(location: 0, length: count) let range = NSRange(location: 0, length: count)
return regex.stringByReplacingMatches(in: self, options: [], range: range, withTemplate: replaceWith) return regex.stringByReplacingMatches(in: self, options: [], range: range, withTemplate: replaceWith)
} catch { return self } } catch { return self }
} }
func leftPad(toWidth width: Int, withString string: String?) -> String { func leftPad(toWidth width: Int, withString string: String?) -> String {
let paddingString = string ?? " " let paddingString = string ?? " "
if self.count >= width { if self.count >= width {
return self return self
} }
let remainingLength: Int = width - self.count let remainingLength: Int = width - self.count
var padString = String() var padString = String()
for _ in 0 ..< remainingLength { for _ in 0 ..< remainingLength {
padString += paddingString padString += paddingString
} }
return "\(padString)\(self)" return "\(padString)\(self)"
} }
var text: Text { var text: Text {
Text(self) Text(self)
} }
} }

View File

@ -9,11 +9,11 @@
import UIKit import UIKit
extension UIApplication { extension UIApplication {
static var appVersion: String? { static var appVersion: String? {
Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String
} }
static var bundleVersion: String? { static var bundleVersion: String? {
Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String
} }
} }

View File

@ -9,7 +9,7 @@
import UIKit import UIKit
extension UIDevice { extension UIDevice {
static var vendorUUIDString: String { static var vendorUUIDString: String {
current.identifierForVendor!.uuidString current.identifierForVendor!.uuidString
} }
} }

View File

@ -10,12 +10,12 @@ import Foundation
extension URLComponents { extension URLComponents {
mutating func addQueryItem(name: String, value: String?) { mutating func addQueryItem(name: String, value: String?) {
if let _ = self.queryItems { if let _ = self.queryItems {
self.queryItems?.append(URLQueryItem(name: name, value: value)) self.queryItems?.append(URLQueryItem(name: name, value: value))
} else { } else {
self.queryItems = [] self.queryItems = []
self.queryItems?.append(URLQueryItem(name: name, value: value)) self.queryItems?.append(URLQueryItem(name: name, value: value))
} }
} }
} }

View File

@ -9,17 +9,17 @@
import Foundation import Foundation
public extension URL { public extension URL {
/// Dictionary of the URL's query parameters /// Dictionary of the URL's query parameters
var queryParameters: [String: String]? { var queryParameters: [String: String]? {
guard let components = URLComponents(url: self, resolvingAgainstBaseURL: false), guard let components = URLComponents(url: self, resolvingAgainstBaseURL: false),
let queryItems = components.queryItems else { return nil } let queryItems = components.queryItems else { return nil }
var items: [String: String] = [:] var items: [String: String] = [:]
for queryItem in queryItems { for queryItem in queryItems {
items[queryItem.name] = queryItem.value items[queryItem.name] = queryItem.value
} }
return items return items
} }
} }

View File

@ -7,17 +7,19 @@
// //
#if os(tvOS) #if os(tvOS)
import TVVLCKit import TVVLCKit
#else #else
import MobileVLCKit import MobileVLCKit
#endif #endif
extension VLCMediaPlayer { extension VLCMediaPlayer {
/// Applies font size to the player /// Applies font size to the player
/// ///
/// This is pretty hacky until VLCKit 4 has a public API to support this /// This is pretty hacky until VLCKit 4 has a public API to support this
func setSubtitleSize(_ size: SubtitleSize) { func setSubtitleSize(_ size: SubtitleSize) {
perform(Selector(("setTextRendererFontSize:")), perform(
with: size.textRendererFontSize) Selector(("setTextRendererFontSize:")),
} with: size.textRendererFontSize
)
}
} }

View File

@ -10,7 +10,7 @@ import Foundation
import SwiftUI import SwiftUI
extension View { extension View {
func eraseToAnyView() -> AnyView { func eraseToAnyView() -> AnyView {
AnyView(self) AnyView(self)
} }
} }

View File

@ -10,19 +10,19 @@ import Foundation
class TranslationService { class TranslationService {
static let shared = TranslationService() static let shared = TranslationService()
func lookupTranslation(forKey key: String, inTable table: String) -> String { func lookupTranslation(forKey key: String, inTable table: String) -> String {
let expectedValue = Bundle.main.localizedString(forKey: key, value: nil, table: table) let expectedValue = Bundle.main.localizedString(forKey: key, value: nil, table: table)
if expectedValue == key || NSLocale.preferredLanguages.first == "en" { if expectedValue == key || NSLocale.preferredLanguages.first == "en" {
guard let path = Bundle.main.path(forResource: "en", ofType: "lproj"), guard let path = Bundle.main.path(forResource: "en", ofType: "lproj"),
let bundle = Bundle(path: path) else { return expectedValue } let bundle = Bundle(path: path) else { return expectedValue }
return NSLocalizedString(key, bundle: bundle, comment: "") return NSLocalizedString(key, bundle: bundle, comment: "")
} else { } else {
return expectedValue return expectedValue
} }
} }
} }

View File

@ -10,29 +10,29 @@ import Defaults
import SwiftUI import SwiftUI
enum AppAppearance: String, CaseIterable, Defaults.Serializable { enum AppAppearance: String, CaseIterable, Defaults.Serializable {
case system case system
case dark case dark
case light case light
var localizedName: String { var localizedName: String {
switch self { switch self {
case .system: case .system:
return L10n.system return L10n.system
case .dark: case .dark:
return L10n.dark return L10n.dark
case .light: case .light:
return L10n.light return L10n.light
} }
} }
var style: UIUserInterfaceStyle { var style: UIUserInterfaceStyle {
switch self { switch self {
case .system: case .system:
return .unspecified return .unspecified
case .dark: case .dark:
return .dark return .dark
case .light: case .light:
return .light return .light
} }
} }
} }

View File

@ -9,6 +9,6 @@
import Foundation import Foundation
struct Bitrates: Codable, Hashable { struct Bitrates: Codable, Hashable {
public var name: String public var name: String
public var value: Int public var value: Int
} }

View File

@ -12,246 +12,288 @@ import Foundation
import JellyfinAPI import JellyfinAPI
enum CPUModel { enum CPUModel {
case A4 case A4
case A5 case A5
case A5X case A5X
case A6 case A6
case A6X case A6X
case A7 case A7
case A7X case A7X
case A8 case A8
case A8X case A8X
case A9 case A9
case A9X case A9X
case A10 case A10
case A10X case A10X
case A11 case A11
case A12 case A12
case A12X case A12X
case A12Z case A12Z
case A13 case A13
case A14 case A14
case M1 case M1
case A99 case A99
} }
class DeviceProfileBuilder { class DeviceProfileBuilder {
public var bitrate: Int = 0 public var bitrate: Int = 0
public func setMaxBitrate(bitrate: Int) { public func setMaxBitrate(bitrate: Int) {
self.bitrate = bitrate self.bitrate = bitrate
} }
public func buildProfile() -> ClientCapabilitiesDeviceProfile { public func buildProfile() -> ClientCapabilitiesDeviceProfile {
let maxStreamingBitrate = bitrate let maxStreamingBitrate = bitrate
let maxStaticBitrate = bitrate let maxStaticBitrate = bitrate
let musicStreamingTranscodingBitrate = bitrate let musicStreamingTranscodingBitrate = bitrate
// Build direct play profiles // Build direct play profiles
var directPlayProfiles: [DirectPlayProfile] = [] var directPlayProfiles: [DirectPlayProfile] = []
directPlayProfiles = directPlayProfiles =
[DirectPlayProfile(container: "mov,mp4,mkv,webm", audioCodec: "aac,mp3,wav", videoCodec: "h264,mpeg4,vp9", type: .video)] [DirectPlayProfile(container: "mov,mp4,mkv,webm", audioCodec: "aac,mp3,wav", videoCodec: "h264,mpeg4,vp9", type: .video)]
// Device supports Dolby Digital (AC3, EAC3) // Device supports Dolby Digital (AC3, EAC3)
if supportsFeature(minimumSupported: .A8X) { if supportsFeature(minimumSupported: .A8X) {
if supportsFeature(minimumSupported: .A9) { if supportsFeature(minimumSupported: .A9) {
directPlayProfiles = [DirectPlayProfile(container: "mov,mp4,mkv,webm", audioCodec: "aac,mp3,wav,ac3,eac3,flac,opus", directPlayProfiles = [DirectPlayProfile(
videoCodec: "hevc,h264,hev1,mpeg4,vp9", container: "mov,mp4,mkv,webm",
type: .video)] // HEVC/H.264 with Dolby Digital audioCodec: "aac,mp3,wav,ac3,eac3,flac,opus",
} else { videoCodec: "hevc,h264,hev1,mpeg4,vp9",
directPlayProfiles = [DirectPlayProfile(container: "mov,mp4,mkv,webm", audioCodec: "ac3,eac3,aac,mp3,wav,opus", type: .video
videoCodec: "h264,mpeg4,vp9", type: .video)] // H.264 with Dolby Digital )] // HEVC/H.264 with Dolby Digital
} } else {
} directPlayProfiles = [DirectPlayProfile(
container: "mov,mp4,mkv,webm",
audioCodec: "ac3,eac3,aac,mp3,wav,opus",
videoCodec: "h264,mpeg4,vp9",
type: .video
)] // H.264 with Dolby Digital
}
}
// Device supports Dolby Vision? // Device supports Dolby Vision?
if supportsFeature(minimumSupported: .A10X) { if supportsFeature(minimumSupported: .A10X) {
directPlayProfiles = [DirectPlayProfile(container: "mov,mp4,mkv,webm", audioCodec: "aac,mp3,wav,ac3,eac3,flac,opus", directPlayProfiles = [DirectPlayProfile(
videoCodec: "dvhe,dvh1,h264,hevc,hev1,mpeg4,vp9", container: "mov,mp4,mkv,webm",
type: .video)] // H.264/HEVC with Dolby Digital - No Atmos - Vision audioCodec: "aac,mp3,wav,ac3,eac3,flac,opus",
} videoCodec: "dvhe,dvh1,h264,hevc,hev1,mpeg4,vp9",
type: .video
)] // H.264/HEVC with Dolby Digital - No Atmos - Vision
}
// Device supports Dolby Atmos? // Device supports Dolby Atmos?
if supportsFeature(minimumSupported: .A12) { if supportsFeature(minimumSupported: .A12) {
directPlayProfiles = [DirectPlayProfile(container: "mov,mp4,mkv,webm", directPlayProfiles = [DirectPlayProfile(
audioCodec: "aac,mp3,wav,ac3,eac3,flac,truehd,dts,dca,opus", container: "mov,mp4,mkv,webm",
videoCodec: "h264,hevc,dvhe,dvh1,h264,hevc,hev1,mpeg4,vp9", audioCodec: "aac,mp3,wav,ac3,eac3,flac,truehd,dts,dca,opus",
type: .video)] // H.264/HEVC with Dolby Digital & Atmos - Vision videoCodec: "h264,hevc,dvhe,dvh1,h264,hevc,hev1,mpeg4,vp9",
} type: .video
)] // H.264/HEVC with Dolby Digital & Atmos - Vision
}
// Build transcoding profiles // Build transcoding profiles
var transcodingProfiles: [TranscodingProfile] = [] var transcodingProfiles: [TranscodingProfile] = []
transcodingProfiles = [TranscodingProfile(container: "ts", type: .video, videoCodec: "h264,mpeg4", audioCodec: "aac,mp3,wav")] transcodingProfiles = [TranscodingProfile(container: "ts", type: .video, videoCodec: "h264,mpeg4", audioCodec: "aac,mp3,wav")]
// Device supports Dolby Digital (AC3, EAC3) // Device supports Dolby Digital (AC3, EAC3)
if supportsFeature(minimumSupported: .A8X) { if supportsFeature(minimumSupported: .A8X) {
if supportsFeature(minimumSupported: .A9) { if supportsFeature(minimumSupported: .A9) {
transcodingProfiles = [TranscodingProfile(container: "ts", type: .video, videoCodec: "h264,hevc,mpeg4", transcodingProfiles = [TranscodingProfile(
audioCodec: "aac,mp3,wav,eac3,ac3,flac,opus", _protocol: "hls", container: "ts",
context: .streaming, maxAudioChannels: "6", minSegments: 2, type: .video,
breakOnNonKeyFrames: true)] videoCodec: "h264,hevc,mpeg4",
} else { audioCodec: "aac,mp3,wav,eac3,ac3,flac,opus",
transcodingProfiles = [TranscodingProfile(container: "ts", type: .video, videoCodec: "h264,mpeg4", _protocol: "hls",
audioCodec: "aac,mp3,wav,eac3,ac3,opus", _protocol: "hls", context: .streaming,
context: .streaming, maxAudioChannels: "6", minSegments: 2, maxAudioChannels: "6",
breakOnNonKeyFrames: true)] minSegments: 2,
} breakOnNonKeyFrames: true
} )]
} else {
transcodingProfiles = [TranscodingProfile(
container: "ts",
type: .video,
videoCodec: "h264,mpeg4",
audioCodec: "aac,mp3,wav,eac3,ac3,opus",
_protocol: "hls",
context: .streaming,
maxAudioChannels: "6",
minSegments: 2,
breakOnNonKeyFrames: true
)]
}
}
// Device supports FLAC? // Device supports FLAC?
if supportsFeature(minimumSupported: .A10X) { if supportsFeature(minimumSupported: .A10X) {
transcodingProfiles = [TranscodingProfile(container: "ts", type: .video, videoCodec: "hevc,h264,mpeg4", transcodingProfiles = [TranscodingProfile(
audioCodec: "aac,mp3,wav,ac3,eac3,flac,opus", _protocol: "hls", container: "ts",
context: .streaming, maxAudioChannels: "6", minSegments: 2, type: .video,
breakOnNonKeyFrames: true)] videoCodec: "hevc,h264,mpeg4",
} audioCodec: "aac,mp3,wav,ac3,eac3,flac,opus",
_protocol: "hls",
context: .streaming,
maxAudioChannels: "6",
minSegments: 2,
breakOnNonKeyFrames: true
)]
}
var codecProfiles: [CodecProfile] = [] var codecProfiles: [CodecProfile] = []
let h264CodecConditions: [ProfileCondition] = [ let h264CodecConditions: [ProfileCondition] = [
ProfileCondition(condition: .notEquals, property: .isAnamorphic, value: "true", isRequired: false), ProfileCondition(condition: .notEquals, property: .isAnamorphic, value: "true", isRequired: false),
ProfileCondition(condition: .equalsAny, property: .videoProfile, value: "high|main|baseline|constrained baseline", ProfileCondition(
isRequired: false), condition: .equalsAny,
ProfileCondition(condition: .lessThanEqual, property: .videoLevel, value: "80", isRequired: false), property: .videoProfile,
ProfileCondition(condition: .notEquals, property: .isInterlaced, value: "true", isRequired: false), value: "high|main|baseline|constrained baseline",
] isRequired: false
let hevcCodecConditions: [ProfileCondition] = [ ),
ProfileCondition(condition: .notEquals, property: .isAnamorphic, value: "true", isRequired: false), ProfileCondition(condition: .lessThanEqual, property: .videoLevel, value: "80", isRequired: false),
ProfileCondition(condition: .equalsAny, property: .videoProfile, value: "high|main|main 10", isRequired: false), ProfileCondition(condition: .notEquals, property: .isInterlaced, value: "true", isRequired: false),
ProfileCondition(condition: .lessThanEqual, property: .videoLevel, value: "175", isRequired: false), ]
ProfileCondition(condition: .notEquals, property: .isInterlaced, value: "true", isRequired: false), let hevcCodecConditions: [ProfileCondition] = [
] ProfileCondition(condition: .notEquals, property: .isAnamorphic, value: "true", isRequired: false),
ProfileCondition(condition: .equalsAny, property: .videoProfile, value: "high|main|main 10", isRequired: false),
ProfileCondition(condition: .lessThanEqual, property: .videoLevel, value: "175", isRequired: false),
ProfileCondition(condition: .notEquals, property: .isInterlaced, value: "true", isRequired: false),
]
codecProfiles.append(CodecProfile(type: .video, applyConditions: h264CodecConditions, codec: "h264")) codecProfiles.append(CodecProfile(type: .video, applyConditions: h264CodecConditions, codec: "h264"))
if supportsFeature(minimumSupported: .A9) { if supportsFeature(minimumSupported: .A9) {
codecProfiles.append(CodecProfile(type: .video, applyConditions: hevcCodecConditions, codec: "hevc")) codecProfiles.append(CodecProfile(type: .video, applyConditions: hevcCodecConditions, codec: "hevc"))
} }
var subtitleProfiles: [SubtitleProfile] = [] var subtitleProfiles: [SubtitleProfile] = []
subtitleProfiles.append(SubtitleProfile(format: "ass", method: .embed)) subtitleProfiles.append(SubtitleProfile(format: "ass", method: .embed))
subtitleProfiles.append(SubtitleProfile(format: "ssa", method: .embed)) subtitleProfiles.append(SubtitleProfile(format: "ssa", method: .embed))
subtitleProfiles.append(SubtitleProfile(format: "subrip", method: .embed)) subtitleProfiles.append(SubtitleProfile(format: "subrip", method: .embed))
subtitleProfiles.append(SubtitleProfile(format: "sub", method: .embed)) subtitleProfiles.append(SubtitleProfile(format: "sub", method: .embed))
subtitleProfiles.append(SubtitleProfile(format: "pgssub", method: .embed)) subtitleProfiles.append(SubtitleProfile(format: "pgssub", method: .embed))
// These need to be filtered. Most subrips are embedded. I hate subtitles. // These need to be filtered. Most subrips are embedded. I hate subtitles.
subtitleProfiles.append(SubtitleProfile(format: "subrip", method: .external)) subtitleProfiles.append(SubtitleProfile(format: "subrip", method: .external))
subtitleProfiles.append(SubtitleProfile(format: "sub", method: .external)) subtitleProfiles.append(SubtitleProfile(format: "sub", method: .external))
subtitleProfiles.append(SubtitleProfile(format: "ass", method: .external)) subtitleProfiles.append(SubtitleProfile(format: "ass", method: .external))
subtitleProfiles.append(SubtitleProfile(format: "ssa", method: .external)) subtitleProfiles.append(SubtitleProfile(format: "ssa", method: .external))
subtitleProfiles.append(SubtitleProfile(format: "vtt", method: .external)) subtitleProfiles.append(SubtitleProfile(format: "vtt", method: .external))
subtitleProfiles.append(SubtitleProfile(format: "ass", method: .external)) subtitleProfiles.append(SubtitleProfile(format: "ass", method: .external))
subtitleProfiles.append(SubtitleProfile(format: "ssa", method: .external)) subtitleProfiles.append(SubtitleProfile(format: "ssa", method: .external))
let responseProfiles: [ResponseProfile] = [ResponseProfile(container: "m4v", type: .video, mimeType: "video/mp4")] let responseProfiles: [ResponseProfile] = [ResponseProfile(container: "m4v", type: .video, mimeType: "video/mp4")]
let profile = ClientCapabilitiesDeviceProfile(maxStreamingBitrate: maxStreamingBitrate, maxStaticBitrate: maxStaticBitrate, let profile = ClientCapabilitiesDeviceProfile(
musicStreamingTranscodingBitrate: musicStreamingTranscodingBitrate, maxStreamingBitrate: maxStreamingBitrate,
directPlayProfiles: directPlayProfiles, transcodingProfiles: transcodingProfiles, maxStaticBitrate: maxStaticBitrate,
containerProfiles: [], musicStreamingTranscodingBitrate: musicStreamingTranscodingBitrate,
codecProfiles: codecProfiles, responseProfiles: responseProfiles, directPlayProfiles: directPlayProfiles,
subtitleProfiles: subtitleProfiles) transcodingProfiles: transcodingProfiles,
containerProfiles: [],
codecProfiles: codecProfiles,
responseProfiles: responseProfiles,
subtitleProfiles: subtitleProfiles
)
return profile return profile
} }
private func supportsFeature(minimumSupported: CPUModel) -> Bool { private func supportsFeature(minimumSupported: CPUModel) -> Bool {
let intValues: [CPUModel: Int] = [ let intValues: [CPUModel: Int] = [
.A4: 1, .A4: 1,
.A5: 2, .A5: 2,
.A5X: 3, .A5X: 3,
.A6: 4, .A6: 4,
.A6X: 5, .A6X: 5,
.A7: 6, .A7: 6,
.A7X: 7, .A7X: 7,
.A8: 8, .A8: 8,
.A8X: 9, .A8X: 9,
.A9: 10, .A9: 10,
.A9X: 11, .A9X: 11,
.A10: 12, .A10: 12,
.A10X: 13, .A10X: 13,
.A11: 14, .A11: 14,
.A12: 15, .A12: 15,
.A12X: 16, .A12X: 16,
.A12Z: 16, .A12Z: 16,
.A13: 17, .A13: 17,
.A14: 18, .A14: 18,
.M1: 19, .M1: 19,
.A99: 99, .A99: 99,
] ]
return intValues[CPUinfo()] ?? 0 >= intValues[minimumSupported] ?? 0 return intValues[CPUinfo()] ?? 0 >= intValues[minimumSupported] ?? 0
} }
/********************************************** /**********************************************
* CPUInfo(): * CPUInfo():
* Returns a hardcoded value of the current * Returns a hardcoded value of the current
* devices CPU name. * devices CPU name.
***********************************************/ ***********************************************/
private func CPUinfo() -> CPUModel { private func CPUinfo() -> CPUModel {
#if targetEnvironment(simulator) #if targetEnvironment(simulator)
let identifier = ProcessInfo().environment["SIMULATOR_MODEL_IDENTIFIER"]! let identifier = ProcessInfo().environment["SIMULATOR_MODEL_IDENTIFIER"]!
#else #else
var systemInfo = utsname() var systemInfo = utsname()
uname(&systemInfo) uname(&systemInfo)
let machineMirror = Mirror(reflecting: systemInfo.machine) let machineMirror = Mirror(reflecting: systemInfo.machine)
let identifier = machineMirror.children.reduce("") { identifier, element in let identifier = machineMirror.children.reduce("") { identifier, element in
guard let value = element.value as? Int8, value != 0 else { return identifier } guard let value = element.value as? Int8, value != 0 else { return identifier }
return identifier + String(UnicodeScalar(UInt8(value))) return identifier + String(UnicodeScalar(UInt8(value)))
} }
#endif #endif
switch identifier { switch identifier {
case "iPod5,1": return .A5 case "iPod5,1": return .A5
case "iPod7,1": return .A8 case "iPod7,1": return .A8
case "iPod9,1": return .A10 case "iPod9,1": return .A10
case "iPhone3,1", "iPhone3,2", "iPhone3,3": return .A4 case "iPhone3,1", "iPhone3,2", "iPhone3,3": return .A4
case "iPhone4,1": return .A5 case "iPhone4,1": return .A5
case "iPhone5,1", "iPhone5,2": return .A6 case "iPhone5,1", "iPhone5,2": return .A6
case "iPhone5,3", "iPhone5,4": return .A6 case "iPhone5,3", "iPhone5,4": return .A6
case "iPhone6,1", "iPhone6,2": return .A7 case "iPhone6,1", "iPhone6,2": return .A7
case "iPhone7,2": return .A8 case "iPhone7,2": return .A8
case "iPhone7,1": return .A8 case "iPhone7,1": return .A8
case "iPhone8,1": return .A9 case "iPhone8,1": return .A9
case "iPhone8,2", "iPhone8,4": return .A9 case "iPhone8,2", "iPhone8,4": return .A9
case "iPhone9,1", "iPhone9,3": return .A10 case "iPhone9,1", "iPhone9,3": return .A10
case "iPhone9,2", "iPhone9,4": return .A10 case "iPhone9,2", "iPhone9,4": return .A10
case "iPhone10,1", "iPhone10,4": return .A11 case "iPhone10,1", "iPhone10,4": return .A11
case "iPhone10,2", "iPhone10,5": return .A11 case "iPhone10,2", "iPhone10,5": return .A11
case "iPhone10,3", "iPhone10,6": return .A11 case "iPhone10,3", "iPhone10,6": return .A11
case "iPhone11,2", "iPhone11,6", "iPhone11,8": return .A12 case "iPhone11,2", "iPhone11,6", "iPhone11,8": return .A12
case "iPhone12,1", "iPhone12,3", "iPhone12,5", "iPhone12,8": return .A13 case "iPhone12,1", "iPhone12,3", "iPhone12,5", "iPhone12,8": return .A13
case "iPhone13,1", "iPhone13,2", "iPhone13,3", "iPhone13,4": return .A14 case "iPhone13,1", "iPhone13,2", "iPhone13,3", "iPhone13,4": return .A14
case "iPad2,1", "iPad2,2", "iPad2,3", "iPad2,4": return .A5 case "iPad2,1", "iPad2,2", "iPad2,3", "iPad2,4": return .A5
case "iPad3,1", "iPad3,2", "iPad3,3": return .A5X case "iPad3,1", "iPad3,2", "iPad3,3": return .A5X
case "iPad3,4", "iPad3,5", "iPad3,6": return .A6X case "iPad3,4", "iPad3,5", "iPad3,6": return .A6X
case "iPad4,1", "iPad4,2", "iPad4,3": return .A7 case "iPad4,1", "iPad4,2", "iPad4,3": return .A7
case "iPad5,3", "iPad5,4": return .A8X case "iPad5,3", "iPad5,4": return .A8X
case "iPad6,11", "iPad6,12": return .A9 case "iPad6,11", "iPad6,12": return .A9
case "iPad2,5", "iPad2,6", "iPad2,7": return .A5 case "iPad2,5", "iPad2,6", "iPad2,7": return .A5
case "iPad4,4", "iPad4,5", "iPad4,6": return .A7 case "iPad4,4", "iPad4,5", "iPad4,6": return .A7
case "iPad4,7", "iPad4,8", "iPad4,9": return .A7 case "iPad4,7", "iPad4,8", "iPad4,9": return .A7
case "iPad5,1", "iPad5,2": return .A8 case "iPad5,1", "iPad5,2": return .A8
case "iPad11,1", "iPad11,2": return .A12 case "iPad11,1", "iPad11,2": return .A12
case "iPad6,3", "iPad6,4": return .A9X case "iPad6,3", "iPad6,4": return .A9X
case "iPad6,7", "iPad6,8": return .A9X case "iPad6,7", "iPad6,8": return .A9X
case "iPad7,1", "iPad7,2": return .A10X case "iPad7,1", "iPad7,2": return .A10X
case "iPad7,3", "iPad7,4": return .A10X case "iPad7,3", "iPad7,4": return .A10X
case "iPad7,5", "iPad7,6", "iPad7,11", "iPad7,12": return .A10 case "iPad7,5", "iPad7,6", "iPad7,11", "iPad7,12": return .A10
case "iPad8,1", "iPad8,2", "iPad8,3", "iPad8,4": return .A12X case "iPad8,1", "iPad8,2", "iPad8,3", "iPad8,4": return .A12X
case "iPad8,5", "iPad8,6", "iPad8,7", "iPad8,8": return .A12X case "iPad8,5", "iPad8,6", "iPad8,7", "iPad8,8": return .A12X
case "iPad8,9", "iPad8,10", "iPad8,11", "iPad8,12": return .A12Z case "iPad8,9", "iPad8,10", "iPad8,11", "iPad8,12": return .A12Z
case "iPad11,3", "iPad11,4", "iPad11,6", "iPad11,7": return .A12 case "iPad11,3", "iPad11,4", "iPad11,6", "iPad11,7": return .A12
case "iPad13,1", "iPad13,2": return .A14 case "iPad13,1", "iPad13,2": return .A14
case "iPad13,8", "iPad13,9", "iPad13,10", "iPad13,11": return .M1 case "iPad13,8", "iPad13,9", "iPad13,10", "iPad13,11": return .M1
case "AppleTV5,3": return .A8 case "AppleTV5,3": return .A8
case "AppleTV6,2": return .A10X case "AppleTV6,2": return .A10X
case "AppleTV11,1": return .A12 case "AppleTV11,1": return .A12
case "AudioAccessory1,1": return .A8 case "AudioAccessory1,1": return .A8
default: return .A99 default: return .A99
} }
} }
} }

View File

@ -13,20 +13,20 @@ import SwiftUI
// Our custom view modifier to track rotation and // Our custom view modifier to track rotation and
// call our action // call our action
struct DeviceRotationViewModifier: ViewModifier { struct DeviceRotationViewModifier: ViewModifier {
let action: (UIDeviceOrientation) -> Void let action: (UIDeviceOrientation) -> Void
func body(content: Content) -> some View { func body(content: Content) -> some View {
content content
.onAppear() .onAppear()
.onReceive(NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)) { _ in .onReceive(NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)) { _ in
action(UIDevice.current.orientation) action(UIDevice.current.orientation)
} }
} }
} }
// A View wrapper to make the modifier easier to use // A View wrapper to make the modifier easier to use
extension View { extension View {
func onRotate(perform action: @escaping (UIDeviceOrientation) -> Void) -> some View { func onRotate(perform action: @escaping (UIDeviceOrientation) -> Void) -> some View {
self.modifier(DeviceRotationViewModifier(action: action)) self.modifier(DeviceRotationViewModifier(action: action))
} }
} }

View File

@ -10,6 +10,6 @@ import Defaults
import Foundation import Foundation
enum HTTPScheme: String, Defaults.Serializable, CaseIterable { enum HTTPScheme: String, Defaults.Serializable, CaseIterable {
case http case http
case https case https
} }

View File

@ -10,15 +10,15 @@ import Defaults
import UIKit import UIKit
enum OverlaySliderColor: String, CaseIterable, DefaultsSerializable { enum OverlaySliderColor: String, CaseIterable, DefaultsSerializable {
case white case white
case jellyfinPurple case jellyfinPurple
var displayLabel: String { var displayLabel: String {
switch self { switch self {
case .white: case .white:
return "White" return "White"
case .jellyfinPurple: case .jellyfinPurple:
return "Jellyfin Purple" return "Jellyfin Purple"
} }
} }
} }

View File

@ -10,15 +10,15 @@ import Defaults
import Foundation import Foundation
enum OverlayType: String, CaseIterable, Defaults.Serializable { enum OverlayType: String, CaseIterable, Defaults.Serializable {
case normal case normal
case compact case compact
var label: String { var label: String {
switch self { switch self {
case .normal: case .normal:
return L10n.normal return L10n.normal
case .compact: case .compact:
return L10n.compact return L10n.compact
} }
} }
} }

View File

@ -9,5 +9,5 @@
import Foundation import Foundation
protocol PillStackable { protocol PillStackable {
var title: String { get } var title: String { get }
} }

View File

@ -9,75 +9,75 @@
import Foundation import Foundation
enum PlaybackSpeed: Double, CaseIterable { enum PlaybackSpeed: Double, CaseIterable {
case quarter = 0.25 case quarter = 0.25
case half = 0.5 case half = 0.5
case threeQuarter = 0.75 case threeQuarter = 0.75
case one = 1.0 case one = 1.0
case oneQuarter = 1.25 case oneQuarter = 1.25
case oneHalf = 1.5 case oneHalf = 1.5
case oneThreeQuarter = 1.75 case oneThreeQuarter = 1.75
case two = 2.0 case two = 2.0
var displayTitle: String { var displayTitle: String {
switch self { switch self {
case .quarter: case .quarter:
return "0.25x" return "0.25x"
case .half: case .half:
return "0.5x" return "0.5x"
case .threeQuarter: case .threeQuarter:
return "0.75x" return "0.75x"
case .one: case .one:
return "1x" return "1x"
case .oneQuarter: case .oneQuarter:
return "1.25x" return "1.25x"
case .oneHalf: case .oneHalf:
return "1.5x" return "1.5x"
case .oneThreeQuarter: case .oneThreeQuarter:
return "1.75x" return "1.75x"
case .two: case .two:
return "2x" return "2x"
} }
} }
var previous: PlaybackSpeed? { var previous: PlaybackSpeed? {
switch self { switch self {
case .quarter: case .quarter:
return nil return nil
case .half: case .half:
return .quarter return .quarter
case .threeQuarter: case .threeQuarter:
return .half return .half
case .one: case .one:
return .threeQuarter return .threeQuarter
case .oneQuarter: case .oneQuarter:
return .one return .one
case .oneHalf: case .oneHalf:
return .oneQuarter return .oneQuarter
case .oneThreeQuarter: case .oneThreeQuarter:
return .oneHalf return .oneHalf
case .two: case .two:
return .oneThreeQuarter return .oneThreeQuarter
} }
} }
var next: PlaybackSpeed? { var next: PlaybackSpeed? {
switch self { switch self {
case .quarter: case .quarter:
return .half return .half
case .half: case .half:
return .threeQuarter return .threeQuarter
case .threeQuarter: case .threeQuarter:
return .one return .one
case .one: case .one:
return .oneQuarter return .oneQuarter
case .oneQuarter: case .oneQuarter:
return .oneHalf return .oneHalf
case .oneHalf: case .oneHalf:
return .oneThreeQuarter return .oneThreeQuarter
case .oneThreeQuarter: case .oneThreeQuarter:
return .two return .two
case .two: case .two:
return nil return nil
} }
} }
} }

View File

@ -9,11 +9,11 @@
import Foundation import Foundation
public protocol PortraitImageStackable { public protocol PortraitImageStackable {
func imageURLConstructor(maxWidth: Int) -> URL func imageURLConstructor(maxWidth: Int) -> URL
var title: String { get } var title: String { get }
var subtitle: String? { get } var subtitle: String? { get }
var blurHash: String { get } var blurHash: String { get }
var failureInitials: String { get } var failureInitials: String { get }
var portraitImageID: String { get } var portraitImageID: String { get }
var showTitle: Bool { get } var showTitle: Bool { get }
} }

View File

@ -9,6 +9,6 @@
import Foundation import Foundation
enum PosterSize { enum PosterSize {
case small case small
case normal case normal
} }

View File

@ -9,50 +9,50 @@
import Defaults import Defaults
enum SubtitleSize: Int32, CaseIterable, Defaults.Serializable { enum SubtitleSize: Int32, CaseIterable, Defaults.Serializable {
case smallest case smallest
case smaller case smaller
case regular case regular
case larger case larger
case largest case largest
} }
// MARK: - appearance // MARK: - appearance
extension SubtitleSize { extension SubtitleSize {
var label: String { var label: String {
switch self { switch self {
case .smallest: case .smallest:
return L10n.smallest return L10n.smallest
case .smaller: case .smaller:
return L10n.smaller return L10n.smaller
case .regular: case .regular:
return L10n.regular return L10n.regular
case .larger: case .larger:
return L10n.larger return L10n.larger
case .largest: case .largest:
return L10n.largest return L10n.largest
} }
} }
} }
// MARK: - sizing for VLC // MARK: - sizing for VLC
extension SubtitleSize { extension SubtitleSize {
/// Value to be passed to VLCKit (via hacky internal property, until VLCKit 4) /// Value to be passed to VLCKit (via hacky internal property, until VLCKit 4)
/// ///
/// note that it doesn't correspond to actual font sizes; a smaller int creates bigger text /// note that it doesn't correspond to actual font sizes; a smaller int creates bigger text
var textRendererFontSize: Int { var textRendererFontSize: Int {
switch self { switch self {
case .smallest: case .smallest:
return 24 return 24
case .smaller: case .smaller:
return 20 return 20
case .regular: case .regular:
return 16 return 16
case .larger: case .larger:
return 12 return 12
case .largest: case .largest:
return 8 return 8
} }
} }
} }

View File

@ -9,8 +9,8 @@
import Foundation import Foundation
struct TrackLanguage: Hashable { struct TrackLanguage: Hashable {
var name: String var name: String
var isoCode: String var isoCode: String
static let auto = TrackLanguage(name: "Auto", isoCode: "Auto") static let auto = TrackLanguage(name: "Auto", isoCode: "Auto")
} }

View File

@ -11,80 +11,80 @@ import Foundation
import JellyfinAPI import JellyfinAPI
struct LibraryFilters: Codable, Hashable { struct LibraryFilters: Codable, Hashable {
var filters: [ItemFilter] = [] var filters: [ItemFilter] = []
var sortOrder: [APISortOrder] = [.descending] var sortOrder: [APISortOrder] = [.descending]
var withGenres: [NameGuidPair] = [] var withGenres: [NameGuidPair] = []
var tags: [String] = [] var tags: [String] = []
var sortBy: [SortBy] = [.name] var sortBy: [SortBy] = [.name]
} }
public enum SortBy: String, Codable, CaseIterable { public enum SortBy: String, Codable, CaseIterable {
case premiereDate = "PremiereDate" case premiereDate = "PremiereDate"
case name = "SortName" case name = "SortName"
case dateAdded = "DateCreated" case dateAdded = "DateCreated"
} }
extension SortBy { extension SortBy {
var localized: String { var localized: String {
switch self { switch self {
case .premiereDate: case .premiereDate:
return "Premiere date" return "Premiere date"
case .name: case .name:
return "Name" return "Name"
case .dateAdded: case .dateAdded:
return "Date added" return "Date added"
} }
} }
} }
extension ItemFilter { extension ItemFilter {
static var supportedTypes: [ItemFilter] { static var supportedTypes: [ItemFilter] {
[.isUnplayed, isPlayed, .isFavorite, .likes] [.isUnplayed, isPlayed, .isFavorite, .likes]
} }
var localized: String { var localized: String {
switch self { switch self {
case .isUnplayed: case .isUnplayed:
return "Unplayed" return "Unplayed"
case .isPlayed: case .isPlayed:
return "Played" return "Played"
case .isFavorite: case .isFavorite:
return "Favorites" return "Favorites"
case .likes: case .likes:
return "Liked Items" return "Liked Items"
default: default:
return "" return ""
} }
} }
} }
extension APISortOrder { extension APISortOrder {
var localized: String { var localized: String {
switch self { switch self {
case .ascending: case .ascending:
return "Ascending" return "Ascending"
case .descending: case .descending:
return "Descending" return "Descending"
} }
} }
} }
enum ItemType: String { enum ItemType: String {
case episode = "Episode" case episode = "Episode"
case movie = "Movie" case movie = "Movie"
case series = "Series" case series = "Series"
case season = "Season" case season = "Season"
var localized: String { var localized: String {
switch self { switch self {
case .episode: case .episode:
return L10n.episodes return L10n.episodes
case .movie: case .movie:
return "Movies" return "Movies"
case .series: case .series:
return "Shows" return "Shows"
default: default:
return "" return ""
} }
} }
} }

View File

@ -10,42 +10,42 @@ import Defaults
import UIKit import UIKit
enum VideoPlayerJumpLength: Int32, CaseIterable, Defaults.Serializable { enum VideoPlayerJumpLength: Int32, CaseIterable, Defaults.Serializable {
case thirty = 30 case thirty = 30
case fifteen = 15 case fifteen = 15
case ten = 10 case ten = 10
case five = 5 case five = 5
var label: String { var label: String {
L10n.jumpLengthSeconds("\(self.rawValue)") L10n.jumpLengthSeconds("\(self.rawValue)")
} }
var shortLabel: String { var shortLabel: String {
"\(self.rawValue)s" "\(self.rawValue)s"
} }
var forwardImageLabel: String { var forwardImageLabel: String {
switch self { switch self {
case .thirty: case .thirty:
return "goforward.30" return "goforward.30"
case .fifteen: case .fifteen:
return "goforward.15" return "goforward.15"
case .ten: case .ten:
return "goforward.10" return "goforward.10"
case .five: case .five:
return "goforward.5" return "goforward.5"
} }
} }
var backwardImageLabel: String { var backwardImageLabel: String {
switch self { switch self {
case .thirty: case .thirty:
return "gobackward.30" return "gobackward.30"
case .fifteen: case .fifteen:
return "gobackward.15" return "gobackward.15"
case .ten: case .ten:
return "gobackward.10" return "gobackward.10"
case .five: case .five:
return "gobackward.5" return "gobackward.5"
} }
} }
} }

View File

@ -10,69 +10,69 @@ import Foundation
import UDPBroadcast import UDPBroadcast
public class ServerDiscovery { public class ServerDiscovery {
public struct ServerLookupResponse: Codable, Hashable, Identifiable { public struct ServerLookupResponse: Codable, Hashable, Identifiable {
public func hash(into hasher: inout Hasher) { public func hash(into hasher: inout Hasher) {
hasher.combine(id) hasher.combine(id)
} }
private let address: String private let address: String
public let id: String public let id: String
public let name: String public let name: String
public var url: URL { public var url: URL {
URL(string: self.address)! URL(string: self.address)!
} }
public var host: String { public var host: String {
let components = URLComponents(string: self.address) let components = URLComponents(string: self.address)
if let host = components?.host { if let host = components?.host {
return host return host
} }
return self.address return self.address
} }
public var port: Int { public var port: Int {
let components = URLComponents(string: self.address) let components = URLComponents(string: self.address)
if let port = components?.port { if let port = components?.port {
return port return port
} }
return 7359 return 7359
} }
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case address = "Address" case address = "Address"
case id = "Id" case id = "Id"
case name = "Name" case name = "Name"
} }
} }
private var connection: UDPBroadcastConnection? private var connection: UDPBroadcastConnection?
init() {} init() {}
public func locateServer(completion: @escaping (ServerLookupResponse?) -> Void) { public func locateServer(completion: @escaping (ServerLookupResponse?) -> Void) {
func receiveHandler(_ ipAddress: String, _ port: Int, _ data: Data) { func receiveHandler(_ ipAddress: String, _ port: Int, _ data: Data) {
do { do {
let response = try JSONDecoder().decode(ServerLookupResponse.self, from: data) let response = try JSONDecoder().decode(ServerLookupResponse.self, from: data)
LogManager.log.debug("Received JellyfinServer from \"\(response.name)\"", tag: "ServerDiscovery") LogManager.log.debug("Received JellyfinServer from \"\(response.name)\"", tag: "ServerDiscovery")
completion(response) completion(response)
} catch { } catch {
completion(nil) completion(nil)
} }
} }
func errorHandler(error: UDPBroadcastConnection.ConnectionError) { func errorHandler(error: UDPBroadcastConnection.ConnectionError) {
LogManager.log.error("Error handling response: \(error.localizedDescription)", tag: "ServerDiscovery") LogManager.log.error("Error handling response: \(error.localizedDescription)", tag: "ServerDiscovery")
} }
do { do {
self.connection = try! UDPBroadcastConnection(port: 7359, handler: receiveHandler, errorHandler: errorHandler) self.connection = try! UDPBroadcastConnection(port: 7359, handler: receiveHandler, errorHandler: errorHandler)
try self.connection?.sendBroadcast("Who is JellyfinServer?") try self.connection?.sendBroadcast("Who is JellyfinServer?")
LogManager.log.debug("Discovery broadcast sent", tag: "ServerDiscovery") LogManager.log.debug("Discovery broadcast sent", tag: "ServerDiscovery")
} catch { } catch {
LogManager.log.error("Error sending discovery broadcast", tag: "ServerDiscovery") LogManager.log.error("Error sending discovery broadcast", tag: "ServerDiscovery")
} }
} }
} }

View File

@ -9,27 +9,27 @@
import Foundation import Foundation
final class BackgroundManager { final class BackgroundManager {
static let current = BackgroundManager() static let current = BackgroundManager()
fileprivate(set) var backgroundURL: URL? fileprivate(set) var backgroundURL: URL?
fileprivate(set) var blurhash: String = "001fC^" fileprivate(set) var blurhash: String = "001fC^"
init() { init() {
backgroundURL = nil backgroundURL = nil
} }
func setBackground(to: URL, hash: String) { func setBackground(to: URL, hash: String) {
self.backgroundURL = to self.backgroundURL = to
self.blurhash = hash self.blurhash = hash
let nc = NotificationCenter.default let nc = NotificationCenter.default
nc.post(name: Notification.Name("backgroundDidChange"), object: nil) nc.post(name: Notification.Name("backgroundDidChange"), object: nil)
} }
func clearBackground() { func clearBackground() {
self.backgroundURL = nil self.backgroundURL = nil
self.blurhash = "001fC^" self.blurhash = "001fC^"
let nc = NotificationCenter.default let nc = NotificationCenter.default
nc.post(name: Notification.Name("backgroundDidChange"), object: nil) nc.post(name: Notification.Name("backgroundDidChange"), object: nil)
} }
} }

View File

@ -11,48 +11,60 @@ import Puppy
class LogManager { class LogManager {
static let log = Puppy() static let log = Puppy()
static func setup() { static func setup() {
let logsDirectory = getDocumentsDirectory().appendingPathComponent("logs", isDirectory: true) let logsDirectory = getDocumentsDirectory().appendingPathComponent("logs", isDirectory: true)
do { do {
try FileManager.default.createDirectory(atPath: logsDirectory.path, try FileManager.default.createDirectory(
withIntermediateDirectories: true, atPath: logsDirectory.path,
attributes: nil) withIntermediateDirectories: true,
} catch { attributes: nil
// logs directory already created )
} } catch {
// logs directory already created
}
let logFileURL = logsDirectory.appendingPathComponent("swiftfin_log.log") let logFileURL = logsDirectory.appendingPathComponent("swiftfin_log.log")
let fileRotationLogger = try! FileRotationLogger("org.jellyfin.swiftfin.logger.file-rotation", let fileRotationLogger = try! FileRotationLogger(
fileURL: logFileURL) "org.jellyfin.swiftfin.logger.file-rotation",
fileRotationLogger.format = LogFormatter() fileURL: logFileURL
)
fileRotationLogger.format = LogFormatter()
let consoleLogger = ConsoleLogger("org.jellyfin.swiftfin.logger.console") let consoleLogger = ConsoleLogger("org.jellyfin.swiftfin.logger.console")
consoleLogger.format = LogFormatter() consoleLogger.format = LogFormatter()
log.add(fileRotationLogger, withLevel: .debug) log.add(fileRotationLogger, withLevel: .debug)
log.add(consoleLogger, withLevel: .debug) log.add(consoleLogger, withLevel: .debug)
} }
private static func getDocumentsDirectory() -> URL { private static func getDocumentsDirectory() -> URL {
// find all possible documents directories for this user // find all possible documents directories for this user
let paths = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask) let paths = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)
// just send back the first one, which ought to be the only one // just send back the first one, which ought to be the only one
return paths[0] return paths[0]
} }
} }
class LogFormatter: LogFormattable { class LogFormatter: LogFormattable {
func formatMessage(_ level: LogLevel, message: String, tag: String, function: String, func formatMessage(
file: String, line: UInt, swiftLogInfo: [String: String], _ level: LogLevel,
label: String, date: Date, threadID: UInt64) -> String message: String,
{ tag: String,
let file = shortFileName(file).replacingOccurrences(of: ".swift", with: "") function: String,
return " [\(level.emoji) \(level)] \(file)#\(line):\(function) \(message)" file: String,
} line: UInt,
swiftLogInfo: [String: String],
label: String,
date: Date,
threadID: UInt64
) -> String {
let file = shortFileName(file).replacingOccurrences(of: ".swift", with: "")
return " [\(level.emoji) \(level)] \(file)#\(line):\(function) \(message)"
}
} }

View File

@ -20,325 +20,353 @@ typealias CurrentLogin = (server: SwiftfinStore.State.Server, user: SwiftfinStor
final class SessionManager { final class SessionManager {
// MARK: currentLogin // MARK: currentLogin
private(set) var currentLogin: CurrentLogin! private(set) var currentLogin: CurrentLogin!
// MARK: main // MARK: main
static let main = SessionManager() static let main = SessionManager()
// MARK: init // MARK: init
private init() { private init() {
if let lastUserID = Defaults[.lastServerUserID], if let lastUserID = Defaults[.lastServerUserID],
let user = try? SwiftfinStore.dataStack.fetchOne(From<SwiftfinStore.Models.StoredUser>(), let user = try? SwiftfinStore.dataStack.fetchOne(
[Where<SwiftfinStore.Models.StoredUser>("id == %@", lastUserID)]) From<SwiftfinStore.Models.StoredUser>(),
{ [Where<SwiftfinStore.Models.StoredUser>("id == %@", lastUserID)]
)
guard let server = user.server, {
let accessToken = user.accessToken else { fatalError("No associated server or access token for last user?") }
guard let existingServer = SwiftfinStore.dataStack.fetchExisting(server) else { return } guard let server = user.server,
let accessToken = user.accessToken else { fatalError("No associated server or access token for last user?") }
JellyfinAPIAPI.basePath = server.currentURI guard let existingServer = SwiftfinStore.dataStack.fetchExisting(server) else { return }
setAuthHeader(with: accessToken.value)
currentLogin = (server: existingServer.state, user: user.state) JellyfinAPIAPI.basePath = server.currentURI
} setAuthHeader(with: accessToken.value)
} currentLogin = (server: existingServer.state, user: user.state)
}
// MARK: fetchServers }
func fetchServers() -> [SwiftfinStore.State.Server] { // MARK: fetchServers
let servers = try! SwiftfinStore.dataStack.fetchAll(From<SwiftfinStore.Models.StoredServer>())
return servers.map(\.state) func fetchServers() -> [SwiftfinStore.State.Server] {
} let servers = try! SwiftfinStore.dataStack.fetchAll(From<SwiftfinStore.Models.StoredServer>())
return servers.map(\.state)
// MARK: fetchUsers }
func fetchUsers(for server: SwiftfinStore.State.Server) -> [SwiftfinStore.State.User] { // MARK: fetchUsers
guard let storedServer = try? SwiftfinStore.dataStack.fetchOne(From<SwiftfinStore.Models.StoredServer>(),
Where<SwiftfinStore.Models.StoredServer>("id == %@", server.id)) func fetchUsers(for server: SwiftfinStore.State.Server) -> [SwiftfinStore.State.User] {
else { fatalError("No stored server associated with given state server?") } guard let storedServer = try? SwiftfinStore.dataStack.fetchOne(
return storedServer.users.map(\.state).sorted(by: { $0.username < $1.username }) From<SwiftfinStore.Models.StoredServer>(),
} Where<SwiftfinStore.Models.StoredServer>("id == %@", server.id)
)
// MARK: connectToServer publisher else { fatalError("No stored server associated with given state server?") }
return storedServer.users.map(\.state).sorted(by: { $0.username < $1.username })
// Connects to a server at the given uri, storing if successful }
func connectToServer(with uri: String) -> AnyPublisher<SwiftfinStore.State.Server, Error> {
var uriComponents = URLComponents(string: uri) ?? URLComponents() // MARK: connectToServer publisher
if uriComponents.scheme == nil { // Connects to a server at the given uri, storing if successful
uriComponents.scheme = Defaults[.defaultHTTPScheme].rawValue func connectToServer(with uri: String) -> AnyPublisher<SwiftfinStore.State.Server, Error> {
} var uriComponents = URLComponents(string: uri) ?? URLComponents()
var uri = uriComponents.string ?? "" if uriComponents.scheme == nil {
uriComponents.scheme = Defaults[.defaultHTTPScheme].rawValue
if uri.last == "/" { }
uri = String(uri.dropLast())
} var uri = uriComponents.string ?? ""
JellyfinAPIAPI.basePath = uri if uri.last == "/" {
uri = String(uri.dropLast())
return SystemAPI.getPublicSystemInfo() }
.tryMap { response -> (SwiftfinStore.Models.StoredServer, UnsafeDataTransaction) in
JellyfinAPIAPI.basePath = uri
let transaction = SwiftfinStore.dataStack.beginUnsafe()
let newServer = transaction.create(Into<SwiftfinStore.Models.StoredServer>()) return SystemAPI.getPublicSystemInfo()
.tryMap { response -> (SwiftfinStore.Models.StoredServer, UnsafeDataTransaction) in
guard let name = response.serverName,
let id = response.id, let transaction = SwiftfinStore.dataStack.beginUnsafe()
let os = response.operatingSystem, let newServer = transaction.create(Into<SwiftfinStore.Models.StoredServer>())
let version = response.version else { throw JellyfinAPIError("Missing server data from network call") }
guard let name = response.serverName,
newServer.uris = [uri] let id = response.id,
newServer.currentURI = uri let os = response.operatingSystem,
newServer.name = name let version = response.version else { throw JellyfinAPIError("Missing server data from network call") }
newServer.id = id
newServer.os = os newServer.uris = [uri]
newServer.version = version newServer.currentURI = uri
newServer.users = [] newServer.name = name
newServer.id = id
// Check for existing server on device newServer.os = os
if let existingServer = try? SwiftfinStore.dataStack.fetchOne(From<SwiftfinStore.Models.StoredServer>(), newServer.version = version
[Where<SwiftfinStore.Models.StoredServer>("id == %@", newServer.users = []
newServer.id)])
{ // Check for existing server on device
throw SwiftfinStore.Error.existingServer(existingServer.state) if let existingServer = try? SwiftfinStore.dataStack.fetchOne(
} From<SwiftfinStore.Models.StoredServer>(),
[Where<SwiftfinStore.Models.StoredServer>(
return (newServer, transaction) "id == %@",
} newServer.id
.handleEvents(receiveOutput: { _, transaction in )]
try? transaction.commitAndWait() ) {
}) throw SwiftfinStore.Error.existingServer(existingServer.state)
.map { server, _ in }
server.state
} return (newServer, transaction)
.eraseToAnyPublisher() }
} .handleEvents(receiveOutput: { _, transaction in
try? transaction.commitAndWait()
// MARK: addURIToServer publisher })
.map { server, _ in
func addURIToServer(server: SwiftfinStore.State.Server, uri: String) -> AnyPublisher<SwiftfinStore.State.Server, Error> { server.state
Just(server) }
.tryMap { server -> (SwiftfinStore.Models.StoredServer, UnsafeDataTransaction) in .eraseToAnyPublisher()
}
let transaction = SwiftfinStore.dataStack.beginUnsafe()
// MARK: addURIToServer publisher
guard let existingServer = try? SwiftfinStore.dataStack.fetchOne(From<SwiftfinStore.Models.StoredServer>(),
[Where<SwiftfinStore.Models.StoredServer>("id == %@", func addURIToServer(server: SwiftfinStore.State.Server, uri: String) -> AnyPublisher<SwiftfinStore.State.Server, Error> {
server.id)]) Just(server)
else { .tryMap { server -> (SwiftfinStore.Models.StoredServer, UnsafeDataTransaction) in
fatalError("No stored server associated with given state server?")
} let transaction = SwiftfinStore.dataStack.beginUnsafe()
guard let editServer = transaction.edit(existingServer) else { fatalError("Can't get proxy for existing object?") } guard let existingServer = try? SwiftfinStore.dataStack.fetchOne(
editServer.uris.insert(uri) From<SwiftfinStore.Models.StoredServer>(),
[Where<SwiftfinStore.Models.StoredServer>(
return (editServer, transaction) "id == %@",
} server.id
.handleEvents(receiveOutput: { _, transaction in )]
try? transaction.commitAndWait() )
}) else {
.map { server, _ in fatalError("No stored server associated with given state server?")
server.state }
}
.eraseToAnyPublisher() guard let editServer = transaction.edit(existingServer) else { fatalError("Can't get proxy for existing object?") }
} editServer.uris.insert(uri)
// MARK: setServerCurrentURI publisher return (editServer, transaction)
}
func setServerCurrentURI(server: SwiftfinStore.State.Server, uri: String) -> AnyPublisher<SwiftfinStore.State.Server, Error> { .handleEvents(receiveOutput: { _, transaction in
Just(server) try? transaction.commitAndWait()
.tryMap { server -> (SwiftfinStore.Models.StoredServer, UnsafeDataTransaction) in })
.map { server, _ in
let transaction = SwiftfinStore.dataStack.beginUnsafe() server.state
}
guard let existingServer = try? SwiftfinStore.dataStack.fetchOne(From<SwiftfinStore.Models.StoredServer>(), .eraseToAnyPublisher()
[Where<SwiftfinStore.Models.StoredServer>("id == %@", }
server.id)])
else { // MARK: setServerCurrentURI publisher
fatalError("No stored server associated with given state server?")
} func setServerCurrentURI(server: SwiftfinStore.State.Server, uri: String) -> AnyPublisher<SwiftfinStore.State.Server, Error> {
Just(server)
if !existingServer.uris.contains(uri) { .tryMap { server -> (SwiftfinStore.Models.StoredServer, UnsafeDataTransaction) in
fatalError("Attempting to set current uri while server doesn't contain it?")
} let transaction = SwiftfinStore.dataStack.beginUnsafe()
guard let editServer = transaction.edit(existingServer) else { fatalError("Can't get proxy for existing object?") } guard let existingServer = try? SwiftfinStore.dataStack.fetchOne(
editServer.currentURI = uri From<SwiftfinStore.Models.StoredServer>(),
[Where<SwiftfinStore.Models.StoredServer>(
return (editServer, transaction) "id == %@",
} server.id
.handleEvents(receiveOutput: { _, transaction in )]
try? transaction.commitAndWait() )
}) else {
.map { server, _ in fatalError("No stored server associated with given state server?")
server.state }
}
.eraseToAnyPublisher() if !existingServer.uris.contains(uri) {
} fatalError("Attempting to set current uri while server doesn't contain it?")
}
// MARK: loginUser publisher
guard let editServer = transaction.edit(existingServer) else { fatalError("Can't get proxy for existing object?") }
// Logs in a user with an associated server, storing if successful editServer.currentURI = uri
func loginUser(server: SwiftfinStore.State.Server, username: String,
password: String) -> AnyPublisher<SwiftfinStore.State.User, Error> return (editServer, transaction)
{ }
setAuthHeader(with: "") .handleEvents(receiveOutput: { _, transaction in
try? transaction.commitAndWait()
JellyfinAPIAPI.basePath = server.currentURI })
.map { server, _ in
return UserAPI.authenticateUserByName(authenticateUserByNameRequest: .init(username: username, pw: password)) server.state
.tryMap { response -> (SwiftfinStore.Models.StoredServer, SwiftfinStore.Models.StoredUser, UnsafeDataTransaction) in }
.eraseToAnyPublisher()
guard let accessToken = response.accessToken else { throw JellyfinAPIError("Access token missing from network call") } }
let transaction = SwiftfinStore.dataStack.beginUnsafe() // MARK: loginUser publisher
let newUser = transaction.create(Into<SwiftfinStore.Models.StoredUser>())
// Logs in a user with an associated server, storing if successful
guard let username = response.user?.name, func loginUser(
let id = response.user?.id else { throw JellyfinAPIError("Missing user data from network call") } server: SwiftfinStore.State.Server,
username: String,
newUser.username = username password: String
newUser.id = id ) -> AnyPublisher<SwiftfinStore.State.User, Error> {
newUser.appleTVID = "" setAuthHeader(with: "")
// Check for existing user on device JellyfinAPIAPI.basePath = server.currentURI
if let existingUser = try? SwiftfinStore.dataStack.fetchOne(From<SwiftfinStore.Models.StoredUser>(),
[Where<SwiftfinStore.Models.StoredUser>("id == %@", return UserAPI.authenticateUserByName(authenticateUserByNameRequest: .init(username: username, pw: password))
newUser.id)]) .tryMap { response -> (SwiftfinStore.Models.StoredServer, SwiftfinStore.Models.StoredUser, UnsafeDataTransaction) in
{
throw SwiftfinStore.Error.existingUser(existingUser.state) guard let accessToken = response.accessToken else { throw JellyfinAPIError("Access token missing from network call") }
}
let transaction = SwiftfinStore.dataStack.beginUnsafe()
let newAccessToken = transaction.create(Into<SwiftfinStore.Models.StoredAccessToken>()) let newUser = transaction.create(Into<SwiftfinStore.Models.StoredUser>())
newAccessToken.value = accessToken
newUser.accessToken = newAccessToken guard let username = response.user?.name,
let id = response.user?.id else { throw JellyfinAPIError("Missing user data from network call") }
guard let userServer = try? SwiftfinStore.dataStack.fetchOne(From<SwiftfinStore.Models.StoredServer>(),
[ newUser.username = username
Where<SwiftfinStore.Models.StoredServer>("id == %@", newUser.id = id
server.id), newUser.appleTVID = ""
])
else { fatalError("No stored server associated with given state server?") } // Check for existing user on device
if let existingUser = try? SwiftfinStore.dataStack.fetchOne(
guard let editUserServer = transaction.edit(userServer) else { fatalError("Can't get proxy for existing object?") } From<SwiftfinStore.Models.StoredUser>(),
editUserServer.users.insert(newUser) [Where<SwiftfinStore.Models.StoredUser>(
"id == %@",
return (editUserServer, newUser, transaction) newUser.id
} )]
.handleEvents(receiveOutput: { [unowned self] server, user, transaction in ) {
setAuthHeader(with: user.accessToken?.value ?? "") throw SwiftfinStore.Error.existingUser(existingUser.state)
try? transaction.commitAndWait() }
// Fetch for the right queue let newAccessToken = transaction.create(Into<SwiftfinStore.Models.StoredAccessToken>())
let currentServer = SwiftfinStore.dataStack.fetchExisting(server)! newAccessToken.value = accessToken
let currentUser = SwiftfinStore.dataStack.fetchExisting(user)! newUser.accessToken = newAccessToken
Defaults[.lastServerUserID] = user.id guard let userServer = try? SwiftfinStore.dataStack.fetchOne(
From<SwiftfinStore.Models.StoredServer>(),
currentLogin = (server: currentServer.state, user: currentUser.state) [
Notifications[.didSignIn].post() Where<SwiftfinStore.Models.StoredServer>(
}) "id == %@",
.map { _, user, _ in server.id
user.state ),
} ]
.eraseToAnyPublisher() )
} else { fatalError("No stored server associated with given state server?") }
// MARK: loginUser guard let editUserServer = transaction.edit(userServer) else { fatalError("Can't get proxy for existing object?") }
editUserServer.users.insert(newUser)
func loginUser(server: SwiftfinStore.State.Server, user: SwiftfinStore.State.User) {
JellyfinAPIAPI.basePath = server.currentURI return (editUserServer, newUser, transaction)
Defaults[.lastServerUserID] = user.id }
setAuthHeader(with: user.accessToken) .handleEvents(receiveOutput: { [unowned self] server, user, transaction in
currentLogin = (server: server, user: user) setAuthHeader(with: user.accessToken?.value ?? "")
Notifications[.didSignIn].post() try? transaction.commitAndWait()
}
// Fetch for the right queue
// MARK: logout let currentServer = SwiftfinStore.dataStack.fetchExisting(server)!
let currentUser = SwiftfinStore.dataStack.fetchExisting(user)!
func logout() {
currentLogin = nil Defaults[.lastServerUserID] = user.id
JellyfinAPIAPI.basePath = ""
setAuthHeader(with: "") currentLogin = (server: currentServer.state, user: currentUser.state)
Defaults[.lastServerUserID] = nil Notifications[.didSignIn].post()
Notifications[.didSignOut].post() })
} .map { _, user, _ in
user.state
// MARK: purge }
.eraseToAnyPublisher()
func purge() { }
// Delete all servers
let servers = fetchServers() // MARK: loginUser
for server in servers { func loginUser(server: SwiftfinStore.State.Server, user: SwiftfinStore.State.User) {
delete(server: server) JellyfinAPIAPI.basePath = server.currentURI
} Defaults[.lastServerUserID] = user.id
setAuthHeader(with: user.accessToken)
Notifications[.didPurge].post() currentLogin = (server: server, user: user)
} Notifications[.didSignIn].post()
}
// MARK: delete user
// MARK: logout
func delete(user: SwiftfinStore.State.User) {
guard let storedUser = try? SwiftfinStore.dataStack.fetchOne(From<SwiftfinStore.Models.StoredUser>(), func logout() {
[Where<SwiftfinStore.Models.StoredUser>("id == %@", user.id)]) currentLogin = nil
else { fatalError("No stored user for state user?") } JellyfinAPIAPI.basePath = ""
_delete(user: storedUser, transaction: nil) setAuthHeader(with: "")
} Defaults[.lastServerUserID] = nil
Notifications[.didSignOut].post()
// MARK: delete server }
func delete(server: SwiftfinStore.State.Server) { // MARK: purge
guard let storedServer = try? SwiftfinStore.dataStack.fetchOne(From<SwiftfinStore.Models.StoredServer>(),
[Where<SwiftfinStore.Models.StoredServer>("id == %@", server.id)]) func purge() {
else { fatalError("No stored server for state server?") } // Delete all servers
_delete(server: storedServer, transaction: nil) let servers = fetchServers()
}
for server in servers {
private func _delete(user: SwiftfinStore.Models.StoredUser, transaction: UnsafeDataTransaction?) { delete(server: server)
guard let storedAccessToken = user.accessToken else { fatalError("No access token for stored user?") } }
let transaction = transaction == nil ? SwiftfinStore.dataStack.beginUnsafe() : transaction! Notifications[.didPurge].post()
transaction.delete(storedAccessToken) }
transaction.delete(user)
try? transaction.commitAndWait() // MARK: delete user
}
func delete(user: SwiftfinStore.State.User) {
private func _delete(server: SwiftfinStore.Models.StoredServer, transaction: UnsafeDataTransaction?) { guard let storedUser = try? SwiftfinStore.dataStack.fetchOne(
let transaction = transaction == nil ? SwiftfinStore.dataStack.beginUnsafe() : transaction! From<SwiftfinStore.Models.StoredUser>(),
[Where<SwiftfinStore.Models.StoredUser>("id == %@", user.id)]
for user in server.users { )
_delete(user: user, transaction: transaction) else { fatalError("No stored user for state user?") }
} _delete(user: storedUser, transaction: nil)
}
transaction.delete(server)
try? transaction.commitAndWait() // MARK: delete server
}
func delete(server: SwiftfinStore.State.Server) {
private func setAuthHeader(with accessToken: String) { guard let storedServer = try? SwiftfinStore.dataStack.fetchOne(
let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String From<SwiftfinStore.Models.StoredServer>(),
var deviceName = UIDevice.current.name [Where<SwiftfinStore.Models.StoredServer>("id == %@", server.id)]
deviceName = deviceName.folding(options: .diacriticInsensitive, locale: .current) )
deviceName = String(deviceName.unicodeScalars.filter { CharacterSet.urlQueryAllowed.contains($0) }) else { fatalError("No stored server for state server?") }
_delete(server: storedServer, transaction: nil)
let platform: String }
#if os(tvOS)
platform = "tvOS" private func _delete(user: SwiftfinStore.Models.StoredUser, transaction: UnsafeDataTransaction?) {
#else guard let storedAccessToken = user.accessToken else { fatalError("No access token for stored user?") }
platform = "iOS"
#endif let transaction = transaction == nil ? SwiftfinStore.dataStack.beginUnsafe() : transaction!
transaction.delete(storedAccessToken)
var header = "MediaBrowser " transaction.delete(user)
header.append("Client=\"Jellyfin \(platform)\", ") try? transaction.commitAndWait()
header.append("Device=\"\(deviceName)\", ") }
header.append("DeviceId=\"\(platform)_\(UIDevice.vendorUUIDString)_\(String(Date().timeIntervalSince1970))\", ")
header.append("Version=\"\(appVersion ?? "0.0.1")\", ") private func _delete(server: SwiftfinStore.Models.StoredServer, transaction: UnsafeDataTransaction?) {
header.append("Token=\"\(accessToken)\"") let transaction = transaction == nil ? SwiftfinStore.dataStack.beginUnsafe() : transaction!
JellyfinAPIAPI.customHeaders["X-Emby-Authorization"] = header for user in server.users {
} _delete(user: user, transaction: transaction)
}
transaction.delete(server)
try? transaction.commitAndWait()
}
private 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)
deviceName = String(deviceName.unicodeScalars.filter { CharacterSet.urlQueryAllowed.contains($0) })
let platform: String
#if os(tvOS)
platform = "tvOS"
#else
platform = "iOS"
#endif
var header = "MediaBrowser "
header.append("Client=\"Jellyfin \(platform)\", ")
header.append("Device=\"\(deviceName)\", ")
header.append("DeviceId=\"\(platform)_\(UIDevice.vendorUUIDString)_\(String(Date().timeIntervalSince1970))\", ")
header.append("Version=\"\(appVersion ?? "0.0.1")\", ")
header.append("Token=\"\(accessToken)\"")
JellyfinAPIAPI.customHeaders["X-Emby-Authorization"] = header
}
} }

View File

@ -10,61 +10,61 @@ import Foundation
class SwiftfinNotification { class SwiftfinNotification {
private let notificationName: Notification.Name private let notificationName: Notification.Name
fileprivate init(_ notificationName: Notification.Name) { fileprivate init(_ notificationName: Notification.Name) {
self.notificationName = notificationName self.notificationName = notificationName
} }
func post(object: Any? = nil) { func post(object: Any? = nil) {
Notifications.main.post(name: notificationName, object: object) Notifications.main.post(name: notificationName, object: object)
} }
func subscribe(_ observer: Any, selector: Selector) { func subscribe(_ observer: Any, selector: Selector) {
Notifications.main.addObserver(observer, selector: selector, name: notificationName, object: nil) Notifications.main.addObserver(observer, selector: selector, name: notificationName, object: nil)
} }
func unsubscribe(_ observer: Any) { func unsubscribe(_ observer: Any) {
Notifications.main.removeObserver(self, name: notificationName, object: nil) Notifications.main.removeObserver(self, name: notificationName, object: nil)
} }
} }
enum Notifications { enum Notifications {
static let main: NotificationCenter = { static let main: NotificationCenter = {
NotificationCenter() NotificationCenter()
}() }()
final class Key { final class Key {
public typealias NotificationKey = Notifications.Key public typealias NotificationKey = Notifications.Key
public let key: String public let key: String
public let underlyingNotification: SwiftfinNotification public let underlyingNotification: SwiftfinNotification
public init(_ key: String) { public init(_ key: String) {
self.key = key self.key = key
self.underlyingNotification = SwiftfinNotification(Notification.Name(key)) self.underlyingNotification = SwiftfinNotification(Notification.Name(key))
} }
} }
static subscript(key: Key) -> SwiftfinNotification { static subscript(key: Key) -> SwiftfinNotification {
key.underlyingNotification key.underlyingNotification
} }
static func unsubscribe(_ observer: Any) { static func unsubscribe(_ observer: Any) {
main.removeObserver(observer) main.removeObserver(observer)
} }
} }
extension Notifications.Key { extension Notifications.Key {
static let didSignIn = NotificationKey("didSignIn") static let didSignIn = NotificationKey("didSignIn")
static let didSignOut = NotificationKey("didSignOut") static let didSignOut = NotificationKey("didSignOut")
static let processDeepLink = NotificationKey("processDeepLink") static let processDeepLink = NotificationKey("processDeepLink")
static let didPurge = NotificationKey("didPurge") static let didPurge = NotificationKey("didPurge")
static let didChangeServerCurrentURI = NotificationKey("didChangeCurrentLoginURI") static let didChangeServerCurrentURI = NotificationKey("didChangeCurrentLoginURI")
static let toggleOfflineMode = NotificationKey("toggleOfflineMode") static let toggleOfflineMode = NotificationKey("toggleOfflineMode")
static let didDeleteOfflineItem = NotificationKey("didDeleteOfflineItem") static let didDeleteOfflineItem = NotificationKey("didDeleteOfflineItem")
static let didAddDownload = NotificationKey("didAddDownload") static let didAddDownload = NotificationKey("didAddDownload")
static let didSendStopReport = NotificationKey("didSendStopReport") static let didSendStopReport = NotificationKey("didSendStopReport")
} }

View File

@ -12,204 +12,222 @@ import Foundation
enum SwiftfinStore { enum SwiftfinStore {
// MARK: State // MARK: State
// Safe, copyable representations of their underlying CoreStoredObject // Safe, copyable representations of their underlying CoreStoredObject
// Relationships are represented by the related object's IDs or value // Relationships are represented by the related object's IDs or value
enum State { enum State {
struct Server { struct Server {
let uris: Set<String> let uris: Set<String>
let currentURI: String let currentURI: String
let name: String let name: String
let id: String let id: String
let os: String let os: String
let version: String let version: String
let userIDs: [String] let userIDs: [String]
fileprivate init(uris: Set<String>, currentURI: String, name: String, id: String, os: String, version: String, fileprivate init(
usersIDs: [String]) uris: Set<String>,
{ currentURI: String,
self.uris = uris name: String,
self.currentURI = currentURI id: String,
self.name = name os: String,
self.id = id version: String,
self.os = os usersIDs: [String]
self.version = version ) {
self.userIDs = usersIDs self.uris = uris
} self.currentURI = currentURI
self.name = name
self.id = id
self.os = os
self.version = version
self.userIDs = usersIDs
}
static var sample: Server { static var sample: Server {
Server(uris: ["https://www.notaurl.com", "http://www.maybeaurl.org"], Server(
currentURI: "https://www.notaurl.com", uris: ["https://www.notaurl.com", "http://www.maybeaurl.org"],
name: "Johnny's Tree", currentURI: "https://www.notaurl.com",
id: "123abc", name: "Johnny's Tree",
os: "macOS", id: "123abc",
version: "1.1.1", os: "macOS",
usersIDs: ["1", "2"]) version: "1.1.1",
} usersIDs: ["1", "2"]
} )
}
}
struct User { struct User {
let username: String let username: String
let id: String let id: String
let serverID: String let serverID: String
let accessToken: String let accessToken: String
fileprivate init(username: String, id: String, serverID: String, accessToken: String) { fileprivate init(username: String, id: String, serverID: String, accessToken: String) {
self.username = username self.username = username
self.id = id self.id = id
self.serverID = serverID self.serverID = serverID
self.accessToken = accessToken self.accessToken = accessToken
} }
static var sample: User { static var sample: User {
User(username: "JohnnyAppleseed", User(
id: "123abc", username: "JohnnyAppleseed",
serverID: "123abc", id: "123abc",
accessToken: "open-sesame") serverID: "123abc",
} accessToken: "open-sesame"
} )
} }
}
}
// MARK: Models // MARK: Models
enum Models { enum Models {
final class StoredServer: CoreStoreObject { final class StoredServer: CoreStoreObject {
@Field.Coded("uris", coder: FieldCoders.Json.self) @Field.Coded("uris", coder: FieldCoders.Json.self)
var uris: Set<String> = [] var uris: Set<String> = []
@Field.Stored("currentURI") @Field.Stored("currentURI")
var currentURI: String = "" var currentURI: String = ""
@Field.Stored("name") @Field.Stored("name")
var name: String = "" var name: String = ""
@Field.Stored("id") @Field.Stored("id")
var id: String = "" var id: String = ""
@Field.Stored("os") @Field.Stored("os")
var os: String = "" var os: String = ""
@Field.Stored("version") @Field.Stored("version")
var version: String = "" var version: String = ""
@Field.Relationship("users", inverse: \StoredUser.$server) @Field.Relationship("users", inverse: \StoredUser.$server)
var users: Set<StoredUser> var users: Set<StoredUser>
var state: State.Server { var state: State.Server {
State.Server(uris: uris, State.Server(
currentURI: currentURI, uris: uris,
name: name, currentURI: currentURI,
id: id, name: name,
os: os, id: id,
version: version, os: os,
usersIDs: users.map(\.id)) version: version,
} usersIDs: users.map(\.id)
} )
}
}
final class StoredUser: CoreStoreObject { final class StoredUser: CoreStoreObject {
@Field.Stored("username") @Field.Stored("username")
var username: String = "" var username: String = ""
@Field.Stored("id") @Field.Stored("id")
var id: String = "" var id: String = ""
@Field.Stored("appleTVID") @Field.Stored("appleTVID")
var appleTVID: String = "" var appleTVID: String = ""
@Field.Relationship("server") @Field.Relationship("server")
var server: StoredServer? var server: StoredServer?
@Field.Relationship("accessToken", inverse: \StoredAccessToken.$user) @Field.Relationship("accessToken", inverse: \StoredAccessToken.$user)
var accessToken: StoredAccessToken? var accessToken: StoredAccessToken?
var state: State.User { var state: State.User {
guard let server = server else { fatalError("No server associated with user") } guard let server = server else { fatalError("No server associated with user") }
guard let accessToken = accessToken else { fatalError("No access token associated with user") } guard let accessToken = accessToken else { fatalError("No access token associated with user") }
return State.User(username: username, return State.User(
id: id, username: username,
serverID: server.id, id: id,
accessToken: accessToken.value) serverID: server.id,
} accessToken: accessToken.value
} )
}
}
final class StoredAccessToken: CoreStoreObject { final class StoredAccessToken: CoreStoreObject {
@Field.Stored("value") @Field.Stored("value")
var value: String = "" var value: String = ""
@Field.Relationship("user") @Field.Relationship("user")
var user: StoredUser? var user: StoredUser?
} }
} }
// MARK: Error // MARK: Error
enum Error { enum Error {
case existingServer(State.Server) case existingServer(State.Server)
case existingUser(State.User) case existingUser(State.User)
} }
// MARK: dataStack // MARK: dataStack
static let dataStack: DataStack = { static let dataStack: DataStack = {
let schema = CoreStoreSchema(modelVersion: "V1", let schema = CoreStoreSchema(
entities: [ modelVersion: "V1",
Entity<SwiftfinStore.Models.StoredServer>("Server"), entities: [
Entity<SwiftfinStore.Models.StoredUser>("User"), Entity<SwiftfinStore.Models.StoredServer>("Server"),
Entity<SwiftfinStore.Models.StoredAccessToken>("AccessToken"), Entity<SwiftfinStore.Models.StoredUser>("User"),
], Entity<SwiftfinStore.Models.StoredAccessToken>("AccessToken"),
versionLock: [ ],
"AccessToken": [ versionLock: [
0xA8C4_75E8_7449_4BB1, "AccessToken": [
0x7948_6E93_449F_0B3D, 0xA8C4_75E8_7449_4BB1,
0xA7DC_4A00_0354_1EDB, 0x7948_6E93_449F_0B3D,
0x9418_3FAE_7580_EF72, 0xA7DC_4A00_0354_1EDB,
], 0x9418_3FAE_7580_EF72,
"Server": [ ],
0x936B_46AC_D8E8_F0E3, "Server": [
0x5989_0D4D_9F3F_885F, 0x936B_46AC_D8E8_F0E3,
0x819C_F7A4_ABF9_8B22, 0x5989_0D4D_9F3F_885F,
0xE161_25C5_AF88_5A06, 0x819C_F7A4_ABF9_8B22,
], 0xE161_25C5_AF88_5A06,
"User": [ ],
0x845D_E08A_74BC_53ED, "User": [
0xE95A_406A_29F3_A5D0, 0x845D_E08A_74BC_53ED,
0x9EDA_7328_21A1_5EA9, 0xE95A_406A_29F3_A5D0,
0xB5A_FA53_1E41_CE8A, 0x9EDA_7328_21A1_5EA9,
], 0xB5A_FA53_1E41_CE8A,
]) ],
]
)
let _dataStack = DataStack(schema) let _dataStack = DataStack(schema)
try! _dataStack.addStorageAndWait(SQLiteStore(fileName: "Swiftfin.sqlite", try! _dataStack.addStorageAndWait(SQLiteStore(
localStorageOptions: .recreateStoreOnModelMismatch)) fileName: "Swiftfin.sqlite",
return _dataStack localStorageOptions: .recreateStoreOnModelMismatch
}() ))
return _dataStack
}()
} }
// MARK: LocalizedError // MARK: LocalizedError
extension SwiftfinStore.Error: LocalizedError { extension SwiftfinStore.Error: LocalizedError {
var title: String { var title: String {
switch self { switch self {
case .existingServer: case .existingServer:
return L10n.existingServer return L10n.existingServer
case .existingUser: case .existingUser:
return L10n.existingUser return L10n.existingUser
} }
} }
var errorDescription: String? { var errorDescription: String? {
switch self { switch self {
case let .existingServer(server): case let .existingServer(server):
return L10n.serverAlreadyConnected(server.name) return L10n.serverAlreadyConnected(server.name)
case let .existingUser(user): case let .existingUser(user):
return L10n.userAlreadySignedIn(user.username) return L10n.userAlreadySignedIn(user.username)
} }
} }
} }

View File

@ -10,87 +10,105 @@ import Defaults
import Foundation import Foundation
extension SwiftfinStore { extension SwiftfinStore {
enum Defaults { enum Defaults {
static let generalSuite: UserDefaults = .init(suiteName: "swiftfinstore-general-defaults")! static let generalSuite: UserDefaults = .init(suiteName: "swiftfinstore-general-defaults")!
static let universalSuite: UserDefaults = .init(suiteName: "swiftfinstore-universal-defaults")! static let universalSuite: UserDefaults = .init(suiteName: "swiftfinstore-universal-defaults")!
} }
} }
extension Defaults.Keys { extension Defaults.Keys {
// Universal settings // Universal settings
static let defaultHTTPScheme = Key<HTTPScheme>("defaultHTTPScheme", default: .http, suite: SwiftfinStore.Defaults.universalSuite) static let defaultHTTPScheme = Key<HTTPScheme>("defaultHTTPScheme", default: .http, suite: SwiftfinStore.Defaults.universalSuite)
static let appAppearance = Key<AppAppearance>("appAppearance", default: .system, suite: SwiftfinStore.Defaults.universalSuite) static let appAppearance = Key<AppAppearance>("appAppearance", default: .system, suite: SwiftfinStore.Defaults.universalSuite)
// General settings // General settings
static let lastServerUserID = Defaults.Key<String?>("lastServerUserID", suite: SwiftfinStore.Defaults.generalSuite) static let lastServerUserID = Defaults.Key<String?>("lastServerUserID", suite: SwiftfinStore.Defaults.generalSuite)
static let inNetworkBandwidth = Key<Int>("InNetworkBandwidth", default: 40_000_000, suite: SwiftfinStore.Defaults.generalSuite) static let inNetworkBandwidth = Key<Int>("InNetworkBandwidth", default: 40_000_000, suite: SwiftfinStore.Defaults.generalSuite)
static let outOfNetworkBandwidth = Key<Int>("OutOfNetworkBandwidth", default: 40_000_000, suite: SwiftfinStore.Defaults.generalSuite) static let outOfNetworkBandwidth = Key<Int>("OutOfNetworkBandwidth", default: 40_000_000, suite: SwiftfinStore.Defaults.generalSuite)
static let isAutoSelectSubtitles = Key<Bool>("isAutoSelectSubtitles", default: false, suite: SwiftfinStore.Defaults.generalSuite) static let isAutoSelectSubtitles = Key<Bool>("isAutoSelectSubtitles", default: false, suite: SwiftfinStore.Defaults.generalSuite)
static let autoSelectSubtitlesLangCode = Key<String>("AutoSelectSubtitlesLangCode", static let autoSelectSubtitlesLangCode = Key<String>(
default: "Auto", "AutoSelectSubtitlesLangCode",
suite: SwiftfinStore.Defaults.generalSuite) default: "Auto",
static let autoSelectAudioLangCode = Key<String>("AutoSelectAudioLangCode", default: "Auto", suite: SwiftfinStore.Defaults.generalSuite) suite: SwiftfinStore.Defaults.generalSuite
)
static let autoSelectAudioLangCode = Key<String>("AutoSelectAudioLangCode", default: "Auto", suite: SwiftfinStore.Defaults.generalSuite)
// Customize settings // Customize settings
static let showPosterLabels = Key<Bool>("showPosterLabels", default: true, suite: SwiftfinStore.Defaults.generalSuite) static let showPosterLabels = Key<Bool>("showPosterLabels", default: true, suite: SwiftfinStore.Defaults.generalSuite)
static let showCastAndCrew = Key<Bool>("showCastAndCrew", default: true, suite: SwiftfinStore.Defaults.generalSuite) static let showCastAndCrew = Key<Bool>("showCastAndCrew", default: true, suite: SwiftfinStore.Defaults.generalSuite)
static let showFlattenView = Key<Bool>("showFlattenView", default: true, suite: SwiftfinStore.Defaults.generalSuite) static let showFlattenView = Key<Bool>("showFlattenView", default: true, suite: SwiftfinStore.Defaults.generalSuite)
// Video player / overlay settings // Video player / overlay settings
static let overlayType = Key<OverlayType>("overlayType", default: .normal, suite: SwiftfinStore.Defaults.generalSuite) static let overlayType = Key<OverlayType>("overlayType", default: .normal, suite: SwiftfinStore.Defaults.generalSuite)
static let jumpGesturesEnabled = Key<Bool>("gesturesEnabled", default: true, suite: SwiftfinStore.Defaults.generalSuite) static let jumpGesturesEnabled = Key<Bool>("gesturesEnabled", default: true, suite: SwiftfinStore.Defaults.generalSuite)
static let systemControlGesturesEnabled = Key<Bool>("systemControlGesturesEnabled", static let systemControlGesturesEnabled = Key<Bool>(
default: true, "systemControlGesturesEnabled",
suite: SwiftfinStore.Defaults.generalSuite) default: true,
static let playerGesturesLockGestureEnabled = Key<Bool>("playerGesturesLockGestureEnabled", suite: SwiftfinStore.Defaults.generalSuite
default: true, )
suite: SwiftfinStore.Defaults.generalSuite) static let playerGesturesLockGestureEnabled = Key<Bool>(
static let seekSlideGestureEnabled = Key<Bool>("seekSlideGestureEnabled", "playerGesturesLockGestureEnabled",
default: true, default: true,
suite: SwiftfinStore.Defaults.generalSuite) suite: SwiftfinStore.Defaults.generalSuite
static let videoPlayerJumpForward = Key<VideoPlayerJumpLength>("videoPlayerJumpForward", )
default: .fifteen, static let seekSlideGestureEnabled = Key<Bool>(
suite: SwiftfinStore.Defaults.generalSuite) "seekSlideGestureEnabled",
static let videoPlayerJumpBackward = Key<VideoPlayerJumpLength>("videoPlayerJumpBackward", default: true,
default: .fifteen, suite: SwiftfinStore.Defaults.generalSuite
suite: SwiftfinStore.Defaults.generalSuite) )
static let autoplayEnabled = Key<Bool>("autoPlayNextItem", default: true, suite: SwiftfinStore.Defaults.generalSuite) static let videoPlayerJumpForward = Key<VideoPlayerJumpLength>(
static let resumeOffset = Key<Bool>("resumeOffset", default: false, suite: SwiftfinStore.Defaults.generalSuite) "videoPlayerJumpForward",
static let subtitleSize = Key<SubtitleSize>("subtitleSize", default: .regular, suite: SwiftfinStore.Defaults.generalSuite) default: .fifteen,
suite: SwiftfinStore.Defaults.generalSuite
)
static let videoPlayerJumpBackward = Key<VideoPlayerJumpLength>(
"videoPlayerJumpBackward",
default: .fifteen,
suite: SwiftfinStore.Defaults.generalSuite
)
static let autoplayEnabled = Key<Bool>("autoPlayNextItem", default: true, suite: SwiftfinStore.Defaults.generalSuite)
static let resumeOffset = Key<Bool>("resumeOffset", default: false, suite: SwiftfinStore.Defaults.generalSuite)
static let subtitleSize = Key<SubtitleSize>("subtitleSize", default: .regular, suite: SwiftfinStore.Defaults.generalSuite)
// Should show video player items // Should show video player items
static let shouldShowPlayPreviousItem = Key<Bool>("shouldShowPreviousItem", default: true, suite: SwiftfinStore.Defaults.generalSuite) static let shouldShowPlayPreviousItem = Key<Bool>("shouldShowPreviousItem", default: true, suite: SwiftfinStore.Defaults.generalSuite)
static let shouldShowPlayNextItem = Key<Bool>("shouldShowNextItem", default: true, suite: SwiftfinStore.Defaults.generalSuite) static let shouldShowPlayNextItem = Key<Bool>("shouldShowNextItem", default: true, suite: SwiftfinStore.Defaults.generalSuite)
static let shouldShowAutoPlay = Key<Bool>("shouldShowAutoPlayNextItem", default: true, suite: SwiftfinStore.Defaults.generalSuite) static let shouldShowAutoPlay = Key<Bool>("shouldShowAutoPlayNextItem", default: true, suite: SwiftfinStore.Defaults.generalSuite)
// Should show missing seasons and episodes // Should show missing seasons and episodes
static let shouldShowMissingSeasons = Key<Bool>("shouldShowMissingSeasons", default: true, suite: SwiftfinStore.Defaults.generalSuite) static let shouldShowMissingSeasons = Key<Bool>("shouldShowMissingSeasons", default: true, suite: SwiftfinStore.Defaults.generalSuite)
static let shouldShowMissingEpisodes = Key<Bool>("shouldShowMissingEpisodes", default: true, suite: SwiftfinStore.Defaults.generalSuite) static let shouldShowMissingEpisodes = Key<Bool>("shouldShowMissingEpisodes", default: true, suite: SwiftfinStore.Defaults.generalSuite)
// Should show video player items in overlay menu // Should show video player items in overlay menu
static let shouldShowJumpButtonsInOverlayMenu = Key<Bool>("shouldShowJumpButtonsInMenu", static let shouldShowJumpButtonsInOverlayMenu = Key<Bool>(
default: true, "shouldShowJumpButtonsInMenu",
suite: SwiftfinStore.Defaults.generalSuite) default: true,
suite: SwiftfinStore.Defaults.generalSuite
)
static let shouldShowChaptersInfoInBottomOverlay = Key<Bool>("shouldShowChaptersInfoInBottomOverlay", static let shouldShowChaptersInfoInBottomOverlay = Key<Bool>(
default: true, "shouldShowChaptersInfoInBottomOverlay",
suite: SwiftfinStore.Defaults.generalSuite) default: true,
suite: SwiftfinStore.Defaults.generalSuite
)
// Experimental settings // Experimental settings
enum Experimental { enum Experimental {
static let syncSubtitleStateWithAdjacent = Key<Bool>("experimental.syncSubtitleState", static let syncSubtitleStateWithAdjacent = Key<Bool>(
default: false, "experimental.syncSubtitleState",
suite: SwiftfinStore.Defaults.generalSuite) default: false,
static let forceDirectPlay = Key<Bool>("forceDirectPlay", default: false, suite: SwiftfinStore.Defaults.generalSuite) suite: SwiftfinStore.Defaults.generalSuite
static let nativePlayer = Key<Bool>("nativePlayer", default: false, suite: SwiftfinStore.Defaults.generalSuite) )
static let liveTVAlphaEnabled = Key<Bool>("liveTVAlphaEnabled", default: false, suite: SwiftfinStore.Defaults.generalSuite) static let forceDirectPlay = Key<Bool>("forceDirectPlay", default: false, suite: SwiftfinStore.Defaults.generalSuite)
static let liveTVForceDirectPlay = Key<Bool>("liveTVForceDirectPlay", default: false, suite: SwiftfinStore.Defaults.generalSuite) static let nativePlayer = Key<Bool>("nativePlayer", default: false, suite: SwiftfinStore.Defaults.generalSuite)
static let liveTVNativePlayer = Key<Bool>("liveTVNativePlayer", default: false, suite: SwiftfinStore.Defaults.generalSuite) static let liveTVAlphaEnabled = Key<Bool>("liveTVAlphaEnabled", default: false, suite: SwiftfinStore.Defaults.generalSuite)
} static let liveTVForceDirectPlay = Key<Bool>("liveTVForceDirectPlay", default: false, suite: SwiftfinStore.Defaults.generalSuite)
static let liveTVNativePlayer = Key<Bool>("liveTVNativePlayer", default: false, suite: SwiftfinStore.Defaults.generalSuite)
}
// tvos specific // tvos specific
static let downActionShowsMenu = Key<Bool>("downActionShowsMenu", default: true, suite: SwiftfinStore.Defaults.generalSuite) static let downActionShowsMenu = Key<Bool>("downActionShowsMenu", default: true, suite: SwiftfinStore.Defaults.generalSuite)
static let confirmClose = Key<Bool>("confirmClose", default: false, suite: SwiftfinStore.Defaults.generalSuite) static let confirmClose = Key<Bool>("confirmClose", default: false, suite: SwiftfinStore.Defaults.generalSuite)
static let tvOSCinematicViews = Key<Bool>("tvOSCinematicViews", default: false, suite: SwiftfinStore.Defaults.generalSuite) static let tvOSCinematicViews = Key<Bool>("tvOSCinematicViews", default: false, suite: SwiftfinStore.Defaults.generalSuite)
} }

View File

@ -9,31 +9,31 @@
import UIKit.UIGestureRecognizerSubclass import UIKit.UIGestureRecognizerSubclass
enum PanDirection { enum PanDirection {
case vertical case vertical
case horizontal case horizontal
} }
class PanDirectionGestureRecognizer: UIPanGestureRecognizer { class PanDirectionGestureRecognizer: UIPanGestureRecognizer {
let direction: PanDirection let direction: PanDirection
init(direction: PanDirection, target: AnyObject, action: Selector) { init(direction: PanDirection, target: AnyObject, action: Selector) {
self.direction = direction self.direction = direction
super.init(target: target, action: action) super.init(target: target, action: action)
} }
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent) { override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent) {
super.touchesMoved(touches, with: event) super.touchesMoved(touches, with: event)
if state == .began { if state == .began {
let vel = velocity(in: view) let vel = velocity(in: view)
switch direction { switch direction {
case .horizontal where abs(vel.y) > abs(vel.x): case .horizontal where abs(vel.y) > abs(vel.x):
state = .cancelled state = .cancelled
case .vertical where abs(vel.x) > abs(vel.y): case .vertical where abs(vel.x) > abs(vel.y):
state = .cancelled state = .cancelled
default: default:
break break
} }
} }
} }
} }

View File

@ -10,17 +10,17 @@ import SwiftUI
final class BasicAppSettingsViewModel: ViewModel { final class BasicAppSettingsViewModel: ViewModel {
let appearances = AppAppearance.allCases let appearances = AppAppearance.allCases
func resetUserSettings() { func resetUserSettings() {
SwiftfinStore.Defaults.generalSuite.removeAll() SwiftfinStore.Defaults.generalSuite.removeAll()
} }
func resetAppSettings() { func resetAppSettings() {
SwiftfinStore.Defaults.universalSuite.removeAll() SwiftfinStore.Defaults.universalSuite.removeAll()
} }
func removeAllUsers() { func removeAllUsers() {
SessionManager.main.purge() SessionManager.main.purge()
} }
} }

View File

@ -13,132 +13,134 @@ import Stinsen
struct AddServerURIPayload: Identifiable { struct AddServerURIPayload: Identifiable {
let server: SwiftfinStore.State.Server let server: SwiftfinStore.State.Server
let uri: String let uri: String
var id: String { var id: String {
server.id.appending(uri) server.id.appending(uri)
} }
} }
final class ConnectToServerViewModel: ViewModel { final class ConnectToServerViewModel: ViewModel {
@RouterObject @RouterObject
var router: ConnectToServerCoodinator.Router? var router: ConnectToServerCoodinator.Router?
@Published @Published
var discoveredServers: Set<ServerDiscovery.ServerLookupResponse> = [] var discoveredServers: Set<ServerDiscovery.ServerLookupResponse> = []
@Published @Published
var searching = false var searching = false
@Published @Published
var addServerURIPayload: AddServerURIPayload? var addServerURIPayload: AddServerURIPayload?
var backAddServerURIPayload: AddServerURIPayload? var backAddServerURIPayload: AddServerURIPayload?
private let discovery = ServerDiscovery() private let discovery = ServerDiscovery()
var alertTitle: String { var alertTitle: String {
var message: String = "" var message: String = ""
if errorMessage?.code != ErrorMessage.noShowErrorCode { if errorMessage?.code != ErrorMessage.noShowErrorCode {
message.append(contentsOf: "\(errorMessage?.code ?? ErrorMessage.noShowErrorCode)\n") message.append(contentsOf: "\(errorMessage?.code ?? ErrorMessage.noShowErrorCode)\n")
} }
message.append(contentsOf: "\(errorMessage?.title ?? L10n.unknownError)") message.append(contentsOf: "\(errorMessage?.title ?? L10n.unknownError)")
return message return message
} }
func connectToServer(uri: String, redirectCount: Int = 0) { func connectToServer(uri: String, redirectCount: Int = 0) {
#if targetEnvironment(simulator) #if targetEnvironment(simulator)
var uri = uri var uri = uri
if uri == "http://localhost" || uri == "localhost" { if uri == "http://localhost" || uri == "localhost" {
uri = "http://localhost:8096" uri = "http://localhost:8096"
} }
#endif #endif
let trimmedURI = uri.trimmingCharacters(in: .whitespaces) let trimmedURI = uri.trimmingCharacters(in: .whitespaces)
LogManager.log.debug("Attempting to connect to server at \"\(trimmedURI)\"", tag: "connectToServer") LogManager.log.debug("Attempting to connect to server at \"\(trimmedURI)\"", tag: "connectToServer")
SessionManager.main.connectToServer(with: trimmedURI) SessionManager.main.connectToServer(with: trimmedURI)
.trackActivity(loading) .trackActivity(loading)
.sink(receiveCompletion: { completion in .sink(receiveCompletion: { completion in
// This is disgusting. ViewModel Error handling overall needs to be refactored // This is disgusting. ViewModel Error handling overall needs to be refactored
switch completion { switch completion {
case .finished: () case .finished: ()
case let .failure(error): case let .failure(error):
switch error { switch error {
case is ErrorResponse: case is ErrorResponse:
let errorResponse = error as! ErrorResponse let errorResponse = error as! ErrorResponse
switch errorResponse { switch errorResponse {
case let .error(_, _, response, _): case let .error(_, _, response, _):
// a url in the response is the result if a redirect // a url in the response is the result if a redirect
if let newURL = response?.url { if let newURL = response?.url {
if redirectCount > 2 { if redirectCount > 2 {
self.handleAPIRequestError(displayMessage: L10n.tooManyRedirects, completion: completion) self.handleAPIRequestError(displayMessage: L10n.tooManyRedirects, completion: completion)
} else { } else {
self self
.connectToServer(uri: newURL.absoluteString .connectToServer(
.removeRegexMatches(pattern: "/web/index.html"), uri: newURL.absoluteString
redirectCount: redirectCount + 1) .removeRegexMatches(pattern: "/web/index.html"),
} redirectCount: redirectCount + 1
} else { )
self.handleAPIRequestError(completion: completion) }
} } else {
} self.handleAPIRequestError(completion: completion)
case is SwiftfinStore.Error: }
let swiftfinError = error as! SwiftfinStore.Error }
switch swiftfinError { case is SwiftfinStore.Error:
case let .existingServer(server): let swiftfinError = error as! SwiftfinStore.Error
self.addServerURIPayload = AddServerURIPayload(server: server, uri: uri) switch swiftfinError {
self.backAddServerURIPayload = AddServerURIPayload(server: server, uri: uri) case let .existingServer(server):
default: self.addServerURIPayload = AddServerURIPayload(server: server, uri: uri)
self.handleAPIRequestError(displayMessage: L10n.unableToConnectServer, completion: completion) self.backAddServerURIPayload = AddServerURIPayload(server: server, uri: uri)
} default:
default: self.handleAPIRequestError(displayMessage: L10n.unableToConnectServer, completion: completion)
self.handleAPIRequestError(displayMessage: L10n.unableToConnectServer, completion: completion) }
} default:
} self.handleAPIRequestError(displayMessage: L10n.unableToConnectServer, completion: completion)
}, receiveValue: { server in }
LogManager.log.debug("Connected to server at \"\(uri)\"", tag: "connectToServer") }
self.router?.route(to: \.userSignIn, server) }, receiveValue: { server in
}) LogManager.log.debug("Connected to server at \"\(uri)\"", tag: "connectToServer")
.store(in: &cancellables) self.router?.route(to: \.userSignIn, server)
} })
.store(in: &cancellables)
}
func discoverServers() { func discoverServers() {
discoveredServers.removeAll() discoveredServers.removeAll()
searching = true searching = true
// Timeout after 3 seconds // Timeout after 3 seconds
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
self.searching = false self.searching = false
} }
discovery.locateServer { [self] server in discovery.locateServer { [self] server in
if let server = server { if let server = server {
discoveredServers.insert(server) discoveredServers.insert(server)
} }
} }
} }
func addURIToServer(addServerURIPayload: AddServerURIPayload) { func addURIToServer(addServerURIPayload: AddServerURIPayload) {
SessionManager.main.addURIToServer(server: addServerURIPayload.server, uri: addServerURIPayload.uri) SessionManager.main.addURIToServer(server: addServerURIPayload.server, uri: addServerURIPayload.uri)
.sink { completion in .sink { completion in
self.handleAPIRequestError(displayMessage: L10n.unableToConnectServer, completion: completion) self.handleAPIRequestError(displayMessage: L10n.unableToConnectServer, completion: completion)
} receiveValue: { server in } receiveValue: { server in
SessionManager.main.setServerCurrentURI(server: server, uri: addServerURIPayload.uri) SessionManager.main.setServerCurrentURI(server: server, uri: addServerURIPayload.uri)
.sink { completion in .sink { completion in
self.handleAPIRequestError(displayMessage: L10n.unableToConnectServer, completion: completion) self.handleAPIRequestError(displayMessage: L10n.unableToConnectServer, completion: completion)
} receiveValue: { _ in } receiveValue: { _ in
self.router?.dismissCoordinator() self.router?.dismissCoordinator()
} }
.store(in: &self.cancellables) .store(in: &self.cancellables)
} }
.store(in: &cancellables) .store(in: &cancellables)
} }
func cancelConnection() { func cancelConnection() {
for cancellable in cancellables { for cancellable in cancellables {
cancellable.cancel() cancellable.cancel()
} }
self.isLoading = false self.isLoading = false
} }
} }

View File

@ -11,66 +11,70 @@ import JellyfinAPI
import SwiftUI import SwiftUI
protocol EpisodesRowManager: ViewModel { protocol EpisodesRowManager: ViewModel {
var item: BaseItemDto { get } var item: BaseItemDto { get }
var seasonsEpisodes: [BaseItemDto: [BaseItemDto]] { get set } var seasonsEpisodes: [BaseItemDto: [BaseItemDto]] { get set }
var selectedSeason: BaseItemDto? { get set } var selectedSeason: BaseItemDto? { get set }
func retrieveSeasons() func retrieveSeasons()
func retrieveEpisodesForSeason(_ season: BaseItemDto) func retrieveEpisodesForSeason(_ season: BaseItemDto)
func select(season: BaseItemDto) func select(season: BaseItemDto)
} }
extension EpisodesRowManager { extension EpisodesRowManager {
var sortedSeasons: [BaseItemDto] { var sortedSeasons: [BaseItemDto] {
Array(seasonsEpisodes.keys).sorted(by: { $0.indexNumber ?? 0 < $1.indexNumber ?? 0 }) Array(seasonsEpisodes.keys).sorted(by: { $0.indexNumber ?? 0 < $1.indexNumber ?? 0 })
} }
// Also retrieves the current season episodes if available // Also retrieves the current season episodes if available
func retrieveSeasons() { func retrieveSeasons() {
TvShowsAPI.getSeasons(seriesId: item.seriesId ?? "", TvShowsAPI.getSeasons(
userId: SessionManager.main.currentLogin.user.id, seriesId: item.seriesId ?? "",
isMissing: Defaults[.shouldShowMissingSeasons] ? nil : false) userId: SessionManager.main.currentLogin.user.id,
.sink { completion in isMissing: Defaults[.shouldShowMissingSeasons] ? nil : false
self.handleAPIRequestError(completion: completion) )
} receiveValue: { response in .sink { completion in
let seasons = response.items ?? [] self.handleAPIRequestError(completion: completion)
seasons.forEach { season in } receiveValue: { response in
self.seasonsEpisodes[season] = [] let seasons = response.items ?? []
seasons.forEach { season in
self.seasonsEpisodes[season] = []
if season.id == self.item.seasonId ?? "" { if season.id == self.item.seasonId ?? "" {
self.selectedSeason = season self.selectedSeason = season
self.retrieveEpisodesForSeason(season) self.retrieveEpisodesForSeason(season)
} else if season.id == self.item.id ?? "" { } else if season.id == self.item.id ?? "" {
self.selectedSeason = season self.selectedSeason = season
self.retrieveEpisodesForSeason(season) self.retrieveEpisodesForSeason(season)
} }
} }
} }
.store(in: &cancellables) .store(in: &cancellables)
} }
func retrieveEpisodesForSeason(_ season: BaseItemDto) { func retrieveEpisodesForSeason(_ season: BaseItemDto) {
guard let seasonID = season.id else { return } guard let seasonID = season.id else { return }
TvShowsAPI.getEpisodes(seriesId: item.seriesId ?? "", TvShowsAPI.getEpisodes(
userId: SessionManager.main.currentLogin.user.id, seriesId: item.seriesId ?? "",
fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people], userId: SessionManager.main.currentLogin.user.id,
seasonId: seasonID, fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people],
isMissing: Defaults[.shouldShowMissingEpisodes] ? nil : false) seasonId: seasonID,
.trackActivity(loading) isMissing: Defaults[.shouldShowMissingEpisodes] ? nil : false
.sink { completion in )
self.handleAPIRequestError(completion: completion) .trackActivity(loading)
} receiveValue: { episodes in .sink { completion in
self.seasonsEpisodes[season] = episodes.items ?? [] self.handleAPIRequestError(completion: completion)
} } receiveValue: { episodes in
.store(in: &cancellables) self.seasonsEpisodes[season] = episodes.items ?? []
} }
.store(in: &cancellables)
}
func select(season: BaseItemDto) { func select(season: BaseItemDto) {
self.selectedSeason = season self.selectedSeason = season
if seasonsEpisodes[season]!.isEmpty { if seasonsEpisodes[season]!.isEmpty {
retrieveEpisodesForSeason(season) retrieveEpisodesForSeason(season)
} }
} }
} }

View File

@ -13,220 +13,228 @@ import JellyfinAPI
final class HomeViewModel: ViewModel { final class HomeViewModel: ViewModel {
@Published @Published
var latestAddedItems: [BaseItemDto] = [] var latestAddedItems: [BaseItemDto] = []
@Published @Published
var resumeItems: [BaseItemDto] = [] var resumeItems: [BaseItemDto] = []
@Published @Published
var nextUpItems: [BaseItemDto] = [] var nextUpItems: [BaseItemDto] = []
@Published @Published
var librariesShowRecentlyAddedIDs: [String] = [] var librariesShowRecentlyAddedIDs: [String] = []
@Published @Published
var libraries: [BaseItemDto] = [] var libraries: [BaseItemDto] = []
// temp // temp
var recentFilterSet = LibraryFilters(filters: [], sortOrder: [.descending], sortBy: [.dateAdded]) var recentFilterSet = LibraryFilters(filters: [], sortOrder: [.descending], sortBy: [.dateAdded])
override init() { override init() {
super.init() super.init()
refresh() refresh()
// Nov. 6, 2021 // Nov. 6, 2021
// This is a workaround since Stinsen doesn't have the ability to rebuild a root at the time of writing. // This is a workaround since Stinsen doesn't have the ability to rebuild a root at the time of writing.
// See ServerDetailViewModel.swift for feature request issue // See ServerDetailViewModel.swift for feature request issue
Notifications[.didSignIn].subscribe(self, selector: #selector(didSignIn)) Notifications[.didSignIn].subscribe(self, selector: #selector(didSignIn))
Notifications[.didSignOut].subscribe(self, selector: #selector(didSignOut)) Notifications[.didSignOut].subscribe(self, selector: #selector(didSignOut))
} }
@objc @objc
private func didSignIn() { private func didSignIn() {
for cancellable in cancellables { for cancellable in cancellables {
cancellable.cancel() cancellable.cancel()
} }
librariesShowRecentlyAddedIDs = [] librariesShowRecentlyAddedIDs = []
libraries = [] libraries = []
resumeItems = [] resumeItems = []
nextUpItems = [] nextUpItems = []
refresh() refresh()
} }
@objc @objc
private func didSignOut() { private func didSignOut() {
for cancellable in cancellables { for cancellable in cancellables {
cancellable.cancel() cancellable.cancel()
} }
cancellables.removeAll() cancellables.removeAll()
} }
@objc @objc
func refresh() { func refresh() {
LogManager.log.debug("Refresh called.") LogManager.log.debug("Refresh called.")
refreshLibrariesLatest() refreshLibrariesLatest()
refreshLatestAddedItems() refreshLatestAddedItems()
refreshResumeItems() refreshResumeItems()
refreshNextUpItems() refreshNextUpItems()
} }
// MARK: Libraries Latest Items // MARK: Libraries Latest Items
private func refreshLibrariesLatest() { private func refreshLibrariesLatest() {
UserViewsAPI.getUserViews(userId: SessionManager.main.currentLogin.user.id) UserViewsAPI.getUserViews(userId: SessionManager.main.currentLogin.user.id)
.trackActivity(loading) .trackActivity(loading)
.sink(receiveCompletion: { completion in .sink(receiveCompletion: { completion in
switch completion { switch completion {
case .finished: () case .finished: ()
case .failure: case .failure:
self.libraries = [] self.libraries = []
} }
self.handleAPIRequestError(completion: completion) self.handleAPIRequestError(completion: completion)
}, receiveValue: { response in }, receiveValue: { response in
var newLibraries: [BaseItemDto] = [] var newLibraries: [BaseItemDto] = []
response.items!.forEach { item in response.items!.forEach { item in
LogManager.log LogManager.log
.debug("Retrieved user view: \(item.id!) (\(item.name ?? "nil")) with type \(item.collectionType ?? "nil")") .debug("Retrieved user view: \(item.id!) (\(item.name ?? "nil")) with type \(item.collectionType ?? "nil")")
if item.collectionType == "movies" || item.collectionType == "tvshows" { if item.collectionType == "movies" || item.collectionType == "tvshows" {
newLibraries.append(item) newLibraries.append(item)
} }
} }
UserAPI.getCurrentUser() UserAPI.getCurrentUser()
.trackActivity(self.loading) .trackActivity(self.loading)
.sink(receiveCompletion: { completion in .sink(receiveCompletion: { completion in
switch completion { switch completion {
case .finished: () case .finished: ()
case .failure: case .failure:
self.libraries = [] self.libraries = []
self.handleAPIRequestError(completion: completion) self.handleAPIRequestError(completion: completion)
} }
}, receiveValue: { response in }, receiveValue: { response in
let excludeIDs = response.configuration?.latestItemsExcludes != nil ? response.configuration! let excludeIDs = response.configuration?.latestItemsExcludes != nil ? response.configuration!
.latestItemsExcludes! : [] .latestItemsExcludes! : []
for excludeID in excludeIDs { for excludeID in excludeIDs {
newLibraries.removeAll { library in newLibraries.removeAll { library in
library.id == excludeID library.id == excludeID
} }
} }
self.libraries = newLibraries self.libraries = newLibraries
}) })
.store(in: &self.cancellables) .store(in: &self.cancellables)
}) })
.store(in: &cancellables) .store(in: &cancellables)
} }
// MARK: Latest Added Items // MARK: Latest Added Items
private func refreshLatestAddedItems() { private func refreshLatestAddedItems() {
UserLibraryAPI.getLatestMedia(userId: SessionManager.main.currentLogin.user.id, UserLibraryAPI.getLatestMedia(
fields: [ userId: SessionManager.main.currentLogin.user.id,
.primaryImageAspectRatio, fields: [
.seriesPrimaryImage, .primaryImageAspectRatio,
.seasonUserData, .seriesPrimaryImage,
.overview, .seasonUserData,
.genres, .overview,
.people, .genres,
.chapters, .people,
], .chapters,
includeItemTypes: [.movie, .series], ],
enableImageTypes: [.primary, .backdrop, .thumb], includeItemTypes: [.movie, .series],
enableUserData: true, enableImageTypes: [.primary, .backdrop, .thumb],
limit: 8) enableUserData: true,
.sink { completion in limit: 8
switch completion { )
case .finished: () .sink { completion in
case .failure: switch completion {
self.nextUpItems = [] case .finished: ()
self.handleAPIRequestError(completion: completion) case .failure:
} self.nextUpItems = []
} receiveValue: { items in self.handleAPIRequestError(completion: completion)
LogManager.log.debug("Retrieved \(String(items.count)) resume items") }
} receiveValue: { items in
LogManager.log.debug("Retrieved \(String(items.count)) resume items")
self.latestAddedItems = items self.latestAddedItems = items
} }
.store(in: &cancellables) .store(in: &cancellables)
} }
// MARK: Resume Items // MARK: Resume Items
private func refreshResumeItems() { private func refreshResumeItems() {
ItemsAPI.getResumeItems(userId: SessionManager.main.currentLogin.user.id, ItemsAPI.getResumeItems(
limit: 6, userId: SessionManager.main.currentLogin.user.id,
fields: [ limit: 6,
.primaryImageAspectRatio, fields: [
.seriesPrimaryImage, .primaryImageAspectRatio,
.seasonUserData, .seriesPrimaryImage,
.overview, .seasonUserData,
.genres, .overview,
.people, .genres,
.chapters, .people,
], .chapters,
enableUserData: true) ],
.trackActivity(loading) enableUserData: true
.sink(receiveCompletion: { completion in )
switch completion { .trackActivity(loading)
case .finished: () .sink(receiveCompletion: { completion in
case .failure: switch completion {
self.resumeItems = [] case .finished: ()
self.handleAPIRequestError(completion: completion) case .failure:
} self.resumeItems = []
}, receiveValue: { response in self.handleAPIRequestError(completion: completion)
LogManager.log.debug("Retrieved \(String(response.items!.count)) resume items") }
}, receiveValue: { response in
LogManager.log.debug("Retrieved \(String(response.items!.count)) resume items")
self.resumeItems = response.items ?? [] self.resumeItems = response.items ?? []
}) })
.store(in: &cancellables) .store(in: &cancellables)
} }
func removeItemFromResume(_ item: BaseItemDto) { func removeItemFromResume(_ item: BaseItemDto) {
guard let itemID = item.id, resumeItems.contains(where: { $0.id == itemID }) else { return } guard let itemID = item.id, resumeItems.contains(where: { $0.id == itemID }) else { return }
PlaystateAPI.markUnplayedItem(userId: SessionManager.main.currentLogin.user.id, PlaystateAPI.markUnplayedItem(
itemId: item.id!) userId: SessionManager.main.currentLogin.user.id,
.sink(receiveCompletion: { [weak self] completion in itemId: item.id!
self?.handleAPIRequestError(completion: completion) )
}, receiveValue: { _ in .sink(receiveCompletion: { [weak self] completion in
self.refreshResumeItems() self?.handleAPIRequestError(completion: completion)
self.refreshNextUpItems() }, receiveValue: { _ in
}) self.refreshResumeItems()
.store(in: &cancellables) self.refreshNextUpItems()
} })
.store(in: &cancellables)
}
// MARK: Next Up Items // MARK: Next Up Items
private func refreshNextUpItems() { private func refreshNextUpItems() {
TvShowsAPI.getNextUp(userId: SessionManager.main.currentLogin.user.id, TvShowsAPI.getNextUp(
limit: 6, userId: SessionManager.main.currentLogin.user.id,
fields: [ limit: 6,
.primaryImageAspectRatio, fields: [
.seriesPrimaryImage, .primaryImageAspectRatio,
.seasonUserData, .seriesPrimaryImage,
.overview, .seasonUserData,
.genres, .overview,
.people, .genres,
.chapters, .people,
], .chapters,
enableUserData: true) ],
.trackActivity(loading) enableUserData: true
.sink(receiveCompletion: { completion in )
switch completion { .trackActivity(loading)
case .finished: () .sink(receiveCompletion: { completion in
case .failure: switch completion {
self.nextUpItems = [] case .finished: ()
self.handleAPIRequestError(completion: completion) case .failure:
} self.nextUpItems = []
}, receiveValue: { response in self.handleAPIRequestError(completion: completion)
LogManager.log.debug("Retrieved \(String(response.items!.count)) nextup items") }
}, receiveValue: { response in
LogManager.log.debug("Retrieved \(String(response.items!.count)) nextup items")
self.nextUpItems = response.items ?? [] self.nextUpItems = response.items ?? []
}) })
.store(in: &cancellables) .store(in: &cancellables)
} }
} }

View File

@ -12,25 +12,27 @@ import JellyfinAPI
final class CollectionItemViewModel: ItemViewModel { final class CollectionItemViewModel: ItemViewModel {
@Published @Published
var collectionItems: [BaseItemDto] = [] var collectionItems: [BaseItemDto] = []
override init(item: BaseItemDto) { override init(item: BaseItemDto) {
super.init(item: item) super.init(item: item)
getCollectionItems() getCollectionItems()
} }
private func getCollectionItems() { private func getCollectionItems() {
ItemsAPI.getItems(userId: SessionManager.main.currentLogin.user.id, ItemsAPI.getItems(
parentId: item.id, userId: SessionManager.main.currentLogin.user.id,
fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people]) parentId: item.id,
.trackActivity(loading) fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people]
.sink { [weak self] completion in )
self?.handleAPIRequestError(completion: completion) .trackActivity(loading)
} receiveValue: { [weak self] response in .sink { [weak self] completion in
self?.collectionItems = response.items ?? [] self?.handleAPIRequestError(completion: completion)
} } receiveValue: { [weak self] response in
.store(in: &cancellables) self?.collectionItems = response.items ?? []
} }
.store(in: &cancellables)
}
} }

View File

@ -13,61 +13,63 @@ import Stinsen
final class EpisodeItemViewModel: ItemViewModel, EpisodesRowManager { final class EpisodeItemViewModel: ItemViewModel, EpisodesRowManager {
@RouterObject @RouterObject
var itemRouter: ItemCoordinator.Router? var itemRouter: ItemCoordinator.Router?
@Published @Published
var series: BaseItemDto? var series: BaseItemDto?
@Published @Published
var seasonsEpisodes: [BaseItemDto: [BaseItemDto]] = [:] var seasonsEpisodes: [BaseItemDto: [BaseItemDto]] = [:]
@Published @Published
var selectedSeason: BaseItemDto? var selectedSeason: BaseItemDto?
override init(item: BaseItemDto) { override init(item: BaseItemDto) {
super.init(item: item) super.init(item: item)
getEpisodeSeries() getEpisodeSeries()
retrieveSeasons() retrieveSeasons()
} }
override func getItemDisplayName() -> String { override func getItemDisplayName() -> String {
guard let episodeLocator = item.getEpisodeLocator() else { return item.name ?? "" } guard let episodeLocator = item.getEpisodeLocator() else { return item.name ?? "" }
return "\(episodeLocator)\n\(item.name ?? "")" return "\(episodeLocator)\n\(item.name ?? "")"
} }
func getEpisodeSeries() { func getEpisodeSeries() {
guard let id = item.seriesId else { return } guard let id = item.seriesId else { return }
UserLibraryAPI.getItem(userId: SessionManager.main.currentLogin.user.id, itemId: id) UserLibraryAPI.getItem(userId: SessionManager.main.currentLogin.user.id, itemId: id)
.trackActivity(loading) .trackActivity(loading)
.sink(receiveCompletion: { [weak self] completion in .sink(receiveCompletion: { [weak self] completion in
self?.handleAPIRequestError(completion: completion) self?.handleAPIRequestError(completion: completion)
}, receiveValue: { [weak self] item in }, receiveValue: { [weak self] item in
self?.series = item self?.series = item
}) })
.store(in: &cancellables) .store(in: &cancellables)
} }
override func updateItem() { override func updateItem() {
ItemsAPI.getItems(userId: SessionManager.main.currentLogin.user.id, ItemsAPI.getItems(
limit: 1, userId: SessionManager.main.currentLogin.user.id,
fields: [ limit: 1,
.primaryImageAspectRatio, fields: [
.seriesPrimaryImage, .primaryImageAspectRatio,
.seasonUserData, .seriesPrimaryImage,
.overview, .seasonUserData,
.genres, .overview,
.people, .genres,
.chapters, .people,
], .chapters,
enableUserData: true, ],
ids: [item.id ?? ""]) enableUserData: true,
.sink { completion in ids: [item.id ?? ""]
self.handleAPIRequestError(completion: completion) )
} receiveValue: { response in .sink { completion in
if let item = response.items?.first { self.handleAPIRequestError(completion: completion)
self.item = item } receiveValue: { response in
self.playButtonItem = item if let item = response.items?.first {
} self.item = item
} self.playButtonItem = item
.store(in: &cancellables) }
} }
.store(in: &cancellables)
}
} }

View File

@ -13,165 +13,171 @@ import UIKit
class ItemViewModel: ViewModel { class ItemViewModel: ViewModel {
@Published @Published
var item: BaseItemDto var item: BaseItemDto
@Published @Published
var playButtonItem: BaseItemDto? { var playButtonItem: BaseItemDto? {
didSet { didSet {
if let playButtonItem = playButtonItem { if let playButtonItem = playButtonItem {
refreshItemVideoPlayerViewModel(for: playButtonItem) refreshItemVideoPlayerViewModel(for: playButtonItem)
} }
} }
} }
@Published @Published
var similarItems: [BaseItemDto] = [] var similarItems: [BaseItemDto] = []
@Published @Published
var isWatched = false var isWatched = false
@Published @Published
var isFavorited = false var isFavorited = false
@Published @Published
var informationItems: [BaseItemDto.ItemDetail] var informationItems: [BaseItemDto.ItemDetail]
@Published @Published
var selectedVideoPlayerViewModel: VideoPlayerViewModel? var selectedVideoPlayerViewModel: VideoPlayerViewModel?
var videoPlayerViewModels: [VideoPlayerViewModel] = [] var videoPlayerViewModels: [VideoPlayerViewModel] = []
init(item: BaseItemDto) { init(item: BaseItemDto) {
self.item = item self.item = item
switch item.itemType { switch item.itemType {
case .episode, .movie: case .episode, .movie:
if !item.missing && !item.unaired { if !item.missing && !item.unaired {
self.playButtonItem = item self.playButtonItem = item
} }
default: () default: ()
} }
informationItems = item.createInformationItems() informationItems = item.createInformationItems()
isFavorited = item.userData?.isFavorite ?? false isFavorited = item.userData?.isFavorite ?? false
isWatched = item.userData?.played ?? false isWatched = item.userData?.played ?? false
super.init() super.init()
getSimilarItems() getSimilarItems()
Notifications[.didSendStopReport].subscribe(self, selector: #selector(receivedStopReport(_:))) Notifications[.didSendStopReport].subscribe(self, selector: #selector(receivedStopReport(_:)))
refreshItemVideoPlayerViewModel(for: item) refreshItemVideoPlayerViewModel(for: item)
} }
@objc @objc
private func receivedStopReport(_ notification: NSNotification) { private func receivedStopReport(_ notification: NSNotification) {
guard let itemID = notification.object as? String else { return } guard let itemID = notification.object as? String else { return }
if itemID == item.id { if itemID == item.id {
updateItem() updateItem()
} else { } else {
// Remove if necessary. Note that this cannot be in deinit as // Remove if necessary. Note that this cannot be in deinit as
// holding as an observer won't allow the object to be deinit-ed // holding as an observer won't allow the object to be deinit-ed
Notifications.unsubscribe(self) Notifications.unsubscribe(self)
} }
} }
func refreshItemVideoPlayerViewModel(for item: BaseItemDto) { func refreshItemVideoPlayerViewModel(for item: BaseItemDto) {
guard item.itemType == .episode || item.itemType == .movie else { return } guard item.itemType == .episode || item.itemType == .movie else { return }
guard !item.missing, !item.unaired else { return } guard !item.missing, !item.unaired else { return }
item.createVideoPlayerViewModel() item.createVideoPlayerViewModel()
.sink { completion in .sink { completion in
self.handleAPIRequestError(completion: completion) self.handleAPIRequestError(completion: completion)
} receiveValue: { viewModels in } receiveValue: { viewModels in
self.videoPlayerViewModels = viewModels self.videoPlayerViewModels = viewModels
self.selectedVideoPlayerViewModel = viewModels.first self.selectedVideoPlayerViewModel = viewModels.first
} }
.store(in: &cancellables) .store(in: &cancellables)
} }
func playButtonText() -> String { func playButtonText() -> String {
if item.unaired { if item.unaired {
return L10n.unaired return L10n.unaired
} }
if item.missing { if item.missing {
return L10n.missing return L10n.missing
} }
if let itemProgressString = item.getItemProgressString() { if let itemProgressString = item.getItemProgressString() {
return itemProgressString return itemProgressString
} }
return L10n.play return L10n.play
} }
func getItemDisplayName() -> String { func getItemDisplayName() -> String {
item.name ?? "" item.name ?? ""
} }
func shouldDisplayRuntime() -> Bool { func shouldDisplayRuntime() -> Bool {
true true
} }
func getSimilarItems() { func getSimilarItems() {
LibraryAPI.getSimilarItems(itemId: item.id!, LibraryAPI.getSimilarItems(
userId: SessionManager.main.currentLogin.user.id, itemId: item.id!,
limit: 10, userId: SessionManager.main.currentLogin.user.id,
fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people]) limit: 10,
.trackActivity(loading) fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people]
.sink(receiveCompletion: { [weak self] completion in )
self?.handleAPIRequestError(completion: completion) .trackActivity(loading)
}, receiveValue: { [weak self] response in .sink(receiveCompletion: { [weak self] completion in
self?.similarItems = response.items ?? [] self?.handleAPIRequestError(completion: completion)
}) }, receiveValue: { [weak self] response in
.store(in: &cancellables) self?.similarItems = response.items ?? []
} })
.store(in: &cancellables)
}
func updateWatchState() { func updateWatchState() {
if isWatched { if isWatched {
PlaystateAPI.markUnplayedItem(userId: SessionManager.main.currentLogin.user.id, PlaystateAPI.markUnplayedItem(
itemId: item.id!) userId: SessionManager.main.currentLogin.user.id,
.trackActivity(loading) itemId: item.id!
.sink(receiveCompletion: { [weak self] completion in )
self?.handleAPIRequestError(completion: completion) .trackActivity(loading)
}, receiveValue: { [weak self] _ in .sink(receiveCompletion: { [weak self] completion in
self?.isWatched = false self?.handleAPIRequestError(completion: completion)
}) }, receiveValue: { [weak self] _ in
.store(in: &cancellables) self?.isWatched = false
} else { })
PlaystateAPI.markPlayedItem(userId: SessionManager.main.currentLogin.user.id, .store(in: &cancellables)
itemId: item.id!) } else {
.trackActivity(loading) PlaystateAPI.markPlayedItem(
.sink(receiveCompletion: { [weak self] completion in userId: SessionManager.main.currentLogin.user.id,
self?.handleAPIRequestError(completion: completion) itemId: item.id!
}, receiveValue: { [weak self] _ in )
self?.isWatched = true .trackActivity(loading)
}) .sink(receiveCompletion: { [weak self] completion in
.store(in: &cancellables) self?.handleAPIRequestError(completion: completion)
} }, receiveValue: { [weak self] _ in
} self?.isWatched = true
})
.store(in: &cancellables)
}
}
func updateFavoriteState() { func updateFavoriteState() {
if isFavorited { if isFavorited {
UserLibraryAPI.unmarkFavoriteItem(userId: SessionManager.main.currentLogin.user.id, itemId: item.id!) UserLibraryAPI.unmarkFavoriteItem(userId: SessionManager.main.currentLogin.user.id, itemId: item.id!)
.trackActivity(loading) .trackActivity(loading)
.sink(receiveCompletion: { [weak self] completion in .sink(receiveCompletion: { [weak self] completion in
self?.handleAPIRequestError(completion: completion) self?.handleAPIRequestError(completion: completion)
}, receiveValue: { [weak self] _ in }, receiveValue: { [weak self] _ in
self?.isFavorited = false self?.isFavorited = false
}) })
.store(in: &cancellables) .store(in: &cancellables)
} else { } else {
UserLibraryAPI.markFavoriteItem(userId: SessionManager.main.currentLogin.user.id, itemId: item.id!) UserLibraryAPI.markFavoriteItem(userId: SessionManager.main.currentLogin.user.id, itemId: item.id!)
.trackActivity(loading) .trackActivity(loading)
.sink(receiveCompletion: { [weak self] completion in .sink(receiveCompletion: { [weak self] completion in
self?.handleAPIRequestError(completion: completion) self?.handleAPIRequestError(completion: completion)
}, receiveValue: { [weak self] _ in }, receiveValue: { [weak self] _ in
self?.isFavorited = true self?.isFavorited = true
}) })
.store(in: &cancellables) .store(in: &cancellables)
} }
} }
// Overridden by subclasses // Overridden by subclasses
func updateItem() {} func updateItem() {}
} }

View File

@ -12,28 +12,30 @@ import JellyfinAPI
final class MovieItemViewModel: ItemViewModel { final class MovieItemViewModel: ItemViewModel {
override func updateItem() { override func updateItem() {
ItemsAPI.getItems(userId: SessionManager.main.currentLogin.user.id, ItemsAPI.getItems(
limit: 1, userId: SessionManager.main.currentLogin.user.id,
fields: [ limit: 1,
.primaryImageAspectRatio, fields: [
.seriesPrimaryImage, .primaryImageAspectRatio,
.seasonUserData, .seriesPrimaryImage,
.overview, .seasonUserData,
.genres, .overview,
.people, .genres,
.chapters, .people,
], .chapters,
enableUserData: true, ],
ids: [item.id ?? ""]) enableUserData: true,
.sink { completion in ids: [item.id ?? ""]
self.handleAPIRequestError(completion: completion) )
} receiveValue: { response in .sink { completion in
if let item = response.items?.first { self.handleAPIRequestError(completion: completion)
self.item = item } receiveValue: { response in
self.playButtonItem = item if let item = response.items?.first {
} self.item = item
} self.playButtonItem = item
.store(in: &cancellables) }
} }
.store(in: &cancellables)
}
} }

View File

@ -13,110 +13,118 @@ import Stinsen
final class SeasonItemViewModel: ItemViewModel, EpisodesRowManager { final class SeasonItemViewModel: ItemViewModel, EpisodesRowManager {
@RouterObject @RouterObject
var itemRouter: ItemCoordinator.Router? var itemRouter: ItemCoordinator.Router?
@Published @Published
var episodes: [BaseItemDto] = [] var episodes: [BaseItemDto] = []
@Published @Published
var seriesItem: BaseItemDto? var seriesItem: BaseItemDto?
@Published @Published
var seasonsEpisodes: [BaseItemDto: [BaseItemDto]] = [:] var seasonsEpisodes: [BaseItemDto: [BaseItemDto]] = [:]
@Published @Published
var selectedSeason: BaseItemDto? var selectedSeason: BaseItemDto?
override init(item: BaseItemDto) { override init(item: BaseItemDto) {
super.init(item: item) super.init(item: item)
getSeriesItem() getSeriesItem()
selectedSeason = item selectedSeason = item
retrieveSeasons() retrieveSeasons()
requestEpisodes() requestEpisodes()
} }
override func playButtonText() -> String { override func playButtonText() -> String {
if item.unaired { if item.unaired {
return L10n.unaired return L10n.unaired
} }
guard let playButtonItem = playButtonItem, let episodeLocator = playButtonItem.getEpisodeLocator() else { return L10n.play } guard let playButtonItem = playButtonItem, let episodeLocator = playButtonItem.getEpisodeLocator() else { return L10n.play }
return episodeLocator return episodeLocator
} }
private func requestEpisodes() { private func requestEpisodes() {
LogManager.log LogManager.log
.debug("Getting episodes in season \(item.id!) (\(item.name!)) of show \(item.seriesId!) (\(item.seriesName!))") .debug("Getting episodes in season \(item.id!) (\(item.name!)) of show \(item.seriesId!) (\(item.seriesName!))")
TvShowsAPI.getEpisodes(seriesId: item.seriesId ?? "", userId: SessionManager.main.currentLogin.user.id, TvShowsAPI.getEpisodes(
fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people], seriesId: item.seriesId ?? "",
seasonId: item.id ?? "") userId: SessionManager.main.currentLogin.user.id,
.trackActivity(loading) fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people],
.sink(receiveCompletion: { [weak self] completion in seasonId: item.id ?? ""
self?.handleAPIRequestError(completion: completion) )
}, receiveValue: { [weak self] response in .trackActivity(loading)
guard let self = self else { return } .sink(receiveCompletion: { [weak self] completion in
self.episodes = response.items ?? [] self?.handleAPIRequestError(completion: completion)
LogManager.log.debug("Retrieved \(String(self.episodes.count)) episodes") }, receiveValue: { [weak self] response in
guard let self = self else { return }
self.episodes = response.items ?? []
LogManager.log.debug("Retrieved \(String(self.episodes.count)) episodes")
self.setNextUpInSeason() self.setNextUpInSeason()
}) })
.store(in: &cancellables) .store(in: &cancellables)
} }
private func setNextUpInSeason() { private func setNextUpInSeason() {
TvShowsAPI.getNextUp(userId: SessionManager.main.currentLogin.user.id, TvShowsAPI.getNextUp(
fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people], userId: SessionManager.main.currentLogin.user.id,
seriesId: item.seriesId ?? "", enableUserData: true) fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people],
.trackActivity(loading) seriesId: item.seriesId ?? "",
.sink(receiveCompletion: { [weak self] completion in enableUserData: true
self?.handleAPIRequestError(completion: completion) )
}, receiveValue: { [weak self] response in .trackActivity(loading)
guard let self = self else { return } .sink(receiveCompletion: { [weak self] completion in
self?.handleAPIRequestError(completion: completion)
}, receiveValue: { [weak self] response in
guard let self = self else { return }
// Find the nextup item that belongs to current season. // Find the nextup item that belongs to current season.
if let nextUpItem = (response.items ?? []).first(where: { episode in if let nextUpItem = (response.items ?? []).first(where: { episode in
!episode.unaired && !episode.missing && episode.seasonId ?? "" == self.item.id! !episode.unaired && !episode.missing && episode.seasonId ?? "" == self.item.id!
}) { }) {
self.playButtonItem = nextUpItem self.playButtonItem = nextUpItem
LogManager.log.debug("Nextup in season \(self.item.id!) (\(self.item.name!)): \(nextUpItem.id!)") LogManager.log.debug("Nextup in season \(self.item.id!) (\(self.item.name!)): \(nextUpItem.id!)")
} }
if self.playButtonItem == nil && !self.episodes.isEmpty { if self.playButtonItem == nil && !self.episodes.isEmpty {
// Fallback to the old mechanism: // Fallback to the old mechanism:
// Sets the play button item to the "Next up" in the season based upon // Sets the play button item to the "Next up" in the season based upon
// the watched status of episodes in the season. // the watched status of episodes in the season.
// Default to the first episode of the season if all have been watched. // Default to the first episode of the season if all have been watched.
var firstUnwatchedSearch: BaseItemDto? var firstUnwatchedSearch: BaseItemDto?
for episode in self.episodes { for episode in self.episodes {
guard let played = episode.userData?.played else { continue } guard let played = episode.userData?.played else { continue }
if !played { if !played {
firstUnwatchedSearch = episode firstUnwatchedSearch = episode
break break
} }
} }
if let firstUnwatched = firstUnwatchedSearch { if let firstUnwatched = firstUnwatchedSearch {
self.playButtonItem = firstUnwatched self.playButtonItem = firstUnwatched
} else { } else {
guard let firstEpisode = self.episodes.first else { return } guard let firstEpisode = self.episodes.first else { return }
self.playButtonItem = firstEpisode self.playButtonItem = firstEpisode
} }
} }
}) })
.store(in: &cancellables) .store(in: &cancellables)
} }
private func getSeriesItem() { private func getSeriesItem() {
guard let seriesID = item.seriesId else { return } guard let seriesID = item.seriesId else { return }
UserLibraryAPI.getItem(userId: SessionManager.main.currentLogin.user.id, UserLibraryAPI.getItem(
itemId: seriesID) userId: SessionManager.main.currentLogin.user.id,
.trackActivity(loading) itemId: seriesID
.sink { [weak self] completion in )
self?.handleAPIRequestError(completion: completion) .trackActivity(loading)
} receiveValue: { [weak self] seriesItem in .sink { [weak self] completion in
self?.seriesItem = seriesItem self?.handleAPIRequestError(completion: completion)
} } receiveValue: { [weak self] seriesItem in
.store(in: &cancellables) self?.seriesItem = seriesItem
} }
.store(in: &cancellables)
}
} }

View File

@ -13,83 +13,88 @@ import JellyfinAPI
final class SeriesItemViewModel: ItemViewModel { final class SeriesItemViewModel: ItemViewModel {
@Published @Published
var seasons: [BaseItemDto] = [] var seasons: [BaseItemDto] = []
override init(item: BaseItemDto) { override init(item: BaseItemDto) {
super.init(item: item) super.init(item: item)
requestSeasons() requestSeasons()
getNextUp() getNextUp()
} }
override func playButtonText() -> String { override func playButtonText() -> String {
if item.unaired { if item.unaired {
return L10n.unaired return L10n.unaired
} }
if item.missing { if item.missing {
return L10n.missing return L10n.missing
} }
guard let playButtonItem = playButtonItem, let episodeLocator = playButtonItem.getEpisodeLocator() else { return L10n.play } guard let playButtonItem = playButtonItem, let episodeLocator = playButtonItem.getEpisodeLocator() else { return L10n.play }
return episodeLocator return episodeLocator
} }
override func shouldDisplayRuntime() -> Bool { override func shouldDisplayRuntime() -> Bool {
false false
} }
private func getNextUp() { private func getNextUp() {
LogManager.log.debug("Getting next up for show \(self.item.id!) (\(self.item.name!))") LogManager.log.debug("Getting next up for show \(self.item.id!) (\(self.item.name!))")
TvShowsAPI.getNextUp(userId: SessionManager.main.currentLogin.user.id, TvShowsAPI.getNextUp(
fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people], userId: SessionManager.main.currentLogin.user.id,
seriesId: self.item.id!, fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people],
enableUserData: true) seriesId: self.item.id!,
.trackActivity(loading) enableUserData: true
.sink(receiveCompletion: { [weak self] completion in )
self?.handleAPIRequestError(completion: completion) .trackActivity(loading)
}, receiveValue: { [weak self] response in .sink(receiveCompletion: { [weak self] completion in
if let nextUpItem = response.items?.first, !nextUpItem.unaired, !nextUpItem.missing { self?.handleAPIRequestError(completion: completion)
self?.playButtonItem = nextUpItem }, receiveValue: { [weak self] response in
} if let nextUpItem = response.items?.first, !nextUpItem.unaired, !nextUpItem.missing {
}) self?.playButtonItem = nextUpItem
.store(in: &cancellables) }
} })
.store(in: &cancellables)
}
private func getRunYears() -> String { private func getRunYears() -> String {
let dateFormatter = DateFormatter() let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy" dateFormatter.dateFormat = "yyyy"
var startYear: String? var startYear: String?
var endYear: String? var endYear: String?
if item.premiereDate != nil { if item.premiereDate != nil {
startYear = dateFormatter.string(from: item.premiereDate!) startYear = dateFormatter.string(from: item.premiereDate!)
} }
if item.endDate != nil { if item.endDate != nil {
endYear = dateFormatter.string(from: item.endDate!) endYear = dateFormatter.string(from: item.endDate!)
} }
return "\(startYear ?? L10n.unknown) - \(endYear ?? L10n.present)" return "\(startYear ?? L10n.unknown) - \(endYear ?? L10n.present)"
} }
private func requestSeasons() { private func requestSeasons() {
LogManager.log.debug("Getting seasons of show \(self.item.id!) (\(self.item.name!))") LogManager.log.debug("Getting seasons of show \(self.item.id!) (\(self.item.name!))")
TvShowsAPI.getSeasons(seriesId: item.id ?? "", userId: SessionManager.main.currentLogin.user.id, TvShowsAPI.getSeasons(
fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people], seriesId: item.id ?? "",
isMissing: Defaults[.shouldShowMissingSeasons] ? nil : false, userId: SessionManager.main.currentLogin.user.id,
enableUserData: true) fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people],
.trackActivity(loading) isMissing: Defaults[.shouldShowMissingSeasons] ? nil : false,
.sink(receiveCompletion: { [weak self] completion in enableUserData: true
self?.handleAPIRequestError(completion: completion) )
}, receiveValue: { [weak self] response in .trackActivity(loading)
self?.seasons = response.items ?? [] .sink(receiveCompletion: { [weak self] completion in
LogManager.log.debug("Retrieved \(String(self?.seasons.count ?? 0)) seasons") self?.handleAPIRequestError(completion: completion)
}) }, receiveValue: { [weak self] response in
.store(in: &cancellables) self?.seasons = response.items ?? []
} LogManager.log.debug("Retrieved \(String(self?.seasons.count ?? 0)) seasons")
})
.store(in: &cancellables)
}
} }

View File

@ -12,39 +12,42 @@ import JellyfinAPI
final class LatestMediaViewModel: ViewModel { final class LatestMediaViewModel: ViewModel {
@Published @Published
var items = [BaseItemDto]() var items = [BaseItemDto]()
let library: BaseItemDto let library: BaseItemDto
init(library: BaseItemDto) { init(library: BaseItemDto) {
self.library = library self.library = library
super.init() super.init()
requestLatestMedia() requestLatestMedia()
} }
func requestLatestMedia() { func requestLatestMedia() {
LogManager.log.debug("Requesting latest media for user id \(SessionManager.main.currentLogin.user.id)") LogManager.log.debug("Requesting latest media for user id \(SessionManager.main.currentLogin.user.id)")
UserLibraryAPI.getLatestMedia(userId: SessionManager.main.currentLogin.user.id, UserLibraryAPI.getLatestMedia(
parentId: library.id ?? "", userId: SessionManager.main.currentLogin.user.id,
fields: [ parentId: library.id ?? "",
.primaryImageAspectRatio, fields: [
.seriesPrimaryImage, .primaryImageAspectRatio,
.seasonUserData, .seriesPrimaryImage,
.overview, .seasonUserData,
.genres, .overview,
.people, .genres,
], .people,
includeItemTypes: [.series, .movie], ],
enableUserData: true, limit: 12) includeItemTypes: [.series, .movie],
.trackActivity(loading) enableUserData: true,
.sink(receiveCompletion: { [weak self] completion in limit: 12
self?.handleAPIRequestError(completion: completion) )
}, receiveValue: { [weak self] response in .trackActivity(loading)
self?.items = response .sink(receiveCompletion: { [weak self] completion in
LogManager.log.debug("Retrieved \(String(self?.items.count ?? 0)) items") self?.handleAPIRequestError(completion: completion)
}) }, receiveValue: { [weak self] response in
.store(in: &cancellables) self?.items = response
} LogManager.log.debug("Retrieved \(String(self?.items.count ?? 0)) items")
})
.store(in: &cancellables)
}
} }

View File

@ -11,72 +11,76 @@ import Foundation
import JellyfinAPI import JellyfinAPI
enum FilterType { enum FilterType {
case tag case tag
case genre case genre
case sortOrder case sortOrder
case sortBy case sortBy
case filter case filter
} }
final class LibraryFilterViewModel: ViewModel { final class LibraryFilterViewModel: ViewModel {
@Published @Published
var modifiedFilters = LibraryFilters() var modifiedFilters = LibraryFilters()
@Published @Published
var possibleGenres = [NameGuidPair]() var possibleGenres = [NameGuidPair]()
@Published @Published
var possibleTags = [String]() var possibleTags = [String]()
@Published @Published
var possibleSortOrders = APISortOrder.allCases var possibleSortOrders = APISortOrder.allCases
@Published @Published
var possibleSortBys = SortBy.allCases var possibleSortBys = SortBy.allCases
@Published @Published
var possibleItemFilters = ItemFilter.supportedTypes var possibleItemFilters = ItemFilter.supportedTypes
@Published @Published
var enabledFilterType: [FilterType] var enabledFilterType: [FilterType]
@Published @Published
var selectedSortOrder: APISortOrder = .descending var selectedSortOrder: APISortOrder = .descending
@Published @Published
var selectedSortBy: SortBy = .name var selectedSortBy: SortBy = .name
var parentId: String = "" var parentId: String = ""
func updateModifiedFilter() { func updateModifiedFilter() {
modifiedFilters.sortOrder = [selectedSortOrder] modifiedFilters.sortOrder = [selectedSortOrder]
modifiedFilters.sortBy = [selectedSortBy] modifiedFilters.sortBy = [selectedSortBy]
} }
func resetFilters() { func resetFilters() {
modifiedFilters = LibraryFilters(filters: [], sortOrder: [.ascending], withGenres: [], tags: [], sortBy: [.name]) modifiedFilters = LibraryFilters(filters: [], sortOrder: [.ascending], withGenres: [], tags: [], sortBy: [.name])
} }
init(filters: LibraryFilters? = nil, init(
enabledFilterType: [FilterType] = [.tag, .genre, .sortBy, .sortOrder, .filter], parentId: String) filters: LibraryFilters? = nil,
{ enabledFilterType: [FilterType] = [.tag, .genre, .sortBy, .sortOrder, .filter],
self.enabledFilterType = enabledFilterType parentId: String
self.selectedSortBy = filters?.sortBy.first ?? .name ) {
self.selectedSortOrder = filters?.sortOrder.first ?? .descending self.enabledFilterType = enabledFilterType
self.parentId = parentId self.selectedSortBy = filters?.sortBy.first ?? .name
self.selectedSortOrder = filters?.sortOrder.first ?? .descending
self.parentId = parentId
super.init() super.init()
if let filters = filters { if let filters = filters {
self.modifiedFilters = filters self.modifiedFilters = filters
} }
requestQueryFilters() requestQueryFilters()
} }
func requestQueryFilters() { func requestQueryFilters() {
FilterAPI.getQueryFilters(userId: SessionManager.main.currentLogin.user.id, FilterAPI.getQueryFilters(
parentId: self.parentId) userId: SessionManager.main.currentLogin.user.id,
.trackActivity(loading) parentId: self.parentId
.sink(receiveCompletion: { [weak self] completion in )
self?.handleAPIRequestError(completion: completion) .trackActivity(loading)
}, receiveValue: { [weak self] queryFilters in .sink(receiveCompletion: { [weak self] completion in
guard let self = self else { return } self?.handleAPIRequestError(completion: completion)
self.possibleGenres = queryFilters.genres ?? [] }, receiveValue: { [weak self] queryFilters in
self.possibleTags = queryFilters.tags ?? [] guard let self = self else { return }
}) self.possibleGenres = queryFilters.genres ?? []
.store(in: &cancellables) self.possibleTags = queryFilters.tags ?? []
} })
.store(in: &cancellables)
}
} }

View File

@ -11,26 +11,26 @@ import JellyfinAPI
final class LibraryListViewModel: ViewModel { final class LibraryListViewModel: ViewModel {
@Published @Published
var libraries: [BaseItemDto] = [] var libraries: [BaseItemDto] = []
// temp // temp
var withFavorites = LibraryFilters(filters: [.isFavorite], sortOrder: [], withGenres: [], sortBy: []) var withFavorites = LibraryFilters(filters: [.isFavorite], sortOrder: [], withGenres: [], sortBy: [])
override init() { override init() {
super.init() super.init()
requestLibraries() requestLibraries()
} }
func requestLibraries() { func requestLibraries() {
UserViewsAPI.getUserViews(userId: SessionManager.main.currentLogin.user.id) UserViewsAPI.getUserViews(userId: SessionManager.main.currentLogin.user.id)
.trackActivity(loading) .trackActivity(loading)
.sink(receiveCompletion: { completion in .sink(receiveCompletion: { completion in
self.handleAPIRequestError(completion: completion) self.handleAPIRequestError(completion: completion)
}, receiveValue: { response in }, receiveValue: { response in
self.libraries = response.items ?? [] self.libraries = response.items ?? []
}) })
.store(in: &cancellables) .store(in: &cancellables)
} }
} }

View File

@ -14,140 +14,166 @@ import SwiftUI
final class LibrarySearchViewModel: ViewModel { final class LibrarySearchViewModel: ViewModel {
@Published @Published
var supportedItemTypeList = [ItemType]() var supportedItemTypeList = [ItemType]()
@Published @Published
var selectedItemType: ItemType = .movie var selectedItemType: ItemType = .movie
@Published @Published
var movieItems = [BaseItemDto]() var movieItems = [BaseItemDto]()
@Published @Published
var showItems = [BaseItemDto]() var showItems = [BaseItemDto]()
@Published @Published
var episodeItems = [BaseItemDto]() var episodeItems = [BaseItemDto]()
@Published @Published
var suggestions = [BaseItemDto]() var suggestions = [BaseItemDto]()
var searchQuerySubject = CurrentValueSubject<String, Never>("") var searchQuerySubject = CurrentValueSubject<String, Never>("")
var parentID: String? var parentID: String?
init(parentID: String?) { init(parentID: String?) {
self.parentID = parentID self.parentID = parentID
super.init() super.init()
searchQuerySubject searchQuerySubject
.filter { !$0.isEmpty } .filter { !$0.isEmpty }
.debounce(for: 0.25, scheduler: DispatchQueue.main) .debounce(for: 0.25, scheduler: DispatchQueue.main)
.sink(receiveValue: search) .sink(receiveValue: search)
.store(in: &cancellables) .store(in: &cancellables)
setupPublishersForSupportedItemType() setupPublishersForSupportedItemType()
requestSuggestions() requestSuggestions()
} }
func setupPublishersForSupportedItemType() { func setupPublishersForSupportedItemType() {
Publishers.CombineLatest3($movieItems, $showItems, $episodeItems) Publishers.CombineLatest3($movieItems, $showItems, $episodeItems)
.debounce(for: 0.25, scheduler: DispatchQueue.main) .debounce(for: 0.25, scheduler: DispatchQueue.main)
.map { arg -> [ItemType] in .map { arg -> [ItemType] in
var typeList = [ItemType]() var typeList = [ItemType]()
if !arg.0.isEmpty { if !arg.0.isEmpty {
typeList.append(.movie) typeList.append(.movie)
} }
if !arg.1.isEmpty { if !arg.1.isEmpty {
typeList.append(.series) typeList.append(.series)
} }
if !arg.2.isEmpty { if !arg.2.isEmpty {
typeList.append(.episode) typeList.append(.episode)
} }
return typeList return typeList
} }
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.sink(receiveValue: { [weak self] typeList in .sink(receiveValue: { [weak self] typeList in
withAnimation { withAnimation {
self?.supportedItemTypeList = typeList self?.supportedItemTypeList = typeList
} }
}) })
.store(in: &cancellables) .store(in: &cancellables)
$supportedItemTypeList $supportedItemTypeList
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.withLatestFrom($selectedItemType) .withLatestFrom($selectedItemType)
.compactMap { selectedItemType in .compactMap { selectedItemType in
if self.supportedItemTypeList.contains(selectedItemType) { if self.supportedItemTypeList.contains(selectedItemType) {
return selectedItemType return selectedItemType
} else { } else {
return self.supportedItemTypeList.first return self.supportedItemTypeList.first
} }
} }
.sink(receiveValue: { [weak self] itemType in .sink(receiveValue: { [weak self] itemType in
withAnimation { withAnimation {
self?.selectedItemType = itemType self?.selectedItemType = itemType
} }
}) })
.store(in: &cancellables) .store(in: &cancellables)
} }
func requestSuggestions() { func requestSuggestions() {
ItemsAPI.getItemsByUserId(userId: SessionManager.main.currentLogin.user.id, ItemsAPI.getItemsByUserId(
limit: 20, userId: SessionManager.main.currentLogin.user.id,
recursive: true, limit: 20,
parentId: parentID, recursive: true,
includeItemTypes: [.movie, .series], parentId: parentID,
sortBy: ["IsFavoriteOrLiked", "Random"], includeItemTypes: [.movie, .series],
imageTypeLimit: 0, sortBy: ["IsFavoriteOrLiked", "Random"],
enableTotalRecordCount: false, imageTypeLimit: 0,
enableImages: false) enableTotalRecordCount: false,
.trackActivity(loading) enableImages: false
.receive(on: DispatchQueue.main) )
.sink(receiveCompletion: { [weak self] completion in .trackActivity(loading)
self?.handleAPIRequestError(completion: completion) .receive(on: DispatchQueue.main)
}, receiveValue: { [weak self] response in .sink(receiveCompletion: { [weak self] completion in
self?.suggestions = response.items ?? [] self?.handleAPIRequestError(completion: completion)
}) }, receiveValue: { [weak self] response in
.store(in: &cancellables) self?.suggestions = response.items ?? []
} })
.store(in: &cancellables)
}
func search(with query: String) { func search(with query: String) {
ItemsAPI.getItemsByUserId(userId: SessionManager.main.currentLogin.user.id, limit: 50, recursive: true, searchTerm: query, ItemsAPI.getItemsByUserId(
sortOrder: [.ascending], parentId: parentID, userId: SessionManager.main.currentLogin.user.id,
fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people], limit: 50,
includeItemTypes: [.movie], sortBy: ["SortName"], enableUserData: true, recursive: true,
enableImages: true) searchTerm: query,
.trackActivity(loading) sortOrder: [.ascending],
.receive(on: DispatchQueue.main) parentId: parentID,
.sink(receiveCompletion: { [weak self] completion in fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people],
self?.handleAPIRequestError(completion: completion) includeItemTypes: [.movie],
}, receiveValue: { [weak self] response in sortBy: ["SortName"],
self?.movieItems = response.items ?? [] enableUserData: true,
}) enableImages: true
.store(in: &cancellables) )
ItemsAPI.getItemsByUserId(userId: SessionManager.main.currentLogin.user.id, limit: 50, recursive: true, searchTerm: query, .trackActivity(loading)
sortOrder: [.ascending], parentId: parentID, .receive(on: DispatchQueue.main)
fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people], .sink(receiveCompletion: { [weak self] completion in
includeItemTypes: [.series], sortBy: ["SortName"], enableUserData: true, self?.handleAPIRequestError(completion: completion)
enableImages: true) }, receiveValue: { [weak self] response in
.trackActivity(loading) self?.movieItems = response.items ?? []
.receive(on: DispatchQueue.main) })
.sink(receiveCompletion: { [weak self] completion in .store(in: &cancellables)
self?.handleAPIRequestError(completion: completion) ItemsAPI.getItemsByUserId(
}, receiveValue: { [weak self] response in userId: SessionManager.main.currentLogin.user.id,
self?.showItems = response.items ?? [] limit: 50,
}) recursive: true,
.store(in: &cancellables) searchTerm: query,
ItemsAPI.getItemsByUserId(userId: SessionManager.main.currentLogin.user.id, limit: 50, recursive: true, searchTerm: query, sortOrder: [.ascending],
sortOrder: [.ascending], parentId: parentID, parentId: parentID,
fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people], fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people],
includeItemTypes: [.episode], sortBy: ["SortName"], enableUserData: true, includeItemTypes: [.series],
enableImages: true) sortBy: ["SortName"],
.trackActivity(loading) enableUserData: true,
.receive(on: DispatchQueue.main) enableImages: true
.sink(receiveCompletion: { [weak self] completion in )
self?.handleAPIRequestError(completion: completion) .trackActivity(loading)
}, receiveValue: { [weak self] response in .receive(on: DispatchQueue.main)
self?.episodeItems = response.items ?? [] .sink(receiveCompletion: { [weak self] completion in
}) self?.handleAPIRequestError(completion: completion)
.store(in: &cancellables) }, receiveValue: { [weak self] response in
} self?.showItems = response.items ?? []
})
.store(in: &cancellables)
ItemsAPI.getItemsByUserId(
userId: SessionManager.main.currentLogin.user.id,
limit: 50,
recursive: true,
searchTerm: query,
sortOrder: [.ascending],
parentId: parentID,
fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people],
includeItemTypes: [.episode],
sortBy: ["SortName"],
enableUserData: true,
enableImages: true
)
.trackActivity(loading)
.receive(on: DispatchQueue.main)
.sink(receiveCompletion: { [weak self] completion in
self?.handleAPIRequestError(completion: completion)
}, receiveValue: { [weak self] response in
self?.episodeItems = response.items ?? []
})
.store(in: &cancellables)
}
} }

View File

@ -16,182 +16,188 @@ import UIKit
typealias LibraryRow = CollectionRow<Int, LibraryRowCell> typealias LibraryRow = CollectionRow<Int, LibraryRowCell>
struct LibraryRowCell: Hashable { struct LibraryRowCell: Hashable {
let id = UUID() let id = UUID()
let item: BaseItemDto? let item: BaseItemDto?
var loadingCell: Bool = false var loadingCell: Bool = false
} }
final class LibraryViewModel: ViewModel { final class LibraryViewModel: ViewModel {
@Published @Published
var items: [BaseItemDto] = [] var items: [BaseItemDto] = []
@Published @Published
var rows: [LibraryRow] = [] var rows: [LibraryRow] = []
@Published @Published
var totalPages = 0 var totalPages = 0
@Published @Published
var currentPage = 0 var currentPage = 0
@Published @Published
var hasNextPage = false var hasNextPage = false
// temp // temp
@Published @Published
var filters: LibraryFilters var filters: LibraryFilters
var parentID: String? var parentID: String?
var person: BaseItemPerson? var person: BaseItemPerson?
var genre: NameGuidPair? var genre: NameGuidPair?
var studio: NameGuidPair? var studio: NameGuidPair?
private let columns: Int private let columns: Int
private let pageItemSize: Int private let pageItemSize: Int
var enabledFilterType: [FilterType] { var enabledFilterType: [FilterType] {
if genre == nil { if genre == nil {
return [.tag, .genre, .sortBy, .sortOrder, .filter] return [.tag, .genre, .sortBy, .sortOrder, .filter]
} else { } else {
return [.tag, .sortBy, .sortOrder, .filter] return [.tag, .sortBy, .sortOrder, .filter]
} }
} }
init(parentID: String? = nil, init(
person: BaseItemPerson? = nil, parentID: String? = nil,
genre: NameGuidPair? = nil, person: BaseItemPerson? = nil,
studio: NameGuidPair? = nil, genre: NameGuidPair? = nil,
filters: LibraryFilters = LibraryFilters(filters: [], sortOrder: [.ascending], withGenres: [], sortBy: [.name]), studio: NameGuidPair? = nil,
columns: Int = 7) filters: LibraryFilters = LibraryFilters(filters: [], sortOrder: [.ascending], withGenres: [], sortBy: [.name]),
{ columns: Int = 7
self.parentID = parentID ) {
self.person = person self.parentID = parentID
self.genre = genre self.person = person
self.studio = studio self.genre = genre
self.filters = filters self.studio = studio
self.columns = columns self.filters = filters
self.columns = columns
// Size is typical size of portrait items // Size is typical size of portrait items
self.pageItemSize = UIScreen.itemsFillableOnScreen(width: 130, height: 185) self.pageItemSize = UIScreen.itemsFillableOnScreen(width: 130, height: 185)
super.init() super.init()
$filters $filters
.sink(receiveValue: { newFilters in .sink(receiveValue: { newFilters in
self.requestItemsAsync(with: newFilters, replaceCurrentItems: true) self.requestItemsAsync(with: newFilters, replaceCurrentItems: true)
}) })
.store(in: &cancellables) .store(in: &cancellables)
} }
func requestItemsAsync(with filters: LibraryFilters, replaceCurrentItems: Bool = false) { func requestItemsAsync(with filters: LibraryFilters, replaceCurrentItems: Bool = false) {
if replaceCurrentItems { if replaceCurrentItems {
self.items = [] self.items = []
} }
let personIDs: [String] = [person].compactMap(\.?.id) let personIDs: [String] = [person].compactMap(\.?.id)
let studioIDs: [String] = [studio].compactMap(\.?.id) let studioIDs: [String] = [studio].compactMap(\.?.id)
let genreIDs: [String] let genreIDs: [String]
if filters.withGenres.isEmpty { if filters.withGenres.isEmpty {
genreIDs = [genre].compactMap(\.?.id) genreIDs = [genre].compactMap(\.?.id)
} else { } else {
genreIDs = filters.withGenres.compactMap(\.id) genreIDs = filters.withGenres.compactMap(\.id)
} }
let sortBy = filters.sortBy.map(\.rawValue) let sortBy = filters.sortBy.map(\.rawValue)
let queryRecursive = Defaults[.showFlattenView] || filters.filters.contains(.isFavorite) || let queryRecursive = Defaults[.showFlattenView] || filters.filters.contains(.isFavorite) ||
self.person != nil || self.person != nil ||
self.genre != nil || self.genre != nil ||
self.studio != nil self.studio != nil
let includeItemTypes: [BaseItemKind] let includeItemTypes: [BaseItemKind]
if filters.filters.contains(.isFavorite) { if filters.filters.contains(.isFavorite) {
includeItemTypes = [.movie, .series, .season, .episode, .boxSet] includeItemTypes = [.movie, .series, .season, .episode, .boxSet]
} else { } else {
includeItemTypes = [.movie, .series, .boxSet] + (Defaults[.showFlattenView] ? [] : [.folder]) includeItemTypes = [.movie, .series, .boxSet] + (Defaults[.showFlattenView] ? [] : [.folder])
} }
ItemsAPI.getItemsByUserId(userId: SessionManager.main.currentLogin.user.id, startIndex: currentPage * pageItemSize, ItemsAPI.getItemsByUserId(
limit: pageItemSize, userId: SessionManager.main.currentLogin.user.id,
recursive: queryRecursive, startIndex: currentPage * pageItemSize,
searchTerm: nil, limit: pageItemSize,
sortOrder: filters.sortOrder.compactMap { SortOrder(rawValue: $0.rawValue) }, recursive: queryRecursive,
parentId: parentID, searchTerm: nil,
fields: [ sortOrder: filters.sortOrder.compactMap { SortOrder(rawValue: $0.rawValue) },
.primaryImageAspectRatio, parentId: parentID,
.seriesPrimaryImage, fields: [
.seasonUserData, .primaryImageAspectRatio,
.overview, .seriesPrimaryImage,
.genres, .seasonUserData,
.people, .overview,
.chapters, .genres,
], .people,
includeItemTypes: includeItemTypes, .chapters,
filters: filters.filters, ],
sortBy: sortBy, includeItemTypes: includeItemTypes,
tags: filters.tags, filters: filters.filters,
enableUserData: true, sortBy: sortBy,
personIds: personIDs, tags: filters.tags,
studioIds: studioIDs, enableUserData: true,
genreIds: genreIDs, personIds: personIDs,
enableImages: true) studioIds: studioIDs,
.trackActivity(loading) genreIds: genreIDs,
.sink(receiveCompletion: { [weak self] completion in enableImages: true
self?.handleAPIRequestError(completion: completion) )
}, receiveValue: { [weak self] response in .trackActivity(loading)
.sink(receiveCompletion: { [weak self] completion in
self?.handleAPIRequestError(completion: completion)
}, receiveValue: { [weak self] response in
guard let self = self else { return } guard let self = self else { return }
let totalPages = ceil(Double(response.totalRecordCount ?? 0) / Double(self.pageItemSize)) let totalPages = ceil(Double(response.totalRecordCount ?? 0) / Double(self.pageItemSize))
self.totalPages = Int(totalPages) self.totalPages = Int(totalPages)
self.hasNextPage = self.currentPage < self.totalPages - 1 self.hasNextPage = self.currentPage < self.totalPages - 1
self.items.append(contentsOf: response.items ?? []) self.items.append(contentsOf: response.items ?? [])
self.rows = self.calculateRows(for: self.items) self.rows = self.calculateRows(for: self.items)
}) })
.store(in: &cancellables) .store(in: &cancellables)
} }
func requestNextPageAsync() { func requestNextPageAsync() {
currentPage += 1 currentPage += 1
requestItemsAsync(with: filters) requestItemsAsync(with: filters)
} }
// tvOS calculations for collection view // tvOS calculations for collection view
private func calculateRows(for itemList: [BaseItemDto]) -> [LibraryRow] { private func calculateRows(for itemList: [BaseItemDto]) -> [LibraryRow] {
guard !itemList.isEmpty else { return [] } guard !itemList.isEmpty else { return [] }
let rowCount = itemList.count / columns let rowCount = itemList.count / columns
var calculatedRows = [LibraryRow]() var calculatedRows = [LibraryRow]()
for i in 0 ... rowCount { for i in 0 ... rowCount {
let firstItemIndex = i * columns let firstItemIndex = i * columns
var lastItemIndex = firstItemIndex + columns var lastItemIndex = firstItemIndex + columns
if lastItemIndex > itemList.count { if lastItemIndex > itemList.count {
lastItemIndex = itemList.count lastItemIndex = itemList.count
} }
var rowCells = [LibraryRowCell]() var rowCells = [LibraryRowCell]()
for item in itemList[firstItemIndex ..< lastItemIndex] { for item in itemList[firstItemIndex ..< lastItemIndex] {
let newCell = LibraryRowCell(item: item) let newCell = LibraryRowCell(item: item)
rowCells.append(newCell) rowCells.append(newCell)
} }
if i == rowCount, hasNextPage { if i == rowCount, hasNextPage {
var loadingCell = LibraryRowCell(item: nil) var loadingCell = LibraryRowCell(item: nil)
loadingCell.loadingCell = true loadingCell.loadingCell = true
rowCells.append(loadingCell) rowCells.append(loadingCell)
} }
calculatedRows.append(LibraryRow(section: i, calculatedRows.append(LibraryRow(
items: rowCells)) section: i,
} items: rowCells
return calculatedRows ))
} }
return calculatedRows
}
} }
extension UIScreen { extension UIScreen {
static func itemsFillableOnScreen(width: CGFloat, height: CGFloat) -> Int { static func itemsFillableOnScreen(width: CGFloat, height: CGFloat) -> Int {
let screenSize = UIScreen.main.bounds.height * UIScreen.main.bounds.width let screenSize = UIScreen.main.bounds.height * UIScreen.main.bounds.width
let itemSize = width * height let itemSize = width * height
#if os(tvOS) #if os(tvOS)
return Int(screenSize / itemSize) * 2 return Int(screenSize / itemSize) * 2
#else #else
return Int(screenSize / itemSize) return Int(screenSize / itemSize)
#endif #endif
} }
} }

View File

@ -13,230 +13,234 @@ import SwiftUICollection
typealias LiveTVChannelRow = CollectionRow<Int, LiveTVChannelRowCell> typealias LiveTVChannelRow = CollectionRow<Int, LiveTVChannelRowCell>
struct LiveTVChannelRowCell: Hashable { struct LiveTVChannelRowCell: Hashable {
let id = UUID() let id = UUID()
let item: LiveTVChannelProgram let item: LiveTVChannelProgram
} }
struct LiveTVChannelProgram: Hashable { struct LiveTVChannelProgram: Hashable {
let id = UUID() let id = UUID()
let channel: BaseItemDto let channel: BaseItemDto
let currentProgram: BaseItemDto? let currentProgram: BaseItemDto?
let programs: [BaseItemDto] let programs: [BaseItemDto]
} }
final class LiveTVChannelsViewModel: ViewModel { final class LiveTVChannelsViewModel: ViewModel {
@Published @Published
var channels = [BaseItemDto]() var channels = [BaseItemDto]()
@Published @Published
var channelPrograms = [LiveTVChannelProgram]() { var channelPrograms = [LiveTVChannelProgram]() {
didSet { didSet {
rows = [] rows = []
let rowChannels = channelPrograms.chunked(into: 4) let rowChannels = channelPrograms.chunked(into: 4)
for (index, rowChans) in rowChannels.enumerated() { for (index, rowChans) in rowChannels.enumerated() {
rows.append(LiveTVChannelRow(section: index, items: rowChans.map { LiveTVChannelRowCell(item: $0) })) rows.append(LiveTVChannelRow(section: index, items: rowChans.map { LiveTVChannelRowCell(item: $0) }))
} }
} }
} }
@Published @Published
var rows = [LiveTVChannelRow]() var rows = [LiveTVChannelRow]()
private var programs = [BaseItemDto]() private var programs = [BaseItemDto]()
private var channelProgramsList = [BaseItemDto: [BaseItemDto]]() private var channelProgramsList = [BaseItemDto: [BaseItemDto]]()
private var timer: Timer? private var timer: Timer?
var timeFormatter: DateFormatter { var timeFormatter: DateFormatter {
let df = DateFormatter() let df = DateFormatter()
df.dateFormat = "h:mm" df.dateFormat = "h:mm"
return df return df
} }
override init() { override init() {
super.init() super.init()
getChannels() getChannels()
startScheduleCheckTimer() startScheduleCheckTimer()
} }
deinit { deinit {
stopScheduleCheckTimer() stopScheduleCheckTimer()
} }
private func getGuideInfo() { private func getGuideInfo() {
LiveTvAPI.getGuideInfo() LiveTvAPI.getGuideInfo()
.trackActivity(loading) .trackActivity(loading)
.sink(receiveCompletion: { [weak self] completion in .sink(receiveCompletion: { [weak self] completion in
self?.handleAPIRequestError(completion: completion) self?.handleAPIRequestError(completion: completion)
}, receiveValue: { [weak self] _ in }, receiveValue: { [weak self] _ in
LogManager.log.debug("Received Guide Info") LogManager.log.debug("Received Guide Info")
guard let self = self else { return } guard let self = self else { return }
self.getChannels() self.getChannels()
}) })
.store(in: &cancellables) .store(in: &cancellables)
} }
func getChannels() { func getChannels() {
LiveTvAPI.getLiveTvChannels(userId: SessionManager.main.currentLogin.user.id, LiveTvAPI.getLiveTvChannels(
startIndex: 0, userId: SessionManager.main.currentLogin.user.id,
limit: 1000, startIndex: 0,
enableImageTypes: [.primary], limit: 1000,
enableUserData: false, enableImageTypes: [.primary],
enableFavoriteSorting: true) enableUserData: false,
.trackActivity(loading) enableFavoriteSorting: true
.sink(receiveCompletion: { [weak self] completion in )
self?.handleAPIRequestError(completion: completion) .trackActivity(loading)
}, receiveValue: { [weak self] response in .sink(receiveCompletion: { [weak self] completion in
LogManager.log.debug("Received \(response.items?.count ?? 0) Channels") self?.handleAPIRequestError(completion: completion)
guard let self = self else { return } }, receiveValue: { [weak self] response in
self.channels = response.items ?? [] LogManager.log.debug("Received \(response.items?.count ?? 0) Channels")
self.getPrograms() guard let self = self else { return }
}) self.channels = response.items ?? []
.store(in: &cancellables) self.getPrograms()
} })
.store(in: &cancellables)
}
private func getPrograms() { private func getPrograms() {
// http://192.168.1.50:8096/LiveTv/Programs // http://192.168.1.50:8096/LiveTv/Programs
guard !channels.isEmpty else { guard !channels.isEmpty else {
LogManager.log.debug("Cannot get programs, channels list empty. ") LogManager.log.debug("Cannot get programs, channels list empty. ")
return return
} }
let channelIds = channels.compactMap(\.id) let channelIds = channels.compactMap(\.id)
let minEndDate = Date.now.addComponentsToDate(hours: -1) let minEndDate = Date.now.addComponentsToDate(hours: -1)
let maxStartDate = minEndDate.addComponentsToDate(hours: 6) let maxStartDate = minEndDate.addComponentsToDate(hours: 6)
let getProgramsRequest = GetProgramsRequest(channelIds: channelIds, let getProgramsRequest = GetProgramsRequest(
userId: SessionManager.main.currentLogin.user.id, channelIds: channelIds,
maxStartDate: maxStartDate, userId: SessionManager.main.currentLogin.user.id,
minEndDate: minEndDate, maxStartDate: maxStartDate,
sortBy: ["StartDate"], minEndDate: minEndDate,
enableImages: true, sortBy: ["StartDate"],
enableTotalRecordCount: false, enableImages: true,
imageTypeLimit: 1, enableTotalRecordCount: false,
enableImageTypes: [.primary], imageTypeLimit: 1,
enableUserData: false) enableImageTypes: [.primary],
enableUserData: false
)
LiveTvAPI.getPrograms(getProgramsRequest: getProgramsRequest) LiveTvAPI.getPrograms(getProgramsRequest: getProgramsRequest)
.trackActivity(loading) .trackActivity(loading)
.sink(receiveCompletion: { [weak self] completion in .sink(receiveCompletion: { [weak self] completion in
self?.handleAPIRequestError(completion: completion) self?.handleAPIRequestError(completion: completion)
}, receiveValue: { [weak self] response in }, receiveValue: { [weak self] response in
LogManager.log.debug("Received \(response.items?.count ?? 0) Programs") LogManager.log.debug("Received \(response.items?.count ?? 0) Programs")
guard let self = self else { return } guard let self = self else { return }
self.programs = response.items ?? [] self.programs = response.items ?? []
self.channelPrograms = self.processChannelPrograms() self.channelPrograms = self.processChannelPrograms()
}) })
.store(in: &cancellables) .store(in: &cancellables)
} }
private func processChannelPrograms() -> [LiveTVChannelProgram] { private func processChannelPrograms() -> [LiveTVChannelProgram] {
var channelPrograms = [LiveTVChannelProgram]() var channelPrograms = [LiveTVChannelProgram]()
let now = Date() let now = Date()
for channel in self.channels { for channel in self.channels {
let prgs = self.programs.filter { item in let prgs = self.programs.filter { item in
item.channelId == channel.id item.channelId == channel.id
} }
DispatchQueue.main.async { DispatchQueue.main.async {
self.channelProgramsList[channel] = prgs self.channelProgramsList[channel] = prgs
} }
var currentPrg: BaseItemDto? var currentPrg: BaseItemDto?
for prg in prgs { for prg in prgs {
if let startDate = prg.startDate, if let startDate = prg.startDate,
let endDate = prg.endDate, let endDate = prg.endDate,
now.timeIntervalSinceReferenceDate > startDate.timeIntervalSinceReferenceDate && now.timeIntervalSinceReferenceDate > startDate.timeIntervalSinceReferenceDate &&
now.timeIntervalSinceReferenceDate < endDate.timeIntervalSinceReferenceDate now.timeIntervalSinceReferenceDate < endDate.timeIntervalSinceReferenceDate
{ {
currentPrg = prg currentPrg = prg
} }
} }
channelPrograms.append(LiveTVChannelProgram(channel: channel, currentProgram: currentPrg, programs: prgs)) channelPrograms.append(LiveTVChannelProgram(channel: channel, currentProgram: currentPrg, programs: prgs))
} }
return channelPrograms return channelPrograms
} }
func startScheduleCheckTimer() { func startScheduleCheckTimer() {
let date = Date() let date = Date()
let calendar = Calendar.current let calendar = Calendar.current
var components = calendar.dateComponents([.era, .year, .month, .day, .hour, .minute], from: date) var components = calendar.dateComponents([.era, .year, .month, .day, .hour, .minute], from: date)
// Run on 10th min of every hour // Run on 10th min of every hour
guard let minute = components.minute else { return } guard let minute = components.minute else { return }
components.second = 0 components.second = 0
components.minute = minute + (10 - (minute % 10)) components.minute = minute + (10 - (minute % 10))
guard let nextMinute = calendar.date(from: components) else { return } guard let nextMinute = calendar.date(from: components) else { return }
if let existingTimer = timer { if let existingTimer = timer {
existingTimer.invalidate() existingTimer.invalidate()
} }
timer = Timer(fire: nextMinute, interval: 60 * 10, repeats: true) { [weak self] _ in timer = Timer(fire: nextMinute, interval: 60 * 10, repeats: true) { [weak self] _ in
guard let self = self else { return } guard let self = self else { return }
LogManager.log.debug("LiveTVChannels schedule check...") LogManager.log.debug("LiveTVChannels schedule check...")
DispatchQueue.global(qos: .background).async { DispatchQueue.global(qos: .background).async {
let newChanPrgs = self.processChannelPrograms() let newChanPrgs = self.processChannelPrograms()
DispatchQueue.main.async { DispatchQueue.main.async {
self.channelPrograms = newChanPrgs self.channelPrograms = newChanPrgs
} }
} }
} }
if let timer = timer { if let timer = timer {
RunLoop.main.add(timer, forMode: .default) RunLoop.main.add(timer, forMode: .default)
} }
} }
func stopScheduleCheckTimer() { func stopScheduleCheckTimer() {
timer?.invalidate() timer?.invalidate()
} }
func fetchVideoPlayerViewModel(item: BaseItemDto, completion: @escaping (VideoPlayerViewModel) -> Void) { func fetchVideoPlayerViewModel(item: BaseItemDto, completion: @escaping (VideoPlayerViewModel) -> Void) {
item.createLiveTVVideoPlayerViewModel() item.createLiveTVVideoPlayerViewModel()
.sink { completion in .sink { completion in
self.handleAPIRequestError(completion: completion) self.handleAPIRequestError(completion: completion)
} receiveValue: { videoPlayerViewModels in } receiveValue: { videoPlayerViewModels in
if let viewModel = videoPlayerViewModels.first { if let viewModel = videoPlayerViewModels.first {
completion(viewModel) completion(viewModel)
} }
} }
.store(in: &self.cancellables) .store(in: &self.cancellables)
} }
} }
extension Array { extension Array {
func chunked(into size: Int) -> [[Element]] { func chunked(into size: Int) -> [[Element]] {
stride(from: 0, to: count, by: size).map { stride(from: 0, to: count, by: size).map {
Array(self[$0 ..< Swift.min($0 + size, count)]) Array(self[$0 ..< Swift.min($0 + size, count)])
} }
} }
} }
extension Date { extension Date {
func addComponentsToDate(seconds sec: Int? = nil, minutes min: Int? = nil, hours hrs: Int? = nil, days d: Int? = nil) -> Date { func addComponentsToDate(seconds sec: Int? = nil, minutes min: Int? = nil, hours hrs: Int? = nil, days d: Int? = nil) -> Date {
var dc = DateComponents() var dc = DateComponents()
if let sec = sec { if let sec = sec {
dc.second = sec dc.second = sec
} }
if let min = min { if let min = min {
dc.minute = min dc.minute = min
} }
if let hrs = hrs { if let hrs = hrs {
dc.hour = hrs dc.hour = hrs
} }
if let d = d { if let d = d {
dc.day = d dc.day = d
} }
return Calendar.current.date(byAdding: dc, to: self)! return Calendar.current.date(byAdding: dc, to: self)!
} }
func midnightUTCDate() -> Date { func midnightUTCDate() -> Date {
var dc: DateComponents = Calendar.current.dateComponents([.year, .month, .day], from: self) var dc: DateComponents = Calendar.current.dateComponents([.year, .month, .day], from: self)
dc.hour = 0 dc.hour = 0
dc.minute = 0 dc.minute = 0
dc.second = 0 dc.second = 0
dc.nanosecond = 0 dc.nanosecond = 0
dc.timeZone = TimeZone(secondsFromGMT: 0) dc.timeZone = TimeZone(secondsFromGMT: 0)
return Calendar.current.date(from: dc)! return Calendar.current.date(from: dc)!
} }
} }

View File

@ -11,202 +11,216 @@ import JellyfinAPI
final class LiveTVProgramsViewModel: ViewModel { final class LiveTVProgramsViewModel: ViewModel {
@Published @Published
var recommendedItems = [BaseItemDto]() var recommendedItems = [BaseItemDto]()
@Published @Published
var seriesItems = [BaseItemDto]() var seriesItems = [BaseItemDto]()
@Published @Published
var movieItems = [BaseItemDto]() var movieItems = [BaseItemDto]()
@Published @Published
var sportsItems = [BaseItemDto]() var sportsItems = [BaseItemDto]()
@Published @Published
var kidsItems = [BaseItemDto]() var kidsItems = [BaseItemDto]()
@Published @Published
var newsItems = [BaseItemDto]() var newsItems = [BaseItemDto]()
private var channels = [String: BaseItemDto]() private var channels = [String: BaseItemDto]()
override init() { override init() {
super.init() super.init()
getChannels() getChannels()
} }
func findChannel(id: String) -> BaseItemDto? { func findChannel(id: String) -> BaseItemDto? {
channels[id] channels[id]
} }
private func getChannels() { private func getChannels() {
LiveTvAPI.getLiveTvChannels(userId: SessionManager.main.currentLogin.user.id, LiveTvAPI.getLiveTvChannels(
startIndex: 0, userId: SessionManager.main.currentLogin.user.id,
limit: 1000, startIndex: 0,
enableImageTypes: [.primary], limit: 1000,
enableUserData: false, enableImageTypes: [.primary],
enableFavoriteSorting: true) enableUserData: false,
.trackActivity(loading) enableFavoriteSorting: true
.sink(receiveCompletion: { [weak self] completion in )
self?.handleAPIRequestError(completion: completion) .trackActivity(loading)
}, receiveValue: { [weak self] response in .sink(receiveCompletion: { [weak self] completion in
LogManager.log.debug("Received \(response.items?.count ?? 0) Channels") self?.handleAPIRequestError(completion: completion)
guard let self = self else { return } }, receiveValue: { [weak self] response in
if let chans = response.items { LogManager.log.debug("Received \(response.items?.count ?? 0) Channels")
for chan in chans { guard let self = self else { return }
if let chanId = chan.id { if let chans = response.items {
self.channels[chanId] = chan for chan in chans {
} if let chanId = chan.id {
} self.channels[chanId] = chan
self.getRecommendedPrograms() }
self.getSeries() }
self.getMovies() self.getRecommendedPrograms()
self.getSports() self.getSeries()
self.getKids() self.getMovies()
self.getNews() self.getSports()
} self.getKids()
}) self.getNews()
.store(in: &cancellables) }
} })
.store(in: &cancellables)
}
private func getRecommendedPrograms() { private func getRecommendedPrograms() {
LiveTvAPI.getRecommendedPrograms(userId: SessionManager.main.currentLogin.user.id, LiveTvAPI.getRecommendedPrograms(
limit: 9, userId: SessionManager.main.currentLogin.user.id,
isAiring: true, limit: 9,
imageTypeLimit: 1, isAiring: true,
enableImageTypes: [.primary, .thumb], imageTypeLimit: 1,
fields: [.channelInfo, .primaryImageAspectRatio], enableImageTypes: [.primary, .thumb],
enableTotalRecordCount: false) fields: [.channelInfo, .primaryImageAspectRatio],
.trackActivity(loading) enableTotalRecordCount: false
.sink(receiveCompletion: { [weak self] completion in )
self?.handleAPIRequestError(completion: completion) .trackActivity(loading)
}, receiveValue: { [weak self] response in .sink(receiveCompletion: { [weak self] completion in
LogManager.log.debug("Received \(String(response.items?.count ?? 0)) Recommended Programs") self?.handleAPIRequestError(completion: completion)
guard let self = self else { return } }, receiveValue: { [weak self] response in
self.recommendedItems = response.items ?? [] LogManager.log.debug("Received \(String(response.items?.count ?? 0)) Recommended Programs")
}) guard let self = self else { return }
.store(in: &cancellables) self.recommendedItems = response.items ?? []
} })
.store(in: &cancellables)
}
private func getSeries() { private func getSeries() {
let getProgramsRequest = GetProgramsRequest(userId: SessionManager.main.currentLogin.user.id, let getProgramsRequest = GetProgramsRequest(
hasAired: false, userId: SessionManager.main.currentLogin.user.id,
isMovie: false, hasAired: false,
isSeries: true, isMovie: false,
isNews: false, isSeries: true,
isKids: false, isNews: false,
isSports: false, isKids: false,
limit: 9, isSports: false,
enableTotalRecordCount: false, limit: 9,
enableImageTypes: [.primary, .thumb], enableTotalRecordCount: false,
fields: [.channelInfo, .primaryImageAspectRatio]) enableImageTypes: [.primary, .thumb],
fields: [.channelInfo, .primaryImageAspectRatio]
)
LiveTvAPI.getPrograms(getProgramsRequest: getProgramsRequest) LiveTvAPI.getPrograms(getProgramsRequest: getProgramsRequest)
.trackActivity(loading) .trackActivity(loading)
.sink(receiveCompletion: { [weak self] completion in .sink(receiveCompletion: { [weak self] completion in
self?.handleAPIRequestError(completion: completion) self?.handleAPIRequestError(completion: completion)
}, receiveValue: { [weak self] response in }, receiveValue: { [weak self] response in
LogManager.log.debug("Received \(String(response.items?.count ?? 0)) Series Items") LogManager.log.debug("Received \(String(response.items?.count ?? 0)) Series Items")
guard let self = self else { return } guard let self = self else { return }
self.seriesItems = response.items ?? [] self.seriesItems = response.items ?? []
}) })
.store(in: &cancellables) .store(in: &cancellables)
} }
private func getMovies() { private func getMovies() {
let getProgramsRequest = GetProgramsRequest(userId: SessionManager.main.currentLogin.user.id, let getProgramsRequest = GetProgramsRequest(
hasAired: false, userId: SessionManager.main.currentLogin.user.id,
isMovie: true, hasAired: false,
isSeries: false, isMovie: true,
isNews: false, isSeries: false,
isKids: false, isNews: false,
isSports: false, isKids: false,
limit: 9, isSports: false,
enableTotalRecordCount: false, limit: 9,
enableImageTypes: [.primary, .thumb], enableTotalRecordCount: false,
fields: [.channelInfo, .primaryImageAspectRatio]) enableImageTypes: [.primary, .thumb],
fields: [.channelInfo, .primaryImageAspectRatio]
)
LiveTvAPI.getPrograms(getProgramsRequest: getProgramsRequest) LiveTvAPI.getPrograms(getProgramsRequest: getProgramsRequest)
.trackActivity(loading) .trackActivity(loading)
.sink(receiveCompletion: { [weak self] completion in .sink(receiveCompletion: { [weak self] completion in
self?.handleAPIRequestError(completion: completion) self?.handleAPIRequestError(completion: completion)
}, receiveValue: { [weak self] response in }, receiveValue: { [weak self] response in
LogManager.log.debug("Received \(String(response.items?.count ?? 0)) Movie Items") LogManager.log.debug("Received \(String(response.items?.count ?? 0)) Movie Items")
guard let self = self else { return } guard let self = self else { return }
self.movieItems = response.items ?? [] self.movieItems = response.items ?? []
}) })
.store(in: &cancellables) .store(in: &cancellables)
} }
private func getSports() { private func getSports() {
let getProgramsRequest = GetProgramsRequest(userId: SessionManager.main.currentLogin.user.id, let getProgramsRequest = GetProgramsRequest(
hasAired: false, userId: SessionManager.main.currentLogin.user.id,
isSports: true, hasAired: false,
limit: 9, isSports: true,
enableTotalRecordCount: false, limit: 9,
enableImageTypes: [.primary, .thumb], enableTotalRecordCount: false,
fields: [.channelInfo, .primaryImageAspectRatio]) enableImageTypes: [.primary, .thumb],
fields: [.channelInfo, .primaryImageAspectRatio]
)
LiveTvAPI.getPrograms(getProgramsRequest: getProgramsRequest) LiveTvAPI.getPrograms(getProgramsRequest: getProgramsRequest)
.trackActivity(loading) .trackActivity(loading)
.sink(receiveCompletion: { [weak self] completion in .sink(receiveCompletion: { [weak self] completion in
self?.handleAPIRequestError(completion: completion) self?.handleAPIRequestError(completion: completion)
}, receiveValue: { [weak self] response in }, receiveValue: { [weak self] response in
LogManager.log.debug("Received \(String(response.items?.count ?? 0)) Sports Items") LogManager.log.debug("Received \(String(response.items?.count ?? 0)) Sports Items")
guard let self = self else { return } guard let self = self else { return }
self.sportsItems = response.items ?? [] self.sportsItems = response.items ?? []
}) })
.store(in: &cancellables) .store(in: &cancellables)
} }
private func getKids() { private func getKids() {
let getProgramsRequest = GetProgramsRequest(userId: SessionManager.main.currentLogin.user.id, let getProgramsRequest = GetProgramsRequest(
hasAired: false, userId: SessionManager.main.currentLogin.user.id,
isKids: true, hasAired: false,
limit: 9, isKids: true,
enableTotalRecordCount: false, limit: 9,
enableImageTypes: [.primary, .thumb], enableTotalRecordCount: false,
fields: [.channelInfo, .primaryImageAspectRatio]) enableImageTypes: [.primary, .thumb],
fields: [.channelInfo, .primaryImageAspectRatio]
)
LiveTvAPI.getPrograms(getProgramsRequest: getProgramsRequest) LiveTvAPI.getPrograms(getProgramsRequest: getProgramsRequest)
.trackActivity(loading) .trackActivity(loading)
.sink(receiveCompletion: { [weak self] completion in .sink(receiveCompletion: { [weak self] completion in
self?.handleAPIRequestError(completion: completion) self?.handleAPIRequestError(completion: completion)
}, receiveValue: { [weak self] response in }, receiveValue: { [weak self] response in
LogManager.log.debug("Received \(String(response.items?.count ?? 0)) Kids Items") LogManager.log.debug("Received \(String(response.items?.count ?? 0)) Kids Items")
guard let self = self else { return } guard let self = self else { return }
self.kidsItems = response.items ?? [] self.kidsItems = response.items ?? []
}) })
.store(in: &cancellables) .store(in: &cancellables)
} }
private func getNews() { private func getNews() {
let getProgramsRequest = GetProgramsRequest(userId: SessionManager.main.currentLogin.user.id, let getProgramsRequest = GetProgramsRequest(
hasAired: false, userId: SessionManager.main.currentLogin.user.id,
isNews: true, hasAired: false,
limit: 9, isNews: true,
enableTotalRecordCount: false, limit: 9,
enableImageTypes: [.primary, .thumb], enableTotalRecordCount: false,
fields: [.channelInfo, .primaryImageAspectRatio]) enableImageTypes: [.primary, .thumb],
fields: [.channelInfo, .primaryImageAspectRatio]
)
LiveTvAPI.getPrograms(getProgramsRequest: getProgramsRequest) LiveTvAPI.getPrograms(getProgramsRequest: getProgramsRequest)
.trackActivity(loading) .trackActivity(loading)
.sink(receiveCompletion: { [weak self] completion in .sink(receiveCompletion: { [weak self] completion in
self?.handleAPIRequestError(completion: completion) self?.handleAPIRequestError(completion: completion)
}, receiveValue: { [weak self] response in }, receiveValue: { [weak self] response in
LogManager.log.debug("Received \(String(response.items?.count ?? 0)) News Items") LogManager.log.debug("Received \(String(response.items?.count ?? 0)) News Items")
guard let self = self else { return } guard let self = self else { return }
self.newsItems = response.items ?? [] self.newsItems = response.items ?? []
}) })
.store(in: &cancellables) .store(in: &cancellables)
} }
func fetchVideoPlayerViewModel(item: BaseItemDto, completion: @escaping (VideoPlayerViewModel) -> Void) { func fetchVideoPlayerViewModel(item: BaseItemDto, completion: @escaping (VideoPlayerViewModel) -> Void) {
item.createLiveTVVideoPlayerViewModel() item.createLiveTVVideoPlayerViewModel()
.sink { completion in .sink { completion in
self.handleAPIRequestError(completion: completion) self.handleAPIRequestError(completion: completion)
} receiveValue: { videoPlayerViewModels in } receiveValue: { videoPlayerViewModels in
if let viewModel = videoPlayerViewModels.first { if let viewModel = videoPlayerViewModels.first {
completion(viewModel) completion(viewModel)
} }
} }
.store(in: &self.cancellables) .store(in: &self.cancellables)
} }
} }

View File

@ -10,24 +10,24 @@ import Foundation
import JellyfinAPI import JellyfinAPI
final class MainTabViewModel: ViewModel { final class MainTabViewModel: ViewModel {
@Published @Published
var backgroundURL: URL? var backgroundURL: URL?
@Published @Published
var lastBackgroundURL: URL? var lastBackgroundURL: URL?
@Published @Published
var backgroundBlurHash: String = "001fC^" var backgroundBlurHash: String = "001fC^"
override init() { override init() {
super.init() super.init()
let nc = NotificationCenter.default let nc = NotificationCenter.default
nc.addObserver(self, selector: #selector(backgroundDidChange), name: Notification.Name("backgroundDidChange"), object: nil) nc.addObserver(self, selector: #selector(backgroundDidChange), name: Notification.Name("backgroundDidChange"), object: nil)
} }
@objc @objc
func backgroundDidChange() { func backgroundDidChange() {
self.lastBackgroundURL = self.backgroundURL self.lastBackgroundURL = self.backgroundURL
self.backgroundURL = BackgroundManager.current.backgroundURL self.backgroundURL = BackgroundManager.current.backgroundURL
self.backgroundBlurHash = BackgroundManager.current.blurhash self.backgroundBlurHash = BackgroundManager.current.blurhash
} }
} }

View File

@ -14,79 +14,81 @@ import SwiftUICollection
final class MovieLibrariesViewModel: ViewModel { final class MovieLibrariesViewModel: ViewModel {
@Published @Published
var rows = [LibraryRow]() var rows = [LibraryRow]()
@Published @Published
var totalPages = 0 var totalPages = 0
@Published @Published
var currentPage = 0 var currentPage = 0
@Published @Published
var hasNextPage = false var hasNextPage = false
@Published @Published
var hasPreviousPage = false var hasPreviousPage = false
private var libraries = [BaseItemDto]() private var libraries = [BaseItemDto]()
private let columns: Int private let columns: Int
@RouterObject @RouterObject
var router: MovieLibrariesCoordinator.Router? var router: MovieLibrariesCoordinator.Router?
init(columns: Int = 7) { init(columns: Int = 7) {
self.columns = columns self.columns = columns
super.init() super.init()
requestLibraries() requestLibraries()
} }
func requestLibraries() { func requestLibraries() {
UserViewsAPI.getUserViews(userId: SessionManager.main.currentLogin.user.id) UserViewsAPI.getUserViews(userId: SessionManager.main.currentLogin.user.id)
.trackActivity(loading) .trackActivity(loading)
.sink(receiveCompletion: { completion in .sink(receiveCompletion: { completion in
self.handleAPIRequestError(completion: completion) self.handleAPIRequestError(completion: completion)
}, receiveValue: { response in }, receiveValue: { response in
if let responseItems = response.items { if let responseItems = response.items {
self.libraries = [] self.libraries = []
for library in responseItems { for library in responseItems {
if library.collectionType == "movies" { if library.collectionType == "movies" {
self.libraries.append(library) self.libraries.append(library)
} }
} }
self.rows = self.calculateRows() self.rows = self.calculateRows()
if self.libraries.count == 1, let library = self.libraries.first { if self.libraries.count == 1, let library = self.libraries.first {
// make this library the root of this stack // make this library the root of this stack
self.router?.coordinator.root(\.rootLibrary, library) self.router?.coordinator.root(\.rootLibrary, library)
} }
} }
}) })
.store(in: &cancellables) .store(in: &cancellables)
} }
private func calculateRows() -> [LibraryRow] { private func calculateRows() -> [LibraryRow] {
guard !libraries.isEmpty else { return [] } guard !libraries.isEmpty else { return [] }
let rowCount = libraries.count / columns let rowCount = libraries.count / columns
var calculatedRows = [LibraryRow]() var calculatedRows = [LibraryRow]()
for i in 0 ... rowCount { for i in 0 ... rowCount {
let firstItemIndex = i * columns let firstItemIndex = i * columns
var lastItemIndex = firstItemIndex + columns var lastItemIndex = firstItemIndex + columns
if lastItemIndex > libraries.count { if lastItemIndex > libraries.count {
lastItemIndex = libraries.count lastItemIndex = libraries.count
} }
var rowCells = [LibraryRowCell]() var rowCells = [LibraryRowCell]()
for item in libraries[firstItemIndex ..< lastItemIndex] { for item in libraries[firstItemIndex ..< lastItemIndex] {
let newCell = LibraryRowCell(item: item) let newCell = LibraryRowCell(item: item)
rowCells.append(newCell) rowCells.append(newCell)
} }
if i == rowCount && hasNextPage { if i == rowCount && hasNextPage {
var loadingCell = LibraryRowCell(item: nil) var loadingCell = LibraryRowCell(item: nil)
loadingCell.loadingCell = true loadingCell.loadingCell = true
rowCells.append(loadingCell) rowCells.append(loadingCell)
} }
calculatedRows.append(LibraryRow(section: i, calculatedRows.append(LibraryRow(
items: rowCells)) section: i,
} items: rowCells
return calculatedRows ))
} }
return calculatedRows
}
} }

View File

@ -11,36 +11,36 @@ import JellyfinAPI
final class QuickConnectSettingsViewModel: ViewModel { final class QuickConnectSettingsViewModel: ViewModel {
@Published @Published
var quickConnectCode = "" var quickConnectCode = ""
@Published @Published
var showSuccessMessage = false var showSuccessMessage = false
var alertTitle: String { var alertTitle: String {
var message: String = "" var message: String = ""
if errorMessage?.code != ErrorMessage.noShowErrorCode { if errorMessage?.code != ErrorMessage.noShowErrorCode {
message.append(contentsOf: "\(errorMessage?.code ?? ErrorMessage.noShowErrorCode)\n") message.append(contentsOf: "\(errorMessage?.code ?? ErrorMessage.noShowErrorCode)\n")
} }
message.append(contentsOf: "\(errorMessage?.title ?? L10n.unknownError)") message.append(contentsOf: "\(errorMessage?.title ?? L10n.unknownError)")
return message return message
} }
func sendQuickConnect() { func sendQuickConnect() {
QuickConnectAPI.authorize(code: self.quickConnectCode) QuickConnectAPI.authorize(code: self.quickConnectCode)
.trackActivity(loading) .trackActivity(loading)
.sink(receiveCompletion: { completion in .sink(receiveCompletion: { completion in
self.handleAPIRequestError(displayMessage: L10n.quickConnectInvalidError, completion: completion) self.handleAPIRequestError(displayMessage: L10n.quickConnectInvalidError, completion: completion)
switch completion { switch completion {
case .failure: case .failure:
LogManager.log.debug("Invalid Quick Connect code entered") LogManager.log.debug("Invalid Quick Connect code entered")
default: default:
break break
} }
}, receiveValue: { _ in }, receiveValue: { _ in
// receiving a successful HTTP response indicates a valid code // receiving a successful HTTP response indicates a valid code
LogManager.log.debug("Valid Quick connect code entered") LogManager.log.debug("Valid Quick connect code entered")
self.showSuccessMessage = true self.showSuccessMessage = true
}) })
.store(in: &cancellables) .store(in: &cancellables)
} }
} }

View File

@ -11,22 +11,22 @@ import JellyfinAPI
class ServerDetailViewModel: ViewModel { class ServerDetailViewModel: ViewModel {
@Published @Published
var server: SwiftfinStore.State.Server var server: SwiftfinStore.State.Server
init(server: SwiftfinStore.State.Server) { init(server: SwiftfinStore.State.Server) {
self.server = server self.server = server
} }
func setServerCurrentURI(uri: String) { func setServerCurrentURI(uri: String) {
SessionManager.main.setServerCurrentURI(server: server, uri: uri) SessionManager.main.setServerCurrentURI(server: server, uri: uri)
.sink { c in .sink { c in
print(c) print(c)
} receiveValue: { newServerState in } receiveValue: { newServerState in
self.server = newServerState self.server = newServerState
Notifications[.didChangeServerCurrentURI].post(object: newServerState) Notifications[.didChangeServerCurrentURI].post(object: newServerState)
} }
.store(in: &cancellables) .store(in: &cancellables)
} }
} }

Some files were not shown because too many files have changed in this diff Show More