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:
build:
name: "Lint 🧹"
runs-on: macos-latest
runs-on: macos-12
steps:
- name: Checkout

View File

@ -1,16 +1,15 @@
# version: 0.47.5
# version: 0.49.11
--swiftversion 5.5
--indent tab
--tabwidth 4
--xcodeindentation enabled
--semicolons never
--stripunusedargs closure-only
--maxwidth 140
--assetliterals visual-width
--wraparguments after-first
--wrapparameters after-first
--wraparguments before-first
--wrapparameters before-first
--wrapcollections before-first
--wrapconditions after-first
--funcattributes prev-line
@ -44,7 +43,6 @@
redundantClosure, \
redundantType
--exclude Pods
--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"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -11,21 +11,21 @@ import JellyfinAPI
struct ErrorMessage: Identifiable {
let code: Int
let title: String
let message: String
let code: Int
let title: String
let message: String
// 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
static let noShowErrorCode = -69420
// 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
static let noShowErrorCode = -69420
var id: String {
"\(code)\(title)\(message)"
}
var id: String {
"\(code)\(title)\(message)"
}
init(code: Int, title: String, message: String) {
self.code = code
self.title = title
self.message = message
}
init(code: Int, title: String, message: String) {
self.code = code
self.title = title
self.message = message
}
}

View File

@ -11,90 +11,104 @@ import JellyfinAPI
enum NetworkError: Error {
/// For the case that the ErrorResponse object has a code of -1
case URLError(response: ErrorResponse, displayMessage: String?)
/// For the case that the ErrorResponse object has a code of -1
case URLError(response: ErrorResponse, displayMessage: String?)
/// For the case that the ErrorRespones object has a code of -2
case HTTPURLError(response: ErrorResponse, displayMessage: String?)
/// For the case that the ErrorRespones object has a code of -2
case HTTPURLError(response: ErrorResponse, displayMessage: String?)
/// For the case that the ErrorResponse object has a positive code
case JellyfinError(response: ErrorResponse, displayMessage: String?)
/// For the case that the ErrorResponse object has a positive code
case JellyfinError(response: ErrorResponse, displayMessage: String?)
var errorMessage: ErrorMessage {
switch self {
case let .URLError(response, displayMessage):
return NetworkError.parseURLError(from: response, displayMessage: displayMessage)
case let .HTTPURLError(response, displayMessage):
return NetworkError.parseHTTPURLError(from: response, displayMessage: displayMessage)
case let .JellyfinError(response, displayMessage):
return NetworkError.parseJellyfinError(from: response, displayMessage: displayMessage)
}
}
var errorMessage: ErrorMessage {
switch self {
case let .URLError(response, displayMessage):
return NetworkError.parseURLError(from: response, displayMessage: displayMessage)
case let .HTTPURLError(response, displayMessage):
return NetworkError.parseHTTPURLError(from: response, displayMessage: displayMessage)
case let .JellyfinError(response, displayMessage):
return NetworkError.parseJellyfinError(from: response, displayMessage: displayMessage)
}
}
private static func parseURLError(from response: ErrorResponse, displayMessage: String?) -> ErrorMessage {
let errorMessage: ErrorMessage
private static func parseURLError(from response: ErrorResponse, displayMessage: String?) -> ErrorMessage {
let errorMessage: ErrorMessage
switch response {
case let .error(_, _, _, err):
switch response {
case let .error(_, _, _, err):
// Code references:
// https://developer.apple.com/documentation/foundation/1508628-url_loading_system_error_codes
switch err._code {
case -1001:
errorMessage = ErrorMessage(code: err._code,
title: L10n.error,
message: L10n.networkTimedOut)
case -1003:
errorMessage = ErrorMessage(code: err._code,
title: L10n.error,
message: L10n.unableToFindHost)
case -1004:
errorMessage = ErrorMessage(code: err._code,
title: L10n.error,
message: L10n.cannotConnectToHost)
default:
errorMessage = ErrorMessage(code: err._code,
title: L10n.error,
message: L10n.unknownError)
}
}
// Code references:
// https://developer.apple.com/documentation/foundation/1508628-url_loading_system_error_codes
switch err._code {
case -1001:
errorMessage = ErrorMessage(
code: err._code,
title: L10n.error,
message: L10n.networkTimedOut
)
case -1003:
errorMessage = ErrorMessage(
code: err._code,
title: L10n.error,
message: L10n.unableToFindHost
)
case -1004:
errorMessage = ErrorMessage(
code: err._code,
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 {
let errorMessage: ErrorMessage
private static func parseHTTPURLError(from response: ErrorResponse, displayMessage: String?) -> ErrorMessage {
let errorMessage: ErrorMessage
// Not implemented as has not run into one of these errors as time of writing
switch response {
case .error:
errorMessage = ErrorMessage(code: 0,
title: L10n.error,
message: "An HTTP URL error has occurred")
}
// Not implemented as has not run into one of these errors as time of writing
switch response {
case .error:
errorMessage = ErrorMessage(
code: 0,
title: L10n.error,
message: "An HTTP URL error has occurred"
)
}
return errorMessage
}
return errorMessage
}
private static func parseJellyfinError(from response: ErrorResponse, displayMessage: String?) -> ErrorMessage {
let errorMessage: ErrorMessage
private static func parseJellyfinError(from response: ErrorResponse, displayMessage: String?) -> ErrorMessage {
let errorMessage: ErrorMessage
switch response {
case let .error(code, _, _, _):
switch response {
case let .error(code, _, _, _):
// Generic HTTP status codes
switch code {
case 401:
errorMessage = ErrorMessage(code: code,
title: L10n.unauthorized,
message: L10n.unauthorizedUser)
default:
errorMessage = ErrorMessage(code: code,
title: L10n.error,
message: displayMessage ?? L10n.unknownError)
}
}
// Generic HTTP status codes
switch code {
case 401:
errorMessage = ErrorMessage(
code: code,
title: L10n.unauthorized,
message: L10n.unauthorizedUser
)
default:
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
public extension UIImage {
convenience init?(blurHash: String, size: CGSize, punch: Float = 1) {
guard blurHash.count >= 6 else { return nil }
convenience init?(blurHash: String, size: CGSize, punch: Float = 1) {
guard blurHash.count >= 6 else { return nil }
let sizeFlag = String(blurHash[0]).decode83()
let numY = (sizeFlag / 9) + 1
let numX = (sizeFlag % 9) + 1
let sizeFlag = String(blurHash[0]).decode83()
let numY = (sizeFlag / 9) + 1
let numX = (sizeFlag % 9) + 1
let quantisedMaximumValue = String(blurHash[1]).decode83()
let maximumValue = Float(quantisedMaximumValue + 1) / 166
let quantisedMaximumValue = String(blurHash[1]).decode83()
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
if i == 0 {
let value = String(blurHash[2 ..< 6]).decode83()
return decodeDC(value)
} else {
let value = String(blurHash[4 + i * 2 ..< 4 + i * 2 + 2]).decode83()
return decodeAC(value, maximumValue: maximumValue * punch)
}
}
let colours: [(Float, Float, Float)] = (0 ..< numX * numY).map { i in
if i == 0 {
let value = String(blurHash[2 ..< 6]).decode83()
return decodeDC(value)
} else {
let value = String(blurHash[4 + i * 2 ..< 4 + i * 2 + 2]).decode83()
return decodeAC(value, maximumValue: maximumValue * punch)
}
}
let width = Int(size.width)
let height = Int(size.height)
let bytesPerRow = width * 3
guard let data = CFDataCreateMutable(kCFAllocatorDefault, bytesPerRow * height) else { return nil }
CFDataSetLength(data, bytesPerRow * height)
guard let pixels = CFDataGetMutableBytePtr(data) else { return nil }
let width = Int(size.width)
let height = Int(size.height)
let bytesPerRow = width * 3
guard let data = CFDataCreateMutable(kCFAllocatorDefault, bytesPerRow * height) else { return nil }
CFDataSetLength(data, bytesPerRow * height)
guard let pixels = CFDataGetMutableBytePtr(data) else { return nil }
for y in 0 ..< height {
for x in 0 ..< width {
var r: Float = 0
var g: Float = 0
var b: Float = 0
for y in 0 ..< height {
for x in 0 ..< width {
var r: Float = 0
var g: Float = 0
var b: Float = 0
for j in 0 ..< numY {
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 colour = colours[i + j * numX]
r += colour.0 * basis
g += colour.1 * basis
b += colour.2 * basis
}
}
for j in 0 ..< numY {
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 colour = colours[i + j * numX]
r += colour.0 * basis
g += colour.1 * basis
b += colour.2 * basis
}
}
let intR = UInt8(linearTosRGB(r))
let intG = UInt8(linearTosRGB(g))
let intB = UInt8(linearTosRGB(b))
let intR = UInt8(linearTosRGB(r))
let intG = UInt8(linearTosRGB(g))
let intB = UInt8(linearTosRGB(b))
pixels[3 * x + 0 + y * bytesPerRow] = intR
pixels[3 * x + 1 + y * bytesPerRow] = intG
pixels[3 * x + 2 + y * bytesPerRow] = intB
}
}
pixels[3 * x + 0 + y * bytesPerRow] = intR
pixels[3 * x + 1 + y * bytesPerRow] = intG
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 cgImage = CGImage(width: width, height: height, bitsPerComponent: 8, bitsPerPixel: 24, bytesPerRow: bytesPerRow,
space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: bitmapInfo, provider: provider, decode: nil,
shouldInterpolate: true, intent: .defaultIntent) 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,
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) {
let intR = value >> 16
let intG = (value >> 8) & 255
let intB = value & 255
return (sRGBToLinear(intR), sRGBToLinear(intG), sRGBToLinear(intB))
let intR = value >> 16
let intG = (value >> 8) & 255
let intB = value & 255
return (sRGBToLinear(intR), sRGBToLinear(intG), sRGBToLinear(intB))
}
private func decodeAC(_ value: Int, maximumValue: Float) -> (Float, Float, Float) {
let quantR = value / (19 * 19)
let quantG = (value / 19) % 19
let quantB = value % 19
let quantR = value / (19 * 19)
let quantG = (value / 19) % 19
let quantB = value % 19
let rgb = (signPow((Float(quantR) - 9) / 9, 2) * maximumValue,
signPow((Float(quantG) - 9) / 9, 2) * maximumValue,
signPow((Float(quantB) - 9) / 9, 2) * maximumValue)
let rgb = (
signPow((Float(quantR) - 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 {
copysign(pow(abs(value), exp), value)
copysign(pow(abs(value), exp), value)
}
private func linearTosRGB(_ value: Float) -> Int {
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) }
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) }
}
private func sRGBToLinear<Type: BinaryInteger>(_ value: Type) -> Float {
let v = Float(Int64(value)) / 255
if v <= 0.04045 { return v / 12.92 } else { return pow((v + 0.055) / 1.055, 2.4) }
let v = Float(Int64(value)) / 255
if v <= 0.04045 { return v / 12.92 } else { return pow((v + 0.055) / 1.055, 2.4) }
}
private let encodeCharacters: [String] = {
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~".map { String($0) }
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~".map { String($0) }
}()
private let decodeCharacters: [String: Int] = {
var dict: [String: Int] = [:]
for (index, character) in encodeCharacters.enumerated() {
dict[character] = index
}
return dict
var dict: [String: Int] = [:]
for (index, character) in encodeCharacters.enumerated() {
dict[character] = index
}
return dict
}()
extension String {
func decode83() -> Int {
var value: Int = 0
for character in self {
if let digit = decodeCharacters[String(character)] {
value = value * 83 + digit
}
}
return value
}
func decode83() -> Int {
var value: Int = 0
for character in self {
if let digit = decodeCharacters[String(character)] {
value = value * 83 + digit
}
}
return value
}
}
private extension String {
subscript(offset: Int) -> Character {
self[index(startIndex, offsetBy: offset)]
}
subscript(offset: Int) -> Character {
self[index(startIndex, offsetBy: offset)]
}
subscript(bounds: CountableClosedRange<Int>) -> Substring {
let start = index(startIndex, offsetBy: bounds.lowerBound)
let end = index(startIndex, offsetBy: bounds.upperBound)
return self[start ... end]
}
subscript(bounds: CountableClosedRange<Int>) -> Substring {
let start = index(startIndex, offsetBy: bounds.lowerBound)
let end = index(startIndex, offsetBy: bounds.upperBound)
return self[start ... end]
}
subscript(bounds: CountableRange<Int>) -> Substring {
let start = index(startIndex, offsetBy: bounds.lowerBound)
let end = index(startIndex, offsetBy: bounds.upperBound)
return self[start ..< end]
}
subscript(bounds: CountableRange<Int>) -> Substring {
let start = index(startIndex, offsetBy: bounds.lowerBound)
let end = index(startIndex, offsetBy: bounds.upperBound)
return self[start ..< end]
}
}

View File

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

View File

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

View File

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

View File

@ -10,21 +10,21 @@ import SwiftUI
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
static let systemFill = Color(UIColor.white)
static let secondarySystemFill = Color(UIColor.gray)
static let tertiarySystemFill = Color(UIColor.black)
static let lightGray = Color(UIColor.lightGray)
#else
static let systemFill = Color(UIColor.systemFill)
static let systemBackground = Color(UIColor.systemBackground)
static let secondarySystemFill = Color(UIColor.secondarySystemBackground)
static let tertiarySystemFill = Color(UIColor.tertiarySystemBackground)
#endif
#if os(tvOS) // tvOS doesn't have these
static let systemFill = Color(UIColor.white)
static let secondarySystemFill = Color(UIColor.gray)
static let tertiarySystemFill = Color(UIColor.black)
static let lightGray = Color(UIColor.lightGray)
#else
static let systemFill = Color(UIColor.systemFill)
static let systemBackground = Color(UIColor.systemBackground)
static let secondarySystemFill = Color(UIColor.secondarySystemBackground)
static let tertiarySystemFill = Color(UIColor.tertiarySystemBackground)
#endif
}
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
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 {
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 {
static var bridge: Defaults.NSSecureCodingBridge<Self> { Defaults.NSSecureCodingBridge() }
static var bridge: Defaults.NSSecureCodingBridge<Self> { Defaults.NSSecureCodingBridge() }
}
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 {
static var bridge: Defaults.RawRepresentableBridge<Self> { Defaults.RawRepresentableBridge() }
static var bridge: Defaults.RawRepresentableBridge<Self> { Defaults.RawRepresentableBridge() }
}
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 {
static var bridge: Defaults.NSSecureCodingBridge<Self> { Defaults.NSSecureCodingBridge() }
static var bridge: Defaults.NSSecureCodingBridge<Self> { Defaults.NSSecureCodingBridge() }
}
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 {
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 {
func subtract(_ other: Double, floor: Double) -> Double {
var v = self - other
func subtract(_ other: Double, floor: Double) -> Double {
var v = self - other
if v < floor {
v += abs(floor - v)
}
if v < floor {
v += abs(floor - v)
}
return v
}
return v
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -10,19 +10,19 @@ import Foundation
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" {
guard let path = Bundle.main.path(forResource: "en", ofType: "lproj"),
let bundle = Bundle(path: path) else { return expectedValue }
if expectedValue == key || NSLocale.preferredLanguages.first == "en" {
guard let path = Bundle.main.path(forResource: "en", ofType: "lproj"),
let bundle = Bundle(path: path) else { return expectedValue }
return NSLocalizedString(key, bundle: bundle, comment: "")
} else {
return expectedValue
}
}
return NSLocalizedString(key, bundle: bundle, comment: "")
} else {
return expectedValue
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -9,50 +9,50 @@
import Defaults
enum SubtitleSize: Int32, CaseIterable, Defaults.Serializable {
case smallest
case smaller
case regular
case larger
case largest
case smallest
case smaller
case regular
case larger
case largest
}
// MARK: - appearance
extension SubtitleSize {
var label: String {
switch self {
case .smallest:
return L10n.smallest
case .smaller:
return L10n.smaller
case .regular:
return L10n.regular
case .larger:
return L10n.larger
case .largest:
return L10n.largest
}
}
var label: String {
switch self {
case .smallest:
return L10n.smallest
case .smaller:
return L10n.smaller
case .regular:
return L10n.regular
case .larger:
return L10n.larger
case .largest:
return L10n.largest
}
}
}
// MARK: - sizing for VLC
extension SubtitleSize {
/// 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
var textRendererFontSize: Int {
switch self {
case .smallest:
return 24
case .smaller:
return 20
case .regular:
return 16
case .larger:
return 12
case .largest:
return 8
}
}
/// 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
var textRendererFontSize: Int {
switch self {
case .smallest:
return 24
case .smaller:
return 20
case .regular:
return 16
case .larger:
return 12
case .largest:
return 8
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -11,48 +11,60 @@ import Puppy
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 {
try FileManager.default.createDirectory(atPath: logsDirectory.path,
withIntermediateDirectories: true,
attributes: nil)
} catch {
// logs directory already created
}
do {
try FileManager.default.createDirectory(
atPath: logsDirectory.path,
withIntermediateDirectories: true,
attributes: nil
)
} 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",
fileURL: logFileURL)
fileRotationLogger.format = LogFormatter()
let fileRotationLogger = try! FileRotationLogger(
"org.jellyfin.swiftfin.logger.file-rotation",
fileURL: logFileURL
)
fileRotationLogger.format = LogFormatter()
let consoleLogger = ConsoleLogger("org.jellyfin.swiftfin.logger.console")
consoleLogger.format = LogFormatter()
let consoleLogger = ConsoleLogger("org.jellyfin.swiftfin.logger.console")
consoleLogger.format = LogFormatter()
log.add(fileRotationLogger, withLevel: .debug)
log.add(consoleLogger, withLevel: .debug)
}
log.add(fileRotationLogger, withLevel: .debug)
log.add(consoleLogger, withLevel: .debug)
}
private static func getDocumentsDirectory() -> URL {
// find all possible documents directories for this user
let paths = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)
private static func getDocumentsDirectory() -> URL {
// find all possible documents directories for this user
let paths = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)
// just send back the first one, which ought to be the only one
return paths[0]
}
// just send back the first one, which ought to be the only one
return paths[0]
}
}
class LogFormatter: LogFormattable {
func formatMessage(_ level: LogLevel, message: String, tag: String, function: String,
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)"
}
func formatMessage(
_ level: LogLevel,
message: String,
tag: String,
function: String,
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 {
// MARK: currentLogin
private(set) var currentLogin: CurrentLogin!
// MARK: main
static let main = SessionManager()
// MARK: init
private init() {
if let lastUserID = Defaults[.lastServerUserID],
let user = try? SwiftfinStore.dataStack.fetchOne(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 }
JellyfinAPIAPI.basePath = server.currentURI
setAuthHeader(with: accessToken.value)
currentLogin = (server: existingServer.state, user: user.state)
}
}
// MARK: fetchServers
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] {
guard let storedServer = try? SwiftfinStore.dataStack.fetchOne(From<SwiftfinStore.Models.StoredServer>(),
Where<SwiftfinStore.Models.StoredServer>("id == %@", server.id))
else { fatalError("No stored server associated with given state server?") }
return storedServer.users.map(\.state).sorted(by: { $0.username < $1.username })
}
// MARK: connectToServer publisher
// 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()
if uriComponents.scheme == nil {
uriComponents.scheme = Defaults[.defaultHTTPScheme].rawValue
}
var uri = uriComponents.string ?? ""
if uri.last == "/" {
uri = String(uri.dropLast())
}
JellyfinAPIAPI.basePath = uri
return SystemAPI.getPublicSystemInfo()
.tryMap { response -> (SwiftfinStore.Models.StoredServer, UnsafeDataTransaction) in
let transaction = SwiftfinStore.dataStack.beginUnsafe()
let newServer = transaction.create(Into<SwiftfinStore.Models.StoredServer>())
guard let name = response.serverName,
let id = response.id,
let os = response.operatingSystem,
let version = response.version else { throw JellyfinAPIError("Missing server data from network call") }
newServer.uris = [uri]
newServer.currentURI = uri
newServer.name = name
newServer.id = id
newServer.os = os
newServer.version = version
newServer.users = []
// Check for existing server on device
if let existingServer = try? SwiftfinStore.dataStack.fetchOne(From<SwiftfinStore.Models.StoredServer>(),
[Where<SwiftfinStore.Models.StoredServer>("id == %@",
newServer.id)])
{
throw SwiftfinStore.Error.existingServer(existingServer.state)
}
return (newServer, transaction)
}
.handleEvents(receiveOutput: { _, transaction in
try? transaction.commitAndWait()
})
.map { server, _ in
server.state
}
.eraseToAnyPublisher()
}
// MARK: addURIToServer publisher
func addURIToServer(server: SwiftfinStore.State.Server, uri: String) -> AnyPublisher<SwiftfinStore.State.Server, Error> {
Just(server)
.tryMap { server -> (SwiftfinStore.Models.StoredServer, UnsafeDataTransaction) in
let transaction = SwiftfinStore.dataStack.beginUnsafe()
guard let existingServer = try? SwiftfinStore.dataStack.fetchOne(From<SwiftfinStore.Models.StoredServer>(),
[Where<SwiftfinStore.Models.StoredServer>("id == %@",
server.id)])
else {
fatalError("No stored server associated with given state server?")
}
guard let editServer = transaction.edit(existingServer) else { fatalError("Can't get proxy for existing object?") }
editServer.uris.insert(uri)
return (editServer, transaction)
}
.handleEvents(receiveOutput: { _, transaction in
try? transaction.commitAndWait()
})
.map { server, _ in
server.state
}
.eraseToAnyPublisher()
}
// MARK: setServerCurrentURI publisher
func setServerCurrentURI(server: SwiftfinStore.State.Server, uri: String) -> AnyPublisher<SwiftfinStore.State.Server, Error> {
Just(server)
.tryMap { server -> (SwiftfinStore.Models.StoredServer, UnsafeDataTransaction) in
let transaction = SwiftfinStore.dataStack.beginUnsafe()
guard let existingServer = try? SwiftfinStore.dataStack.fetchOne(From<SwiftfinStore.Models.StoredServer>(),
[Where<SwiftfinStore.Models.StoredServer>("id == %@",
server.id)])
else {
fatalError("No stored server associated with given state server?")
}
if !existingServer.uris.contains(uri) {
fatalError("Attempting to set current uri while server doesn't contain it?")
}
guard let editServer = transaction.edit(existingServer) else { fatalError("Can't get proxy for existing object?") }
editServer.currentURI = uri
return (editServer, transaction)
}
.handleEvents(receiveOutput: { _, transaction in
try? transaction.commitAndWait()
})
.map { server, _ in
server.state
}
.eraseToAnyPublisher()
}
// MARK: loginUser publisher
// Logs in a user with an associated server, storing if successful
func loginUser(server: SwiftfinStore.State.Server, username: String,
password: String) -> AnyPublisher<SwiftfinStore.State.User, Error>
{
setAuthHeader(with: "")
JellyfinAPIAPI.basePath = server.currentURI
return UserAPI.authenticateUserByName(authenticateUserByNameRequest: .init(username: username, pw: password))
.tryMap { response -> (SwiftfinStore.Models.StoredServer, SwiftfinStore.Models.StoredUser, UnsafeDataTransaction) in
guard let accessToken = response.accessToken else { throw JellyfinAPIError("Access token missing from network call") }
let transaction = SwiftfinStore.dataStack.beginUnsafe()
let newUser = transaction.create(Into<SwiftfinStore.Models.StoredUser>())
guard let username = response.user?.name,
let id = response.user?.id else { throw JellyfinAPIError("Missing user data from network call") }
newUser.username = username
newUser.id = id
newUser.appleTVID = ""
// Check for existing user on device
if let existingUser = try? SwiftfinStore.dataStack.fetchOne(From<SwiftfinStore.Models.StoredUser>(),
[Where<SwiftfinStore.Models.StoredUser>("id == %@",
newUser.id)])
{
throw SwiftfinStore.Error.existingUser(existingUser.state)
}
let newAccessToken = transaction.create(Into<SwiftfinStore.Models.StoredAccessToken>())
newAccessToken.value = accessToken
newUser.accessToken = newAccessToken
guard let userServer = try? SwiftfinStore.dataStack.fetchOne(From<SwiftfinStore.Models.StoredServer>(),
[
Where<SwiftfinStore.Models.StoredServer>("id == %@",
server.id),
])
else { fatalError("No stored server associated with given state server?") }
guard let editUserServer = transaction.edit(userServer) else { fatalError("Can't get proxy for existing object?") }
editUserServer.users.insert(newUser)
return (editUserServer, newUser, transaction)
}
.handleEvents(receiveOutput: { [unowned self] server, user, transaction in
setAuthHeader(with: user.accessToken?.value ?? "")
try? transaction.commitAndWait()
// Fetch for the right queue
let currentServer = SwiftfinStore.dataStack.fetchExisting(server)!
let currentUser = SwiftfinStore.dataStack.fetchExisting(user)!
Defaults[.lastServerUserID] = user.id
currentLogin = (server: currentServer.state, user: currentUser.state)
Notifications[.didSignIn].post()
})
.map { _, user, _ in
user.state
}
.eraseToAnyPublisher()
}
// MARK: loginUser
func loginUser(server: SwiftfinStore.State.Server, user: SwiftfinStore.State.User) {
JellyfinAPIAPI.basePath = server.currentURI
Defaults[.lastServerUserID] = user.id
setAuthHeader(with: user.accessToken)
currentLogin = (server: server, user: user)
Notifications[.didSignIn].post()
}
// MARK: logout
func logout() {
currentLogin = nil
JellyfinAPIAPI.basePath = ""
setAuthHeader(with: "")
Defaults[.lastServerUserID] = nil
Notifications[.didSignOut].post()
}
// MARK: purge
func purge() {
// Delete all servers
let servers = fetchServers()
for server in servers {
delete(server: server)
}
Notifications[.didPurge].post()
}
// MARK: delete user
func delete(user: SwiftfinStore.State.User) {
guard let storedUser = try? SwiftfinStore.dataStack.fetchOne(From<SwiftfinStore.Models.StoredUser>(),
[Where<SwiftfinStore.Models.StoredUser>("id == %@", user.id)])
else { fatalError("No stored user for state user?") }
_delete(user: storedUser, transaction: nil)
}
// MARK: delete server
func delete(server: SwiftfinStore.State.Server) {
guard let storedServer = try? SwiftfinStore.dataStack.fetchOne(From<SwiftfinStore.Models.StoredServer>(),
[Where<SwiftfinStore.Models.StoredServer>("id == %@", server.id)])
else { fatalError("No stored server for state server?") }
_delete(server: storedServer, transaction: nil)
}
private func _delete(user: SwiftfinStore.Models.StoredUser, transaction: UnsafeDataTransaction?) {
guard let storedAccessToken = user.accessToken else { fatalError("No access token for stored user?") }
let transaction = transaction == nil ? SwiftfinStore.dataStack.beginUnsafe() : transaction!
transaction.delete(storedAccessToken)
transaction.delete(user)
try? transaction.commitAndWait()
}
private func _delete(server: SwiftfinStore.Models.StoredServer, transaction: UnsafeDataTransaction?) {
let transaction = transaction == nil ? SwiftfinStore.dataStack.beginUnsafe() : transaction!
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
}
// MARK: currentLogin
private(set) var currentLogin: CurrentLogin!
// MARK: main
static let main = SessionManager()
// MARK: init
private init() {
if let lastUserID = Defaults[.lastServerUserID],
let user = try? SwiftfinStore.dataStack.fetchOne(
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 }
JellyfinAPIAPI.basePath = server.currentURI
setAuthHeader(with: accessToken.value)
currentLogin = (server: existingServer.state, user: user.state)
}
}
// MARK: fetchServers
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] {
guard let storedServer = try? SwiftfinStore.dataStack.fetchOne(
From<SwiftfinStore.Models.StoredServer>(),
Where<SwiftfinStore.Models.StoredServer>("id == %@", server.id)
)
else { fatalError("No stored server associated with given state server?") }
return storedServer.users.map(\.state).sorted(by: { $0.username < $1.username })
}
// MARK: connectToServer publisher
// 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()
if uriComponents.scheme == nil {
uriComponents.scheme = Defaults[.defaultHTTPScheme].rawValue
}
var uri = uriComponents.string ?? ""
if uri.last == "/" {
uri = String(uri.dropLast())
}
JellyfinAPIAPI.basePath = uri
return SystemAPI.getPublicSystemInfo()
.tryMap { response -> (SwiftfinStore.Models.StoredServer, UnsafeDataTransaction) in
let transaction = SwiftfinStore.dataStack.beginUnsafe()
let newServer = transaction.create(Into<SwiftfinStore.Models.StoredServer>())
guard let name = response.serverName,
let id = response.id,
let os = response.operatingSystem,
let version = response.version else { throw JellyfinAPIError("Missing server data from network call") }
newServer.uris = [uri]
newServer.currentURI = uri
newServer.name = name
newServer.id = id
newServer.os = os
newServer.version = version
newServer.users = []
// Check for existing server on device
if let existingServer = try? SwiftfinStore.dataStack.fetchOne(
From<SwiftfinStore.Models.StoredServer>(),
[Where<SwiftfinStore.Models.StoredServer>(
"id == %@",
newServer.id
)]
) {
throw SwiftfinStore.Error.existingServer(existingServer.state)
}
return (newServer, transaction)
}
.handleEvents(receiveOutput: { _, transaction in
try? transaction.commitAndWait()
})
.map { server, _ in
server.state
}
.eraseToAnyPublisher()
}
// MARK: addURIToServer publisher
func addURIToServer(server: SwiftfinStore.State.Server, uri: String) -> AnyPublisher<SwiftfinStore.State.Server, Error> {
Just(server)
.tryMap { server -> (SwiftfinStore.Models.StoredServer, UnsafeDataTransaction) in
let transaction = SwiftfinStore.dataStack.beginUnsafe()
guard let existingServer = try? SwiftfinStore.dataStack.fetchOne(
From<SwiftfinStore.Models.StoredServer>(),
[Where<SwiftfinStore.Models.StoredServer>(
"id == %@",
server.id
)]
)
else {
fatalError("No stored server associated with given state server?")
}
guard let editServer = transaction.edit(existingServer) else { fatalError("Can't get proxy for existing object?") }
editServer.uris.insert(uri)
return (editServer, transaction)
}
.handleEvents(receiveOutput: { _, transaction in
try? transaction.commitAndWait()
})
.map { server, _ in
server.state
}
.eraseToAnyPublisher()
}
// MARK: setServerCurrentURI publisher
func setServerCurrentURI(server: SwiftfinStore.State.Server, uri: String) -> AnyPublisher<SwiftfinStore.State.Server, Error> {
Just(server)
.tryMap { server -> (SwiftfinStore.Models.StoredServer, UnsafeDataTransaction) in
let transaction = SwiftfinStore.dataStack.beginUnsafe()
guard let existingServer = try? SwiftfinStore.dataStack.fetchOne(
From<SwiftfinStore.Models.StoredServer>(),
[Where<SwiftfinStore.Models.StoredServer>(
"id == %@",
server.id
)]
)
else {
fatalError("No stored server associated with given state server?")
}
if !existingServer.uris.contains(uri) {
fatalError("Attempting to set current uri while server doesn't contain it?")
}
guard let editServer = transaction.edit(existingServer) else { fatalError("Can't get proxy for existing object?") }
editServer.currentURI = uri
return (editServer, transaction)
}
.handleEvents(receiveOutput: { _, transaction in
try? transaction.commitAndWait()
})
.map { server, _ in
server.state
}
.eraseToAnyPublisher()
}
// MARK: loginUser publisher
// Logs in a user with an associated server, storing if successful
func loginUser(
server: SwiftfinStore.State.Server,
username: String,
password: String
) -> AnyPublisher<SwiftfinStore.State.User, Error> {
setAuthHeader(with: "")
JellyfinAPIAPI.basePath = server.currentURI
return UserAPI.authenticateUserByName(authenticateUserByNameRequest: .init(username: username, pw: password))
.tryMap { response -> (SwiftfinStore.Models.StoredServer, SwiftfinStore.Models.StoredUser, UnsafeDataTransaction) in
guard let accessToken = response.accessToken else { throw JellyfinAPIError("Access token missing from network call") }
let transaction = SwiftfinStore.dataStack.beginUnsafe()
let newUser = transaction.create(Into<SwiftfinStore.Models.StoredUser>())
guard let username = response.user?.name,
let id = response.user?.id else { throw JellyfinAPIError("Missing user data from network call") }
newUser.username = username
newUser.id = id
newUser.appleTVID = ""
// Check for existing user on device
if let existingUser = try? SwiftfinStore.dataStack.fetchOne(
From<SwiftfinStore.Models.StoredUser>(),
[Where<SwiftfinStore.Models.StoredUser>(
"id == %@",
newUser.id
)]
) {
throw SwiftfinStore.Error.existingUser(existingUser.state)
}
let newAccessToken = transaction.create(Into<SwiftfinStore.Models.StoredAccessToken>())
newAccessToken.value = accessToken
newUser.accessToken = newAccessToken
guard let userServer = try? SwiftfinStore.dataStack.fetchOne(
From<SwiftfinStore.Models.StoredServer>(),
[
Where<SwiftfinStore.Models.StoredServer>(
"id == %@",
server.id
),
]
)
else { fatalError("No stored server associated with given state server?") }
guard let editUserServer = transaction.edit(userServer) else { fatalError("Can't get proxy for existing object?") }
editUserServer.users.insert(newUser)
return (editUserServer, newUser, transaction)
}
.handleEvents(receiveOutput: { [unowned self] server, user, transaction in
setAuthHeader(with: user.accessToken?.value ?? "")
try? transaction.commitAndWait()
// Fetch for the right queue
let currentServer = SwiftfinStore.dataStack.fetchExisting(server)!
let currentUser = SwiftfinStore.dataStack.fetchExisting(user)!
Defaults[.lastServerUserID] = user.id
currentLogin = (server: currentServer.state, user: currentUser.state)
Notifications[.didSignIn].post()
})
.map { _, user, _ in
user.state
}
.eraseToAnyPublisher()
}
// MARK: loginUser
func loginUser(server: SwiftfinStore.State.Server, user: SwiftfinStore.State.User) {
JellyfinAPIAPI.basePath = server.currentURI
Defaults[.lastServerUserID] = user.id
setAuthHeader(with: user.accessToken)
currentLogin = (server: server, user: user)
Notifications[.didSignIn].post()
}
// MARK: logout
func logout() {
currentLogin = nil
JellyfinAPIAPI.basePath = ""
setAuthHeader(with: "")
Defaults[.lastServerUserID] = nil
Notifications[.didSignOut].post()
}
// MARK: purge
func purge() {
// Delete all servers
let servers = fetchServers()
for server in servers {
delete(server: server)
}
Notifications[.didPurge].post()
}
// MARK: delete user
func delete(user: SwiftfinStore.State.User) {
guard let storedUser = try? SwiftfinStore.dataStack.fetchOne(
From<SwiftfinStore.Models.StoredUser>(),
[Where<SwiftfinStore.Models.StoredUser>("id == %@", user.id)]
)
else { fatalError("No stored user for state user?") }
_delete(user: storedUser, transaction: nil)
}
// MARK: delete server
func delete(server: SwiftfinStore.State.Server) {
guard let storedServer = try? SwiftfinStore.dataStack.fetchOne(
From<SwiftfinStore.Models.StoredServer>(),
[Where<SwiftfinStore.Models.StoredServer>("id == %@", server.id)]
)
else { fatalError("No stored server for state server?") }
_delete(server: storedServer, transaction: nil)
}
private func _delete(user: SwiftfinStore.Models.StoredUser, transaction: UnsafeDataTransaction?) {
guard let storedAccessToken = user.accessToken else { fatalError("No access token for stored user?") }
let transaction = transaction == nil ? SwiftfinStore.dataStack.beginUnsafe() : transaction!
transaction.delete(storedAccessToken)
transaction.delete(user)
try? transaction.commitAndWait()
}
private func _delete(server: SwiftfinStore.Models.StoredServer, transaction: UnsafeDataTransaction?) {
let transaction = transaction == nil ? SwiftfinStore.dataStack.beginUnsafe() : transaction!
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 {
private let notificationName: Notification.Name
private let notificationName: Notification.Name
fileprivate init(_ notificationName: Notification.Name) {
self.notificationName = notificationName
}
fileprivate init(_ notificationName: Notification.Name) {
self.notificationName = notificationName
}
func post(object: Any? = nil) {
Notifications.main.post(name: notificationName, object: object)
}
func post(object: Any? = nil) {
Notifications.main.post(name: notificationName, object: object)
}
func subscribe(_ observer: Any, selector: Selector) {
Notifications.main.addObserver(observer, selector: selector, name: notificationName, object: nil)
}
func subscribe(_ observer: Any, selector: Selector) {
Notifications.main.addObserver(observer, selector: selector, name: notificationName, object: nil)
}
func unsubscribe(_ observer: Any) {
Notifications.main.removeObserver(self, name: notificationName, object: nil)
}
func unsubscribe(_ observer: Any) {
Notifications.main.removeObserver(self, name: notificationName, object: nil)
}
}
enum Notifications {
static let main: NotificationCenter = {
NotificationCenter()
}()
static let main: NotificationCenter = {
NotificationCenter()
}()
final class Key {
public typealias NotificationKey = Notifications.Key
final class Key {
public typealias NotificationKey = Notifications.Key
public let key: String
public let underlyingNotification: SwiftfinNotification
public let key: String
public let underlyingNotification: SwiftfinNotification
public init(_ key: String) {
self.key = key
self.underlyingNotification = SwiftfinNotification(Notification.Name(key))
}
}
public init(_ key: String) {
self.key = key
self.underlyingNotification = SwiftfinNotification(Notification.Name(key))
}
}
static subscript(key: Key) -> SwiftfinNotification {
key.underlyingNotification
}
static subscript(key: Key) -> SwiftfinNotification {
key.underlyingNotification
}
static func unsubscribe(_ observer: Any) {
main.removeObserver(observer)
}
static func unsubscribe(_ observer: Any) {
main.removeObserver(observer)
}
}
extension Notifications.Key {
static let didSignIn = NotificationKey("didSignIn")
static let didSignOut = NotificationKey("didSignOut")
static let processDeepLink = NotificationKey("processDeepLink")
static let didPurge = NotificationKey("didPurge")
static let didChangeServerCurrentURI = NotificationKey("didChangeCurrentLoginURI")
static let toggleOfflineMode = NotificationKey("toggleOfflineMode")
static let didDeleteOfflineItem = NotificationKey("didDeleteOfflineItem")
static let didAddDownload = NotificationKey("didAddDownload")
static let didSendStopReport = NotificationKey("didSendStopReport")
static let didSignIn = NotificationKey("didSignIn")
static let didSignOut = NotificationKey("didSignOut")
static let processDeepLink = NotificationKey("processDeepLink")
static let didPurge = NotificationKey("didPurge")
static let didChangeServerCurrentURI = NotificationKey("didChangeCurrentLoginURI")
static let toggleOfflineMode = NotificationKey("toggleOfflineMode")
static let didDeleteOfflineItem = NotificationKey("didDeleteOfflineItem")
static let didAddDownload = NotificationKey("didAddDownload")
static let didSendStopReport = NotificationKey("didSendStopReport")
}

View File

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

View File

@ -10,87 +10,105 @@ import Defaults
import Foundation
extension SwiftfinStore {
enum Defaults {
static let generalSuite: UserDefaults = .init(suiteName: "swiftfinstore-general-defaults")!
enum 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 {
// Universal settings
static let defaultHTTPScheme = Key<HTTPScheme>("defaultHTTPScheme", default: .http, suite: SwiftfinStore.Defaults.universalSuite)
static let appAppearance = Key<AppAppearance>("appAppearance", default: .system, suite: SwiftfinStore.Defaults.universalSuite)
// Universal settings
static let defaultHTTPScheme = Key<HTTPScheme>("defaultHTTPScheme", default: .http, suite: SwiftfinStore.Defaults.universalSuite)
static let appAppearance = Key<AppAppearance>("appAppearance", default: .system, suite: SwiftfinStore.Defaults.universalSuite)
// General settings
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 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 autoSelectSubtitlesLangCode = Key<String>("AutoSelectSubtitlesLangCode",
default: "Auto",
suite: SwiftfinStore.Defaults.generalSuite)
static let autoSelectAudioLangCode = Key<String>("AutoSelectAudioLangCode", default: "Auto", suite: SwiftfinStore.Defaults.generalSuite)
// General settings
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 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 autoSelectSubtitlesLangCode = Key<String>(
"AutoSelectSubtitlesLangCode",
default: "Auto",
suite: SwiftfinStore.Defaults.generalSuite
)
static let autoSelectAudioLangCode = Key<String>("AutoSelectAudioLangCode", default: "Auto", suite: SwiftfinStore.Defaults.generalSuite)
// Customize settings
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 showFlattenView = Key<Bool>("showFlattenView", default: true, suite: SwiftfinStore.Defaults.generalSuite)
// Customize settings
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 showFlattenView = Key<Bool>("showFlattenView", default: true, suite: SwiftfinStore.Defaults.generalSuite)
// Video player / overlay settings
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 systemControlGesturesEnabled = Key<Bool>("systemControlGesturesEnabled",
default: true,
suite: SwiftfinStore.Defaults.generalSuite)
static let playerGesturesLockGestureEnabled = Key<Bool>("playerGesturesLockGestureEnabled",
default: true,
suite: SwiftfinStore.Defaults.generalSuite)
static let seekSlideGestureEnabled = Key<Bool>("seekSlideGestureEnabled",
default: true,
suite: SwiftfinStore.Defaults.generalSuite)
static let videoPlayerJumpForward = Key<VideoPlayerJumpLength>("videoPlayerJumpForward",
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)
// Video player / overlay settings
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 systemControlGesturesEnabled = Key<Bool>(
"systemControlGesturesEnabled",
default: true,
suite: SwiftfinStore.Defaults.generalSuite
)
static let playerGesturesLockGestureEnabled = Key<Bool>(
"playerGesturesLockGestureEnabled",
default: true,
suite: SwiftfinStore.Defaults.generalSuite
)
static let seekSlideGestureEnabled = Key<Bool>(
"seekSlideGestureEnabled",
default: true,
suite: SwiftfinStore.Defaults.generalSuite
)
static let videoPlayerJumpForward = Key<VideoPlayerJumpLength>(
"videoPlayerJumpForward",
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
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 shouldShowAutoPlay = Key<Bool>("shouldShowAutoPlayNextItem", default: true, suite: SwiftfinStore.Defaults.generalSuite)
// Should show video player items
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 shouldShowAutoPlay = Key<Bool>("shouldShowAutoPlayNextItem", default: true, suite: SwiftfinStore.Defaults.generalSuite)
// Should show missing seasons and episodes
static let shouldShowMissingSeasons = Key<Bool>("shouldShowMissingSeasons", default: true, suite: SwiftfinStore.Defaults.generalSuite)
static let shouldShowMissingEpisodes = Key<Bool>("shouldShowMissingEpisodes", default: true, suite: SwiftfinStore.Defaults.generalSuite)
// Should show missing seasons and episodes
static let shouldShowMissingSeasons = Key<Bool>("shouldShowMissingSeasons", 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
static let shouldShowJumpButtonsInOverlayMenu = Key<Bool>("shouldShowJumpButtonsInMenu",
default: true,
suite: SwiftfinStore.Defaults.generalSuite)
// Should show video player items in overlay menu
static let shouldShowJumpButtonsInOverlayMenu = Key<Bool>(
"shouldShowJumpButtonsInMenu",
default: true,
suite: SwiftfinStore.Defaults.generalSuite
)
static let shouldShowChaptersInfoInBottomOverlay = Key<Bool>("shouldShowChaptersInfoInBottomOverlay",
default: true,
suite: SwiftfinStore.Defaults.generalSuite)
static let shouldShowChaptersInfoInBottomOverlay = Key<Bool>(
"shouldShowChaptersInfoInBottomOverlay",
default: true,
suite: SwiftfinStore.Defaults.generalSuite
)
// Experimental settings
enum Experimental {
static let syncSubtitleStateWithAdjacent = Key<Bool>("experimental.syncSubtitleState",
default: false,
suite: SwiftfinStore.Defaults.generalSuite)
static let forceDirectPlay = Key<Bool>("forceDirectPlay", default: false, 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 liveTVForceDirectPlay = Key<Bool>("liveTVForceDirectPlay", default: false, suite: SwiftfinStore.Defaults.generalSuite)
static let liveTVNativePlayer = Key<Bool>("liveTVNativePlayer", default: false, suite: SwiftfinStore.Defaults.generalSuite)
}
// Experimental settings
enum Experimental {
static let syncSubtitleStateWithAdjacent = Key<Bool>(
"experimental.syncSubtitleState",
default: false,
suite: SwiftfinStore.Defaults.generalSuite
)
static let forceDirectPlay = Key<Bool>("forceDirectPlay", default: false, 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 liveTVForceDirectPlay = Key<Bool>("liveTVForceDirectPlay", default: false, suite: SwiftfinStore.Defaults.generalSuite)
static let liveTVNativePlayer = Key<Bool>("liveTVNativePlayer", default: false, suite: SwiftfinStore.Defaults.generalSuite)
}
// tvos specific
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 tvOSCinematicViews = Key<Bool>("tvOSCinematicViews", default: false, suite: SwiftfinStore.Defaults.generalSuite)
// tvos specific
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 tvOSCinematicViews = Key<Bool>("tvOSCinematicViews", default: false, suite: SwiftfinStore.Defaults.generalSuite)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -13,83 +13,88 @@ import JellyfinAPI
final class SeriesItemViewModel: ItemViewModel {
@Published
var seasons: [BaseItemDto] = []
@Published
var seasons: [BaseItemDto] = []
override init(item: BaseItemDto) {
super.init(item: item)
override init(item: BaseItemDto) {
super.init(item: item)
requestSeasons()
getNextUp()
}
requestSeasons()
getNextUp()
}
override func playButtonText() -> String {
override func playButtonText() -> String {
if item.unaired {
return L10n.unaired
}
if item.unaired {
return L10n.unaired
}
if item.missing {
return L10n.missing
}
if item.missing {
return L10n.missing
}
guard let playButtonItem = playButtonItem, let episodeLocator = playButtonItem.getEpisodeLocator() else { return L10n.play }
return episodeLocator
}
guard let playButtonItem = playButtonItem, let episodeLocator = playButtonItem.getEpisodeLocator() else { return L10n.play }
return episodeLocator
}
override func shouldDisplayRuntime() -> Bool {
false
}
override func shouldDisplayRuntime() -> Bool {
false
}
private func getNextUp() {
private func getNextUp() {
LogManager.log.debug("Getting next up for show \(self.item.id!) (\(self.item.name!))")
TvShowsAPI.getNextUp(userId: SessionManager.main.currentLogin.user.id,
fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people],
seriesId: self.item.id!,
enableUserData: true)
.trackActivity(loading)
.sink(receiveCompletion: { [weak self] completion in
self?.handleAPIRequestError(completion: completion)
}, receiveValue: { [weak self] response in
if let nextUpItem = response.items?.first, !nextUpItem.unaired, !nextUpItem.missing {
self?.playButtonItem = nextUpItem
}
})
.store(in: &cancellables)
}
LogManager.log.debug("Getting next up for show \(self.item.id!) (\(self.item.name!))")
TvShowsAPI.getNextUp(
userId: SessionManager.main.currentLogin.user.id,
fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people],
seriesId: self.item.id!,
enableUserData: true
)
.trackActivity(loading)
.sink(receiveCompletion: { [weak self] completion in
self?.handleAPIRequestError(completion: completion)
}, receiveValue: { [weak self] response in
if let nextUpItem = response.items?.first, !nextUpItem.unaired, !nextUpItem.missing {
self?.playButtonItem = nextUpItem
}
})
.store(in: &cancellables)
}
private func getRunYears() -> String {
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy"
private func getRunYears() -> String {
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy"
var startYear: String?
var endYear: String?
var startYear: String?
var endYear: String?
if item.premiereDate != nil {
startYear = dateFormatter.string(from: item.premiereDate!)
}
if item.premiereDate != nil {
startYear = dateFormatter.string(from: item.premiereDate!)
}
if item.endDate != nil {
endYear = dateFormatter.string(from: item.endDate!)
}
if item.endDate != nil {
endYear = dateFormatter.string(from: item.endDate!)
}
return "\(startYear ?? L10n.unknown) - \(endYear ?? L10n.present)"
}
return "\(startYear ?? L10n.unknown) - \(endYear ?? L10n.present)"
}
private func requestSeasons() {
LogManager.log.debug("Getting seasons of show \(self.item.id!) (\(self.item.name!))")
TvShowsAPI.getSeasons(seriesId: item.id ?? "", userId: SessionManager.main.currentLogin.user.id,
fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people],
isMissing: Defaults[.shouldShowMissingSeasons] ? nil : false,
enableUserData: true)
.trackActivity(loading)
.sink(receiveCompletion: { [weak self] completion in
self?.handleAPIRequestError(completion: completion)
}, receiveValue: { [weak self] response in
self?.seasons = response.items ?? []
LogManager.log.debug("Retrieved \(String(self?.seasons.count ?? 0)) seasons")
})
.store(in: &cancellables)
}
private func requestSeasons() {
LogManager.log.debug("Getting seasons of show \(self.item.id!) (\(self.item.name!))")
TvShowsAPI.getSeasons(
seriesId: item.id ?? "",
userId: SessionManager.main.currentLogin.user.id,
fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people],
isMissing: Defaults[.shouldShowMissingSeasons] ? nil : false,
enableUserData: true
)
.trackActivity(loading)
.sink(receiveCompletion: { [weak self] completion in
self?.handleAPIRequestError(completion: completion)
}, receiveValue: { [weak self] response in
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 {
@Published
var items = [BaseItemDto]()
@Published
var items = [BaseItemDto]()
let library: BaseItemDto
let library: BaseItemDto
init(library: BaseItemDto) {
self.library = library
super.init()
init(library: BaseItemDto) {
self.library = library
super.init()
requestLatestMedia()
}
requestLatestMedia()
}
func requestLatestMedia() {
LogManager.log.debug("Requesting latest media for user id \(SessionManager.main.currentLogin.user.id)")
UserLibraryAPI.getLatestMedia(userId: SessionManager.main.currentLogin.user.id,
parentId: library.id ?? "",
fields: [
.primaryImageAspectRatio,
.seriesPrimaryImage,
.seasonUserData,
.overview,
.genres,
.people,
],
includeItemTypes: [.series, .movie],
enableUserData: true, limit: 12)
.trackActivity(loading)
.sink(receiveCompletion: { [weak self] completion in
self?.handleAPIRequestError(completion: completion)
}, receiveValue: { [weak self] response in
self?.items = response
LogManager.log.debug("Retrieved \(String(self?.items.count ?? 0)) items")
})
.store(in: &cancellables)
}
func requestLatestMedia() {
LogManager.log.debug("Requesting latest media for user id \(SessionManager.main.currentLogin.user.id)")
UserLibraryAPI.getLatestMedia(
userId: SessionManager.main.currentLogin.user.id,
parentId: library.id ?? "",
fields: [
.primaryImageAspectRatio,
.seriesPrimaryImage,
.seasonUserData,
.overview,
.genres,
.people,
],
includeItemTypes: [.series, .movie],
enableUserData: true,
limit: 12
)
.trackActivity(loading)
.sink(receiveCompletion: { [weak self] completion in
self?.handleAPIRequestError(completion: completion)
}, receiveValue: { [weak self] response in
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
enum FilterType {
case tag
case genre
case sortOrder
case sortBy
case filter
case tag
case genre
case sortOrder
case sortBy
case filter
}
final class LibraryFilterViewModel: ViewModel {
@Published
var modifiedFilters = LibraryFilters()
@Published
var modifiedFilters = LibraryFilters()
@Published
var possibleGenres = [NameGuidPair]()
@Published
var possibleTags = [String]()
@Published
var possibleSortOrders = APISortOrder.allCases
@Published
var possibleSortBys = SortBy.allCases
@Published
var possibleItemFilters = ItemFilter.supportedTypes
@Published
var enabledFilterType: [FilterType]
@Published
var selectedSortOrder: APISortOrder = .descending
@Published
var selectedSortBy: SortBy = .name
@Published
var possibleGenres = [NameGuidPair]()
@Published
var possibleTags = [String]()
@Published
var possibleSortOrders = APISortOrder.allCases
@Published
var possibleSortBys = SortBy.allCases
@Published
var possibleItemFilters = ItemFilter.supportedTypes
@Published
var enabledFilterType: [FilterType]
@Published
var selectedSortOrder: APISortOrder = .descending
@Published
var selectedSortBy: SortBy = .name
var parentId: String = ""
var parentId: String = ""
func updateModifiedFilter() {
modifiedFilters.sortOrder = [selectedSortOrder]
modifiedFilters.sortBy = [selectedSortBy]
}
func updateModifiedFilter() {
modifiedFilters.sortOrder = [selectedSortOrder]
modifiedFilters.sortBy = [selectedSortBy]
}
func resetFilters() {
modifiedFilters = LibraryFilters(filters: [], sortOrder: [.ascending], withGenres: [], tags: [], sortBy: [.name])
}
func resetFilters() {
modifiedFilters = LibraryFilters(filters: [], sortOrder: [.ascending], withGenres: [], tags: [], sortBy: [.name])
}
init(filters: LibraryFilters? = nil,
enabledFilterType: [FilterType] = [.tag, .genre, .sortBy, .sortOrder, .filter], parentId: String)
{
self.enabledFilterType = enabledFilterType
self.selectedSortBy = filters?.sortBy.first ?? .name
self.selectedSortOrder = filters?.sortOrder.first ?? .descending
self.parentId = parentId
init(
filters: LibraryFilters? = nil,
enabledFilterType: [FilterType] = [.tag, .genre, .sortBy, .sortOrder, .filter],
parentId: String
) {
self.enabledFilterType = enabledFilterType
self.selectedSortBy = filters?.sortBy.first ?? .name
self.selectedSortOrder = filters?.sortOrder.first ?? .descending
self.parentId = parentId
super.init()
if let filters = filters {
self.modifiedFilters = filters
}
requestQueryFilters()
}
super.init()
if let filters = filters {
self.modifiedFilters = filters
}
requestQueryFilters()
}
func requestQueryFilters() {
FilterAPI.getQueryFilters(userId: SessionManager.main.currentLogin.user.id,
parentId: self.parentId)
.trackActivity(loading)
.sink(receiveCompletion: { [weak self] completion in
self?.handleAPIRequestError(completion: completion)
}, receiveValue: { [weak self] queryFilters in
guard let self = self else { return }
self.possibleGenres = queryFilters.genres ?? []
self.possibleTags = queryFilters.tags ?? []
})
.store(in: &cancellables)
}
func requestQueryFilters() {
FilterAPI.getQueryFilters(
userId: SessionManager.main.currentLogin.user.id,
parentId: self.parentId
)
.trackActivity(loading)
.sink(receiveCompletion: { [weak self] completion in
self?.handleAPIRequestError(completion: completion)
}, receiveValue: { [weak self] queryFilters in
guard let self = self else { return }
self.possibleGenres = queryFilters.genres ?? []
self.possibleTags = queryFilters.tags ?? []
})
.store(in: &cancellables)
}
}

View File

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

View File

@ -14,140 +14,166 @@ import SwiftUI
final class LibrarySearchViewModel: ViewModel {
@Published
var supportedItemTypeList = [ItemType]()
@Published
var supportedItemTypeList = [ItemType]()
@Published
var selectedItemType: ItemType = .movie
@Published
var selectedItemType: ItemType = .movie
@Published
var movieItems = [BaseItemDto]()
@Published
var showItems = [BaseItemDto]()
@Published
var episodeItems = [BaseItemDto]()
@Published
var movieItems = [BaseItemDto]()
@Published
var showItems = [BaseItemDto]()
@Published
var episodeItems = [BaseItemDto]()
@Published
var suggestions = [BaseItemDto]()
@Published
var suggestions = [BaseItemDto]()
var searchQuerySubject = CurrentValueSubject<String, Never>("")
var parentID: String?
var searchQuerySubject = CurrentValueSubject<String, Never>("")
var parentID: String?
init(parentID: String?) {
self.parentID = parentID
super.init()
init(parentID: String?) {
self.parentID = parentID
super.init()
searchQuerySubject
.filter { !$0.isEmpty }
.debounce(for: 0.25, scheduler: DispatchQueue.main)
.sink(receiveValue: search)
.store(in: &cancellables)
setupPublishersForSupportedItemType()
searchQuerySubject
.filter { !$0.isEmpty }
.debounce(for: 0.25, scheduler: DispatchQueue.main)
.sink(receiveValue: search)
.store(in: &cancellables)
setupPublishersForSupportedItemType()
requestSuggestions()
}
requestSuggestions()
}
func setupPublishersForSupportedItemType() {
Publishers.CombineLatest3($movieItems, $showItems, $episodeItems)
.debounce(for: 0.25, scheduler: DispatchQueue.main)
.map { arg -> [ItemType] in
var typeList = [ItemType]()
if !arg.0.isEmpty {
typeList.append(.movie)
}
if !arg.1.isEmpty {
typeList.append(.series)
}
if !arg.2.isEmpty {
typeList.append(.episode)
}
return typeList
}
.receive(on: DispatchQueue.main)
.sink(receiveValue: { [weak self] typeList in
withAnimation {
self?.supportedItemTypeList = typeList
}
})
.store(in: &cancellables)
func setupPublishersForSupportedItemType() {
Publishers.CombineLatest3($movieItems, $showItems, $episodeItems)
.debounce(for: 0.25, scheduler: DispatchQueue.main)
.map { arg -> [ItemType] in
var typeList = [ItemType]()
if !arg.0.isEmpty {
typeList.append(.movie)
}
if !arg.1.isEmpty {
typeList.append(.series)
}
if !arg.2.isEmpty {
typeList.append(.episode)
}
return typeList
}
.receive(on: DispatchQueue.main)
.sink(receiveValue: { [weak self] typeList in
withAnimation {
self?.supportedItemTypeList = typeList
}
})
.store(in: &cancellables)
$supportedItemTypeList
.receive(on: DispatchQueue.main)
.withLatestFrom($selectedItemType)
.compactMap { selectedItemType in
if self.supportedItemTypeList.contains(selectedItemType) {
return selectedItemType
} else {
return self.supportedItemTypeList.first
}
}
.sink(receiveValue: { [weak self] itemType in
withAnimation {
self?.selectedItemType = itemType
}
})
.store(in: &cancellables)
}
$supportedItemTypeList
.receive(on: DispatchQueue.main)
.withLatestFrom($selectedItemType)
.compactMap { selectedItemType in
if self.supportedItemTypeList.contains(selectedItemType) {
return selectedItemType
} else {
return self.supportedItemTypeList.first
}
}
.sink(receiveValue: { [weak self] itemType in
withAnimation {
self?.selectedItemType = itemType
}
})
.store(in: &cancellables)
}
func requestSuggestions() {
ItemsAPI.getItemsByUserId(userId: SessionManager.main.currentLogin.user.id,
limit: 20,
recursive: true,
parentId: parentID,
includeItemTypes: [.movie, .series],
sortBy: ["IsFavoriteOrLiked", "Random"],
imageTypeLimit: 0,
enableTotalRecordCount: false,
enableImages: false)
.trackActivity(loading)
.receive(on: DispatchQueue.main)
.sink(receiveCompletion: { [weak self] completion in
self?.handleAPIRequestError(completion: completion)
}, receiveValue: { [weak self] response in
self?.suggestions = response.items ?? []
})
.store(in: &cancellables)
}
func requestSuggestions() {
ItemsAPI.getItemsByUserId(
userId: SessionManager.main.currentLogin.user.id,
limit: 20,
recursive: true,
parentId: parentID,
includeItemTypes: [.movie, .series],
sortBy: ["IsFavoriteOrLiked", "Random"],
imageTypeLimit: 0,
enableTotalRecordCount: false,
enableImages: false
)
.trackActivity(loading)
.receive(on: DispatchQueue.main)
.sink(receiveCompletion: { [weak self] completion in
self?.handleAPIRequestError(completion: completion)
}, receiveValue: { [weak self] response in
self?.suggestions = response.items ?? []
})
.store(in: &cancellables)
}
func search(with query: String) {
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: [.movie], 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?.movieItems = 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: [.series], 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?.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)
}
func search(with query: String) {
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: [.movie],
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?.movieItems = 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: [.series],
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?.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>
struct LibraryRowCell: Hashable {
let id = UUID()
let item: BaseItemDto?
var loadingCell: Bool = false
let id = UUID()
let item: BaseItemDto?
var loadingCell: Bool = false
}
final class LibraryViewModel: ViewModel {
@Published
var items: [BaseItemDto] = []
@Published
var rows: [LibraryRow] = []
@Published
var items: [BaseItemDto] = []
@Published
var rows: [LibraryRow] = []
@Published
var totalPages = 0
@Published
var currentPage = 0
@Published
var hasNextPage = false
@Published
var totalPages = 0
@Published
var currentPage = 0
@Published
var hasNextPage = false
// temp
@Published
var filters: LibraryFilters
// temp
@Published
var filters: LibraryFilters
var parentID: String?
var person: BaseItemPerson?
var genre: NameGuidPair?
var studio: NameGuidPair?
private let columns: Int
private let pageItemSize: Int
var parentID: String?
var person: BaseItemPerson?
var genre: NameGuidPair?
var studio: NameGuidPair?
private let columns: Int
private let pageItemSize: Int
var enabledFilterType: [FilterType] {
if genre == nil {
return [.tag, .genre, .sortBy, .sortOrder, .filter]
} else {
return [.tag, .sortBy, .sortOrder, .filter]
}
}
var enabledFilterType: [FilterType] {
if genre == nil {
return [.tag, .genre, .sortBy, .sortOrder, .filter]
} else {
return [.tag, .sortBy, .sortOrder, .filter]
}
}
init(parentID: String? = nil,
person: BaseItemPerson? = nil,
genre: NameGuidPair? = nil,
studio: NameGuidPair? = nil,
filters: LibraryFilters = LibraryFilters(filters: [], sortOrder: [.ascending], withGenres: [], sortBy: [.name]),
columns: Int = 7)
{
self.parentID = parentID
self.person = person
self.genre = genre
self.studio = studio
self.filters = filters
self.columns = columns
init(
parentID: String? = nil,
person: BaseItemPerson? = nil,
genre: NameGuidPair? = nil,
studio: NameGuidPair? = nil,
filters: LibraryFilters = LibraryFilters(filters: [], sortOrder: [.ascending], withGenres: [], sortBy: [.name]),
columns: Int = 7
) {
self.parentID = parentID
self.person = person
self.genre = genre
self.studio = studio
self.filters = filters
self.columns = columns
// Size is typical size of portrait items
self.pageItemSize = UIScreen.itemsFillableOnScreen(width: 130, height: 185)
// Size is typical size of portrait items
self.pageItemSize = UIScreen.itemsFillableOnScreen(width: 130, height: 185)
super.init()
super.init()
$filters
.sink(receiveValue: { newFilters in
self.requestItemsAsync(with: newFilters, replaceCurrentItems: true)
})
.store(in: &cancellables)
}
$filters
.sink(receiveValue: { newFilters in
self.requestItemsAsync(with: newFilters, replaceCurrentItems: true)
})
.store(in: &cancellables)
}
func requestItemsAsync(with filters: LibraryFilters, replaceCurrentItems: Bool = false) {
func requestItemsAsync(with filters: LibraryFilters, replaceCurrentItems: Bool = false) {
if replaceCurrentItems {
self.items = []
}
if replaceCurrentItems {
self.items = []
}
let personIDs: [String] = [person].compactMap(\.?.id)
let studioIDs: [String] = [studio].compactMap(\.?.id)
let genreIDs: [String]
if filters.withGenres.isEmpty {
genreIDs = [genre].compactMap(\.?.id)
} else {
genreIDs = filters.withGenres.compactMap(\.id)
}
let sortBy = filters.sortBy.map(\.rawValue)
let queryRecursive = Defaults[.showFlattenView] || filters.filters.contains(.isFavorite) ||
self.person != nil ||
self.genre != nil ||
self.studio != nil
let includeItemTypes: [BaseItemKind]
if filters.filters.contains(.isFavorite) {
includeItemTypes = [.movie, .series, .season, .episode, .boxSet]
} else {
includeItemTypes = [.movie, .series, .boxSet] + (Defaults[.showFlattenView] ? [] : [.folder])
}
let personIDs: [String] = [person].compactMap(\.?.id)
let studioIDs: [String] = [studio].compactMap(\.?.id)
let genreIDs: [String]
if filters.withGenres.isEmpty {
genreIDs = [genre].compactMap(\.?.id)
} else {
genreIDs = filters.withGenres.compactMap(\.id)
}
let sortBy = filters.sortBy.map(\.rawValue)
let queryRecursive = Defaults[.showFlattenView] || filters.filters.contains(.isFavorite) ||
self.person != nil ||
self.genre != nil ||
self.studio != nil
let includeItemTypes: [BaseItemKind]
if filters.filters.contains(.isFavorite) {
includeItemTypes = [.movie, .series, .season, .episode, .boxSet]
} else {
includeItemTypes = [.movie, .series, .boxSet] + (Defaults[.showFlattenView] ? [] : [.folder])
}
ItemsAPI.getItemsByUserId(userId: SessionManager.main.currentLogin.user.id, startIndex: currentPage * pageItemSize,
limit: pageItemSize,
recursive: queryRecursive,
searchTerm: nil,
sortOrder: filters.sortOrder.compactMap { SortOrder(rawValue: $0.rawValue) },
parentId: parentID,
fields: [
.primaryImageAspectRatio,
.seriesPrimaryImage,
.seasonUserData,
.overview,
.genres,
.people,
.chapters,
],
includeItemTypes: includeItemTypes,
filters: filters.filters,
sortBy: sortBy,
tags: filters.tags,
enableUserData: true,
personIds: personIDs,
studioIds: studioIDs,
genreIds: genreIDs,
enableImages: true)
.trackActivity(loading)
.sink(receiveCompletion: { [weak self] completion in
self?.handleAPIRequestError(completion: completion)
}, receiveValue: { [weak self] response in
ItemsAPI.getItemsByUserId(
userId: SessionManager.main.currentLogin.user.id,
startIndex: currentPage * pageItemSize,
limit: pageItemSize,
recursive: queryRecursive,
searchTerm: nil,
sortOrder: filters.sortOrder.compactMap { SortOrder(rawValue: $0.rawValue) },
parentId: parentID,
fields: [
.primaryImageAspectRatio,
.seriesPrimaryImage,
.seasonUserData,
.overview,
.genres,
.people,
.chapters,
],
includeItemTypes: includeItemTypes,
filters: filters.filters,
sortBy: sortBy,
tags: filters.tags,
enableUserData: true,
personIds: personIDs,
studioIds: studioIDs,
genreIds: genreIDs,
enableImages: true
)
.trackActivity(loading)
.sink(receiveCompletion: { [weak self] completion in
self?.handleAPIRequestError(completion: completion)
}, receiveValue: { [weak self] response in
guard let self = self else { return }
let totalPages = ceil(Double(response.totalRecordCount ?? 0) / Double(self.pageItemSize))
guard let self = self else { return }
let totalPages = ceil(Double(response.totalRecordCount ?? 0) / Double(self.pageItemSize))
self.totalPages = Int(totalPages)
self.hasNextPage = self.currentPage < self.totalPages - 1
self.items.append(contentsOf: response.items ?? [])
self.rows = self.calculateRows(for: self.items)
})
.store(in: &cancellables)
}
self.totalPages = Int(totalPages)
self.hasNextPage = self.currentPage < self.totalPages - 1
self.items.append(contentsOf: response.items ?? [])
self.rows = self.calculateRows(for: self.items)
})
.store(in: &cancellables)
}
func requestNextPageAsync() {
currentPage += 1
requestItemsAsync(with: filters)
}
func requestNextPageAsync() {
currentPage += 1
requestItemsAsync(with: filters)
}
// tvOS calculations for collection view
private func calculateRows(for itemList: [BaseItemDto]) -> [LibraryRow] {
guard !itemList.isEmpty else { return [] }
let rowCount = itemList.count / columns
var calculatedRows = [LibraryRow]()
for i in 0 ... rowCount {
let firstItemIndex = i * columns
var lastItemIndex = firstItemIndex + columns
if lastItemIndex > itemList.count {
lastItemIndex = itemList.count
}
// tvOS calculations for collection view
private func calculateRows(for itemList: [BaseItemDto]) -> [LibraryRow] {
guard !itemList.isEmpty else { return [] }
let rowCount = itemList.count / columns
var calculatedRows = [LibraryRow]()
for i in 0 ... rowCount {
let firstItemIndex = i * columns
var lastItemIndex = firstItemIndex + columns
if lastItemIndex > itemList.count {
lastItemIndex = itemList.count
}
var rowCells = [LibraryRowCell]()
for item in itemList[firstItemIndex ..< lastItemIndex] {
let newCell = LibraryRowCell(item: item)
rowCells.append(newCell)
}
if i == rowCount, hasNextPage {
var loadingCell = LibraryRowCell(item: nil)
loadingCell.loadingCell = true
rowCells.append(loadingCell)
}
var rowCells = [LibraryRowCell]()
for item in itemList[firstItemIndex ..< lastItemIndex] {
let newCell = LibraryRowCell(item: item)
rowCells.append(newCell)
}
if i == rowCount, hasNextPage {
var loadingCell = LibraryRowCell(item: nil)
loadingCell.loadingCell = true
rowCells.append(loadingCell)
}
calculatedRows.append(LibraryRow(section: i,
items: rowCells))
}
return calculatedRows
}
calculatedRows.append(LibraryRow(
section: i,
items: rowCells
))
}
return calculatedRows
}
}
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 itemSize = width * height
let screenSize = UIScreen.main.bounds.height * UIScreen.main.bounds.width
let itemSize = width * height
#if os(tvOS)
return Int(screenSize / itemSize) * 2
#else
return Int(screenSize / itemSize)
#endif
}
#if os(tvOS)
return Int(screenSize / itemSize) * 2
#else
return Int(screenSize / itemSize)
#endif
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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