diff --git a/.swiftformat b/.swiftformat index 9cb225e2..a4733581 100644 --- a/.swiftformat +++ b/.swiftformat @@ -1,18 +1,49 @@ # version: 0.47.5 ---indent 4 #indent ---self init-only # redundantSelf ---semicolons never # semicolons ---stripunusedargs closure-only # unusedArguments ---maxwidth 140 #wrap ---assetliterals visual-width #wrap ---wraparguments after-first # wrapArguments ---wrapparameters after-first # wrapArguments ---wrapcollections before-first # wrapArguments ---wrapconditions after-first # wrapArguments ---funcattributes prev-line # wrapAttributes ---typeattributes prev-line # wrapAttributes ---varattributes prev-line # wrapAttributes +--swiftversion 5.5 + +--indent tab +--tabwidth 4 +--xcodeindentation enabled +--self init-only +--semicolons never +--stripunusedargs closure-only +--maxwidth 140 +--assetliterals visual-width +--wraparguments after-first +--wrapparameters after-first +--wrapcollections before-first +--wrapconditions after-first +--funcattributes prev-line +--typeattributes prev-line +--varattributes prev-line +--trailingclosures +--shortoptionals "always" + +--enable isEmpty, \ + leadingDelimiters, \ + wrapEnumCases, \ + typeSugar, \ + void, \ + trailingSpace, \ + spaceInsideParens, \ + spaceInsideGenerics, \ + spaceInsideComments, \ + spaceInsideBrackets, \ + spaceInsideBraces, \ + blankLinesAroundMark, \ + redundantLet, \ + redundantInit, \ + blankLinesAroundMark + +--disable strongOutlets, \ + yodaConditions, \ + blankLinesAtStartOfScope,\ + andOperator, \ + redundantFileprivate, \ + redundantSelf + +--exclude Pods + +--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" ---enable isEmpty ---disable strongOutlets,yodaConditions \ No newline at end of file diff --git a/Shared/Coordinators/BasicAppSettingsCoordinator.swift b/Shared/Coordinators/BasicAppSettingsCoordinator.swift index f9b0d5f5..251082a8 100644 --- a/Shared/Coordinators/BasicAppSettingsCoordinator.swift +++ b/Shared/Coordinators/BasicAppSettingsCoordinator.swift @@ -1,11 +1,10 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Foundation import Stinsen @@ -13,11 +12,13 @@ import SwiftUI final class BasicAppSettingsCoordinator: NavigationCoordinatable { - let stack = NavigationStack(initial: \BasicAppSettingsCoordinator.start) + let stack = NavigationStack(initial: \BasicAppSettingsCoordinator.start) - @Root var start = makeStart + @Root + var start = makeStart - @ViewBuilder func makeStart() -> some View { - BasicAppSettingsView(viewModel: BasicAppSettingsViewModel()) - } + @ViewBuilder + func makeStart() -> some View { + BasicAppSettingsView(viewModel: BasicAppSettingsViewModel()) + } } diff --git a/Shared/Coordinators/ConnectToServerCoodinator.swift b/Shared/Coordinators/ConnectToServerCoodinator.swift index ef2321f7..e6e833c5 100644 --- a/Shared/Coordinators/ConnectToServerCoodinator.swift +++ b/Shared/Coordinators/ConnectToServerCoodinator.swift @@ -1,11 +1,10 @@ // -/* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Foundation import Stinsen @@ -13,16 +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 { - return 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()) + } } diff --git a/Shared/Coordinators/FilterCoordinator.swift b/Shared/Coordinators/FilterCoordinator.swift index 7ff6b7c5..10a13f05 100644 --- a/Shared/Coordinators/FilterCoordinator.swift +++ b/Shared/Coordinators/FilterCoordinator.swift @@ -1,11 +1,10 @@ // -/* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Foundation import Stinsen @@ -15,21 +14,24 @@ typealias FilterCoordinatorParams = (filters: Binding, 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, enabledFilterType: [FilterType], parentId: String) { - _filters = filters - self.enabledFilterType = enabledFilterType - self.parentId = parentId - } + init(filters: Binding, 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) + } } diff --git a/Shared/Coordinators/HomeCoordinator.swift b/Shared/Coordinators/HomeCoordinator.swift index e373de0b..3ce9bc3f 100644 --- a/Shared/Coordinators/HomeCoordinator.swift +++ b/Shared/Coordinators/HomeCoordinator.swift @@ -1,11 +1,10 @@ // -/* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Foundation import JellyfinAPI @@ -14,36 +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 { - NavigationViewCoordinator(SettingsCoordinator()) - } + func makeSettings() -> NavigationViewCoordinator { + 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 { - return NavigationViewCoordinator(ItemCoordinator(item: item)) - } + func makeModalItem(item: BaseItemDto) -> NavigationViewCoordinator { + NavigationViewCoordinator(ItemCoordinator(item: item)) + } - func makeModalLibrary(params: LibraryCoordinatorParams) -> NavigationViewCoordinator { - return NavigationViewCoordinator(LibraryCoordinator(viewModel: params.viewModel, title: params.title)) - } + func makeModalLibrary(params: LibraryCoordinatorParams) -> NavigationViewCoordinator { + NavigationViewCoordinator(LibraryCoordinator(viewModel: params.viewModel, title: params.title)) + } - @ViewBuilder func makeStart() -> some View { - HomeView() - } + @ViewBuilder + func makeStart() -> some View { + HomeView() + } } diff --git a/Shared/Coordinators/ItemCoordinator.swift b/Shared/Coordinators/ItemCoordinator.swift index f9d13164..9bb29489 100644 --- a/Shared/Coordinators/ItemCoordinator.swift +++ b/Shared/Coordinators/ItemCoordinator.swift @@ -1,11 +1,10 @@ // -/* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Foundation import JellyfinAPI @@ -14,37 +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 makeItemOverview(item: BaseItemDto) -> NavigationViewCoordinator { - NavigationViewCoordinator(ItemOverviewCoordinator(item: itemDto)) - } + func makeItem(item: BaseItemDto) -> ItemCoordinator { + ItemCoordinator(item: item) + } - func makeVideoPlayer(viewModel: VideoPlayerViewModel) -> NavigationViewCoordinator { - NavigationViewCoordinator(VideoPlayerCoordinator(viewModel: viewModel)) - } + func makeItemOverview(item: BaseItemDto) -> NavigationViewCoordinator { + NavigationViewCoordinator(ItemOverviewCoordinator(item: itemDto)) + } - @ViewBuilder func makeStart() -> some View { - ItemNavigationView(item: itemDto) - } + func makeVideoPlayer(viewModel: VideoPlayerViewModel) -> NavigationViewCoordinator { + NavigationViewCoordinator(VideoPlayerCoordinator(viewModel: viewModel)) + } + + @ViewBuilder + func makeStart() -> some View { + ItemNavigationView(item: itemDto) + } } diff --git a/Shared/Coordinators/ItemOverviewCoordinator.swift b/Shared/Coordinators/ItemOverviewCoordinator.swift index 870109ec..db093f56 100644 --- a/Shared/Coordinators/ItemOverviewCoordinator.swift +++ b/Shared/Coordinators/ItemOverviewCoordinator.swift @@ -1,33 +1,34 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// +import JellyfinAPI import Stinsen import SwiftUI -import JellyfinAPI final class ItemOverviewCoordinator: NavigationCoordinatable { - let stack = NavigationStack(initial: \ItemOverviewCoordinator.start) - - @Root var start = makeStart - - let item: BaseItemDto - - init(item: BaseItemDto) { - self.item = item - } - - @ViewBuilder func makeStart() -> some View { - #if os(tvOS) - EmptyView() - #else - ItemOverviewView(item: item) - #endif - } + let stack = NavigationStack(initial: \ItemOverviewCoordinator.start) + + @Root + var start = makeStart + + let item: BaseItemDto + + init(item: BaseItemDto) { + self.item = item + } + + @ViewBuilder + func makeStart() -> some View { + #if os(tvOS) + EmptyView() + #else + ItemOverviewView(item: item) + #endif + } } diff --git a/Shared/Coordinators/LibraryCoordinator.swift b/Shared/Coordinators/LibraryCoordinator.swift index 50a4485a..1678e407 100644 --- a/Shared/Coordinators/LibraryCoordinator.swift +++ b/Shared/Coordinators/LibraryCoordinator.swift @@ -1,11 +1,10 @@ // -/* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Foundation import JellyfinAPI @@ -16,41 +15,47 @@ 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 { - NavigationViewCoordinator(FilterCoordinator(filters: params.filters, - enabledFilterType: params.enabledFilterType, - parentId: params.parentId)) - } + func makeFilter(params: FilterCoordinatorParams) -> NavigationViewCoordinator { + 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 { - return NavigationViewCoordinator(ItemCoordinator(item: item)) - } + func makeModalItem(item: BaseItemDto) -> NavigationViewCoordinator { + NavigationViewCoordinator(ItemCoordinator(item: item)) + } } diff --git a/Shared/Coordinators/LibraryListCoordinator.swift b/Shared/Coordinators/LibraryListCoordinator.swift index 932004bf..a413ff83 100644 --- a/Shared/Coordinators/LibraryListCoordinator.swift +++ b/Shared/Coordinators/LibraryListCoordinator.swift @@ -1,11 +1,10 @@ // -/* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Foundation import Stinsen @@ -13,28 +12,31 @@ 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 - - let viewModel: LibraryListViewModel + @Root + var start = makeStart + @Route(.push) + var search = makeSearch + @Route(.push) + var library = makeLibrary - init(viewModel: LibraryListViewModel) { - self.viewModel = viewModel - } - - func makeLibrary(params: LibraryCoordinatorParams) -> LibraryCoordinator { - LibraryCoordinator(viewModel: params.viewModel, title: params.title) - } + let viewModel: LibraryListViewModel - func makeSearch(viewModel: LibrarySearchViewModel) -> SearchCoordinator { - SearchCoordinator(viewModel: viewModel) - } + init(viewModel: LibraryListViewModel) { + self.viewModel = viewModel + } - @ViewBuilder - func makeStart() -> some View { - LibraryListView(viewModel: self.viewModel) - } + func makeLibrary(params: LibraryCoordinatorParams) -> LibraryCoordinator { + LibraryCoordinator(viewModel: params.viewModel, title: params.title) + } + + func makeSearch(viewModel: LibrarySearchViewModel) -> SearchCoordinator { + SearchCoordinator(viewModel: viewModel) + } + + @ViewBuilder + func makeStart() -> some View { + LibraryListView(viewModel: self.viewModel) + } } diff --git a/Shared/Coordinators/LiveTVChannelsCoordinator.swift b/Shared/Coordinators/LiveTVChannelsCoordinator.swift index 4b521208..1797bd14 100644 --- a/Shared/Coordinators/LiveTVChannelsCoordinator.swift +++ b/Shared/Coordinators/LiveTVChannelsCoordinator.swift @@ -1,11 +1,10 @@ // -/* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Foundation import JellyfinAPI @@ -13,34 +12,38 @@ 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 - - func makeModalItem(item: BaseItemDto) -> NavigationViewCoordinator { - return NavigationViewCoordinator(ItemCoordinator(item: item)) - } - - func makeVideoPlayer(item: BaseItemDto) -> NavigationViewCoordinator { -// NavigationViewCoordinator(VideoPlayerCoordinator(item: item)) - NavigationViewCoordinator(EmptyViewCoordinator()) - } - - @ViewBuilder - func makeStart() -> some View { - LiveTVChannelsView() - } + @Root + var start = makeStart + @Route(.modal) + var modalItem = makeModalItem + @Route(.fullScreen) + var videoPlayer = makeVideoPlayer + + func makeModalItem(item: BaseItemDto) -> NavigationViewCoordinator { + NavigationViewCoordinator(ItemCoordinator(item: item)) + } + + func makeVideoPlayer(item: BaseItemDto) -> NavigationViewCoordinator { + // NavigationViewCoordinator(VideoPlayerCoordinator(item: item)) + NavigationViewCoordinator(EmptyViewCoordinator()) + } + + @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 - - @ViewBuilder - func makeStart() -> some View { - Text("Empty") - } + @Root + var start = makeStart + + @ViewBuilder + func makeStart() -> some View { + Text("Empty") + } } diff --git a/Shared/Coordinators/LiveTVProgramsCoordinator.swift b/Shared/Coordinators/LiveTVProgramsCoordinator.swift index a216dd46..af708d30 100644 --- a/Shared/Coordinators/LiveTVProgramsCoordinator.swift +++ b/Shared/Coordinators/LiveTVProgramsCoordinator.swift @@ -1,11 +1,10 @@ // -/* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Foundation import JellyfinAPI @@ -13,19 +12,21 @@ import Stinsen import SwiftUI final class LiveTVProgramsCoordinator: NavigationCoordinatable { - - let stack = NavigationStack(initial: \LiveTVProgramsCoordinator.start) - @Root var start = makeStart - @Route(.fullScreen) var videoPlayer = makeVideoPlayer - - func makeVideoPlayer(item: BaseItemDto) -> NavigationViewCoordinator { -// NavigationViewCoordinator(VideoPlayerCoordinator(item: item)) - NavigationViewCoordinator(EmptyViewCoordinator()) - } - - @ViewBuilder - func makeStart() -> some View { - LiveTVProgramsView() - } + let stack = NavigationStack(initial: \LiveTVProgramsCoordinator.start) + + @Root + var start = makeStart + @Route(.fullScreen) + var videoPlayer = makeVideoPlayer + + func makeVideoPlayer(item: BaseItemDto) -> NavigationViewCoordinator { + // NavigationViewCoordinator(VideoPlayerCoordinator(item: item)) + NavigationViewCoordinator(EmptyViewCoordinator()) + } + + @ViewBuilder + func makeStart() -> some View { + LiveTVProgramsView() + } } diff --git a/Shared/Coordinators/LiveTVTabCoordinator.swift b/Shared/Coordinators/LiveTVTabCoordinator.swift index 6d0d28d8..afcf7ab8 100644 --- a/Shared/Coordinators/LiveTVTabCoordinator.swift +++ b/Shared/Coordinators/LiveTVTabCoordinator.swift @@ -1,57 +1,62 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Foundation -import SwiftUI import Stinsen +import SwiftUI final class LiveTVTabCoordinator: TabCoordinatable { - 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 - - func makePrograms() -> NavigationViewCoordinator { - return NavigationViewCoordinator(LiveTVProgramsCoordinator()) - } - - @ViewBuilder func makeProgramsTab(isActive: Bool) -> some View { - HStack { - Image(systemName: "tv") - Text("Programs") - } - } - - func makeChannels() -> NavigationViewCoordinator { - return NavigationViewCoordinator(LiveTVChannelsCoordinator()) - } - - @ViewBuilder func makeChannelsTab(isActive: Bool) -> some View { - HStack { - Image(systemName: "square.grid.3x3") - Text("Channels") - } - } - - func makeHome() -> LiveTVHomeView { - return LiveTVHomeView() - } - - @ViewBuilder func makeHomeTab(isActive: Bool) -> some View { - HStack { - Image(systemName: "house") - Text("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 + + func makePrograms() -> NavigationViewCoordinator { + NavigationViewCoordinator(LiveTVProgramsCoordinator()) + } + + @ViewBuilder + func makeProgramsTab(isActive: Bool) -> some View { + HStack { + Image(systemName: "tv") + Text("Programs") + } + } + + func makeChannels() -> NavigationViewCoordinator { + NavigationViewCoordinator(LiveTVChannelsCoordinator()) + } + + @ViewBuilder + func makeChannelsTab(isActive: Bool) -> some View { + HStack { + Image(systemName: "square.grid.3x3") + Text("Channels") + } + } + + func makeHome() -> LiveTVHomeView { + LiveTVHomeView() + } + + @ViewBuilder + func makeHomeTab(isActive: Bool) -> some View { + HStack { + Image(systemName: "house") + Text("Home") + } + } } diff --git a/Shared/Coordinators/MainCoordinator/iOSMainCoordinator.swift b/Shared/Coordinators/MainCoordinator/iOSMainCoordinator.swift index 5a1e5eb7..7e47fa65 100644 --- a/Shared/Coordinators/MainCoordinator/iOSMainCoordinator.swift +++ b/Shared/Coordinators/MainCoordinator/iOSMainCoordinator.swift @@ -1,11 +1,10 @@ // -/* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Combine import Defaults @@ -16,83 +15,91 @@ import SwiftUI import WidgetKit final class MainCoordinator: NavigationCoordinatable { - var stack: NavigationStack + var stack: NavigationStack - @Root var mainTab = makeMainTab - @Root var serverList = makeServerList - - private var cancellables = Set() + @Root + var mainTab = makeMainTab + @Root + var serverList = makeServerList - init() { - if SessionManager.main.currentLogin != nil { - self.stack = NavigationStack(initial: \MainCoordinator.mainTab) - } else { - self.stack = NavigationStack(initial: \MainCoordinator.serverList) - } + private var cancellables = Set() - ImageCache.shared.costLimit = 125 * 1024 * 1024 // 125MB memory - DataLoader.sharedUrlCache.diskCapacity = 1000 * 1024 * 1024 // 1000MB disk + init() { + if SessionManager.main.currentLogin != nil { + self.stack = NavigationStack(initial: \MainCoordinator.mainTab) + } else { + self.stack = NavigationStack(initial: \MainCoordinator.serverList) + } - WidgetCenter.shared.reloadAllTimelines() - UIScrollView.appearance().keyboardDismissMode = .onDrag + ImageCache.shared.costLimit = 125 * 1024 * 1024 // 125MB memory + DataLoader.sharedUrlCache.diskCapacity = 1000 * 1024 * 1024 // 1000MB disk - // 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) + WidgetCenter.shared.reloadAllTimelines() + UIScrollView.appearance().keyboardDismissMode = .onDrag - // Notification setup for state - let nc = SwiftfinNotificationCenter.main - nc.addObserver(self, selector: #selector(didLogIn), name: SwiftfinNotificationCenter.Keys.didSignIn, object: nil) - nc.addObserver(self, selector: #selector(didLogOut), name: SwiftfinNotificationCenter.Keys.didSignOut, object: nil) - nc.addObserver(self, selector: #selector(processDeepLink), name: SwiftfinNotificationCenter.Keys.processDeepLink, object: nil) - nc.addObserver(self, selector: #selector(didChangeServerCurrentURI), name: SwiftfinNotificationCenter.Keys.didChangeServerCurrentURI, object: nil) - - Defaults.publisher(.appAppearance) - .sink { _ in - JellyfinPlayerApp.setupAppearance() - } - .store(in: &cancellables) - } + // 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) - @objc func didLogIn() { - LogManager.shared.log.info("Received `didSignIn` from SwiftfinNotificationCenter.") - root(\.mainTab) - } + // Notification setup for state + let nc = SwiftfinNotificationCenter.main + nc.addObserver(self, selector: #selector(didLogIn), name: SwiftfinNotificationCenter.Keys.didSignIn, object: nil) + nc.addObserver(self, selector: #selector(didLogOut), name: SwiftfinNotificationCenter.Keys.didSignOut, object: nil) + nc.addObserver(self, selector: #selector(processDeepLink), name: SwiftfinNotificationCenter.Keys.processDeepLink, object: nil) + nc.addObserver(self, selector: #selector(didChangeServerCurrentURI), + name: SwiftfinNotificationCenter.Keys.didChangeServerCurrentURI, object: nil) - @objc func didLogOut() { - LogManager.shared.log.info("Received `didSignOut` from SwiftfinNotificationCenter.") - root(\.serverList) - } + Defaults.publisher(.appAppearance) + .sink { _ in + JellyfinPlayerApp.setupAppearance() + } + .store(in: &cancellables) + } - @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 didLogIn() { + LogManager.shared.log.info("Received `didSignIn` from SwiftfinNotificationCenter.") + root(\.mainTab) + } - @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 didLogOut() { + LogManager.shared.log.info("Received `didSignOut` from SwiftfinNotificationCenter.") + root(\.serverList) + } - func makeMainTab() -> MainTabCoordinator { - MainTabCoordinator() - } + @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) + } + } + } - func makeServerList() -> NavigationViewCoordinator { - NavigationViewCoordinator(ServerListCoordinator()) - } + @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 makeServerList() -> NavigationViewCoordinator { + NavigationViewCoordinator(ServerListCoordinator()) + } } diff --git a/Shared/Coordinators/MainCoordinator/iOSMainTabCoordinator.swift b/Shared/Coordinators/MainCoordinator/iOSMainTabCoordinator.swift index d3777a77..e9d22210 100644 --- a/Shared/Coordinators/MainCoordinator/iOSMainTabCoordinator.swift +++ b/Shared/Coordinators/MainCoordinator/iOSMainTabCoordinator.swift @@ -1,50 +1,54 @@ // -/* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Foundation -import SwiftUI 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) var home = makeHome - @Route(tabItem: makeAllMediaTab) var allMedia = makeAllMedia + @Route(tabItem: makeHomeTab) + var home = makeHome + @Route(tabItem: makeAllMediaTab) + var allMedia = makeAllMedia - func makeHome() -> NavigationViewCoordinator { - return NavigationViewCoordinator(HomeCoordinator()) - } + func makeHome() -> NavigationViewCoordinator { + NavigationViewCoordinator(HomeCoordinator()) + } - @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 { - return NavigationViewCoordinator(LibraryListCoordinator(viewModel: LibraryListViewModel())) - } + func makeAllMedia() -> NavigationViewCoordinator { + NavigationViewCoordinator(LibraryListCoordinator(viewModel: LibraryListViewModel())) + } - @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() + } + } + } } diff --git a/Shared/Coordinators/MainCoordinator/tvOSMainCoordinator.swift b/Shared/Coordinators/MainCoordinator/tvOSMainCoordinator.swift index 2a088e88..632b7790 100644 --- a/Shared/Coordinators/MainCoordinator/tvOSMainCoordinator.swift +++ b/Shared/Coordinators/MainCoordinator/tvOSMainCoordinator.swift @@ -1,11 +1,10 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Foundation import Nuke @@ -13,55 +12,60 @@ import Stinsen import SwiftUI final class MainCoordinator: NavigationCoordinatable { - var stack = NavigationStack(initial: \MainCoordinator.mainTab) + var stack = NavigationStack(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 - let nc = SwiftfinNotificationCenter.main - nc.addObserver(self, selector: #selector(didLogIn), name: SwiftfinNotificationCenter.Keys.didSignIn, object: nil) - nc.addObserver(self, selector: #selector(didLogOut), name: SwiftfinNotificationCenter.Keys.didSignOut, object: nil) - } + // Notification setup for state + let nc = SwiftfinNotificationCenter.main + nc.addObserver(self, selector: #selector(didLogIn), name: SwiftfinNotificationCenter.Keys.didSignIn, object: nil) + nc.addObserver(self, selector: #selector(didLogOut), name: SwiftfinNotificationCenter.Keys.didSignOut, object: nil) + } - @objc func didLogIn() { - LogManager.shared.log.info("Received `didSignIn` from NSNotificationCenter.") - root(\.mainTab) - } + @objc + func didLogIn() { + LogManager.shared.log.info("Received `didSignIn` from NSNotificationCenter.") + root(\.mainTab) + } - @objc func didLogOut() { - LogManager.shared.log.info("Received `didSignOut` from NSNotificationCenter.") - root(\.serverList) - } + @objc + func didLogOut() { + LogManager.shared.log.info("Received `didSignOut` from NSNotificationCenter.") + root(\.serverList) + } - func makeMainTab() -> MainTabCoordinator { - MainTabCoordinator() - } + func makeMainTab() -> MainTabCoordinator { + MainTabCoordinator() + } - func makeServerList() -> NavigationViewCoordinator { - NavigationViewCoordinator(ServerListCoordinator()) - } + func makeServerList() -> NavigationViewCoordinator { + NavigationViewCoordinator(ServerListCoordinator()) + } - func makeLiveTV() -> LiveTVTabCoordinator { - LiveTVTabCoordinator() - } + func makeLiveTV() -> LiveTVTabCoordinator { + LiveTVTabCoordinator() + } } diff --git a/Shared/Coordinators/MainCoordinator/tvOSMainTabCoordinator.swift b/Shared/Coordinators/MainCoordinator/tvOSMainTabCoordinator.swift index de56dd0a..d4ff0f5b 100644 --- a/Shared/Coordinators/MainCoordinator/tvOSMainTabCoordinator.swift +++ b/Shared/Coordinators/MainCoordinator/tvOSMainTabCoordinator.swift @@ -1,80 +1,89 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Foundation -import SwiftUI 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 { - return NavigationViewCoordinator(HomeCoordinator()) - } + func makeHome() -> NavigationViewCoordinator { + 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 { - return NavigationViewCoordinator(TVLibrariesCoordinator(viewModel: TVLibrariesViewModel(), title: "TV Shows")) - } + func makeTv() -> NavigationViewCoordinator { + NavigationViewCoordinator(TVLibrariesCoordinator(viewModel: TVLibrariesViewModel(), title: "TV Shows")) + } - @ViewBuilder func makeTvTab(isActive: Bool) -> some View { - HStack { - Image(systemName: "tv") - Text("TV Shows") - } - } + @ViewBuilder + func makeTvTab(isActive: Bool) -> some View { + HStack { + Image(systemName: "tv") + Text("TV Shows") + } + } - func makeMovies() -> NavigationViewCoordinator { - return NavigationViewCoordinator(MovieLibrariesCoordinator(viewModel: MovieLibrariesViewModel(), title: "Movies")) - } + func makeMovies() -> NavigationViewCoordinator { + NavigationViewCoordinator(MovieLibrariesCoordinator(viewModel: MovieLibrariesViewModel(), title: "Movies")) + } - @ViewBuilder func makeMoviesTab(isActive: Bool) -> some View { - HStack { - Image(systemName: "film") - Text("Movies") - } - } + @ViewBuilder + func makeMoviesTab(isActive: Bool) -> some View { + HStack { + Image(systemName: "film") + Text("Movies") + } + } - func makeOther() -> NavigationViewCoordinator { - return NavigationViewCoordinator(LibraryListCoordinator(viewModel: LibraryListViewModel())) - } + func makeOther() -> NavigationViewCoordinator { + NavigationViewCoordinator(LibraryListCoordinator(viewModel: LibraryListViewModel())) + } - @ViewBuilder func makeOtherTab(isActive: Bool) -> some View { - HStack { - Image(systemName: "folder") - Text("Other") - } - } + @ViewBuilder + func makeOtherTab(isActive: Bool) -> some View { + HStack { + Image(systemName: "folder") + Text("Other") + } + } - func makeSettings() -> NavigationViewCoordinator { - return NavigationViewCoordinator(SettingsCoordinator()) - } + func makeSettings() -> NavigationViewCoordinator { + NavigationViewCoordinator(SettingsCoordinator()) + } - @ViewBuilder func makeSettingsTab(isActive: Bool) -> some View { - Image(systemName: "gearshape.fill") - } + @ViewBuilder + func makeSettingsTab(isActive: Bool) -> some View { + Image(systemName: "gearshape.fill") + } } diff --git a/Shared/Coordinators/MoviesLibrariesCoordinator.swift b/Shared/Coordinators/MoviesLibrariesCoordinator.swift index 9adda2e5..15e14888 100644 --- a/Shared/Coordinators/MoviesLibrariesCoordinator.swift +++ b/Shared/Coordinators/MoviesLibrariesCoordinator.swift @@ -1,11 +1,10 @@ // -/* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Foundation import JellyfinAPI @@ -14,24 +13,27 @@ import SwiftUI final class MovieLibrariesCoordinator: NavigationCoordinatable { - let stack = NavigationStack(initial: \MovieLibrariesCoordinator.start) + let stack = NavigationStack(initial: \MovieLibrariesCoordinator.start) - @Root var start = makeStart - @Route(.push) var library = makeLibrary + @Root + var start = makeStart + @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) + } } diff --git a/Shared/Coordinators/SearchCoordinator.swift b/Shared/Coordinators/SearchCoordinator.swift index 40388163..7a8160f4 100644 --- a/Shared/Coordinators/SearchCoordinator.swift +++ b/Shared/Coordinators/SearchCoordinator.swift @@ -1,35 +1,37 @@ // -/* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Foundation +import JellyfinAPI import Stinsen import SwiftUI -import JellyfinAPI 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) + } } diff --git a/Shared/Coordinators/ServerDetailCoordinator.swift b/Shared/Coordinators/ServerDetailCoordinator.swift index 8b50efe1..6b16cba0 100644 --- a/Shared/Coordinators/ServerDetailCoordinator.swift +++ b/Shared/Coordinators/ServerDetailCoordinator.swift @@ -1,11 +1,10 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Foundation import Stinsen @@ -13,17 +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) + } } diff --git a/Shared/Coordinators/ServerListCoordinator.swift b/Shared/Coordinators/ServerListCoordinator.swift index ba773b7b..94a444f6 100644 --- a/Shared/Coordinators/ServerListCoordinator.swift +++ b/Shared/Coordinators/ServerListCoordinator.swift @@ -1,11 +1,10 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Foundation import Stinsen @@ -13,26 +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 { - NavigationViewCoordinator(BasicAppSettingsCoordinator()) - } + func makeBasicAppSettings() -> NavigationViewCoordinator { + NavigationViewCoordinator(BasicAppSettingsCoordinator()) + } - @ViewBuilder func makeStart() -> some View { - ServerListView(viewModel: ServerListViewModel()) - } + @ViewBuilder + func makeStart() -> some View { + ServerListView(viewModel: ServerListViewModel()) + } } diff --git a/Shared/Coordinators/SettingsCoordinator.swift b/Shared/Coordinators/SettingsCoordinator.swift index 6d91d12a..49ec4775 100644 --- a/Shared/Coordinators/SettingsCoordinator.swift +++ b/Shared/Coordinators/SettingsCoordinator.swift @@ -1,11 +1,10 @@ // -/* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Foundation import Stinsen @@ -13,28 +12,36 @@ 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 + @Root + var start = makeStart + @Route(.push) + var serverDetail = makeServerDetail + @Route(.push) + var overlaySettings = makeOverlaySettings + @Route(.push) + var experimentalSettings = makeExperimentalSettings - @ViewBuilder func makeServerDetail() -> some View { - let viewModel = ServerDetailViewModel(server: SessionManager.main.currentLogin.server) - ServerDetailView(viewModel: viewModel) - } - - @ViewBuilder func makeOverlaySettings() -> some View { - OverlaySettingsView() - } - - @ViewBuilder func makeExperimentalSettings() -> some View { - ExperimentalSettingsView() - } + @ViewBuilder + func makeServerDetail() -> some View { + let viewModel = ServerDetailViewModel(server: SessionManager.main.currentLogin.server) + ServerDetailView(viewModel: viewModel) + } - @ViewBuilder func makeStart() -> some View { - let viewModel = SettingsViewModel(server: SessionManager.main.currentLogin.server, user: SessionManager.main.currentLogin.user) - SettingsView(viewModel: viewModel) - } + @ViewBuilder + func makeOverlaySettings() -> some View { + OverlaySettingsView() + } + + @ViewBuilder + func makeExperimentalSettings() -> some View { + ExperimentalSettingsView() + } + + @ViewBuilder + func makeStart() -> some View { + let viewModel = SettingsViewModel(server: SessionManager.main.currentLogin.server, user: SessionManager.main.currentLogin.user) + SettingsView(viewModel: viewModel) + } } diff --git a/Shared/Coordinators/TVLibrariesCoordinator.swift b/Shared/Coordinators/TVLibrariesCoordinator.swift index b3f85337..b2ec1121 100644 --- a/Shared/Coordinators/TVLibrariesCoordinator.swift +++ b/Shared/Coordinators/TVLibrariesCoordinator.swift @@ -1,11 +1,10 @@ // -/* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Foundation import JellyfinAPI @@ -14,24 +13,27 @@ import SwiftUI final class TVLibrariesCoordinator: NavigationCoordinatable { - let stack = NavigationStack(initial: \TVLibrariesCoordinator.start) + let stack = NavigationStack(initial: \TVLibrariesCoordinator.start) - @Root var start = makeStart - @Route(.push) var library = makeLibrary + @Root + var start = makeStart + @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) + } } diff --git a/Shared/Coordinators/UserListCoordinator.swift b/Shared/Coordinators/UserListCoordinator.swift index 949c1018..2a08f9fa 100644 --- a/Shared/Coordinators/UserListCoordinator.swift +++ b/Shared/Coordinators/UserListCoordinator.swift @@ -1,11 +1,10 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Foundation import Stinsen @@ -13,27 +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 { - return UserSignInCoordinator(viewModel: .init(server: server)) - } + func makeUserSignIn(server: SwiftfinStore.State.Server) -> UserSignInCoordinator { + UserSignInCoordinator(viewModel: .init(server: server)) + } - func makeServerDetail(server: SwiftfinStore.State.Server) -> ServerDetailCoordinator { - return 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) + } } diff --git a/Shared/Coordinators/UserSignInCoordinator.swift b/Shared/Coordinators/UserSignInCoordinator.swift index 09d45e1e..c47dad49 100644 --- a/Shared/Coordinators/UserSignInCoordinator.swift +++ b/Shared/Coordinators/UserSignInCoordinator.swift @@ -1,11 +1,10 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Foundation import Stinsen @@ -13,17 +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) + } } diff --git a/Shared/Coordinators/VideoPlayerCoordinator/iOSVideoPlayerCoordinator.swift b/Shared/Coordinators/VideoPlayerCoordinator/iOSVideoPlayerCoordinator.swift index 33ff2ee4..e193b581 100644 --- a/Shared/Coordinators/VideoPlayerCoordinator/iOSVideoPlayerCoordinator.swift +++ b/Shared/Coordinators/VideoPlayerCoordinator/iOSVideoPlayerCoordinator.swift @@ -1,11 +1,10 @@ // -/* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Defaults import Foundation @@ -15,24 +14,26 @@ 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 { - 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 { + VLCPlayerView(viewModel: self.viewModel) + .navigationBarHidden(true) + .statusBar(hidden: true) + .ignoresSafeArea() + .prefersHomeIndicatorAutoHidden(true) + .supportedOrientations(UIDevice.current.userInterfaceIdiom == .pad ? .all : .landscape) + }.ignoresSafeArea() + } } diff --git a/Shared/Coordinators/VideoPlayerCoordinator/tvOSVideoPlayerCoordinator.swift b/Shared/Coordinators/VideoPlayerCoordinator/tvOSVideoPlayerCoordinator.swift index cffcb52f..e01e279f 100644 --- a/Shared/Coordinators/VideoPlayerCoordinator/tvOSVideoPlayerCoordinator.swift +++ b/Shared/Coordinators/VideoPlayerCoordinator/tvOSVideoPlayerCoordinator.swift @@ -1,11 +1,10 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Defaults import Foundation @@ -15,20 +14,21 @@ 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 { - VLCPlayerView(viewModel: viewModel) - .navigationBarHidden(true) - .ignoresSafeArea() - } + @ViewBuilder + func makeStart() -> some View { + VLCPlayerView(viewModel: viewModel) + .navigationBarHidden(true) + .ignoresSafeArea() + } } - diff --git a/Shared/Errors/ErrorMessage.swift b/Shared/Errors/ErrorMessage.swift index 6b2ddb8b..38cb0d55 100644 --- a/Shared/Errors/ErrorMessage.swift +++ b/Shared/Errors/ErrorMessage.swift @@ -1,35 +1,34 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Foundation import JellyfinAPI struct ErrorMessage: Identifiable { - let code: Int - let title: String - let displayMessage: String - let logConstructor: LogConstructor + let code: Int + let title: String + let displayMessage: String + let logConstructor: LogConstructor - // 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 { - return "\(code)\(title)\(logConstructor.message)" - } + var id: String { + "\(code)\(title)\(logConstructor.message)" + } - /// If the custom displayMessage is `nil`, it will be set to the given logConstructor's message - init(code: Int, title: String, displayMessage: String?, logConstructor: LogConstructor) { - self.code = code - self.title = title - self.displayMessage = displayMessage ?? logConstructor.message - self.logConstructor = logConstructor - } + /// If the custom displayMessage is `nil`, it will be set to the given logConstructor's message + init(code: Int, title: String, displayMessage: String?, logConstructor: LogConstructor) { + self.code = code + self.title = title + self.displayMessage = displayMessage ?? logConstructor.message + self.logConstructor = logConstructor + } } diff --git a/Shared/Errors/LogConstructor.swift b/Shared/Errors/LogConstructor.swift index df603a9d..cd59a3bb 100644 --- a/Shared/Errors/LogConstructor.swift +++ b/Shared/Errors/LogConstructor.swift @@ -1,20 +1,19 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Foundation import JellyfinAPI struct LogConstructor { - var message: String - let tag: String - let level: LogLevel - let function: String - let file: String - let line: UInt + var message: String + let tag: String + let level: LogLevel + let function: String + let file: String + let line: UInt } diff --git a/Shared/Errors/NetworkError.swift b/Shared/Errors/NetworkError.swift index 0905158c..8e68b2eb 100644 --- a/Shared/Errors/NetworkError.swift +++ b/Shared/Errors/NetworkError.swift @@ -1,11 +1,10 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Foundation import JellyfinAPI @@ -13,139 +12,146 @@ import JellyfinAPI /** The implementation of the network errors here are a temporary measure. It is very repetitive, messy, and doesn't fulfill the entire specification of "error reporting". - The specific kind of errors here should be created and surfaced from within JellyfinAPI on API calls. + + Needs to be replaced */ enum NetworkError: Error { - /// For the case that the ErrorResponse object has a code of -1 - case URLError(response: ErrorResponse, displayMessage: String?, logConstructor: LogConstructor) + /// For the case that the ErrorResponse object has a code of -1 + case URLError(response: ErrorResponse, displayMessage: String?, logConstructor: LogConstructor) - /// For the case that the ErrorRespones object has a code of -2 - case HTTPURLError(response: ErrorResponse, displayMessage: String?, logConstructor: LogConstructor) + /// For the case that the ErrorRespones object has a code of -2 + case HTTPURLError(response: ErrorResponse, displayMessage: String?, logConstructor: LogConstructor) - /// For the case that the ErrorResponse object has a positive code - case JellyfinError(response: ErrorResponse, displayMessage: String?, logConstructor: LogConstructor) + /// For the case that the ErrorResponse object has a positive code + case JellyfinError(response: ErrorResponse, displayMessage: String?, logConstructor: LogConstructor) - var errorMessage: ErrorMessage { - switch self { - case .URLError(let response, let displayMessage, let logConstructor): - return NetworkError.parseURLError(from: response, displayMessage: displayMessage, logConstructor: logConstructor) - case .HTTPURLError(let response, let displayMessage, let logConstructor): - return NetworkError.parseHTTPURLError(from: response, displayMessage: displayMessage, logConstructor: logConstructor) - case .JellyfinError(let response, let displayMessage, let logConstructor): - return NetworkError.parseJellyfinError(from: response, displayMessage: displayMessage, logConstructor: logConstructor) - } - } + var errorMessage: ErrorMessage { + switch self { + case let .URLError(response, displayMessage, logConstructor): + return NetworkError.parseURLError(from: response, displayMessage: displayMessage, logConstructor: logConstructor) + case let .HTTPURLError(response, displayMessage, logConstructor): + return NetworkError.parseHTTPURLError(from: response, displayMessage: displayMessage, logConstructor: logConstructor) + case let .JellyfinError(response, displayMessage, logConstructor): + return NetworkError.parseJellyfinError(from: response, displayMessage: displayMessage, logConstructor: logConstructor) + } + } - func logMessage() { - let logConstructor = errorMessage.logConstructor - let logFunction: (@autoclosure () -> String, String, String, String, UInt) -> Void + func logMessage() { + let logConstructor = errorMessage.logConstructor + let logFunction: (@autoclosure () -> String, String, String, String, UInt) -> Void - switch logConstructor.level { - case .trace: - logFunction = LogManager.shared.log.trace - case .debug: - logFunction = LogManager.shared.log.debug - case .information: - logFunction = LogManager.shared.log.info - case .warning: - logFunction = LogManager.shared.log.warning - case .error: - logFunction = LogManager.shared.log.error - case .critical: - logFunction = LogManager.shared.log.critical - case ._none: - logFunction = LogManager.shared.log.debug - } + switch logConstructor.level { + case .trace: + logFunction = LogManager.shared.log.trace + case .debug: + logFunction = LogManager.shared.log.debug + case .information: + logFunction = LogManager.shared.log.info + case .warning: + logFunction = LogManager.shared.log.warning + case .error: + logFunction = LogManager.shared.log.error + case .critical: + logFunction = LogManager.shared.log.critical + case ._none: + logFunction = LogManager.shared.log.debug + } - logFunction(logConstructor.message, logConstructor.tag, logConstructor.function, logConstructor.file, logConstructor.line) - } + logFunction(logConstructor.message, logConstructor.tag, logConstructor.function, logConstructor.file, logConstructor.line) + } - private static func parseURLError(from response: ErrorResponse, displayMessage: String?, logConstructor: LogConstructor) -> ErrorMessage { + private static func parseURLError(from response: ErrorResponse, displayMessage: String?, + logConstructor: LogConstructor) -> ErrorMessage + { - let errorMessage: ErrorMessage - var logMessage = "An error has occurred." - var logConstructor = logConstructor + let errorMessage: ErrorMessage + var logMessage = "An error has occurred." + var logConstructor = logConstructor - switch response { - case .error(_, _, _, let err): + switch response { + case let .error(_, _, _, err): - // These codes are currently referenced from: - // https://developer.apple.com/documentation/foundation/1508628-url_loading_system_error_codes - switch err._code { - case -1001: - logMessage = "Network timed out." - logConstructor.message = logMessage - errorMessage = ErrorMessage(code: err._code, - title: "Timed Out", - displayMessage: displayMessage, - logConstructor: logConstructor) - case -1004: - logMessage = "Cannot connect to host." - logConstructor.message = logMessage - errorMessage = ErrorMessage(code: err._code, - title: L10n.error, - displayMessage: displayMessage, - logConstructor: logConstructor) - default: - logConstructor.message = logMessage - errorMessage = ErrorMessage(code: err._code, - title: L10n.error, - displayMessage: displayMessage, - logConstructor: logConstructor) - } - } + // These codes are currently referenced from: + // https://developer.apple.com/documentation/foundation/1508628-url_loading_system_error_codes + switch err._code { + case -1001: + logMessage = "Network timed out." + logConstructor.message = logMessage + errorMessage = ErrorMessage(code: err._code, + title: "Timed Out", + displayMessage: displayMessage, + logConstructor: logConstructor) + case -1004: + logMessage = "Cannot connect to host." + logConstructor.message = logMessage + errorMessage = ErrorMessage(code: err._code, + title: L10n.error, + displayMessage: displayMessage, + logConstructor: logConstructor) + default: + logConstructor.message = logMessage + errorMessage = ErrorMessage(code: err._code, + title: L10n.error, + displayMessage: displayMessage, + logConstructor: logConstructor) + } + } - return errorMessage - } + return errorMessage + } - private static func parseHTTPURLError(from response: ErrorResponse, displayMessage: String?, logConstructor: LogConstructor) -> ErrorMessage { + private static func parseHTTPURLError(from response: ErrorResponse, displayMessage: String?, + logConstructor: LogConstructor) -> ErrorMessage + { - let errorMessage: ErrorMessage - let logMessage = "An HTTP URL error has occurred" - var logConstructor = logConstructor + let errorMessage: ErrorMessage + let logMessage = "An HTTP URL error has occurred" + var logConstructor = logConstructor - // Not implemented as has not run into one of these errors as time of writing - switch response { - case .error: - logConstructor.message = logMessage - errorMessage = ErrorMessage(code: 0, - title: L10n.error, - displayMessage: displayMessage, - logConstructor: logConstructor) - } + // Not implemented as has not run into one of these errors as time of writing + switch response { + case .error: + logConstructor.message = logMessage + errorMessage = ErrorMessage(code: 0, + title: L10n.error, + displayMessage: displayMessage, + logConstructor: logConstructor) + } - return errorMessage - } + return errorMessage + } - private static func parseJellyfinError(from response: ErrorResponse, displayMessage: String?, logConstructor: LogConstructor) -> ErrorMessage { + private static func parseJellyfinError(from response: ErrorResponse, displayMessage: String?, + logConstructor: LogConstructor) -> ErrorMessage + { - let errorMessage: ErrorMessage - var logMessage = "An error has occurred." - var logConstructor = logConstructor + let errorMessage: ErrorMessage + var logMessage = "An error has occurred." + var logConstructor = logConstructor - switch response { - case .error(let code, _, _, _): + switch response { + case let .error(code, _, _, _): - // Generic HTTP status codes - switch code { - case 401: - logMessage = "User is unauthorized." - logConstructor.message = logMessage - errorMessage = ErrorMessage(code: code, - title: "Unauthorized", - displayMessage: displayMessage, - logConstructor: logConstructor) - default: - logConstructor.message = logMessage - errorMessage = ErrorMessage(code: code, - title: L10n.error, - displayMessage: displayMessage, - logConstructor: logConstructor) - } - } + // Generic HTTP status codes + switch code { + case 401: + logMessage = "User is unauthorized." + logConstructor.message = logMessage + errorMessage = ErrorMessage(code: code, + title: "Unauthorized", + displayMessage: displayMessage, + logConstructor: logConstructor) + default: + logConstructor.message = logMessage + errorMessage = ErrorMessage(code: code, + title: L10n.error, + displayMessage: displayMessage, + logConstructor: logConstructor) + } + } - return errorMessage - } + return errorMessage + } } diff --git a/Shared/Extensions/BlurHashDecode.swift b/Shared/Extensions/BlurHashDecode.swift index ea29b29d..b3de466c 100644 --- a/Shared/Extensions/BlurHashDecode.swift +++ b/Shared/Extensions/BlurHashDecode.swift @@ -1,166 +1,151 @@ -/* - Copyright (c) 2018 Wolt Enterprises - - Permission is hereby granted, free of charge, to any person obtaining a copy - of this software and associated documentation files (the "Software"), to deal - in the Software without restriction, including without limitation the rights - to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - copies of the Software, and to permit persons to whom the Software is - furnished to do so, subject to the following conditions: - - The above copyright notice and this permission notice shall be included in all - copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - SOFTWARE. - */ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import UIKit -extension UIImage { - public convenience init?(blurHash: String, size: CGSize, punch: Float = 1) { - guard blurHash.count >= 6 else { return nil } +public extension UIImage { + 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 { - return 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(_ 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] = { - return "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 { - return self[index(startIndex, offsetBy: offset)] - } + subscript(offset: Int) -> Character { + self[index(startIndex, offsetBy: offset)] + } - subscript (bounds: CountableClosedRange) -> Substring { - let start = index(startIndex, offsetBy: bounds.lowerBound) - let end = index(startIndex, offsetBy: bounds.upperBound) - return self[start...end] - } + subscript(bounds: CountableClosedRange) -> Substring { + let start = index(startIndex, offsetBy: bounds.lowerBound) + let end = index(startIndex, offsetBy: bounds.upperBound) + return self[start ... end] + } - subscript (bounds: CountableRange) -> Substring { - let start = index(startIndex, offsetBy: bounds.lowerBound) - let end = index(startIndex, offsetBy: bounds.upperBound) - return self[start..) -> Substring { + let start = index(startIndex, offsetBy: bounds.lowerBound) + let end = index(startIndex, offsetBy: bounds.upperBound) + return self[start ..< end] + } } diff --git a/Shared/Extensions/CGSizeExtensions.swift b/Shared/Extensions/CGSizeExtensions.swift index 6269df00..08026424 100644 --- a/Shared/Extensions/CGSizeExtensions.swift +++ b/Shared/Extensions/CGSizeExtensions.swift @@ -1,17 +1,16 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import UIKit extension CGSize { - - static func Circle(radius: CGFloat) -> CGSize { - return CGSize(width: radius, height: radius) - } + + static func Circle(radius: CGFloat) -> CGSize { + CGSize(width: radius, height: radius) + } } diff --git a/Shared/Extensions/CollectionExtensions.swift b/Shared/Extensions/CollectionExtensions.swift index e295933f..2157aecf 100644 --- a/Shared/Extensions/CollectionExtensions.swift +++ b/Shared/Extensions/CollectionExtensions.swift @@ -1,22 +1,23 @@ -/* SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// 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? { - return 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 + } } diff --git a/Shared/Extensions/ColorExtension.swift b/Shared/Extensions/ColorExtension.swift index b5869500..059e9966 100644 --- a/Shared/Extensions/ColorExtension.swift +++ b/Shared/Extensions/ColorExtension.swift @@ -1,31 +1,30 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import SwiftUI -extension Color { +public extension Color { - static let jellyfinPurple = Color(uiColor: .jellyfinPurple) + internal static let jellyfinPurple = Color(uiColor: .jellyfinPurple) - #if os(tvOS) // tvOS doesn't have these - public static let systemFill = Color(UIColor.white) - public static let secondarySystemFill = Color(UIColor.gray) - public static let tertiarySystemFill = Color(UIColor.black) - public static let lightGray = Color(UIColor.lightGray) - #else - public static let systemFill = Color(UIColor.systemFill) - public static let systemBackground = Color(UIColor.systemBackground) - public static let secondarySystemFill = Color(UIColor.secondarySystemBackground) - public 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) } diff --git a/Shared/Extensions/DoubleExtensions.swift b/Shared/Extensions/DoubleExtensions.swift index 4d1dc42b..9231a5eb 100644 --- a/Shared/Extensions/DoubleExtensions.swift +++ b/Shared/Extensions/DoubleExtensions.swift @@ -1,23 +1,22 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Foundation extension Double { - - func subtract(_ other: Double, floor: Double) -> Double { - var v = self - other - - if v < floor { - v += abs(floor - v) - } - - return v - } + + func subtract(_ other: Double, floor: Double) -> Double { + var v = self - other + + if v < floor { + v += abs(floor - v) + } + + return v + } } diff --git a/Shared/Extensions/ImageExtensions.swift b/Shared/Extensions/ImageExtensions.swift index 15f7522e..ec0f476f 100644 --- a/Shared/Extensions/ImageExtensions.swift +++ b/Shared/Extensions/ImageExtensions.swift @@ -1,21 +1,22 @@ -/* SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// 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() + } + } } diff --git a/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+Stackable.swift b/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+Stackable.swift index 1118f3c4..fb1b6bb4 100644 --- a/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+Stackable.swift +++ b/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+Stackable.swift @@ -1,65 +1,65 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Defaults import Foundation import JellyfinAPI // MARK: PortraitImageStackable + extension BaseItemDto: PortraitImageStackable { - public var portraitImageID: String { - return id ?? "no id" - } - - public func imageURLContsructor(maxWidth: Int) -> URL { - switch self.itemType { - case .episode: - return getSeriesPrimaryImage(maxWidth: maxWidth) - default: - return self.getPrimaryImage(maxWidth: maxWidth) - } - } + public var portraitImageID: String { + id ?? "no id" + } - public var title: String { - switch self.itemType { - case .episode: - return self.seriesName ?? self.name ?? "" - default: - return self.name ?? "" - } - } + public func imageURLContsructor(maxWidth: Int) -> URL { + switch self.itemType { + case .episode: + return getSeriesPrimaryImage(maxWidth: maxWidth) + default: + return self.getPrimaryImage(maxWidth: maxWidth) + } + } - public var subtitle: String? { - switch self.itemType { - case .episode: - return getEpisodeLocator() - default: - return nil - } - } + public var title: String { + switch self.itemType { + case .episode: + return self.seriesName ?? self.name ?? "" + default: + return self.name ?? "" + } + } - public var blurHash: String { - return self.getPrimaryImageBlurHash() - } + public var subtitle: String? { + switch self.itemType { + case .episode: + return getEpisodeLocator() + default: + return nil + } + } - 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: - return Defaults[.showPosterLabels] - default: - return true - } - } + 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 showTitle: Bool { + switch self.itemType { + case .episode, .series, .movie: + return Defaults[.showPosterLabels] + default: + return true + } + } } diff --git a/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+VideoPlayerViewModel.swift b/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+VideoPlayerViewModel.swift index 74c6e3cc..7011a1ec 100644 --- a/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+VideoPlayerViewModel.swift +++ b/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+VideoPlayerViewModel.swift @@ -1,11 +1,10 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Combine import Defaults @@ -13,97 +12,98 @@ import JellyfinAPI import UIKit extension BaseItemDto { - func createVideoPlayerViewModel() -> AnyPublisher { - let builder = DeviceProfileBuilder() - // TODO: fix bitrate settings - builder.setMaxBitrate(bitrate: 60000000) - let profile = builder.buildProfile() - - let playbackInfo = PlaybackInfoDto(userId: SessionManager.main.currentLogin.user.id, - maxStreamingBitrate: 60000000, - startTimeTicks: self.userData?.playbackPositionTicks ?? 0, - deviceProfile: profile, - autoOpenLiveStream: true) - - return MediaInfoAPI.getPostedPlaybackInfo(itemId: self.id!, - userId: SessionManager.main.currentLogin.user.id, - maxStreamingBitrate: 60000000, - startTimeTicks: self.userData?.playbackPositionTicks ?? 0, - autoOpenLiveStream: true, - playbackInfoDto: playbackInfo) - .map({ response -> VideoPlayerViewModel in - let mediaSource = response.mediaSources!.first! - - let audioStreams = mediaSource.mediaStreams?.filter({ $0.type == .audio }) ?? [] - let subtitleStreams = mediaSource.mediaStreams?.filter({ $0.type == .subtitle }) ?? [] - - let defaultAudioStream = audioStreams.first(where: { $0.index! == mediaSource.defaultAudioStreamIndex! }) - - let defaultSubtitleStream = subtitleStreams.first(where: { $0.index! == mediaSource.defaultSubtitleStreamIndex ?? -1 }) - - // MARK: Stream - var streamURL = URLComponents(string: SessionManager.main.currentLogin.server.currentURI)! - - let streamType: ServerStreamType - - if let transcodeURL = mediaSource.transcodingUrl { - streamType = .transcode - streamURL.path = transcodeURL - } else { - streamType = .direct - streamURL.path = "/Videos/\(self.id!)/stream" - } + func createVideoPlayerViewModel() -> AnyPublisher { + let builder = DeviceProfileBuilder() + // TODO: fix bitrate settings + builder.setMaxBitrate(bitrate: 60_000_000) + let profile = builder.buildProfile() - streamURL.addQueryItem(name: "Static", value: "true") - streamURL.addQueryItem(name: "MediaSourceId", value: self.id!) - streamURL.addQueryItem(name: "Tag", value: self.etag) - streamURL.addQueryItem(name: "MinSegments", value: "6") - - // MARK: VidoPlayerViewModel Creation - - var subtitle: String? = nil - - // MARK: Attach media content to self - - var modifiedSelfItem = self - modifiedSelfItem.mediaStreams = mediaSource.mediaStreams - - // TODO: other forms of media subtitle - if self.itemType == .episode { - if let seriesName = self.seriesName, let episodeLocator = self.getEpisodeLocator() { - subtitle = "\(seriesName) - \(episodeLocator)" - } - } - - let subtitlesEnabled = defaultSubtitleStream != nil - - let shouldShowAutoPlay = Defaults[.shouldShowAutoPlay] && itemType == .episode - let autoplayEnabled = Defaults[.autoplayEnabled] && shouldShowAutoPlay - - let overlayType = Defaults[.overlayType] - - let shouldShowPlayPreviousItem = Defaults[.shouldShowPlayPreviousItem] && itemType == .episode - let shouldShowPlayNextItem = Defaults[.shouldShowPlayNextItem] && itemType == .episode - - let videoPlayerViewModel = VideoPlayerViewModel(item: modifiedSelfItem, - title: modifiedSelfItem.name ?? "", - subtitle: subtitle, - streamURL: streamURL.url!, - streamType: streamType, - response: response, - audioStreams: audioStreams, - subtitleStreams: subtitleStreams, - selectedAudioStreamIndex: defaultAudioStream?.index ?? -1, - selectedSubtitleStreamIndex: defaultSubtitleStream?.index ?? -1, - subtitlesEnabled: subtitlesEnabled, - autoplayEnabled: autoplayEnabled, - overlayType: overlayType, - shouldShowPlayPreviousItem: shouldShowPlayPreviousItem, - shouldShowPlayNextItem: shouldShowPlayNextItem, - shouldShowAutoPlay: shouldShowAutoPlay) - - return videoPlayerViewModel - }) - .eraseToAnyPublisher() - } + let playbackInfo = PlaybackInfoDto(userId: SessionManager.main.currentLogin.user.id, + maxStreamingBitrate: 60_000_000, + startTimeTicks: self.userData?.playbackPositionTicks ?? 0, + deviceProfile: profile, + autoOpenLiveStream: true) + + return MediaInfoAPI.getPostedPlaybackInfo(itemId: self.id!, + userId: SessionManager.main.currentLogin.user.id, + maxStreamingBitrate: 60_000_000, + startTimeTicks: self.userData?.playbackPositionTicks ?? 0, + autoOpenLiveStream: true, + playbackInfoDto: playbackInfo) + .map { response -> VideoPlayerViewModel in + let mediaSource = response.mediaSources!.first! + + let audioStreams = mediaSource.mediaStreams?.filter { $0.type == .audio } ?? [] + let subtitleStreams = mediaSource.mediaStreams?.filter { $0.type == .subtitle } ?? [] + + let defaultAudioStream = audioStreams.first(where: { $0.index! == mediaSource.defaultAudioStreamIndex! }) + + let defaultSubtitleStream = subtitleStreams.first(where: { $0.index! == mediaSource.defaultSubtitleStreamIndex ?? -1 }) + + // MARK: Stream + + var streamURL = URLComponents(string: SessionManager.main.currentLogin.server.currentURI)! + + let streamType: ServerStreamType + + if let transcodeURL = mediaSource.transcodingUrl { + streamType = .transcode + streamURL.path = transcodeURL + } else { + streamType = .direct + streamURL.path = "/Videos/\(self.id!)/stream" + } + + streamURL.addQueryItem(name: "Static", value: "true") + streamURL.addQueryItem(name: "MediaSourceId", value: self.id!) + streamURL.addQueryItem(name: "Tag", value: self.etag) + streamURL.addQueryItem(name: "MinSegments", value: "6") + + // MARK: VidoPlayerViewModel Creation + + var subtitle: String? + + // MARK: Attach media content to self + + var modifiedSelfItem = self + modifiedSelfItem.mediaStreams = mediaSource.mediaStreams + + // TODO: other forms of media subtitle + if self.itemType == .episode { + if let seriesName = self.seriesName, let episodeLocator = self.getEpisodeLocator() { + subtitle = "\(seriesName) - \(episodeLocator)" + } + } + + let subtitlesEnabled = defaultSubtitleStream != nil + + let shouldShowAutoPlay = Defaults[.shouldShowAutoPlay] && itemType == .episode + let autoplayEnabled = Defaults[.autoplayEnabled] && shouldShowAutoPlay + + let overlayType = Defaults[.overlayType] + + let shouldShowPlayPreviousItem = Defaults[.shouldShowPlayPreviousItem] && itemType == .episode + let shouldShowPlayNextItem = Defaults[.shouldShowPlayNextItem] && itemType == .episode + + let videoPlayerViewModel = VideoPlayerViewModel(item: modifiedSelfItem, + title: modifiedSelfItem.name ?? "", + subtitle: subtitle, + streamURL: streamURL.url!, + streamType: streamType, + response: response, + audioStreams: audioStreams, + subtitleStreams: subtitleStreams, + selectedAudioStreamIndex: defaultAudioStream?.index ?? -1, + selectedSubtitleStreamIndex: defaultSubtitleStream?.index ?? -1, + subtitlesEnabled: subtitlesEnabled, + autoplayEnabled: autoplayEnabled, + overlayType: overlayType, + shouldShowPlayPreviousItem: shouldShowPlayPreviousItem, + shouldShowPlayNextItem: shouldShowPlayNextItem, + shouldShowAutoPlay: shouldShowAutoPlay) + + return videoPlayerViewModel + } + .eraseToAnyPublisher() + } } diff --git a/Shared/Extensions/JellyfinAPIExtensions/BaseItemDtoExtensions.swift b/Shared/Extensions/JellyfinAPIExtensions/BaseItemDtoExtensions.swift index 508271ab..e9f7aae0 100644 --- a/Shared/Extensions/JellyfinAPIExtensions/BaseItemDtoExtensions.swift +++ b/Shared/Extensions/JellyfinAPIExtensions/BaseItemDtoExtensions.swift @@ -1,11 +1,10 @@ // -/* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Foundation import JellyfinAPI @@ -14,282 +13,285 @@ 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 { - return imageBlurHashes?.backdrop?[imgTag] ?? "001fC^" - } else { - return imageBlurHashes?.primary?[imgTag] ?? "001fC^" - } - } + if imgURL.queryParameters?[ImageType.backdrop.rawValue] == nil { + 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 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 { - 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 getSeriesPrimaryImage(maxWidth: Int) -> URL { + 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 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" - } - } - - 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 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 + 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" + } + } - enum ItemType: String { - case movie = "Movie" - case season = "Season" - case episode = "Episode" - case series = "Series" - case boxset = "BoxSet" + func getLiveStartTimeString(formatter: DateFormatter) -> String { + if let startDate = self.startDate { + return formatter.string(from: startDate) + } + return " " + } - case unknown + func getLiveEndTimeString(formatter: DateFormatter) -> String { + if let endDate = self.endDate { + return formatter.string(from: endDate) + } + return " " + } - var showDetails: Bool { - switch self { - case .season, .series: - return false - default: - return true - } - } - } + 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 + } - var itemType: ItemType { - guard let originalType = type, let knownType = ItemType(rawValue: originalType) else { return .unknown } - return knownType - } + // MARK: ItemType - // MARK: PortraitHeaderViewURL + enum ItemType: String { + case movie = "Movie" + case season = "Season" + case episode = "Episode" + case series = "Series" + case boxset = "BoxSet" - func portraitHeaderViewURL(maxWidth: Int) -> URL { - switch itemType { - case .movie, .season, .series, .boxset: - return getPrimaryImage(maxWidth: maxWidth) - case .episode: - return getSeriesPrimaryImage(maxWidth: maxWidth) - case .unknown: - return getPrimaryImage(maxWidth: maxWidth) - } - } - - // MARK: ItemDetail - - struct ItemDetail { - let title: String - let content: String - } - - func createInformationItems() -> [ItemDetail] { - var informationItems: [ItemDetail] = [] - - if let productionYear = productionYear { - informationItems.append(ItemDetail(title: "Released", content: "\(productionYear)")) - } - - if let rating = officialRating { - informationItems.append(ItemDetail(title: "Rated", content: "\(rating)")) - } - - if let runtime = getItemRuntime() { - informationItems.append(ItemDetail(title: "Runtime", content: runtime)) - } - - return informationItems - } - - func createMediaItems() -> [ItemDetail] { - var mediaItems: [ItemDetail] = [] - - if let container = container { - let containerList = container.split(separator: ",").joined(separator: ", ") - - if containerList.count > 1 { - mediaItems.append(ItemDetail(title: "Containers", content: containerList)) - } else { - mediaItems.append(ItemDetail(title: "Container", content: containerList)) - } - } - - 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 ?? "No Title") (\($0.codec ?? "No Codec"))" }).joined(separator: ", ") - mediaItems.append(ItemDetail(title: "Audio", content: audioList)) - } - - if !subtitleStreams.isEmpty { - let subtitleList = subtitleStreams.compactMap({ "\($0.displayTitle ?? "No Title") (\($0.codec ?? "No Codec"))" }).joined(separator: ", ") - mediaItems.append(ItemDetail(title: "Subtitles", content: subtitleList)) - } - } - - return mediaItems - } + case unknown + + var showDetails: Bool { + switch self { + case .season, .series: + return false + default: + return true + } + } + } + + var itemType: ItemType { + guard let originalType = type, let knownType = ItemType(rawValue: originalType) else { return .unknown } + return knownType + } + + // MARK: PortraitHeaderViewURL + + func portraitHeaderViewURL(maxWidth: Int) -> URL { + switch itemType { + case .movie, .season, .series, .boxset: + return getPrimaryImage(maxWidth: maxWidth) + case .episode: + return getSeriesPrimaryImage(maxWidth: maxWidth) + case .unknown: + return getPrimaryImage(maxWidth: maxWidth) + } + } + + // MARK: ItemDetail + + struct ItemDetail { + let title: String + let content: String + } + + func createInformationItems() -> [ItemDetail] { + var informationItems: [ItemDetail] = [] + + if let productionYear = productionYear { + informationItems.append(ItemDetail(title: "Released", content: "\(productionYear)")) + } + + if let rating = officialRating { + informationItems.append(ItemDetail(title: "Rated", content: "\(rating)")) + } + + if let runtime = getItemRuntime() { + informationItems.append(ItemDetail(title: "Runtime", content: runtime)) + } + + return informationItems + } + + func createMediaItems() -> [ItemDetail] { + var mediaItems: [ItemDetail] = [] + + if let container = container { + let containerList = container.split(separator: ",").joined(separator: ", ") + + if containerList.count > 1 { + mediaItems.append(ItemDetail(title: "Containers", content: containerList)) + } else { + mediaItems.append(ItemDetail(title: "Container", content: containerList)) + } + } + + 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 ?? "No Title") (\($0.codec ?? "No Codec"))" } + .joined(separator: ", ") + mediaItems.append(ItemDetail(title: "Audio", content: audioList)) + } + + if !subtitleStreams.isEmpty { + let subtitleList = subtitleStreams.compactMap { "\($0.displayTitle ?? "No Title") (\($0.codec ?? "No Codec"))" } + .joined(separator: ", ") + mediaItems.append(ItemDetail(title: "Subtitles", content: subtitleList)) + } + } + + return mediaItems + } } diff --git a/Shared/Extensions/JellyfinAPIExtensions/BaseItemPersonExtensions.swift b/Shared/Extensions/JellyfinAPIExtensions/BaseItemPersonExtensions.swift index 2c7cc7af..5b72e8cb 100644 --- a/Shared/Extensions/JellyfinAPIExtensions/BaseItemPersonExtensions.swift +++ b/Shared/Extensions/JellyfinAPIExtensions/BaseItemPersonExtensions.swift @@ -1,9 +1,10 @@ -/* SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Foundation import JellyfinAPI @@ -11,99 +12,103 @@ import UIKit extension BaseItemPerson { - // MARK: Get Image - func getImage(baseURL: String, maxWidth: Int) -> URL { - let x = UIScreen.main.nativeScale * CGFloat(maxWidth) + // MARK: Get Image - let urlString = ImageAPI.getItemImageWithRequestBuilder(itemId: id ?? "", - imageType: .primary, - maxWidth: Int(x), - quality: 96, - tag: primaryImageTag).URLString - return URL(string: urlString)! - } + func getImage(baseURL: String, maxWidth: Int) -> URL { + let x = UIScreen.main.nativeScale * CGFloat(maxWidth) - func getBlurHash() -> String { - let imgURL = getImage(baseURL: "", maxWidth: 1) - guard let imgTag = imgURL.queryParameters?["tag"], - let hash = imageBlurHashes?.primary?[imgTag] - else { - return "001fC^" - } + let urlString = ImageAPI.getItemImageWithRequestBuilder(itemId: id ?? "", + imageType: .primary, + maxWidth: Int(x), + quality: 96, + tag: primaryImageTag).URLString + return URL(string: urlString)! + } - return hash - } + func getBlurHash() -> String { + let imgURL = getImage(baseURL: "", maxWidth: 1) + guard let imgTag = imgURL.queryParameters?["tag"], + let hash = imageBlurHashes?.primary?[imgTag] + else { + return "001fC^" + } - // MARK: First Role + return hash + } - // 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 "()" 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 } + // MARK: First Role - guard let firstRole = split.first?.trimmingCharacters(in: CharacterSet(charactersIn: " ")), let lastRole = split.last?.trimmingCharacters(in: CharacterSet(charactersIn: " ")) 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 "()" 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 } - var final = firstRole + guard let firstRole = split.first?.trimmingCharacters(in: CharacterSet(charactersIn: " ")), + let lastRole = split.last?.trimmingCharacters(in: CharacterSet(charactersIn: " ")) else { return role } - if let lastOpenIndex = lastRole.lastIndex(of: "("), let lastClosingIndex = lastRole.lastIndex(of: ")") { - let roleText = lastRole[lastOpenIndex...lastClosingIndex] - final.append(" \(roleText)") - } + var final = firstRole - return final - } + if let lastOpenIndex = lastRole.lastIndex(of: "("), let lastClosingIndex = lastRole.lastIndex(of: ")") { + let roleText = lastRole[lastOpenIndex ... lastClosingIndex] + final.append(" \(roleText)") + } + + return final + } } // MARK: PortraitImageStackable + extension BaseItemPerson: PortraitImageStackable { - public var portraitImageID: String { - return (id ?? "noid") + title + (subtitle ?? "nodescription") + blurHash + failureInitials - } - - public func imageURLContsructor(maxWidth: Int) -> URL { - return self.getImage(baseURL: SessionManager.main.currentLogin.server.currentURI, maxWidth: maxWidth) - } + public var portraitImageID: String { + (id ?? "noid") + title + (subtitle ?? "nodescription") + blurHash + failureInitials + } - public var title: String { - return self.name ?? "" - } + public func imageURLContsructor(maxWidth: Int) -> URL { + self.getImage(baseURL: SessionManager.main.currentLogin.server.currentURI, maxWidth: maxWidth) + } - public var subtitle: String? { - return self.firstRole() - } + public var title: String { + self.name ?? "" + } - public var blurHash: String { - return self.getBlurHash() - } + public var subtitle: String? { + self.firstRole() + } - 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 { - return true - } + 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 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] { - return self.allCases.map({ $0.rawValue }) - } - } + static var allCasesRaw: [String] { + self.allCases.map(\.rawValue) + } + } } diff --git a/Shared/Extensions/JellyfinAPIExtensions/JellyfinAPIError.swift b/Shared/Extensions/JellyfinAPIExtensions/JellyfinAPIError.swift index 0bfe3a81..8fa3df29 100644 --- a/Shared/Extensions/JellyfinAPIExtensions/JellyfinAPIError.swift +++ b/Shared/Extensions/JellyfinAPIExtensions/JellyfinAPIError.swift @@ -1,23 +1,22 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// 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 { - return message - } + var localizedDescription: String { + message + } } diff --git a/Shared/Extensions/JellyfinAPIExtensions/MediaStreamExtension.swift b/Shared/Extensions/JellyfinAPIExtensions/MediaStreamExtension.swift index cb0084bf..7dc5f33d 100644 --- a/Shared/Extensions/JellyfinAPIExtensions/MediaStreamExtension.swift +++ b/Shared/Extensions/JellyfinAPIExtensions/MediaStreamExtension.swift @@ -1,22 +1,21 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Foundation import JellyfinAPI extension MediaStream { - - func externalURL(base: String) -> URL? { - guard let deliveryURL = deliveryUrl else { return nil } - var baseComponents = URLComponents(string: base) - baseComponents?.path += deliveryURL - - return baseComponents?.url - } + + func externalURL(base: String) -> URL? { + guard let deliveryURL = deliveryUrl else { return nil } + var baseComponents = URLComponents(string: base) + baseComponents?.path += deliveryURL + + return baseComponents?.url + } } diff --git a/Shared/Extensions/JellyfinAPIExtensions/NameGUIDPairExtensions.swift b/Shared/Extensions/JellyfinAPIExtensions/NameGUIDPairExtensions.swift index bc7311fa..44e8b177 100644 --- a/Shared/Extensions/JellyfinAPIExtensions/NameGUIDPairExtensions.swift +++ b/Shared/Extensions/JellyfinAPIExtensions/NameGUIDPairExtensions.swift @@ -1,17 +1,16 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Foundation import JellyfinAPI extension NameGuidPair: PillStackable { - var title: String { - return self.name ?? "" - } + var title: String { + self.name ?? "" + } } diff --git a/Shared/Extensions/StringExtensions.swift b/Shared/Extensions/StringExtensions.swift index 64b4a63d..2421bc2c 100644 --- a/Shared/Extensions/StringExtensions.swift +++ b/Shared/Extensions/StringExtensions.swift @@ -1,39 +1,40 @@ -/* JellyfinPlayer/Swiftfin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// 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) + } } diff --git a/Shared/Extensions/UIDeviceExtensions.swift b/Shared/Extensions/UIDeviceExtensions.swift index e837782f..05a495f0 100644 --- a/Shared/Extensions/UIDeviceExtensions.swift +++ b/Shared/Extensions/UIDeviceExtensions.swift @@ -1,16 +1,15 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import UIKit extension UIDevice { - static var vendorUUIDString: String { - return current.identifierForVendor!.uuidString - } + static var vendorUUIDString: String { + current.identifierForVendor!.uuidString + } } diff --git a/Shared/Extensions/URLComponentsExtensions.swift b/Shared/Extensions/URLComponentsExtensions.swift index 17d1235a..d1436a91 100644 --- a/Shared/Extensions/URLComponentsExtensions.swift +++ b/Shared/Extensions/URLComponentsExtensions.swift @@ -1,22 +1,21 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// 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)) + } + } } diff --git a/Shared/Extensions/URLExtensions.swift b/Shared/Extensions/URLExtensions.swift index ec7ea5d2..6c1251a6 100644 --- a/Shared/Extensions/URLExtensions.swift +++ b/Shared/Extensions/URLExtensions.swift @@ -1,26 +1,25 @@ // -/* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// 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 + } } diff --git a/Shared/Extensions/ViewExtensions.swift b/Shared/Extensions/ViewExtensions.swift index f93a5ee0..23b4a0bd 100644 --- a/Shared/Extensions/ViewExtensions.swift +++ b/Shared/Extensions/ViewExtensions.swift @@ -1,17 +1,16 @@ // -/* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Foundation import SwiftUI extension View { - func eraseToAnyView() -> AnyView { - return AnyView(self) - } + func eraseToAnyView() -> AnyView { + AnyView(self) + } } diff --git a/Shared/Generated/Strings.swift b/Shared/Generated/Strings.swift index 6e6ea374..b18f4dde 100644 --- a/Shared/Generated/Strings.swift +++ b/Shared/Generated/Strings.swift @@ -1,3 +1,11 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + // swiftlint:disable all // Generated using SwiftGen — https://github.com/SwiftGen/SwiftGen @@ -10,185 +18,194 @@ import Foundation // swiftlint:disable explicit_type_interface function_parameter_count identifier_name line_length // swiftlint:disable nesting type_body_length type_name vertical_whitespace_opening_braces internal enum L10n { - /// Accessibility - internal static let accessibility = L10n.tr("Localizable", "accessibility") - /// Add URL - internal static let addURL = L10n.tr("Localizable", "addURL") - /// All Genres - internal static let allGenres = L10n.tr("Localizable", "allGenres") - /// All Media - internal static let allMedia = L10n.tr("Localizable", "allMedia") - /// Appearance - internal static let appearance = L10n.tr("Localizable", "appearance") - /// Apply - internal static let apply = L10n.tr("Localizable", "apply") - /// Audio & Captions - internal static let audioAndCaptions = L10n.tr("Localizable", "audioAndCaptions") - /// Audio Track - internal static let audioTrack = L10n.tr("Localizable", "audioTrack") - /// Back - internal static let back = L10n.tr("Localizable", "back") - /// CAST - internal static let cast = L10n.tr("Localizable", "cast") - /// Change Server - internal static let changeServer = L10n.tr("Localizable", "changeServer") - /// Closed Captions - internal static let closedCaptions = L10n.tr("Localizable", "closedCaptions") - /// Connect - internal static let connect = L10n.tr("Localizable", "connect") - /// Connect Manually - internal static let connectManually = L10n.tr("Localizable", "connectManually") - /// Connect to Jellyfin - internal static let connectToJellyfin = L10n.tr("Localizable", "connectToJellyfin") - /// Connect to Server - internal static let connectToServer = L10n.tr("Localizable", "connectToServer") - /// Continue Watching - internal static let continueWatching = L10n.tr("Localizable", "continueWatching") - /// Dark - internal static let dark = L10n.tr("Localizable", "dark") - /// DIRECTOR - internal static let director = L10n.tr("Localizable", "director") - /// Discovered Servers - internal static let discoveredServers = L10n.tr("Localizable", "discoveredServers") - /// Display order - internal static let displayOrder = L10n.tr("Localizable", "displayOrder") - /// Empty Next Up - internal static let emptyNextUp = L10n.tr("Localizable", "emptyNextUp") - /// Episodes - internal static let episodes = L10n.tr("Localizable", "episodes") - /// Error - internal static let error = L10n.tr("Localizable", "error") - /// Existing Server - internal static let existingServer = L10n.tr("Localizable", "existingServer") - /// Filter Results - internal static let filterResults = L10n.tr("Localizable", "filterResults") - /// Filters - internal static let filters = L10n.tr("Localizable", "filters") - /// Genres - internal static let genres = L10n.tr("Localizable", "genres") - /// Home - internal static let home = L10n.tr("Localizable", "home") - /// Latest %@ - internal static func latestWithString(_ p1: Any) -> String { - return L10n.tr("Localizable", "latestWithString", String(describing: p1)) - } - /// Library - internal static let library = L10n.tr("Localizable", "library") - /// Light - internal static let light = L10n.tr("Localizable", "light") - /// Loading - internal static let loading = L10n.tr("Localizable", "loading") - /// Local Servers - internal static let localServers = L10n.tr("Localizable", "localServers") - /// Login - internal static let login = L10n.tr("Localizable", "login") - /// Login to %@ - internal static func loginToWithString(_ p1: Any) -> String { - return L10n.tr("Localizable", "loginToWithString", String(describing: p1)) - } - /// More Like This - internal static let moreLikeThis = L10n.tr("Localizable", "moreLikeThis") - /// Next Up - internal static let nextUp = L10n.tr("Localizable", "nextUp") - /// No Cast devices found.. - internal static let noCastdevicesfound = L10n.tr("Localizable", "noCastdevicesfound") - /// No results. - internal static let noResults = L10n.tr("Localizable", "noResults") - /// Type: %@ not implemented yet :( - internal static func notImplementedYetWithType(_ p1: Any) -> String { - return L10n.tr("Localizable", "notImplementedYetWithType", String(describing: p1)) - } - /// Ok - internal static let ok = L10n.tr("Localizable", "ok") - /// Other User - internal static let otherUser = L10n.tr("Localizable", "otherUser") - /// Page %1$@ of %2$@ - internal static func pageOfWithNumbers(_ p1: Any, _ p2: Any) -> String { - return L10n.tr("Localizable", "pageOfWithNumbers", String(describing: p1), String(describing: p2)) - } - /// Password - internal static let password = L10n.tr("Localizable", "password") - /// Play - internal static let play = L10n.tr("Localizable", "play") - /// Playback settings - internal static let playbackSettings = L10n.tr("Localizable", "playbackSettings") - /// Playback Speed - internal static let playbackSpeed = L10n.tr("Localizable", "playbackSpeed") - /// Play Next - internal static let playNext = L10n.tr("Localizable", "playNext") - /// Reset - internal static let reset = L10n.tr("Localizable", "reset") - /// Search… - internal static let search = L10n.tr("Localizable", "search") - /// S%1$@:E%2$@ - internal static func seasonAndEpisode(_ p1: Any, _ p2: Any) -> String { - return L10n.tr("Localizable", "seasonAndEpisode", String(describing: p1), String(describing: p2)) - } - /// Seasons - internal static let seasons = L10n.tr("Localizable", "seasons") - /// See All - internal static let seeAll = L10n.tr("Localizable", "seeAll") - /// Select Cast Destination - internal static let selectCastDestination = L10n.tr("Localizable", "selectCastDestination") - /// Server %s already exists. Add new URL? - internal static func serverAlreadyExistsPrompt(_ p1: UnsafePointer) -> String { - return L10n.tr("Localizable", "serverAlreadyExistsPrompt", p1) - } - /// Server Information - internal static let serverInformation = L10n.tr("Localizable", "serverInformation") - /// Server URL - internal static let serverURL = L10n.tr("Localizable", "serverURL") - /// Signed in as %@ - internal static func signedInAsWithString(_ p1: Any) -> String { - return L10n.tr("Localizable", "signedInAsWithString", String(describing: p1)) - } - /// Sort by - internal static let sortBy = L10n.tr("Localizable", "sortBy") - /// STUDIO - internal static let studio = L10n.tr("Localizable", "studio") - /// Studios - internal static let studios = L10n.tr("Localizable", "studios") - /// Suggestions - internal static let suggestions = L10n.tr("Localizable", "suggestions") - /// Switch user - internal static let switchUser = L10n.tr("Localizable", "switchUser") - /// System - internal static let system = L10n.tr("Localizable", "system") - /// Tags - internal static let tags = L10n.tr("Localizable", "tags") - /// Try again - internal static let tryAgain = L10n.tr("Localizable", "tryAgain") - /// Unknown Error - internal static let unknownError = L10n.tr("Localizable", "unknownError") - /// Username - internal static let username = L10n.tr("Localizable", "username") - /// Who's watching? - internal static let whosWatching = L10n.tr("Localizable", "WhosWatching") - /// WIP - internal static let wip = L10n.tr("Localizable", "wip") - /// Your Favorites - internal static let yourFavorites = L10n.tr("Localizable", "yourFavorites") + /// Accessibility + internal static let accessibility = L10n.tr("Localizable", "accessibility") + /// Add URL + internal static let addURL = L10n.tr("Localizable", "addURL") + /// All Genres + internal static let allGenres = L10n.tr("Localizable", "allGenres") + /// All Media + internal static let allMedia = L10n.tr("Localizable", "allMedia") + /// Appearance + internal static let appearance = L10n.tr("Localizable", "appearance") + /// Apply + internal static let apply = L10n.tr("Localizable", "apply") + /// Audio & Captions + internal static let audioAndCaptions = L10n.tr("Localizable", "audioAndCaptions") + /// Audio Track + internal static let audioTrack = L10n.tr("Localizable", "audioTrack") + /// Back + internal static let back = L10n.tr("Localizable", "back") + /// CAST + internal static let cast = L10n.tr("Localizable", "cast") + /// Change Server + internal static let changeServer = L10n.tr("Localizable", "changeServer") + /// Closed Captions + internal static let closedCaptions = L10n.tr("Localizable", "closedCaptions") + /// Connect + internal static let connect = L10n.tr("Localizable", "connect") + /// Connect Manually + internal static let connectManually = L10n.tr("Localizable", "connectManually") + /// Connect to Jellyfin + internal static let connectToJellyfin = L10n.tr("Localizable", "connectToJellyfin") + /// Connect to Server + internal static let connectToServer = L10n.tr("Localizable", "connectToServer") + /// Continue Watching + internal static let continueWatching = L10n.tr("Localizable", "continueWatching") + /// Dark + internal static let dark = L10n.tr("Localizable", "dark") + /// DIRECTOR + internal static let director = L10n.tr("Localizable", "director") + /// Discovered Servers + internal static let discoveredServers = L10n.tr("Localizable", "discoveredServers") + /// Display order + internal static let displayOrder = L10n.tr("Localizable", "displayOrder") + /// Empty Next Up + internal static let emptyNextUp = L10n.tr("Localizable", "emptyNextUp") + /// Episodes + internal static let episodes = L10n.tr("Localizable", "episodes") + /// Error + internal static let error = L10n.tr("Localizable", "error") + /// Existing Server + internal static let existingServer = L10n.tr("Localizable", "existingServer") + /// Filter Results + internal static let filterResults = L10n.tr("Localizable", "filterResults") + /// Filters + internal static let filters = L10n.tr("Localizable", "filters") + /// Genres + internal static let genres = L10n.tr("Localizable", "genres") + /// Home + internal static let home = L10n.tr("Localizable", "home") + /// Latest %@ + internal static func latestWithString(_ p1: Any) -> String { + L10n.tr("Localizable", "latestWithString", String(describing: p1)) + } + + /// Library + internal static let library = L10n.tr("Localizable", "library") + /// Light + internal static let light = L10n.tr("Localizable", "light") + /// Loading + internal static let loading = L10n.tr("Localizable", "loading") + /// Local Servers + internal static let localServers = L10n.tr("Localizable", "localServers") + /// Login + internal static let login = L10n.tr("Localizable", "login") + /// Login to %@ + internal static func loginToWithString(_ p1: Any) -> String { + L10n.tr("Localizable", "loginToWithString", String(describing: p1)) + } + + /// More Like This + internal static let moreLikeThis = L10n.tr("Localizable", "moreLikeThis") + /// Next Up + internal static let nextUp = L10n.tr("Localizable", "nextUp") + /// No Cast devices found.. + internal static let noCastdevicesfound = L10n.tr("Localizable", "noCastdevicesfound") + /// No results. + internal static let noResults = L10n.tr("Localizable", "noResults") + /// Type: %@ not implemented yet :( + internal static func notImplementedYetWithType(_ p1: Any) -> String { + L10n.tr("Localizable", "notImplementedYetWithType", String(describing: p1)) + } + + /// Ok + internal static let ok = L10n.tr("Localizable", "ok") + /// Other User + internal static let otherUser = L10n.tr("Localizable", "otherUser") + /// Page %1$@ of %2$@ + internal static func pageOfWithNumbers(_ p1: Any, _ p2: Any) -> String { + L10n.tr("Localizable", "pageOfWithNumbers", String(describing: p1), String(describing: p2)) + } + + /// Password + internal static let password = L10n.tr("Localizable", "password") + /// Play + internal static let play = L10n.tr("Localizable", "play") + /// Playback settings + internal static let playbackSettings = L10n.tr("Localizable", "playbackSettings") + /// Playback Speed + internal static let playbackSpeed = L10n.tr("Localizable", "playbackSpeed") + /// Play Next + internal static let playNext = L10n.tr("Localizable", "playNext") + /// Reset + internal static let reset = L10n.tr("Localizable", "reset") + /// Search… + internal static let search = L10n.tr("Localizable", "search") + /// S%1$@:E%2$@ + internal static func seasonAndEpisode(_ p1: Any, _ p2: Any) -> String { + L10n.tr("Localizable", "seasonAndEpisode", String(describing: p1), String(describing: p2)) + } + + /// Seasons + internal static let seasons = L10n.tr("Localizable", "seasons") + /// See All + internal static let seeAll = L10n.tr("Localizable", "seeAll") + /// Select Cast Destination + internal static let selectCastDestination = L10n.tr("Localizable", "selectCastDestination") + /// Server %s already exists. Add new URL? + internal static func serverAlreadyExistsPrompt(_ p1: UnsafePointer) -> String { + L10n.tr("Localizable", "serverAlreadyExistsPrompt", p1) + } + + /// Server Information + internal static let serverInformation = L10n.tr("Localizable", "serverInformation") + /// Server URL + internal static let serverURL = L10n.tr("Localizable", "serverURL") + /// Signed in as %@ + internal static func signedInAsWithString(_ p1: Any) -> String { + L10n.tr("Localizable", "signedInAsWithString", String(describing: p1)) + } + + /// Sort by + internal static let sortBy = L10n.tr("Localizable", "sortBy") + /// STUDIO + internal static let studio = L10n.tr("Localizable", "studio") + /// Studios + internal static let studios = L10n.tr("Localizable", "studios") + /// Suggestions + internal static let suggestions = L10n.tr("Localizable", "suggestions") + /// Switch user + internal static let switchUser = L10n.tr("Localizable", "switchUser") + /// System + internal static let system = L10n.tr("Localizable", "system") + /// Tags + internal static let tags = L10n.tr("Localizable", "tags") + /// Try again + internal static let tryAgain = L10n.tr("Localizable", "tryAgain") + /// Unknown Error + internal static let unknownError = L10n.tr("Localizable", "unknownError") + /// Username + internal static let username = L10n.tr("Localizable", "username") + /// Who's watching? + internal static let whosWatching = L10n.tr("Localizable", "WhosWatching") + /// WIP + internal static let wip = L10n.tr("Localizable", "wip") + /// Your Favorites + internal static let yourFavorites = L10n.tr("Localizable", "yourFavorites") } + // swiftlint:enable explicit_type_interface function_parameter_count identifier_name line_length // swiftlint:enable nesting type_body_length type_name vertical_whitespace_opening_braces // MARK: - Implementation Details extension L10n { - private static func tr(_ table: String, _ key: String, _ args: CVarArg...) -> String { - let format = BundleToken.bundle.localizedString(forKey: key, value: nil, table: table) - return String(format: format, locale: Locale.current, arguments: args) - } + private static func tr(_ table: String, _ key: String, _ args: CVarArg...) -> String { + let format = BundleToken.bundle.localizedString(forKey: key, value: nil, table: table) + return String(format: format, locale: Locale.current, arguments: args) + } } // swiftlint:disable convenience_type private final class BundleToken { - static let bundle: Bundle = { - #if SWIFT_PACKAGE - return Bundle.module - #else - return Bundle(for: BundleToken.self) - #endif - }() + static let bundle: Bundle = { + #if SWIFT_PACKAGE + return Bundle.module + #else + return Bundle(for: BundleToken.self) + #endif + }() } + // swiftlint:enable convenience_type diff --git a/Shared/Objects/AppAppearance.swift b/Shared/Objects/AppAppearance.swift index fdf6ba8d..0b5d0b5c 100644 --- a/Shared/Objects/AppAppearance.swift +++ b/Shared/Objects/AppAppearance.swift @@ -1,39 +1,38 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// 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 + } + } } diff --git a/Shared/Objects/Bitrates.swift b/Shared/Objects/Bitrates.swift index 6a07989f..435a00e1 100644 --- a/Shared/Objects/Bitrates.swift +++ b/Shared/Objects/Bitrates.swift @@ -1,15 +1,14 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Foundation struct Bitrates: Codable, Hashable { - public var name: String - public var value: Int + public var name: String + public var value: Int } diff --git a/Shared/Objects/DetailItem.swift b/Shared/Objects/DetailItem.swift index fd017f2d..7e1c0b6a 100644 --- a/Shared/Objects/DetailItem.swift +++ b/Shared/Objects/DetailItem.swift @@ -1,25 +1,23 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Foundation import JellyfinAPI enum DetailItemType: String { - case movie = "Movie" - case season = "Season" - case series = "Series" - case episode = "Episode" + case movie = "Movie" + case season = "Season" + case series = "Series" + case episode = "Episode" } struct DetailItem { - let baseItem: BaseItemDto - let type: DetailItemType - + let baseItem: BaseItemDto + let type: DetailItemType } diff --git a/Shared/Objects/DeviceProfileBuilder.swift b/Shared/Objects/DeviceProfileBuilder.swift index 66c6f889..b68d3ccd 100644 --- a/Shared/Objects/DeviceProfileBuilder.swift +++ b/Shared/Objects/DeviceProfileBuilder.swift @@ -1,9 +1,10 @@ -/* JellyfinPlayer/Swiftfin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// // lol can someone buy me a coffee this took forever :| @@ -11,196 +12,241 @@ 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 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 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() -> DeviceProfile { - let maxStreamingBitrate = bitrate - let maxStaticBitrate = bitrate - let musicStreamingTranscodingBitrate = bitrate + public func buildProfile() -> DeviceProfile { + 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 = DeviceProfile(maxStreamingBitrate: maxStreamingBitrate, maxStaticBitrate: maxStaticBitrate, musicStreamingTranscodingBitrate: musicStreamingTranscodingBitrate, directPlayProfiles: directPlayProfiles, transcodingProfiles: transcodingProfiles, containerProfiles: [], codecProfiles: codecProfiles, responseProfiles: responseProfiles, subtitleProfiles: subtitleProfiles) + let profile = DeviceProfile(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, .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, + .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 "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 "AppleTV5,3": return .A8 + case "AppleTV6,2": return .A10X + case "AppleTV11,1": return .A12 + case "AudioAccessory1,1": return .A8 + default: return .A99 + } + } } diff --git a/Shared/Objects/DeviceRotationViewModifier.swift b/Shared/Objects/DeviceRotationViewModifier.swift index 9543501c..afd0050a 100644 --- a/Shared/Objects/DeviceRotationViewModifier.swift +++ b/Shared/Objects/DeviceRotationViewModifier.swift @@ -1,11 +1,10 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// // https://www.hackingwithswift.com/quick-start/swiftui/how-to-detect-device-rotation import Foundation @@ -14,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)) + } } diff --git a/Shared/Objects/HTTPScheme.swift b/Shared/Objects/HTTPScheme.swift index 28152234..3f81fa39 100644 --- a/Shared/Objects/HTTPScheme.swift +++ b/Shared/Objects/HTTPScheme.swift @@ -1,16 +1,15 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Defaults import Foundation enum HTTPScheme: String, Defaults.Serializable, CaseIterable { - case http - case https + case http + case https } diff --git a/Shared/Objects/OverlaySliderColor.swift b/Shared/Objects/OverlaySliderColor.swift index 4844d851..b5b59286 100644 --- a/Shared/Objects/OverlaySliderColor.swift +++ b/Shared/Objects/OverlaySliderColor.swift @@ -1,25 +1,24 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Defaults import UIKit enum OverlaySliderColor: String, CaseIterable, DefaultsSerializable { - case white - case jellyfinPurple - - var displayLabel: String { - switch self { - case .white: - return "White" - case .jellyfinPurple: - return "Jellyfin Purple" - } - } + case white + case jellyfinPurple + + var displayLabel: String { + switch self { + case .white: + return "White" + case .jellyfinPurple: + return "Jellyfin Purple" + } + } } diff --git a/Shared/Objects/OverlayType.swift b/Shared/Objects/OverlayType.swift index 22737a15..eacbe607 100644 --- a/Shared/Objects/OverlayType.swift +++ b/Shared/Objects/OverlayType.swift @@ -1,25 +1,24 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Defaults import Foundation enum OverlayType: String, CaseIterable, Defaults.Serializable { - case normal - case compact - - var label: String { - switch self { - case .normal: - return "Normal" - case .compact: - return "Compact" - } - } + case normal + case compact + + var label: String { + switch self { + case .normal: + return "Normal" + case .compact: + return "Compact" + } + } } diff --git a/Shared/Objects/PillStackable.swift b/Shared/Objects/PillStackable.swift index 4d0296eb..6321219d 100644 --- a/Shared/Objects/PillStackable.swift +++ b/Shared/Objects/PillStackable.swift @@ -1,14 +1,13 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Foundation protocol PillStackable { - var title: String { get } + var title: String { get } } diff --git a/Shared/Objects/PlaybackSpeed.swift b/Shared/Objects/PlaybackSpeed.swift new file mode 100644 index 00000000..98921723 --- /dev/null +++ b/Shared/Objects/PlaybackSpeed.swift @@ -0,0 +1,41 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +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 + + 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" + } + } +} diff --git a/Shared/Objects/PortraitImageStackable.swift b/Shared/Objects/PortraitImageStackable.swift index ceb98529..07af7bda 100644 --- a/Shared/Objects/PortraitImageStackable.swift +++ b/Shared/Objects/PortraitImageStackable.swift @@ -1,20 +1,19 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Foundation public protocol PortraitImageStackable { - func imageURLContsructor(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 imageURLContsructor(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 } } diff --git a/Shared/Objects/PosterSize.swift b/Shared/Objects/PosterSize.swift index 799a27a7..85b3f365 100644 --- a/Shared/Objects/PosterSize.swift +++ b/Shared/Objects/PosterSize.swift @@ -1,15 +1,14 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Foundation enum PosterSize { - case small - case normal + case small + case normal } diff --git a/Shared/Objects/TrackLanguage.swift b/Shared/Objects/TrackLanguage.swift index 03833245..28b062ae 100644 --- a/Shared/Objects/TrackLanguage.swift +++ b/Shared/Objects/TrackLanguage.swift @@ -1,17 +1,16 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// 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") } diff --git a/Shared/Objects/Typings.swift b/Shared/Objects/Typings.swift index 64293d42..4858c823 100644 --- a/Shared/Objects/Typings.swift +++ b/Shared/Objects/Typings.swift @@ -1,89 +1,90 @@ -/* JellyfinPlayer/Swiftfin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Combine 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 "" + } + } } diff --git a/Shared/Objects/VideoPlayerJumpLength.swift b/Shared/Objects/VideoPlayerJumpLength.swift index 84b1ab73..5a701d03 100644 --- a/Shared/Objects/VideoPlayerJumpLength.swift +++ b/Shared/Objects/VideoPlayerJumpLength.swift @@ -1,52 +1,51 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// -import UIKit 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 { - return "\(self.rawValue) seconds" - } - - var shortLabel: String { - return "\(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 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 label: String { + "\(self.rawValue) seconds" + } + + 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 backwardImageLabel: String { + switch self { + case .thirty: + return "gobackward.30" + case .fifteen: + return "gobackward.15" + case .ten: + return "gobackward.10" + case .five: + return "gobackward.5" + } + } } diff --git a/Shared/ServerDiscovery/ServerDiscovery.swift b/Shared/ServerDiscovery/ServerDiscovery.swift index 841b8889..81f6de75 100644 --- a/Shared/ServerDiscovery/ServerDiscovery.swift +++ b/Shared/ServerDiscovery/ServerDiscovery.swift @@ -1,79 +1,76 @@ // -/* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Created by Noah Kamara - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Foundation public class ServerDiscovery { - public struct ServerLookupResponse: Codable, Hashable, Identifiable { + public struct ServerLookupResponse: Codable, Hashable, Identifiable { - public func hash(into hasher: inout Hasher) { - return 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 host: String { - let components = URLComponents(string: self.address) - if let host = components?.host { - return host - } - return self.address - } + public var url: URL { + URL(string: self.address)! + } - public var port: Int { - let components = URLComponents(string: self.address) - if let port = components?.port { - return port - } - return 7359 - } + public var host: String { + let components = URLComponents(string: self.address) + if let host = components?.host { + return host + } + return self.address + } - enum CodingKeys: String, CodingKey { - case address = "Address" - case id = "Id" - case name = "Name" - } - } + public var port: Int { + let components = URLComponents(string: self.address) + if let port = components?.port { + return port + } + return 7359 + } - private let broadcastConn: UDPBroadcastConnection + enum CodingKeys: String, CodingKey { + case address = "Address" + case id = "Id" + case name = "Name" + } + } - public init() { - func receiveHandler(_ ipAddress: String, _ port: Int, _ response: Data) { - } + private let broadcastConn: UDPBroadcastConnection - func errorHandler(error: UDPBroadcastConnection.ConnectionError) { - } - self.broadcastConn = try! UDPBroadcastConnection(port: 7359, handler: receiveHandler, errorHandler: errorHandler) - } + public init() { + func receiveHandler(_ ipAddress: String, _ port: Int, _ response: Data) {} - 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.shared.log.debug("Received JellyfinServer from \"\(response.name)\"", tag: "ServerDiscovery") - completion(response) - } catch { - completion(nil) - } - } - self.broadcastConn.handler = receiveHandler - do { - try broadcastConn.sendBroadcast("Who is JellyfinServer?") - LogManager.shared.log.debug("Discovery broadcast sent", tag: "ServerDiscovery") - } catch { - print(error) - } - } + func errorHandler(error: UDPBroadcastConnection.ConnectionError) {} + self.broadcastConn = try! UDPBroadcastConnection(port: 7359, handler: receiveHandler, errorHandler: errorHandler) + } + + 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.shared.log.debug("Received JellyfinServer from \"\(response.name)\"", tag: "ServerDiscovery") + completion(response) + } catch { + completion(nil) + } + } + self.broadcastConn.handler = receiveHandler + do { + try broadcastConn.sendBroadcast("Who is JellyfinServer?") + LogManager.shared.log.debug("Discovery broadcast sent", tag: "ServerDiscovery") + } catch { + print(error) + } + } } diff --git a/Shared/ServerDiscovery/UDPBroadCastConnection.swift b/Shared/ServerDiscovery/UDPBroadCastConnection.swift index c00d0de6..8261fc41 100644 --- a/Shared/ServerDiscovery/UDPBroadCastConnection.swift +++ b/Shared/ServerDiscovery/UDPBroadCastConnection.swift @@ -1,290 +1,289 @@ // -/* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Created by Gunter Hager on 10.02.16. - * Copyright © 2016 Gunter Hager. All rights reserved. - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// -import Foundation import Darwin +import Foundation // Addresses let INADDR_ANY = in_addr(s_addr: 0) -let INADDR_BROADCAST = in_addr(s_addr: 0xffffffff) +let INADDR_BROADCAST = in_addr(s_addr: 0xFFFF_FFFF) /// An object representing the UDP broadcast connection. Uses a dispatch source to handle the incoming traffic on the UDP socket. open class UDPBroadcastConnection { - // MARK: Properties + // MARK: Properties - /// The address of the UDP socket. - var address: sockaddr_in + /// The address of the UDP socket. + var address: sockaddr_in - /// Type of a closure that handles incoming UDP packets. - public typealias ReceiveHandler = (_ ipAddress: String, _ port: Int, _ response: Data) -> Void - /// Closure that handles incoming UDP packets. - var handler: ReceiveHandler? + /// Type of a closure that handles incoming UDP packets. + public typealias ReceiveHandler = (_ ipAddress: String, _ port: Int, _ response: Data) -> Void + /// Closure that handles incoming UDP packets. + var handler: ReceiveHandler? - /// Type of a closure that handles errors that were encountered during receiving UDP packets. - public typealias ErrorHandler = (_ error: ConnectionError) -> Void - /// Closure that handles errors that were encountered during receiving UDP packets. - var errorHandler: ErrorHandler? + /// Type of a closure that handles errors that were encountered during receiving UDP packets. + public typealias ErrorHandler = (_ error: ConnectionError) -> Void + /// Closure that handles errors that were encountered during receiving UDP packets. + var errorHandler: ErrorHandler? - /// A dispatch source for reading data from the UDP socket. - var responseSource: DispatchSourceRead? + /// A dispatch source for reading data from the UDP socket. + var responseSource: DispatchSourceRead? - /// The dispatch queue to run responseSource & reconnection on - var dispatchQueue: DispatchQueue = DispatchQueue.main + /// The dispatch queue to run responseSource & reconnection on + var dispatchQueue = DispatchQueue.main - /// Bind to port to start listening without first sending a message - var shouldBeBound: Bool = false + /// Bind to port to start listening without first sending a message + var shouldBeBound: Bool = false - // MARK: Initializers + // MARK: Initializers - /// Initializes the UDP connection with the correct port address. + /// Initializes the UDP connection with the correct port address. - /// - Note: This doesn't open a socket! The socket is opened transparently as needed when sending broadcast messages. If you want to open a socket immediately, use the `bindIt` parameter. This will also try to reopen the socket if it gets closed. - /// - /// - Parameters: - /// - port: Number of the UDP port to use. - /// - bindIt: Opens a port immediately if true, on demand if false. Default is false. - /// - handler: Handler that gets called when data is received. - /// - errorHandler: Handler that gets called when an error occurs. - /// - Throws: Throws a `ConnectionError` if an error occurs. - public init(port: UInt16, bindIt: Bool = false, handler: ReceiveHandler?, errorHandler: ErrorHandler?) throws { - self.address = sockaddr_in( - sin_len: __uint8_t(MemoryLayout.size), - sin_family: sa_family_t(AF_INET), - sin_port: UDPBroadcastConnection.htonsPort(port: port), - sin_addr: INADDR_BROADCAST, - sin_zero: ( 0, 0, 0, 0, 0, 0, 0, 0 ) - ) + /// - Note: This doesn't open a socket! The socket is opened transparently as needed when sending broadcast messages. If you want to open a socket immediately, use the `bindIt` parameter. This will also try to reopen the socket if it gets closed. + /// + /// - Parameters: + /// - port: Number of the UDP port to use. + /// - bindIt: Opens a port immediately if true, on demand if false. Default is false. + /// - handler: Handler that gets called when data is received. + /// - errorHandler: Handler that gets called when an error occurs. + /// - Throws: Throws a `ConnectionError` if an error occurs. + public init(port: UInt16, bindIt: Bool = false, handler: ReceiveHandler?, errorHandler: ErrorHandler?) throws { + self.address = sockaddr_in(sin_len: __uint8_t(MemoryLayout.size), + sin_family: sa_family_t(AF_INET), + sin_port: UDPBroadcastConnection.htonsPort(port: port), + sin_addr: INADDR_BROADCAST, + sin_zero: (0, 0, 0, 0, 0, 0, 0, 0)) - self.handler = handler - self.errorHandler = errorHandler - self.shouldBeBound = bindIt - if bindIt { - try createSocket() - } - } + self.handler = handler + self.errorHandler = errorHandler + self.shouldBeBound = bindIt + if bindIt { + try createSocket() + } + } - deinit { - if responseSource != nil { - responseSource!.cancel() - } - } + deinit { + if responseSource != nil { + responseSource!.cancel() + } + } - // MARK: Interface + // MARK: Interface - /// Create a UDP socket for broadcasting and set up cancel and event handlers - /// - /// - Throws: Throws a `ConnectionError` if an error occurs. - fileprivate func createSocket() throws { + /// Create a UDP socket for broadcasting and set up cancel and event handlers + /// + /// - Throws: Throws a `ConnectionError` if an error occurs. + fileprivate func createSocket() throws { - // Create new socket - let newSocket = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP) - guard newSocket > 0 else { throw ConnectionError.createSocketFailed } + // Create new socket + let newSocket = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP) + guard newSocket > 0 else { throw ConnectionError.createSocketFailed } - // Enable broadcast on socket - var broadcastEnable = Int32(1) - let ret = setsockopt(newSocket, SOL_SOCKET, SO_BROADCAST, &broadcastEnable, socklen_t(MemoryLayout.size)) - if ret == -1 { - debugPrint("Couldn't enable broadcast on socket") - close(newSocket) - throw ConnectionError.enableBroadcastFailed - } + // Enable broadcast on socket + var broadcastEnable = Int32(1) + let ret = setsockopt(newSocket, SOL_SOCKET, SO_BROADCAST, &broadcastEnable, socklen_t(MemoryLayout.size)) + if ret == -1 { + debugPrint("Couldn't enable broadcast on socket") + close(newSocket) + throw ConnectionError.enableBroadcastFailed + } - // Bind socket if needed - if shouldBeBound { - var saddr = sockaddr(sa_len: 0, sa_family: 0, - sa_data: (0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)) - self.address.sin_addr = INADDR_ANY - memcpy(&saddr, &self.address, MemoryLayout.size) - self.address.sin_addr = INADDR_BROADCAST - let isBound = bind(newSocket, &saddr, socklen_t(MemoryLayout.size)) - if isBound == -1 { - debugPrint("Couldn't bind socket") - close(newSocket) - throw ConnectionError.bindSocketFailed - } - } + // Bind socket if needed + if shouldBeBound { + var saddr = sockaddr(sa_len: 0, sa_family: 0, + sa_data: (0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)) + self.address.sin_addr = INADDR_ANY + memcpy(&saddr, &self.address, MemoryLayout.size) + self.address.sin_addr = INADDR_BROADCAST + let isBound = bind(newSocket, &saddr, socklen_t(MemoryLayout.size)) + if isBound == -1 { + debugPrint("Couldn't bind socket") + close(newSocket) + throw ConnectionError.bindSocketFailed + } + } - // Disable global SIGPIPE handler so that the app doesn't crash - setNoSigPipe(socket: newSocket) + // Disable global SIGPIPE handler so that the app doesn't crash + setNoSigPipe(socket: newSocket) - // Set up a dispatch source - let newResponseSource = DispatchSource.makeReadSource(fileDescriptor: newSocket, queue: dispatchQueue) + // Set up a dispatch source + let newResponseSource = DispatchSource.makeReadSource(fileDescriptor: newSocket, queue: dispatchQueue) - // Set up cancel handler - newResponseSource.setCancelHandler { - // debugPrint("Closing UDP socket") - let UDPSocket = Int32(newResponseSource.handle) - shutdown(UDPSocket, SHUT_RDWR) - close(UDPSocket) - } + // Set up cancel handler + newResponseSource.setCancelHandler { + // debugPrint("Closing UDP socket") + let UDPSocket = Int32(newResponseSource.handle) + shutdown(UDPSocket, SHUT_RDWR) + close(UDPSocket) + } - // Set up event handler (gets called when data arrives at the UDP socket) - newResponseSource.setEventHandler { [unowned self] in - guard let source = self.responseSource else { return } + // Set up event handler (gets called when data arrives at the UDP socket) + newResponseSource.setEventHandler { [unowned self] in + guard let source = self.responseSource else { return } - var socketAddress = sockaddr_storage() - var socketAddressLength = socklen_t(MemoryLayout.size) - let response = [UInt8](repeating: 0, count: 4096) - let UDPSocket = Int32(source.handle) + var socketAddress = sockaddr_storage() + var socketAddressLength = socklen_t(MemoryLayout.size) + let response = [UInt8](repeating: 0, count: 4096) + let UDPSocket = Int32(source.handle) - let bytesRead = withUnsafeMutablePointer(to: &socketAddress) { - recvfrom(UDPSocket, UnsafeMutableRawPointer(mutating: response), response.count, 0, UnsafeMutableRawPointer($0).bindMemory(to: sockaddr.self, capacity: 1), &socketAddressLength) - } + let bytesRead = withUnsafeMutablePointer(to: &socketAddress) { + recvfrom(UDPSocket, UnsafeMutableRawPointer(mutating: response), response.count, 0, + UnsafeMutableRawPointer($0).bindMemory(to: sockaddr.self, capacity: 1), &socketAddressLength) + } - do { - guard bytesRead > 0 else { - self.closeConnection() - if bytesRead == 0 { - debugPrint("recvfrom returned EOF") - throw ConnectionError.receivedEndOfFile - } else { - if let errorString = String(validatingUTF8: strerror(errno)) { - debugPrint("recvfrom failed: \(errorString)") - } - throw ConnectionError.receiveFailed(code: errno) - } - } + do { + guard bytesRead > 0 else { + self.closeConnection() + if bytesRead == 0 { + debugPrint("recvfrom returned EOF") + throw ConnectionError.receivedEndOfFile + } else { + if let errorString = String(validatingUTF8: strerror(errno)) { + debugPrint("recvfrom failed: \(errorString)") + } + throw ConnectionError.receiveFailed(code: errno) + } + } - guard let endpoint = withUnsafePointer(to: &socketAddress, { self.getEndpointFromSocketAddress(socketAddressPointer: UnsafeRawPointer($0).bindMemory(to: sockaddr.self, capacity: 1)) }) - else { - // debugPrint("Failed to get the address and port from the socket address received from recvfrom") - self.closeConnection() - return - } + guard let endpoint = withUnsafePointer(to: &socketAddress, + { + self + .getEndpointFromSocketAddress(socketAddressPointer: UnsafeRawPointer($0) + .bindMemory(to: sockaddr.self, capacity: 1)) }) + else { + // debugPrint("Failed to get the address and port from the socket address received from recvfrom") + self.closeConnection() + return + } - // debugPrint("UDP connection received \(bytesRead) bytes from \(endpoint.host):\(endpoint.port)") + // debugPrint("UDP connection received \(bytesRead) bytes from \(endpoint.host):\(endpoint.port)") - let responseBytes = Data(response[0.. Int in + let memory = UnsafeRawPointer(pointer).bindMemory(to: sockaddr.self, capacity: 1) + return sendto(UDPSocket, broadcastMessage.baseAddress, broadcastMessageLength, 0, memory, socketLength) + } - guard let source = responseSource else { return } - let UDPSocket = Int32(source.handle) - let socketLength = socklen_t(address.sin_len) - try data.withUnsafeBytes { (broadcastMessage) in - let broadcastMessageLength = data.count - let sent = withUnsafeMutablePointer(to: &address) { pointer -> Int in - let memory = UnsafeRawPointer(pointer).bindMemory(to: sockaddr.self, capacity: 1) - return sendto(UDPSocket, broadcastMessage.baseAddress, broadcastMessageLength, 0, memory, socketLength) - } + guard sent > 0 else { + closeConnection() + throw ConnectionError.sendingMessageFailed(code: errno) + } + } + } - guard sent > 0 else { - closeConnection() - throw ConnectionError.sendingMessageFailed(code: errno) - } - } - } + /// Close the connection. + /// + /// - Parameter reopen: Automatically reopens the connection if true. Defaults to true. + open func closeConnection(reopen: Bool = true) { + if let source = responseSource { + source.cancel() + responseSource = nil + } + if shouldBeBound && reopen { + dispatchQueue.async { + do { + try self.createSocket() + } catch { + self.errorHandler?(ConnectionError.reopeningSocketFailed(error: error)) + } + } + } + } - /// Close the connection. - /// - /// - Parameter reopen: Automatically reopens the connection if true. Defaults to true. - open func closeConnection(reopen: Bool = true) { - if let source = responseSource { - source.cancel() - responseSource = nil - } - if shouldBeBound && reopen { - dispatchQueue.async { - do { - try self.createSocket() - } catch { - self.errorHandler?(ConnectionError.reopeningSocketFailed(error: error)) - } - } - } - } + // MARK: - Helper - // MARK: - Helper + /// Convert a sockaddr structure into an IP address string and port. + /// + /// - Parameter socketAddressPointer: socketAddressPointer: Pointer to a socket address. + /// - Returns: Returns a tuple of the host IP address and the port in the socket address given. + func getEndpointFromSocketAddress(socketAddressPointer: UnsafePointer) -> (host: String, port: Int)? { + let socketAddress = UnsafePointer(socketAddressPointer).pointee - /// Convert a sockaddr structure into an IP address string and port. - /// - /// - Parameter socketAddressPointer: socketAddressPointer: Pointer to a socket address. - /// - Returns: Returns a tuple of the host IP address and the port in the socket address given. - func getEndpointFromSocketAddress(socketAddressPointer: UnsafePointer) -> (host: String, port: Int)? { - let socketAddress = UnsafePointer(socketAddressPointer).pointee + switch Int32(socketAddress.sa_family) { + case AF_INET: + var socketAddressInet = UnsafeRawPointer(socketAddressPointer).load(as: sockaddr_in.self) + let length = Int(INET_ADDRSTRLEN) + 2 + var buffer = [CChar](repeating: 0, count: length) + let hostCString = inet_ntop(AF_INET, &socketAddressInet.sin_addr, &buffer, socklen_t(length)) + let port = Int(UInt16(socketAddressInet.sin_port).byteSwapped) + return (String(cString: hostCString!), port) - switch Int32(socketAddress.sa_family) { - case AF_INET: - var socketAddressInet = UnsafeRawPointer(socketAddressPointer).load(as: sockaddr_in.self) - let length = Int(INET_ADDRSTRLEN) + 2 - var buffer = [CChar](repeating: 0, count: length) - let hostCString = inet_ntop(AF_INET, &socketAddressInet.sin_addr, &buffer, socklen_t(length)) - let port = Int(UInt16(socketAddressInet.sin_port).byteSwapped) - return (String(cString: hostCString!), port) + case AF_INET6: + var socketAddressInet6 = UnsafeRawPointer(socketAddressPointer).load(as: sockaddr_in6.self) + let length = Int(INET6_ADDRSTRLEN) + 2 + var buffer = [CChar](repeating: 0, count: length) + let hostCString = inet_ntop(AF_INET6, &socketAddressInet6.sin6_addr, &buffer, socklen_t(length)) + let port = Int(UInt16(socketAddressInet6.sin6_port).byteSwapped) + return (String(cString: hostCString!), port) - case AF_INET6: - var socketAddressInet6 = UnsafeRawPointer(socketAddressPointer).load(as: sockaddr_in6.self) - let length = Int(INET6_ADDRSTRLEN) + 2 - var buffer = [CChar](repeating: 0, count: length) - let hostCString = inet_ntop(AF_INET6, &socketAddressInet6.sin6_addr, &buffer, socklen_t(length)) - let port = Int(UInt16(socketAddressInet6.sin6_port).byteSwapped) - return (String(cString: hostCString!), port) + default: + return nil + } + } - default: - return nil - } - } + // MARK: - Private - // MARK: - Private + /// Prevents crashes when blocking calls are pending and the app is paused (via Home button). + /// + /// - Parameter socket: The socket for which the signal should be disabled. + fileprivate func setNoSigPipe(socket: CInt) { + var no_sig_pipe: Int32 = 1 + setsockopt(socket, SOL_SOCKET, SO_NOSIGPIPE, &no_sig_pipe, socklen_t(MemoryLayout.size)) + } - /// Prevents crashes when blocking calls are pending and the app is paused (via Home button). - /// - /// - Parameter socket: The socket for which the signal should be disabled. - fileprivate func setNoSigPipe(socket: CInt) { - var no_sig_pipe: Int32 = 1 - setsockopt(socket, SOL_SOCKET, SO_NOSIGPIPE, &no_sig_pipe, socklen_t(MemoryLayout.size)) - } - - fileprivate class func htonsPort(port: in_port_t) -> in_port_t { - let isLittleEndian = Int(OSHostByteOrder()) == OSLittleEndian - return isLittleEndian ? _OSSwapInt16(port) : port - } - - fileprivate class func ntohs(value: CUnsignedShort) -> CUnsignedShort { - return (value << 8) + (value >> 8) - } + fileprivate class func htonsPort(port: in_port_t) -> in_port_t { + let isLittleEndian = Int(OSHostByteOrder()) == OSLittleEndian + return isLittleEndian ? _OSSwapInt16(port) : port + } + fileprivate class func ntohs(value: CUnsignedShort) -> CUnsignedShort { + (value << 8) + (value >> 8) + } } // Created by Gunter Hager on 25.03.19. @@ -292,25 +291,24 @@ open class UDPBroadcastConnection { // public extension UDPBroadcastConnection { - enum ConnectionError: Error { - // Creating socket - case createSocketFailed - case enableBroadcastFailed - case bindSocketFailed + enum ConnectionError: Error { + // Creating socket + case createSocketFailed + case enableBroadcastFailed + case bindSocketFailed - // Sending message - case messageEncodingFailed - case sendingMessageFailed(code: Int32) + // Sending message + case messageEncodingFailed + case sendingMessageFailed(code: Int32) - // Receiving data - case receivedEndOfFile - case receiveFailed(code: Int32) + // Receiving data + case receivedEndOfFile + case receiveFailed(code: Int32) - // Closing socket - case reopeningSocketFailed(error: Error) - - // Underlying - case underlying(error: Error) - } + // Closing socket + case reopeningSocketFailed(error: Error) + // Underlying + case underlying(error: Error) + } } diff --git a/Shared/Singleton/BackgroundManager.swift b/Shared/Singleton/BackgroundManager.swift index 8475b170..79aaf7fa 100644 --- a/Shared/Singleton/BackgroundManager.swift +++ b/Shared/Singleton/BackgroundManager.swift @@ -1,36 +1,35 @@ // -/* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// 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) + } } diff --git a/Shared/Singleton/LogManager.swift b/Shared/Singleton/LogManager.swift index 601ea4f1..de1af46b 100644 --- a/Shared/Singleton/LogManager.swift +++ b/Shared/Singleton/LogManager.swift @@ -1,56 +1,56 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Foundation import Puppy class LogManager { - static let shared = LogManager() - let log = Puppy() + static let shared = LogManager() + let log = Puppy() - init() { - let console = ConsoleLogger("com.swiftfin.ConsoleLogger") - let fileURL = self.getDocumentsDirectory().appendingPathComponent("logs.txt") - let FM = FileManager() - _ = try? FM.removeItem(at: fileURL) + init() { + let console = ConsoleLogger("com.swiftfin.ConsoleLogger") + let fileURL = self.getDocumentsDirectory().appendingPathComponent("logs.txt") + let FM = FileManager() + _ = try? FM.removeItem(at: fileURL) - do { - let file = try FileLogger("com.swiftfin", fileURL: fileURL) - file.format = LogFormatter() - log.add(file, withLevel: .debug) - } catch let err { - log.error("Couldn't initialize file logger.") - print(err) - } - console.format = LogFormatter() - log.add(console, withLevel: .debug) - log.info("Logger initialized.") - } + do { + let file = try FileLogger("com.swiftfin", fileURL: fileURL) + file.format = LogFormatter() + log.add(file, withLevel: .debug) + } catch let err { + log.error("Couldn't initialize file logger.") + print(err) + } + console.format = LogFormatter() + log.add(console, withLevel: .debug) + log.info("Logger initialized.") + } - func logFileURL() -> URL { - return self.getDocumentsDirectory().appendingPathComponent("logs.txt") - } + func logFileURL() -> URL { + self.getDocumentsDirectory().appendingPathComponent("logs.txt") + } - func getDocumentsDirectory() -> URL { - // find all possible documents directories for this user - let paths = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask) + 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)" + } } diff --git a/Shared/Singleton/SessionManager.swift b/Shared/Singleton/SessionManager.swift index bdf96aae..abba3f1d 100644 --- a/Shared/Singleton/SessionManager.swift +++ b/Shared/Singleton/SessionManager.swift @@ -1,11 +1,10 @@ // -/* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Combine import CoreData @@ -18,299 +17,328 @@ import UIKit typealias CurrentLogin = (server: SwiftfinStore.State.Server, user: SwiftfinStore.State.User) // MARK: NewSessionManager + final class SessionManager { - - // MARK: currentLogin - - private(set) var currentLogin: CurrentLogin! + // MARK: currentLogin - // MARK: main - - static let main = SessionManager() + private(set) var currentLogin: CurrentLogin! - // MARK: init - private init() { - if let lastUserID = Defaults[.lastServerUserID], - let user = try? SwiftfinStore.dataStack.fetchOne(From(), - [Where("id == %@", lastUserID)]) { + // MARK: main - 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 } + static let main = SessionManager() - JellyfinAPI.basePath = server.currentURI - setAuthHeader(with: accessToken.value) - currentLogin = (server: existingServer.state, user: user.state) - } - } + // MARK: init - // MARK: fetchServers - func fetchServers() -> [SwiftfinStore.State.Server] { - let servers = try! SwiftfinStore.dataStack.fetchAll(From()) - return servers.map({ $0.state }) - } + private init() { + if let lastUserID = Defaults[.lastServerUserID], + let user = try? SwiftfinStore.dataStack.fetchOne(From(), + [Where("id == %@", lastUserID)]) + { - // MARK: fetchUsers - func fetchUsers(for server: SwiftfinStore.State.Server) -> [SwiftfinStore.State.User] { - guard let storedServer = try? SwiftfinStore.dataStack.fetchOne(From(), - Where("id == %@", server.id)) - else { fatalError("No stored server associated with given state server?") } - return storedServer.users.map({ $0.state }).sorted(by: { $0.username < $1.username }) - } + 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 } - // MARK: connectToServer publisher - // Connects to a server at the given uri, storing if successful - func connectToServer(with uri: String) -> AnyPublisher { - var uriComponents = URLComponents(string: uri) ?? URLComponents() + JellyfinAPI.basePath = server.currentURI + setAuthHeader(with: accessToken.value) + currentLogin = (server: existingServer.state, user: user.state) + } + } - if uriComponents.scheme == nil { - uriComponents.scheme = Defaults[.defaultHTTPScheme].rawValue - } + // MARK: fetchServers - var uri = uriComponents.string ?? "" + func fetchServers() -> [SwiftfinStore.State.Server] { + let servers = try! SwiftfinStore.dataStack.fetchAll(From()) + return servers.map(\.state) + } - if uri.last == "/" { - uri = String(uri.dropLast()) - } + // MARK: fetchUsers - JellyfinAPI.basePath = uri + func fetchUsers(for server: SwiftfinStore.State.Server) -> [SwiftfinStore.State.User] { + guard let storedServer = try? SwiftfinStore.dataStack.fetchOne(From(), + Where("id == %@", server.id)) + else { fatalError("No stored server associated with given state server?") } + return storedServer.users.map(\.state).sorted(by: { $0.username < $1.username }) + } - return SystemAPI.getPublicSystemInfo() - .tryMap({ response -> (SwiftfinStore.Models.StoredServer, UnsafeDataTransaction) in + // MARK: connectToServer publisher - let transaction = SwiftfinStore.dataStack.beginUnsafe() - let newServer = transaction.create(Into()) + // Connects to a server at the given uri, storing if successful + func connectToServer(with uri: String) -> AnyPublisher { + var uriComponents = URLComponents(string: uri) ?? URLComponents() - 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") } + if uriComponents.scheme == nil { + uriComponents.scheme = Defaults[.defaultHTTPScheme].rawValue + } - newServer.uris = [uri] - newServer.currentURI = uri - newServer.name = name - newServer.id = id - newServer.os = os - newServer.version = version - newServer.users = [] + var uri = uriComponents.string ?? "" - // Check for existing server on device - if let existingServer = try? SwiftfinStore.dataStack.fetchOne(From(), - [Where("id == %@", newServer.id)]) { - throw SwiftfinStore.Errors.existingServer(existingServer.state) - } + if uri.last == "/" { + uri = String(uri.dropLast()) + } - return (newServer, transaction) - }) - .handleEvents(receiveOutput: { (_, transaction) in - try? transaction.commitAndWait() - }) - .map({ (server, _) in - return server.state - }) - .eraseToAnyPublisher() - } + JellyfinAPI.basePath = uri - // MARK: addURIToServer publisher - func addURIToServer(server: SwiftfinStore.State.Server, uri: String) -> AnyPublisher { - return Just(server) - .tryMap { server -> (SwiftfinStore.Models.StoredServer, UnsafeDataTransaction) in + return SystemAPI.getPublicSystemInfo() + .tryMap { response -> (SwiftfinStore.Models.StoredServer, UnsafeDataTransaction) in - let transaction = SwiftfinStore.dataStack.beginUnsafe() + let transaction = SwiftfinStore.dataStack.beginUnsafe() + let newServer = transaction.create(Into()) - guard let existingServer = try? SwiftfinStore.dataStack.fetchOne(From(), - [Where("id == %@", server.id)]) else { - fatalError("No stored server associated with given state server?") - } + 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") } - guard let editServer = transaction.edit(existingServer) else { fatalError("Can't get proxy for existing object?") } - editServer.uris.insert(uri) + newServer.uris = [uri] + newServer.currentURI = uri + newServer.name = name + newServer.id = id + newServer.os = os + newServer.version = version + newServer.users = [] - return (editServer, transaction) - } - .handleEvents(receiveOutput: { (_, transaction) in - try? transaction.commitAndWait() - }) - .map({ (server, _) in - return server.state - }) - .eraseToAnyPublisher() - } + // Check for existing server on device + if let existingServer = try? SwiftfinStore.dataStack.fetchOne(From(), + [Where("id == %@", + newServer.id)]) + { + throw SwiftfinStore.Errors.existingServer(existingServer.state) + } - // MARK: setServerCurrentURI publisher - func setServerCurrentURI(server: SwiftfinStore.State.Server, uri: String) -> AnyPublisher { - return Just(server) - .tryMap { server -> (SwiftfinStore.Models.StoredServer, UnsafeDataTransaction) in + return (newServer, transaction) + } + .handleEvents(receiveOutput: { _, transaction in + try? transaction.commitAndWait() + }) + .map { server, _ in + server.state + } + .eraseToAnyPublisher() + } - let transaction = SwiftfinStore.dataStack.beginUnsafe() + // MARK: addURIToServer publisher - guard let existingServer = try? SwiftfinStore.dataStack.fetchOne(From(), - [Where("id == %@", server.id)]) else { - fatalError("No stored server associated with given state server?") - } + func addURIToServer(server: SwiftfinStore.State.Server, uri: String) -> AnyPublisher { + Just(server) + .tryMap { server -> (SwiftfinStore.Models.StoredServer, UnsafeDataTransaction) in - if !existingServer.uris.contains(uri) { - fatalError("Attempting to set current uri while server doesn't contain it?") - } + let transaction = SwiftfinStore.dataStack.beginUnsafe() - guard let editServer = transaction.edit(existingServer) else { fatalError("Can't get proxy for existing object?") } - editServer.currentURI = uri + guard let existingServer = try? SwiftfinStore.dataStack.fetchOne(From(), + [Where("id == %@", + server.id)]) + else { + fatalError("No stored server associated with given state server?") + } - return (editServer, transaction) - } - .handleEvents(receiveOutput: { (_, transaction) in - try? transaction.commitAndWait() - }) - .map({ (server, _) in - return server.state - }) - .eraseToAnyPublisher() - } + guard let editServer = transaction.edit(existingServer) else { fatalError("Can't get proxy for existing object?") } + editServer.uris.insert(uri) - // 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 { - setAuthHeader(with: "") + return (editServer, transaction) + } + .handleEvents(receiveOutput: { _, transaction in + try? transaction.commitAndWait() + }) + .map { server, _ in + server.state + } + .eraseToAnyPublisher() + } - JellyfinAPI.basePath = server.currentURI + // MARK: setServerCurrentURI publisher - return UserAPI.authenticateUserByName(authenticateUserByName: AuthenticateUserByName(username: username, pw: password)) - .tryMap({ response -> (SwiftfinStore.Models.StoredServer, SwiftfinStore.Models.StoredUser, UnsafeDataTransaction) in + func setServerCurrentURI(server: SwiftfinStore.State.Server, uri: String) -> AnyPublisher { + Just(server) + .tryMap { server -> (SwiftfinStore.Models.StoredServer, UnsafeDataTransaction) in - guard let accessToken = response.accessToken else { throw JellyfinAPIError("Access token missing from network call") } + let transaction = SwiftfinStore.dataStack.beginUnsafe() - let transaction = SwiftfinStore.dataStack.beginUnsafe() - let newUser = transaction.create(Into()) + guard let existingServer = try? SwiftfinStore.dataStack.fetchOne(From(), + [Where("id == %@", + server.id)]) + else { + fatalError("No stored server associated with given state server?") + } - 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 = "" + if !existingServer.uris.contains(uri) { + fatalError("Attempting to set current uri while server doesn't contain it?") + } - // Check for existing user on device - if let existingUser = try? SwiftfinStore.dataStack.fetchOne(From(), - [Where("id == %@", newUser.id)]) { - throw SwiftfinStore.Errors.existingUser(existingUser.state) - } + guard let editServer = transaction.edit(existingServer) else { fatalError("Can't get proxy for existing object?") } + editServer.currentURI = uri - let newAccessToken = transaction.create(Into()) - newAccessToken.value = accessToken - newUser.accessToken = newAccessToken + return (editServer, transaction) + } + .handleEvents(receiveOutput: { _, transaction in + try? transaction.commitAndWait() + }) + .map { server, _ in + server.state + } + .eraseToAnyPublisher() + } - guard let userServer = try? SwiftfinStore.dataStack.fetchOne(From(), - [Where("id == %@", server.id)]) - else { fatalError("No stored server associated with given state server?") } + // MARK: loginUser publisher - guard let editUserServer = transaction.edit(userServer) else { fatalError("Can't get proxy for existing object?") } - editUserServer.users.insert(newUser) + // Logs in a user with an associated server, storing if successful + func loginUser(server: SwiftfinStore.State.Server, username: String, + password: String) -> AnyPublisher + { + setAuthHeader(with: "") - return (editUserServer, newUser, transaction) - }) - .handleEvents(receiveOutput: { [unowned self] (server, user, transaction) in - setAuthHeader(with: user.accessToken?.value ?? "") - try? transaction.commitAndWait() + JellyfinAPI.basePath = server.currentURI - // Fetch for the right queue - let currentServer = SwiftfinStore.dataStack.fetchExisting(server)! - let currentUser = SwiftfinStore.dataStack.fetchExisting(user)! + return UserAPI.authenticateUserByName(authenticateUserByName: AuthenticateUserByName(username: username, pw: password)) + .tryMap { response -> (SwiftfinStore.Models.StoredServer, SwiftfinStore.Models.StoredUser, UnsafeDataTransaction) in - Defaults[.lastServerUserID] = user.id + guard let accessToken = response.accessToken else { throw JellyfinAPIError("Access token missing from network call") } - currentLogin = (server: currentServer.state, user: currentUser.state) - SwiftfinNotificationCenter.main.post(name: SwiftfinNotificationCenter.Keys.didSignIn, object: nil) - }) - .map({ (_, user, _) in - return user.state - }) - .eraseToAnyPublisher() - } + let transaction = SwiftfinStore.dataStack.beginUnsafe() + let newUser = transaction.create(Into()) - // MARK: loginUser - func loginUser(server: SwiftfinStore.State.Server, user: SwiftfinStore.State.User) { - JellyfinAPI.basePath = server.currentURI - Defaults[.lastServerUserID] = user.id - setAuthHeader(with: user.accessToken) - currentLogin = (server: server, user: user) - SwiftfinNotificationCenter.main.post(name: SwiftfinNotificationCenter.Keys.didSignIn, object: nil) - } + guard let username = response.user?.name, + let id = response.user?.id else { throw JellyfinAPIError("Missing user data from network call") } - // MARK: logout - func logout() { - currentLogin = nil - JellyfinAPI.basePath = "" - setAuthHeader(with: "") - Defaults[.lastServerUserID] = nil - SwiftfinNotificationCenter.main.post(name: SwiftfinNotificationCenter.Keys.didSignOut, object: nil) - } + newUser.username = username + newUser.id = id + newUser.appleTVID = "" - // MARK: purge - func purge() { - // Delete all servers - let servers = fetchServers() + // Check for existing user on device + if let existingUser = try? SwiftfinStore.dataStack.fetchOne(From(), + [Where("id == %@", + newUser.id)]) + { + throw SwiftfinStore.Errors.existingUser(existingUser.state) + } - for server in servers { - delete(server: server) - } + let newAccessToken = transaction.create(Into()) + newAccessToken.value = accessToken + newUser.accessToken = newAccessToken - SwiftfinNotificationCenter.main.post(name: SwiftfinNotificationCenter.Keys.didPurge, object: nil) - } + guard let userServer = try? SwiftfinStore.dataStack.fetchOne(From(), + [ + Where("id == %@", + server.id), + ]) + else { fatalError("No stored server associated with given state server?") } - // MARK: delete user - func delete(user: SwiftfinStore.State.User) { - guard let storedUser = try? SwiftfinStore.dataStack.fetchOne(From(), - [Where("id == %@", user.id)]) else { fatalError("No stored user for state user?")} - _delete(user: storedUser, transaction: nil) - } + guard let editUserServer = transaction.edit(userServer) else { fatalError("Can't get proxy for existing object?") } + editUserServer.users.insert(newUser) - // MARK: delete server - func delete(server: SwiftfinStore.State.Server) { - guard let storedServer = try? SwiftfinStore.dataStack.fetchOne(From(), - [Where("id == %@", server.id)]) else { fatalError("No stored server for state server?")} - _delete(server: storedServer, transaction: nil) - } + return (editUserServer, newUser, transaction) + } + .handleEvents(receiveOutput: { [unowned self] server, user, transaction in + setAuthHeader(with: user.accessToken?.value ?? "") + try? transaction.commitAndWait() - private func _delete(user: SwiftfinStore.Models.StoredUser, transaction: UnsafeDataTransaction?) { - guard let storedAccessToken = user.accessToken else { fatalError("No access token for stored user?")} + // Fetch for the right queue + let currentServer = SwiftfinStore.dataStack.fetchExisting(server)! + let currentUser = SwiftfinStore.dataStack.fetchExisting(user)! - let transaction = transaction == nil ? SwiftfinStore.dataStack.beginUnsafe() : transaction! - transaction.delete(storedAccessToken) - transaction.delete(user) - try? transaction.commitAndWait() - } + Defaults[.lastServerUserID] = user.id - private func _delete(server: SwiftfinStore.Models.StoredServer, transaction: UnsafeDataTransaction?) { - let transaction = transaction == nil ? SwiftfinStore.dataStack.beginUnsafe() : transaction! + currentLogin = (server: currentServer.state, user: currentUser.state) + SwiftfinNotificationCenter.main.post(name: SwiftfinNotificationCenter.Keys.didSignIn, object: nil) + }) + .map { _, user, _ in + user.state + } + .eraseToAnyPublisher() + } - for user in server.users { - _delete(user: user, transaction: transaction) - } + // MARK: loginUser - transaction.delete(server) - try? transaction.commitAndWait() - } + func loginUser(server: SwiftfinStore.State.Server, user: SwiftfinStore.State.User) { + JellyfinAPI.basePath = server.currentURI + Defaults[.lastServerUserID] = user.id + setAuthHeader(with: user.accessToken) + currentLogin = (server: server, user: user) + SwiftfinNotificationCenter.main.post(name: SwiftfinNotificationCenter.Keys.didSignIn, object: nil) + } - 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) }) + // MARK: logout - let platform: String - #if os(tvOS) - platform = "tvOS" - #else - platform = "iOS" - #endif + func logout() { + currentLogin = nil + JellyfinAPI.basePath = "" + setAuthHeader(with: "") + Defaults[.lastServerUserID] = nil + SwiftfinNotificationCenter.main.post(name: SwiftfinNotificationCenter.Keys.didSignOut, object: nil) + } - 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)\"") + // MARK: purge - JellyfinAPI.customHeaders["X-Emby-Authorization"] = header - } + func purge() { + // Delete all servers + let servers = fetchServers() + + for server in servers { + delete(server: server) + } + + SwiftfinNotificationCenter.main.post(name: SwiftfinNotificationCenter.Keys.didPurge, object: nil) + } + + // MARK: delete user + + func delete(user: SwiftfinStore.State.User) { + guard let storedUser = try? SwiftfinStore.dataStack.fetchOne(From(), + [Where("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(), + [Where("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)\"") + + JellyfinAPI.customHeaders["X-Emby-Authorization"] = header + } } diff --git a/Shared/Singleton/SwiftfinNotificationCenter.swift b/Shared/Singleton/SwiftfinNotificationCenter.swift index a5d8407f..ae01e73a 100644 --- a/Shared/Singleton/SwiftfinNotificationCenter.swift +++ b/Shared/Singleton/SwiftfinNotificationCenter.swift @@ -1,25 +1,24 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Foundation enum SwiftfinNotificationCenter { - static let main: NotificationCenter = { - return NotificationCenter() - }() + static let main: NotificationCenter = { + NotificationCenter() + }() - enum Keys { - static let didSignIn = Notification.Name("didSignIn") - static let didSignOut = Notification.Name("didSignOut") - static let processDeepLink = Notification.Name("processDeepLink") - static let didPurge = Notification.Name("didPurge") - static let didChangeServerCurrentURI = Notification.Name("didChangeCurrentLoginURI") - } + enum Keys { + static let didSignIn = Notification.Name("didSignIn") + static let didSignOut = Notification.Name("didSignOut") + static let processDeepLink = Notification.Name("processDeepLink") + static let didPurge = Notification.Name("didPurge") + static let didChangeServerCurrentURI = Notification.Name("didChangeCurrentLoginURI") + } } diff --git a/Shared/SwiftfinStore/SwiftfinStore.swift b/Shared/SwiftfinStore/SwiftfinStore.swift index 14411370..9629c1da 100644 --- a/Shared/SwiftfinStore/SwiftfinStore.swift +++ b/Shared/SwiftfinStore/SwiftfinStore.swift @@ -1,198 +1,215 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// -import Foundation import CoreStore import Defaults +import Foundation enum SwiftfinStore { - // MARK: State - // Safe, copyable representations of their underlying CoreStoredObject - // Relationships are represented by the related object's IDs or value - enum State { + // MARK: State - struct Server { - let uris: Set - let currentURI: String - let name: String - let id: String - let os: String - let version: String - let userIDs: [String] + // Safe, copyable representations of their underlying CoreStoredObject + // Relationships are represented by the related object's IDs or value + enum State { - fileprivate init(uris: Set, 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 - } + struct Server { + let uris: Set + let currentURI: String + let name: String + let id: String + let os: String + let version: String + let userIDs: [String] - static var sample: Server { - return 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"]) - } - } + fileprivate init(uris: Set, 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 + } - struct User { - let username: String - let id: String - let serverID: String - let accessToken: String + 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"]) + } + } - fileprivate init(username: String, id: String, serverID: String, accessToken: String) { - self.username = username - self.id = id - self.serverID = serverID - self.accessToken = accessToken - } + struct User { + let username: String + let id: String + let serverID: String + let accessToken: String - static var sample: User { - return User(username: "JohnnyAppleseed", - id: "123abc", - serverID: "123abc", - accessToken: "open-sesame") - } - } - } + fileprivate init(username: String, id: String, serverID: String, accessToken: String) { + self.username = username + self.id = id + self.serverID = serverID + self.accessToken = accessToken + } - // MARK: Models - enum Models { + static var sample: User { + User(username: "JohnnyAppleseed", + id: "123abc", + serverID: "123abc", + accessToken: "open-sesame") + } + } + } - final class StoredServer: CoreStoreObject { + // MARK: Models - @Field.Coded("uris", coder: FieldCoders.Json.self) - var uris: Set = [] + enum Models { - @Field.Stored("currentURI") - var currentURI: String = "" + final class StoredServer: CoreStoreObject { - @Field.Stored("name") - var name: String = "" + @Field.Coded("uris", coder: FieldCoders.Json.self) + var uris: Set = [] - @Field.Stored("id") - var id: String = "" + @Field.Stored("currentURI") + var currentURI: String = "" - @Field.Stored("os") - var os: String = "" + @Field.Stored("name") + var name: String = "" - @Field.Stored("version") - var version: String = "" + @Field.Stored("id") + var id: String = "" - @Field.Relationship("users", inverse: \StoredUser.$server) - var users: Set + @Field.Stored("os") + var os: String = "" - var state: State.Server { - return State.Server(uris: uris, - currentURI: currentURI, - name: name, - id: id, - os: os, - version: version, - usersIDs: users.map({ $0.id })) - } - } + @Field.Stored("version") + var version: String = "" - final class StoredUser: CoreStoreObject { + @Field.Relationship("users", inverse: \StoredUser.$server) + var users: Set - @Field.Stored("username") - var username: String = "" + var state: State.Server { + State.Server(uris: uris, + currentURI: currentURI, + name: name, + id: id, + os: os, + version: version, + usersIDs: users.map(\.id)) + } + } - @Field.Stored("id") - var id: String = "" + final class StoredUser: CoreStoreObject { - @Field.Stored("appleTVID") - var appleTVID: String = "" + @Field.Stored("username") + var username: String = "" - @Field.Relationship("server") - var server: StoredServer? + @Field.Stored("id") + var id: String = "" - @Field.Relationship("accessToken", inverse: \StoredAccessToken.$user) - var accessToken: StoredAccessToken? + @Field.Stored("appleTVID") + var appleTVID: String = "" - 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) - } - } + @Field.Relationship("server") + var server: StoredServer? - final class StoredAccessToken: CoreStoreObject { + @Field.Relationship("accessToken", inverse: \StoredAccessToken.$user) + var accessToken: StoredAccessToken? - @Field.Stored("value") - var value: String = "" + 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) + } + } - @Field.Relationship("user") - var user: StoredUser? - } - } + final class StoredAccessToken: CoreStoreObject { - // MARK: Errors - enum Errors { - case existingServer(State.Server) - case existingUser(State.User) - } + @Field.Stored("value") + var value: String = "" - // MARK: dataStack - static let dataStack: DataStack = { - let schema = CoreStoreSchema(modelVersion: "V1", - entities: [ - Entity("Server"), - Entity("User"), - Entity("AccessToken") - ], - versionLock: [ - "AccessToken": [0xa8c475e874494bb1, 0x79486e93449f0b3d, 0xa7dc4a0003541edb, 0x94183fae7580ef72], - "Server": [0x936b46acd8e8f0e3, 0x59890d4d9f3f885f, 0x819cf7a4abf98b22, 0xe16125c5af885a06], - "User": [0x845de08a74bc53ed, 0xe95a406a29f3a5d0, 0x9eda732821a15ea9, 0xb5afa531e41ce8a] - ]) + @Field.Relationship("user") + var user: StoredUser? + } + } - let _dataStack = DataStack(schema) - try! _dataStack.addStorageAndWait( - SQLiteStore( - fileName: "Swiftfin.sqlite", - localStorageOptions: .recreateStoreOnModelMismatch - ) - ) - return _dataStack - }() + // MARK: Errors + + enum Errors { + case existingServer(State.Server) + case existingUser(State.User) + } + + // MARK: dataStack + + static let dataStack: DataStack = { + let schema = CoreStoreSchema(modelVersion: "V1", + entities: [ + Entity("Server"), + Entity("User"), + Entity("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 + }() } // MARK: LocalizedError + extension SwiftfinStore.Errors: LocalizedError { - var title: String { - switch self { - case .existingServer: - return "Existing Server" - case .existingUser: - return "Existing User" - } - } + var title: String { + switch self { + case .existingServer: + return "Existing Server" + case .existingUser: + return "Existing User" + } + } - var errorDescription: String? { - switch self { - case .existingServer(let server): - return "Server \(server.name) already exists with same server ID" - case .existingUser(let user): - return "User \(user.username) already exists with same user ID" - } - } + var errorDescription: String? { + switch self { + case let .existingServer(server): + return "Server \(server.name) already exists with same server ID" + case let .existingUser(user): + return "User \(user.username) already exists with same user ID" + } + } } diff --git a/Shared/SwiftfinStore/SwiftfinStoreDefaults.swift b/Shared/SwiftfinStore/SwiftfinStoreDefaults.swift index ecb744a0..d8ed248b 100644 --- a/Shared/SwiftfinStore/SwiftfinStoreDefaults.swift +++ b/Shared/SwiftfinStore/SwiftfinStoreDefaults.swift @@ -1,71 +1,75 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Defaults import Foundation extension SwiftfinStore { - enum Defaults { + enum Defaults { - static let generalSuite: UserDefaults = { - return UserDefaults(suiteName: "swiftfinstore-general-defaults")! - }() - - static let universalSuite: UserDefaults = { - return UserDefaults(suiteName: "swiftfinstore-universal-defaults")! - }() - } + static let generalSuite: UserDefaults = { + UserDefaults(suiteName: "swiftfinstore-general-defaults")! + }() + + static let universalSuite: UserDefaults = { + UserDefaults(suiteName: "swiftfinstore-universal-defaults")! + }() + } } extension Defaults.Keys { - - // Universal settings - static let defaultHTTPScheme = Key("defaultHTTPScheme", default: .http, suite: SwiftfinStore.Defaults.universalSuite) - static let appAppearance = Key("appAppearance", default: .system, suite: SwiftfinStore.Defaults.universalSuite) - - // General settings - static let lastServerUserID = Defaults.Key("lastServerUserID", suite: SwiftfinStore.Defaults.generalSuite) - static let inNetworkBandwidth = Key("InNetworkBandwidth", default: 40_000_000, suite: SwiftfinStore.Defaults.generalSuite) - static let outOfNetworkBandwidth = Key("OutOfNetworkBandwidth", default: 40_000_000, suite: SwiftfinStore.Defaults.generalSuite) - static let isAutoSelectSubtitles = Key("isAutoSelectSubtitles", default: false, suite: SwiftfinStore.Defaults.generalSuite) - static let autoSelectSubtitlesLangCode = Key("AutoSelectSubtitlesLangCode", default: "Auto", suite: SwiftfinStore.Defaults.generalSuite) - static let autoSelectAudioLangCode = Key("AutoSelectAudioLangCode", default: "Auto", suite: SwiftfinStore.Defaults.generalSuite) - - // Customize settings - static let showPosterLabels = Key("showPosterLabels", default: true, suite: SwiftfinStore.Defaults.generalSuite) - static let showCastAndCrew = Key("showCastAndCrew", default: true, suite: SwiftfinStore.Defaults.generalSuite) - - // Video player / overlay settings - static let overlayType = Key("overlayType", default: .normal, suite: SwiftfinStore.Defaults.generalSuite) - static let jumpGesturesEnabled = Key("gesturesEnabled", default: true, suite: SwiftfinStore.Defaults.generalSuite) - static let videoPlayerJumpForward = Key("videoPlayerJumpForward", default: .fifteen, suite: SwiftfinStore.Defaults.generalSuite) - static let videoPlayerJumpBackward = Key("videoPlayerJumpBackward", default: .fifteen, suite: SwiftfinStore.Defaults.generalSuite) - static let autoplayEnabled = Key("autoPlayNextItem", default: true, suite: SwiftfinStore.Defaults.generalSuite) - static let resumeOffset = Key("resumeOffset", default: false, suite: SwiftfinStore.Defaults.generalSuite) - - // Should show video player items - static let shouldShowPlayPreviousItem = Key("shouldShowPreviousItem", default: true, suite: SwiftfinStore.Defaults.generalSuite) - static let shouldShowPlayNextItem = Key("shouldShowNextItem", default: true, suite: SwiftfinStore.Defaults.generalSuite) - static let shouldShowAutoPlay = Key("shouldShowAutoPlayNextItem", default: true, suite: SwiftfinStore.Defaults.generalSuite) - - // Should show video player items in overlay menu - static let shouldShowJumpButtonsInOverlayMenu = Key("shouldShowJumpButtonsInMenu", default: true, suite: SwiftfinStore.Defaults.generalSuite) - - // Experimental settings - struct Experimental { - static let syncSubtitleStateWithAdjacent = Key("experimental.syncSubtitleState", default: false, suite: SwiftfinStore.Defaults.generalSuite) - static let liveTVAlphaEnabled = Key("liveTVAlphaEnabled", default: false, suite: SwiftfinStore.Defaults.generalSuite) - } - - // tvos specific - static let downActionShowsMenu = Key("downActionShowsMenu", default: true, suite: SwiftfinStore.Defaults.generalSuite) - static let confirmClose = Key("confirmClose", default: false, suite: SwiftfinStore.Defaults.generalSuite) - static let tvOSCinematicViews = Key("tvOSCinematicViews", default: false, suite: SwiftfinStore.Defaults.generalSuite) + + // Universal settings + static let defaultHTTPScheme = Key("defaultHTTPScheme", default: .http, suite: SwiftfinStore.Defaults.universalSuite) + static let appAppearance = Key("appAppearance", default: .system, suite: SwiftfinStore.Defaults.universalSuite) + + // General settings + static let lastServerUserID = Defaults.Key("lastServerUserID", suite: SwiftfinStore.Defaults.generalSuite) + static let inNetworkBandwidth = Key("InNetworkBandwidth", default: 40_000_000, suite: SwiftfinStore.Defaults.generalSuite) + static let outOfNetworkBandwidth = Key("OutOfNetworkBandwidth", default: 40_000_000, suite: SwiftfinStore.Defaults.generalSuite) + static let isAutoSelectSubtitles = Key("isAutoSelectSubtitles", default: false, suite: SwiftfinStore.Defaults.generalSuite) + static let autoSelectSubtitlesLangCode = Key("AutoSelectSubtitlesLangCode", default: "Auto", + suite: SwiftfinStore.Defaults.generalSuite) + static let autoSelectAudioLangCode = Key("AutoSelectAudioLangCode", default: "Auto", suite: SwiftfinStore.Defaults.generalSuite) + + // Customize settings + static let showPosterLabels = Key("showPosterLabels", default: true, suite: SwiftfinStore.Defaults.generalSuite) + static let showCastAndCrew = Key("showCastAndCrew", default: true, suite: SwiftfinStore.Defaults.generalSuite) + + // Video player / overlay settings + static let overlayType = Key("overlayType", default: .normal, suite: SwiftfinStore.Defaults.generalSuite) + static let jumpGesturesEnabled = Key("gesturesEnabled", default: true, suite: SwiftfinStore.Defaults.generalSuite) + static let videoPlayerJumpForward = Key("videoPlayerJumpForward", default: .fifteen, + suite: SwiftfinStore.Defaults.generalSuite) + static let videoPlayerJumpBackward = Key("videoPlayerJumpBackward", default: .fifteen, + suite: SwiftfinStore.Defaults.generalSuite) + static let autoplayEnabled = Key("autoPlayNextItem", default: true, suite: SwiftfinStore.Defaults.generalSuite) + static let resumeOffset = Key("resumeOffset", default: false, suite: SwiftfinStore.Defaults.generalSuite) + + // Should show video player items + static let shouldShowPlayPreviousItem = Key("shouldShowPreviousItem", default: true, suite: SwiftfinStore.Defaults.generalSuite) + static let shouldShowPlayNextItem = Key("shouldShowNextItem", default: true, suite: SwiftfinStore.Defaults.generalSuite) + static let shouldShowAutoPlay = Key("shouldShowAutoPlayNextItem", default: true, suite: SwiftfinStore.Defaults.generalSuite) + + // Should show video player items in overlay menu + static let shouldShowJumpButtonsInOverlayMenu = Key("shouldShowJumpButtonsInMenu", default: true, + suite: SwiftfinStore.Defaults.generalSuite) + + // Experimental settings + enum Experimental { + static let syncSubtitleStateWithAdjacent = Key("experimental.syncSubtitleState", default: false, + suite: SwiftfinStore.Defaults.generalSuite) + static let liveTVAlphaEnabled = Key("liveTVAlphaEnabled", default: false, suite: SwiftfinStore.Defaults.generalSuite) + } + + // tvos specific + static let downActionShowsMenu = Key("downActionShowsMenu", default: true, suite: SwiftfinStore.Defaults.generalSuite) + static let confirmClose = Key("confirmClose", default: false, suite: SwiftfinStore.Defaults.generalSuite) + static let tvOSCinematicViews = Key("tvOSCinematicViews", default: false, suite: SwiftfinStore.Defaults.generalSuite) } diff --git a/Shared/ViewModels/BasicAppSettingsViewModel.swift b/Shared/ViewModels/BasicAppSettingsViewModel.swift index fb5c8887..b70c5346 100644 --- a/Shared/ViewModels/BasicAppSettingsViewModel.swift +++ b/Shared/ViewModels/BasicAppSettingsViewModel.swift @@ -1,27 +1,26 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import SwiftUI final class BasicAppSettingsViewModel: ViewModel { - let appearances = AppAppearance.allCases + let appearances = AppAppearance.allCases - func resetUserSettings() { - SwiftfinStore.Defaults.generalSuite.removeAll() - } - - func resetAppSettings() { - SwiftfinStore.Defaults.universalSuite.removeAll() - } - - func removeAllUsers() { - SessionManager.main.purge() - } + func resetUserSettings() { + SwiftfinStore.Defaults.generalSuite.removeAll() + } + + func resetAppSettings() { + SwiftfinStore.Defaults.universalSuite.removeAll() + } + + func removeAllUsers() { + SessionManager.main.purge() + } } diff --git a/Shared/ViewModels/ConnectToServerViewModel.swift b/Shared/ViewModels/ConnectToServerViewModel.swift index 4e2ec728..53ac9164 100644 --- a/Shared/ViewModels/ConnectToServerViewModel.swift +++ b/Shared/ViewModels/ConnectToServerViewModel.swift @@ -1,11 +1,10 @@ // -/* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Combine import Foundation @@ -14,113 +13,120 @@ import Stinsen struct AddServerURIPayload: Identifiable { - let server: SwiftfinStore.State.Server - let uri: String + let server: SwiftfinStore.State.Server + let uri: String - var id: String { - return server.id.appending(uri) - } + var id: String { + server.id.appending(uri) + } } final class ConnectToServerViewModel: ViewModel { - @RouterObject var router: ConnectToServerCoodinator.Router? - @Published var discoveredServers: Set = [] - @Published var searching = false - @Published var addServerURIPayload: AddServerURIPayload? - var backAddServerURIPayload: AddServerURIPayload? + @RouterObject + var router: ConnectToServerCoodinator.Router? + @Published + var discoveredServers: Set = [] + @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 ?? "Unkown Error")") - 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 ?? "Unkown Error")") + return message + } - func connectToServer(uri: String) { - #if targetEnvironment(simulator) - var uri = uri - if uri == "localhost" { - uri = "http://localhost:8096" - } - #endif - - let trimmedURI = uri.trimmingCharacters(in: .whitespaces) + func connectToServer(uri: String) { + #if targetEnvironment(simulator) + var uri = uri + if uri == "localhost" { + uri = "http://localhost:8096" + } + #endif - LogManager.shared.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 .failure(let error): - switch error { - case is SwiftfinStore.Errors: - let swiftfinError = error as! SwiftfinStore.Errors - switch swiftfinError { - case .existingServer(let server): - self.addServerURIPayload = AddServerURIPayload(server: server, uri: uri) - self.backAddServerURIPayload = AddServerURIPayload(server: server, uri: uri) - default: - self.handleAPIRequestError(displayMessage: "Unable to connect to server.", logLevel: .critical, tag: "connectToServer", - completion: completion) - } - default: - self.handleAPIRequestError(displayMessage: "Unable to connect to server.", logLevel: .critical, tag: "connectToServer", - completion: completion) - } - } - }, receiveValue: { server in - LogManager.shared.log.debug("Connected to server at \"\(uri)\"", tag: "connectToServer") - self.router?.route(to: \.userSignIn, server) - }) - .store(in: &cancellables) - } + let trimmedURI = uri.trimmingCharacters(in: .whitespaces) - func discoverServers() { - discoveredServers.removeAll() - searching = true + LogManager.shared.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 SwiftfinStore.Errors: + let swiftfinError = error as! SwiftfinStore.Errors + switch swiftfinError { + case let .existingServer(server): + self.addServerURIPayload = AddServerURIPayload(server: server, uri: uri) + self.backAddServerURIPayload = AddServerURIPayload(server: server, uri: uri) + default: + self.handleAPIRequestError(displayMessage: "Unable to connect to server.", logLevel: .critical, + tag: "connectToServer", + completion: completion) + } + default: + self.handleAPIRequestError(displayMessage: "Unable to connect to server.", logLevel: .critical, + tag: "connectToServer", + completion: completion) + } + } + }, receiveValue: { server in + LogManager.shared.log.debug("Connected to server at \"\(uri)\"", tag: "connectToServer") + self.router?.route(to: \.userSignIn, server) + }) + .store(in: &cancellables) + } - // Timeout after 3 seconds - DispatchQueue.main.asyncAfter(deadline: .now() + 3) { - self.searching = false - } + func discoverServers() { + discoveredServers.removeAll() + searching = true - discovery.locateServer { [self] server in - if let server = server { - discoveredServers.insert(server) - } - } - } + // Timeout after 3 seconds + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + self.searching = false + } - func addURIToServer(addServerURIPayload: AddServerURIPayload) { - SessionManager.main.addURIToServer(server: addServerURIPayload.server, uri: addServerURIPayload.uri) - .sink { completion in - self.handleAPIRequestError(displayMessage: "Unable to connect to server.", logLevel: .critical, tag: "connectToServer", - completion: completion) - } receiveValue: { server in - SessionManager.main.setServerCurrentURI(server: server, uri: addServerURIPayload.uri) - .sink { completion in - self.handleAPIRequestError(displayMessage: "Unable to connect to server.", logLevel: .critical, tag: "connectToServer", - completion: completion) - } receiveValue: { _ in - self.router?.dismissCoordinator() - } - .store(in: &self.cancellables) - } - .store(in: &cancellables) - } + discovery.locateServer { [self] server in + if let server = server { + discoveredServers.insert(server) + } + } + } - func cancelConnection() { - for cancellable in cancellables { - cancellable.cancel() - } + func addURIToServer(addServerURIPayload: AddServerURIPayload) { + SessionManager.main.addURIToServer(server: addServerURIPayload.server, uri: addServerURIPayload.uri) + .sink { completion in + self.handleAPIRequestError(displayMessage: "Unable to connect to server.", logLevel: .critical, tag: "connectToServer", + completion: completion) + } receiveValue: { server in + SessionManager.main.setServerCurrentURI(server: server, uri: addServerURIPayload.uri) + .sink { completion in + self.handleAPIRequestError(displayMessage: "Unable to connect to server.", logLevel: .critical, + tag: "connectToServer", + completion: completion) + } receiveValue: { _ in + self.router?.dismissCoordinator() + } + .store(in: &self.cancellables) + } + .store(in: &cancellables) + } - self.isLoading = false - } + func cancelConnection() { + for cancellable in cancellables { + cancellable.cancel() + } + + self.isLoading = false + } } diff --git a/Shared/ViewModels/EpisodesRowViewModel.swift b/Shared/ViewModels/EpisodesRowViewModel.swift index 68686728..7ec32d00 100644 --- a/Shared/ViewModels/EpisodesRowViewModel.swift +++ b/Shared/ViewModels/EpisodesRowViewModel.swift @@ -1,81 +1,85 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import JellyfinAPI import SwiftUI final class EpisodesRowViewModel: ViewModel { - - // TODO: Protocol these viewmodels for generalization instead of Episode - - @ObservedObject var episodeItemViewModel: EpisodeItemViewModel - @Published var seasonsEpisodes: [BaseItemDto: [BaseItemDto]] = [:] - @Published var selectedSeason: BaseItemDto? { - willSet { - if seasonsEpisodes[newValue!]!.isEmpty { - retrieveEpisodesForSeason(newValue!) - } - } - } - - init(episodeItemViewModel: EpisodeItemViewModel) { - self.episodeItemViewModel = episodeItemViewModel - super.init() - - retrieveSeasons() - } - - private func retrieveSeasons() { - TvShowsAPI.getSeasons(seriesId: episodeItemViewModel.item.seriesId ?? "", - userId: SessionManager.main.currentLogin.user.id) - .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.episodeItemViewModel.item.seasonId ?? "" { - self.selectedSeason = season - } - } - } - .store(in: &cancellables) - } - - private func retrieveEpisodesForSeason(_ season: BaseItemDto) { - guard let seasonID = season.id else { return } - - TvShowsAPI.getEpisodes(seriesId: episodeItemViewModel.item.seriesId ?? "", - userId: SessionManager.main.currentLogin.user.id, - fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people], - seasonId: seasonID) - .trackActivity(loading) - .sink { completion in - self.handleAPIRequestError(completion: completion) - } receiveValue: { episodes in - self.seasonsEpisodes[season] = episodes.items ?? [] - } - .store(in: &cancellables) - } + + // TODO: Protocol these viewmodels for generalization instead of Episode + + @ObservedObject + var episodeItemViewModel: EpisodeItemViewModel + @Published + var seasonsEpisodes: [BaseItemDto: [BaseItemDto]] = [:] + @Published + var selectedSeason: BaseItemDto? { + willSet { + if seasonsEpisodes[newValue!]!.isEmpty { + retrieveEpisodesForSeason(newValue!) + } + } + } + + init(episodeItemViewModel: EpisodeItemViewModel) { + self.episodeItemViewModel = episodeItemViewModel + super.init() + + retrieveSeasons() + } + + private func retrieveSeasons() { + TvShowsAPI.getSeasons(seriesId: episodeItemViewModel.item.seriesId ?? "", + userId: SessionManager.main.currentLogin.user.id) + .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.episodeItemViewModel.item.seasonId ?? "" { + self.selectedSeason = season + } + } + } + .store(in: &cancellables) + } + + private func retrieveEpisodesForSeason(_ season: BaseItemDto) { + guard let seasonID = season.id else { return } + + TvShowsAPI.getEpisodes(seriesId: episodeItemViewModel.item.seriesId ?? "", + userId: SessionManager.main.currentLogin.user.id, + fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people], + seasonId: seasonID) + .trackActivity(loading) + .sink { completion in + self.handleAPIRequestError(completion: completion) + } receiveValue: { episodes in + self.seasonsEpisodes[season] = episodes.items ?? [] + } + .store(in: &cancellables) + } } final class SingleSeasonEpisodesRowViewModel: ViewModel { - - // TODO: Protocol these viewmodels for generalization instead of Season - - @ObservedObject var seasonItemViewModel: SeasonItemViewModel - @Published var episodes: [BaseItemDto] - - init(seasonItemViewModel: SeasonItemViewModel) { - self.seasonItemViewModel = seasonItemViewModel - self.episodes = seasonItemViewModel.episodes - super.init() - } + + // TODO: Protocol these viewmodels for generalization instead of Season + + @ObservedObject + var seasonItemViewModel: SeasonItemViewModel + @Published + var episodes: [BaseItemDto] + + init(seasonItemViewModel: SeasonItemViewModel) { + self.seasonItemViewModel = seasonItemViewModel + self.episodes = seasonItemViewModel.episodes + super.init() + } } diff --git a/Shared/ViewModels/HomeViewModel.swift b/Shared/ViewModels/HomeViewModel.swift index 8207fedd..0de66a2f 100644 --- a/Shared/ViewModels/HomeViewModel.swift +++ b/Shared/ViewModels/HomeViewModel.swift @@ -1,11 +1,10 @@ // -/* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import ActivityIndicator import Combine @@ -14,168 +13,206 @@ 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 = 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 - let nc = SwiftfinNotificationCenter.main - nc.addObserver(self, selector: #selector(didSignIn), name: SwiftfinNotificationCenter.Keys.didSignIn, object: nil) - nc.addObserver(self, selector: #selector(didSignOut), name: SwiftfinNotificationCenter.Keys.didSignOut, object: nil) - } + // 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 + let nc = SwiftfinNotificationCenter.main + nc.addObserver(self, selector: #selector(didSignIn), name: SwiftfinNotificationCenter.Keys.didSignIn, object: nil) + nc.addObserver(self, selector: #selector(didSignOut), name: SwiftfinNotificationCenter.Keys.didSignOut, object: nil) + } - @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.shared.log.debug("Refresh called.") - - refreshLibrariesLatest() - refreshLatestAddedItems() - refreshResumeItems() - refreshNextUpItems() - } - - // 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 = [] - } - - self.handleAPIRequestError(completion: completion) - }, receiveValue: { response in + @objc + func refresh() { + LogManager.shared.log.debug("Refresh called.") - var newLibraries: [BaseItemDto] = [] + refreshLibrariesLatest() + refreshLatestAddedItems() + refreshResumeItems() + refreshNextUpItems() + } - response.items!.forEach { item in - LogManager.shared.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) - } - } + // MARK: Libraries Latest Items - 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! : [] + 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 = [] + } - for excludeID in excludeIDs { - newLibraries.removeAll { library in - return library.id == excludeID - } - } + self.handleAPIRequestError(completion: completion) + }, receiveValue: { response in - self.libraries = newLibraries - }) - .store(in: &self.cancellables) - }) - .store(in: &cancellables) - } - - // MARK: Latest Added Items - private func refreshLatestAddedItems() { - UserLibraryAPI.getLatestMedia(userId: SessionManager.main.currentLogin.user.id, - fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people, .chapters], - 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.shared.log.debug("Retrieved \(String(items.count)) resume items") - - self.latestAddedItems = items - } - .store(in: &cancellables) - } - - // 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.shared.log.debug("Retrieved \(String(response.items!.count)) resume items") + var newLibraries: [BaseItemDto] = [] - self.resumeItems = response.items ?? [] - }) - .store(in: &cancellables) - } - - // 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.shared.log.debug("Retrieved \(String(response.items!.count)) nextup items") + response.items!.forEach { item in + LogManager.shared.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) + } + } - self.nextUpItems = response.items ?? [] - }) - .store(in: &cancellables) - } + 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 + } + } + + self.libraries = newLibraries + }) + .store(in: &self.cancellables) + }) + .store(in: &cancellables) + } + + // MARK: Latest Added Items + + private func refreshLatestAddedItems() { + UserLibraryAPI.getLatestMedia(userId: SessionManager.main.currentLogin.user.id, + fields: [ + .primaryImageAspectRatio, + .seriesPrimaryImage, + .seasonUserData, + .overview, + .genres, + .people, + .chapters, + ], + 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.shared.log.debug("Retrieved \(String(items.count)) resume items") + + self.latestAddedItems = items + } + .store(in: &cancellables) + } + + // 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.shared.log.debug("Retrieved \(String(response.items!.count)) resume items") + + self.resumeItems = response.items ?? [] + }) + .store(in: &cancellables) + } + + // 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.shared.log.debug("Retrieved \(String(response.items!.count)) nextup items") + + self.nextUpItems = response.items ?? [] + }) + .store(in: &cancellables) + } } diff --git a/Shared/ViewModels/ItemViewModel/CollectionItemViewModel.swift b/Shared/ViewModels/ItemViewModel/CollectionItemViewModel.swift index 0cab922b..5d094b9c 100644 --- a/Shared/ViewModels/ItemViewModel/CollectionItemViewModel.swift +++ b/Shared/ViewModels/ItemViewModel/CollectionItemViewModel.swift @@ -1,36 +1,36 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Combine import Foundation import JellyfinAPI final class CollectionItemViewModel: ItemViewModel { - - @Published var collectionItems: [BaseItemDto] = [] - - override init(item: BaseItemDto) { - super.init(item: item) - - 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) - } + + @Published + var collectionItems: [BaseItemDto] = [] + + override init(item: BaseItemDto) { + super.init(item: item) + + 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) + } } diff --git a/Shared/ViewModels/ItemViewModel/EpisodeItemViewModel.swift b/Shared/ViewModels/ItemViewModel/EpisodeItemViewModel.swift index 00d11973..bf3cd6a1 100644 --- a/Shared/ViewModels/ItemViewModel/EpisodeItemViewModel.swift +++ b/Shared/ViewModels/ItemViewModel/EpisodeItemViewModel.swift @@ -1,11 +1,10 @@ // -/* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Combine import Foundation @@ -13,34 +12,36 @@ import JellyfinAPI import Stinsen final class EpisodeItemViewModel: ItemViewModel { - - @RouterObject var itemRouter: ItemCoordinator.Router? - @Published var series: BaseItemDto? - - override init(item: BaseItemDto) { - super.init(item: item) - - getEpisodeSeries() - } - override func getItemDisplayName() -> String { - guard let episodeLocator = item.getEpisodeLocator() else { return item.name ?? "" } - return "\(episodeLocator)\n\(item.name ?? "")" - } + @RouterObject + var itemRouter: ItemCoordinator.Router? + @Published + var series: BaseItemDto? - override func shouldDisplayRuntime() -> Bool { - return false - } + override init(item: BaseItemDto) { + super.init(item: item) - 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) - } + getEpisodeSeries() + } + + override func getItemDisplayName() -> String { + guard let episodeLocator = item.getEpisodeLocator() else { return item.name ?? "" } + return "\(episodeLocator)\n\(item.name ?? "")" + } + + override func shouldDisplayRuntime() -> Bool { + false + } + + 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) + } } diff --git a/Shared/ViewModels/ItemViewModel/ItemViewModel.swift b/Shared/ViewModels/ItemViewModel/ItemViewModel.swift index cd06f9da..0d820b17 100644 --- a/Shared/ViewModels/ItemViewModel/ItemViewModel.swift +++ b/Shared/ViewModels/ItemViewModel/ItemViewModel.swift @@ -1,11 +1,10 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Combine import Foundation @@ -14,121 +13,130 @@ import UIKit class ItemViewModel: ViewModel { - @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 mediaItems: [BaseItemDto.ItemDetail] - var itemVideoPlayerViewModel: VideoPlayerViewModel? + @Published + var item: BaseItemDto + @Published + var playButtonItem: BaseItemDto? { + didSet { + if let playButtonItem = playButtonItem { + refreshItemVideoPlayerViewModel(for: playButtonItem) + } + } + } - init(item: BaseItemDto) { - self.item = item + @Published + var similarItems: [BaseItemDto] = [] + @Published + var isWatched = false + @Published + var isFavorited = false + @Published + var informationItems: [BaseItemDto.ItemDetail] + @Published + var mediaItems: [BaseItemDto.ItemDetail] + var itemVideoPlayerViewModel: VideoPlayerViewModel? - switch item.itemType { - case .episode, .movie: - self.playButtonItem = item - default: () - } - - informationItems = item.createInformationItems() - mediaItems = item.createMediaItems() + init(item: BaseItemDto) { + self.item = item - isFavorited = item.userData?.isFavorite ?? false - isWatched = item.userData?.played ?? false - super.init() + switch item.itemType { + case .episode, .movie: + self.playButtonItem = item + default: () + } - getSimilarItems() - - refreshItemVideoPlayerViewModel(for: item) - } - - func refreshItemVideoPlayerViewModel(for item: BaseItemDto) { - item.createVideoPlayerViewModel() - .sink { completion in - self.handleAPIRequestError(completion: completion) - } receiveValue: { videoPlayerViewModel in - self.itemVideoPlayerViewModel = videoPlayerViewModel - self.mediaItems = videoPlayerViewModel.item.createMediaItems() - } - .store(in: &cancellables) - } + informationItems = item.createInformationItems() + mediaItems = item.createMediaItems() - func playButtonText() -> String { - if let itemProgressString = item.getItemProgressString() { - return itemProgressString - } - - return L10n.play - } + isFavorited = item.userData?.isFavorite ?? false + isWatched = item.userData?.played ?? false + super.init() - func getItemDisplayName() -> String { - return item.name ?? "" - } + getSimilarItems() - func shouldDisplayRuntime() -> Bool { - return true - } + refreshItemVideoPlayerViewModel(for: item) + } - func getSimilarItems() { - LibraryAPI.getSimilarItems(itemId: item.id!, userId: SessionManager.main.currentLogin.user.id, limit: 20, 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 refreshItemVideoPlayerViewModel(for item: BaseItemDto) { + item.createVideoPlayerViewModel() + .sink { completion in + self.handleAPIRequestError(completion: completion) + } receiveValue: { videoPlayerViewModel in + self.itemVideoPlayerViewModel = videoPlayerViewModel + self.mediaItems = videoPlayerViewModel.item.createMediaItems() + } + .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 playButtonText() -> String { + if let itemProgressString = item.getItemProgressString() { + return itemProgressString + } - 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) - } - } + return L10n.play + } + + func getItemDisplayName() -> String { + item.name ?? "" + } + + func shouldDisplayRuntime() -> Bool { + true + } + + func getSimilarItems() { + LibraryAPI.getSimilarItems(itemId: item.id!, userId: SessionManager.main.currentLogin.user.id, limit: 20, + 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 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) + } + } } diff --git a/Shared/ViewModels/ItemViewModel/MovieItemViewModel.swift b/Shared/ViewModels/ItemViewModel/MovieItemViewModel.swift index 31ad3862..e8c9e1b8 100644 --- a/Shared/ViewModels/ItemViewModel/MovieItemViewModel.swift +++ b/Shared/ViewModels/ItemViewModel/MovieItemViewModel.swift @@ -1,15 +1,13 @@ // -/* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Combine import Foundation import JellyfinAPI -final class MovieItemViewModel: ItemViewModel { -} +final class MovieItemViewModel: ItemViewModel {} diff --git a/Shared/ViewModels/ItemViewModel/SeasonItemViewModel.swift b/Shared/ViewModels/ItemViewModel/SeasonItemViewModel.swift index 78e7edc3..1c6555fc 100644 --- a/Shared/ViewModels/ItemViewModel/SeasonItemViewModel.swift +++ b/Shared/ViewModels/ItemViewModel/SeasonItemViewModel.swift @@ -1,11 +1,10 @@ // -/* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Combine import Foundation @@ -13,76 +12,79 @@ import JellyfinAPI import Stinsen final class SeasonItemViewModel: ItemViewModel { - - @RouterObject var itemRouter: ItemCoordinator.Router? - @Published var episodes: [BaseItemDto] = [] - @Published var seriesItem: BaseItemDto? - override init(item: BaseItemDto) { - super.init(item: item) + @RouterObject + var itemRouter: ItemCoordinator.Router? + @Published + var episodes: [BaseItemDto] = [] + @Published + var seriesItem: BaseItemDto? - getSeriesItem() - requestEpisodes() - } + override init(item: BaseItemDto) { + super.init(item: item) - override func playButtonText() -> String { - guard let playButtonItem = playButtonItem else { return L10n.play } - guard let episodeLocator = playButtonItem.getEpisodeLocator() else { return L10n.play } - return episodeLocator - } + getSeriesItem() + requestEpisodes() + } - private func requestEpisodes() { - LogManager.shared.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 - self?.episodes = response.items ?? [] - LogManager.shared.log.debug("Retrieved \(String(self?.episodes.count ?? 0)) episodes") + override func playButtonText() -> String { + guard let playButtonItem = playButtonItem else { return L10n.play } + guard let episodeLocator = playButtonItem.getEpisodeLocator() else { return L10n.play } + return episodeLocator + } - self?.setNextUpInSeason() - }) - .store(in: &cancellables) - } + private func requestEpisodes() { + LogManager.shared.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 + self?.episodes = response.items ?? [] + LogManager.shared.log.debug("Retrieved \(String(self?.episodes.count ?? 0)) episodes") - // 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. - private func setNextUpInSeason() { - guard !episodes.isEmpty else { return } + self?.setNextUpInSeason() + }) + .store(in: &cancellables) + } - var firstUnwatchedSearch: BaseItemDto? + // 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. + private func setNextUpInSeason() { + guard !episodes.isEmpty else { return } - for episode in episodes { - guard let played = episode.userData?.played else { continue } - if !played { - firstUnwatchedSearch = episode - break - } - } + var firstUnwatchedSearch: BaseItemDto? - if let firstUnwatched = firstUnwatchedSearch { - playButtonItem = firstUnwatched - } else { - guard let firstEpisode = episodes.first else { return } - playButtonItem = firstEpisode - } - } - - 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) - } + for episode in episodes { + guard let played = episode.userData?.played else { continue } + if !played { + firstUnwatchedSearch = episode + break + } + } + + if let firstUnwatched = firstUnwatchedSearch { + playButtonItem = firstUnwatched + } else { + guard let firstEpisode = episodes.first else { return } + playButtonItem = firstEpisode + } + } + + 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) + } } diff --git a/Shared/ViewModels/ItemViewModel/SeriesItemViewModel.swift b/Shared/ViewModels/ItemViewModel/SeriesItemViewModel.swift index 94f3dc6c..a8f0e4ef 100644 --- a/Shared/ViewModels/ItemViewModel/SeriesItemViewModel.swift +++ b/Shared/ViewModels/ItemViewModel/SeriesItemViewModel.swift @@ -1,11 +1,10 @@ // -/* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Combine import Foundation @@ -13,68 +12,73 @@ 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 { - guard let playButtonItem = playButtonItem else { return L10n.play } - guard let episodeLocator = playButtonItem.getEpisodeLocator() else { return L10n.play } - return episodeLocator - } + override func playButtonText() -> String { + guard let playButtonItem = playButtonItem else { return L10n.play } + guard let episodeLocator = playButtonItem.getEpisodeLocator() else { return L10n.play } + return episodeLocator + } - override func shouldDisplayRuntime() -> Bool { - return false - } + override func shouldDisplayRuntime() -> Bool { + false + } - private func getNextUp() { + private func getNextUp() { - LogManager.shared.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 { - self?.playButtonItem = nextUpItem - } - }) - .store(in: &cancellables) - } + LogManager.shared.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 { + 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 ?? "Unknown") - \(endYear ?? "Present")" - } + return "\(startYear ?? "Unknown") - \(endYear ?? "Present")" + } - private func requestSeasons() { - LogManager.shared.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], enableUserData: true) - .trackActivity(loading) - .sink(receiveCompletion: { [weak self] completion in - self?.handleAPIRequestError(completion: completion) - }, receiveValue: { [weak self] response in - self?.seasons = response.items ?? [] - LogManager.shared.log.debug("Retrieved \(String(self?.seasons.count ?? 0)) seasons") - }) - .store(in: &cancellables) - } + private func requestSeasons() { + LogManager.shared.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], + enableUserData: true) + .trackActivity(loading) + .sink(receiveCompletion: { [weak self] completion in + self?.handleAPIRequestError(completion: completion) + }, receiveValue: { [weak self] response in + self?.seasons = response.items ?? [] + LogManager.shared.log.debug("Retrieved \(String(self?.seasons.count ?? 0)) seasons") + }) + .store(in: &cancellables) + } } diff --git a/Shared/ViewModels/LatestMediaViewModel.swift b/Shared/ViewModels/LatestMediaViewModel.swift index 88cc1564..aac3cbf1 100644 --- a/Shared/ViewModels/LatestMediaViewModel.swift +++ b/Shared/ViewModels/LatestMediaViewModel.swift @@ -1,11 +1,10 @@ // -/* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Combine import Foundation @@ -13,38 +12,39 @@ import JellyfinAPI final class LatestMediaViewModel: ViewModel { - @Published var items = [BaseItemDto]() - - let library: BaseItemDto + @Published + var items = [BaseItemDto]() - init(library: BaseItemDto) { - self.library = library - super.init() + let library: BaseItemDto - requestLatestMedia() - } + init(library: BaseItemDto) { + self.library = library + super.init() - func requestLatestMedia() { - LogManager.shared.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.shared.log.debug("Retrieved \(String(self?.items.count ?? 0)) items") - }) - .store(in: &cancellables) - } + requestLatestMedia() + } + + func requestLatestMedia() { + LogManager.shared.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.shared.log.debug("Retrieved \(String(self?.items.count ?? 0)) items") + }) + .store(in: &cancellables) + } } diff --git a/Shared/ViewModels/LibraryFilterViewModel.swift b/Shared/ViewModels/LibraryFilterViewModel.swift index 127e4d99..2e607508 100644 --- a/Shared/ViewModels/LibraryFilterViewModel.swift +++ b/Shared/ViewModels/LibraryFilterViewModel.swift @@ -1,72 +1,81 @@ // -/* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Combine 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) + } } diff --git a/Shared/ViewModels/LibraryListViewModel.swift b/Shared/ViewModels/LibraryListViewModel.swift index 0e8845bd..25ed0557 100644 --- a/Shared/ViewModels/LibraryListViewModel.swift +++ b/Shared/ViewModels/LibraryListViewModel.swift @@ -1,36 +1,36 @@ // -/* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Foundation 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) + } } diff --git a/Shared/ViewModels/LibrarySearchViewModel.swift b/Shared/ViewModels/LibrarySearchViewModel.swift index 24523a7a..6613425a 100644 --- a/Shared/ViewModels/LibrarySearchViewModel.swift +++ b/Shared/ViewModels/LibrarySearchViewModel.swift @@ -1,11 +1,10 @@ // -/* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Combine import CombineExt @@ -15,134 +14,140 @@ 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("") - var parentID: String? + var searchQuerySubject = CurrentValueSubject("") + 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: [ItemType.movie.rawValue], 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: [ItemType.series.rawValue], 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: [ItemType.episode.rawValue], 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: [ItemType.movie.rawValue], 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: [ItemType.series.rawValue], 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: [ItemType.episode.rawValue], 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) + } } diff --git a/Shared/ViewModels/LibraryViewModel.swift b/Shared/ViewModels/LibraryViewModel.swift index cde5c290..4f41a662 100644 --- a/Shared/ViewModels/LibraryViewModel.swift +++ b/Shared/ViewModels/LibraryViewModel.swift @@ -1,11 +1,10 @@ // -/* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Combine import Foundation @@ -15,184 +14,209 @@ import SwiftUICollection typealias LibraryRow = CollectionRow 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 { - var parentID: String? - var person: BaseItemPerson? - var genre: NameGuidPair? - var studio: NameGuidPair? + var parentID: String? + var person: BaseItemPerson? + var genre: NameGuidPair? + var studio: NameGuidPair? - @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 hasPreviousPage = false + @Published + var totalPages = 0 + @Published + var currentPage = 0 + @Published + var hasNextPage = false + @Published + var hasPreviousPage = false - // temp - @Published var filters: LibraryFilters + // temp + @Published + var filters: LibraryFilters - private let columns: Int - private var libraries = [BaseItemDto]() + private let columns: Int + private var libraries = [BaseItemDto]() - 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 - super.init() + 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 + super.init() - $filters - .sink(receiveValue: requestItems(with:)) - .store(in: &cancellables) - } + $filters + .sink(receiveValue: requestItems(with:)) + .store(in: &cancellables) + } - func requestItems(with filters: LibraryFilters) { - 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) + func requestItems(with filters: LibraryFilters) { + 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) - ItemsAPI.getItemsByUserId(userId: SessionManager.main.currentLogin.user.id, - startIndex: currentPage * 100, - limit: 100, - recursive: true, - searchTerm: nil, - sortOrder: filters.sortOrder, - parentId: parentID, - fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people, .chapters], - includeItemTypes: filters.filters.contains(.isFavorite) ? ["Movie", "Series", "Season", "Episode"] : ["Movie", "Series", "BoxSet"], - 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 - LogManager.shared.log.debug("Received \(String(response.items?.count ?? 0)) items in library \(self?.parentID ?? "nil")") - guard let self = self else { return } - let totalPages = ceil(Double(response.totalRecordCount ?? 0) / 100.0) - self.totalPages = Int(totalPages) - self.hasPreviousPage = self.currentPage > 0 - self.hasNextPage = self.currentPage < self.totalPages - 1 - self.items = response.items ?? [] - self.rows = self.calculateRows(for: self.items) - }) - .store(in: &cancellables) - } + ItemsAPI.getItemsByUserId(userId: SessionManager.main.currentLogin.user.id, + startIndex: currentPage * 100, + limit: 100, + recursive: true, + searchTerm: nil, + sortOrder: filters.sortOrder, + parentId: parentID, + fields: [ + .primaryImageAspectRatio, + .seriesPrimaryImage, + .seasonUserData, + .overview, + .genres, + .people, + .chapters, + ], + includeItemTypes: filters.filters + .contains(.isFavorite) ? ["Movie", "Series", "Season", "Episode"] : ["Movie", "Series", "BoxSet"], + 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 + LogManager.shared.log.debug("Received \(String(response.items?.count ?? 0)) items in library \(self?.parentID ?? "nil")") + guard let self = self else { return } + let totalPages = ceil(Double(response.totalRecordCount ?? 0) / 100.0) + self.totalPages = Int(totalPages) + self.hasPreviousPage = self.currentPage > 0 + self.hasNextPage = self.currentPage < self.totalPages - 1 + self.items = response.items ?? [] + self.rows = self.calculateRows(for: self.items) + }) + .store(in: &cancellables) + } - func requestItemsAsync(with filters: LibraryFilters) { - 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) + func requestItemsAsync(with filters: LibraryFilters) { + 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) - ItemsAPI.getItemsByUserId(userId: SessionManager.main.currentLogin.user.id, startIndex: currentPage * 100, - limit: 100, - recursive: true, - searchTerm: nil, - sortOrder: filters.sortOrder, - parentId: parentID, - fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people, .chapters], - includeItemTypes: filters.filters.contains(.isFavorite) ? ["Movie", "Series", "Season", "Episode"] : ["Movie", "Series"], - filters: filters.filters, - sortBy: sortBy, - tags: filters.tags, - enableUserData: true, - personIds: personIDs, - studioIds: studioIDs, - genreIds: genreIDs, - enableImages: true) - .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) / 100.0) - self.totalPages = Int(totalPages) - self.hasPreviousPage = self.currentPage > 0 - self.hasNextPage = self.currentPage < self.totalPages - 1 - self.items.append(contentsOf: response.items ?? []) - self.rows = self.calculateRows(for: self.items) - }) - .store(in: &cancellables) - } + ItemsAPI.getItemsByUserId(userId: SessionManager.main.currentLogin.user.id, startIndex: currentPage * 100, + limit: 100, + recursive: true, + searchTerm: nil, + sortOrder: filters.sortOrder, + parentId: parentID, + fields: [ + .primaryImageAspectRatio, + .seriesPrimaryImage, + .seasonUserData, + .overview, + .genres, + .people, + .chapters, + ], + includeItemTypes: filters.filters + .contains(.isFavorite) ? ["Movie", "Series", "Season", "Episode"] : ["Movie", "Series"], + filters: filters.filters, + sortBy: sortBy, + tags: filters.tags, + enableUserData: true, + personIds: personIDs, + studioIds: studioIDs, + genreIds: genreIDs, + enableImages: true) + .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) / 100.0) + self.totalPages = Int(totalPages) + self.hasPreviousPage = self.currentPage > 0 + self.hasNextPage = self.currentPage < self.totalPages - 1 + self.items.append(contentsOf: response.items ?? []) + self.rows = self.calculateRows(for: self.items) + }) + .store(in: &cancellables) + } - func requestNextPage() { - currentPage += 1 - requestItems(with: filters) - } + func requestNextPage() { + currentPage += 1 + requestItems(with: filters) + } - func requestNextPageAsync() { - currentPage += 1 - requestItemsAsync(with: filters) - } + func requestNextPageAsync() { + currentPage += 1 + requestItemsAsync(with: filters) + } - func requestPreviousPage() { - currentPage -= 1 - requestItems(with: filters) - } + func requestPreviousPage() { + currentPage -= 1 + requestItems(with: filters) + } - 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 - } + 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 + } } diff --git a/Shared/ViewModels/LiveTVChannelsViewModel.swift b/Shared/ViewModels/LiveTVChannelsViewModel.swift index 626f3924..f2f61acd 100644 --- a/Shared/ViewModels/LiveTVChannelsViewModel.swift +++ b/Shared/ViewModels/LiveTVChannelsViewModel.swift @@ -1,11 +1,10 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Foundation import JellyfinAPI @@ -14,216 +13,217 @@ import SwiftUICollection typealias LiveTVChannelRow = CollectionRow 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 program: BaseItemDto? + let id = UUID() + let channel: BaseItemDto + let program: 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 rows = [LiveTVChannelRow]() - - 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 - } - - override init() { - super.init() - - getChannels() - startScheduleCheckTimer() - } - - deinit { - stopScheduleCheckTimer() - } - - private func getGuideInfo() { - LiveTvAPI.getGuideInfo() - .trackActivity(loading) - .sink(receiveCompletion: { [weak self] completion in - self?.handleAPIRequestError(completion: completion) - }, receiveValue: { [weak self] response in - LogManager.shared.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.shared.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.count > 0 else { - LogManager.shared.log.debug("Cannot get programs, channels list empty. ") - return - } - let channelIds = channels.compactMap { $0.id } - - let minEndDate = Date.now.addComponentsToDate(hours: -1) - let maxStartDate = minEndDate.addComponentsToDate(hours: 6) - - let getProgramsDto = GetProgramsDto( - 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(getProgramsDto: getProgramsDto) - .trackActivity(loading) - .sink(receiveCompletion: { [weak self] completion in - self?.handleAPIRequestError(completion: completion) - }, receiveValue: { [weak self] response in - LogManager.shared.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 - } - - 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, program: currentPrg)) - } - return channelPrograms - } - - 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)) - - 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] timer in - guard let self = self else { return } - LogManager.shared.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() - } + + @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]() + + 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 + } + + override init() { + super.init() + + getChannels() + startScheduleCheckTimer() + } + + deinit { + stopScheduleCheckTimer() + } + + private func getGuideInfo() { + LiveTvAPI.getGuideInfo() + .trackActivity(loading) + .sink(receiveCompletion: { [weak self] completion in + self?.handleAPIRequestError(completion: completion) + }, receiveValue: { [weak self] _ in + LogManager.shared.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.shared.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.shared.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 getProgramsDto = GetProgramsDto(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(getProgramsDto: getProgramsDto) + .trackActivity(loading) + .sink(receiveCompletion: { [weak self] completion in + self?.handleAPIRequestError(completion: completion) + }, receiveValue: { [weak self] response in + LogManager.shared.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 + } + + 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, program: currentPrg)) + } + return channelPrograms + } + + 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)) + + 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.shared.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() + } } extension Array { - func chunked(into size: Int) -> [[Element]] { - return 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 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 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)! + } } diff --git a/Shared/ViewModels/LiveTVProgramsViewModel.swift b/Shared/ViewModels/LiveTVProgramsViewModel.swift index 862e3534..f3ea55df 100644 --- a/Shared/ViewModels/LiveTVProgramsViewModel.swift +++ b/Shared/ViewModels/LiveTVProgramsViewModel.swift @@ -1,204 +1,200 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Foundation 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]() - - private var channels = [String:BaseItemDto]() - - override init() { - super.init() - - getChannels() - } - - func findChannel(id: String) -> BaseItemDto? { - return 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.shared.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.shared.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 getProgramsDto = GetProgramsDto(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(getProgramsDto: getProgramsDto) - .trackActivity(loading) - .sink(receiveCompletion: { [weak self] completion in - self?.handleAPIRequestError(completion: completion) - }, receiveValue: { [weak self] response in - LogManager.shared.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 getProgramsDto = GetProgramsDto(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(getProgramsDto: getProgramsDto) - .trackActivity(loading) - .sink(receiveCompletion: { [weak self] completion in - self?.handleAPIRequestError(completion: completion) - }, receiveValue: { [weak self] response in - LogManager.shared.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 getProgramsDto = GetProgramsDto(userId: SessionManager.main.currentLogin.user.id, - hasAired: false, - isSports: true, - limit: 9, - enableTotalRecordCount: false, - enableImageTypes: [.primary, .thumb], - fields: [.channelInfo, .primaryImageAspectRatio] - ) - - LiveTvAPI.getPrograms(getProgramsDto: getProgramsDto) - .trackActivity(loading) - .sink(receiveCompletion: { [weak self] completion in - self?.handleAPIRequestError(completion: completion) - }, receiveValue: { [weak self] response in - LogManager.shared.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 getProgramsDto = GetProgramsDto(userId: SessionManager.main.currentLogin.user.id, - hasAired: false, - isKids: true, - limit: 9, - enableTotalRecordCount: false, - enableImageTypes: [.primary, .thumb], - fields: [.channelInfo, .primaryImageAspectRatio] - ) - - LiveTvAPI.getPrograms(getProgramsDto: getProgramsDto) - .trackActivity(loading) - .sink(receiveCompletion: { [weak self] completion in - self?.handleAPIRequestError(completion: completion) - }, receiveValue: { [weak self] response in - LogManager.shared.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 getProgramsDto = GetProgramsDto(userId: SessionManager.main.currentLogin.user.id, - hasAired: false, - isNews: true, - limit: 9, - enableTotalRecordCount: false, - enableImageTypes: [.primary, .thumb], - fields: [.channelInfo, .primaryImageAspectRatio] - ) - - LiveTvAPI.getPrograms(getProgramsDto: getProgramsDto) - .trackActivity(loading) - .sink(receiveCompletion: { [weak self] completion in - self?.handleAPIRequestError(completion: completion) - }, receiveValue: { [weak self] response in - LogManager.shared.log.debug("Received \(String(response.items?.count ?? 0)) News Items") - guard let self = self else { return } - self.newsItems = response.items ?? [] - }) - .store(in: &cancellables) - } + + @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]() + + override init() { + super.init() + + getChannels() + } + + 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.shared.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.shared.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 getProgramsDto = GetProgramsDto(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(getProgramsDto: getProgramsDto) + .trackActivity(loading) + .sink(receiveCompletion: { [weak self] completion in + self?.handleAPIRequestError(completion: completion) + }, receiveValue: { [weak self] response in + LogManager.shared.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 getProgramsDto = GetProgramsDto(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(getProgramsDto: getProgramsDto) + .trackActivity(loading) + .sink(receiveCompletion: { [weak self] completion in + self?.handleAPIRequestError(completion: completion) + }, receiveValue: { [weak self] response in + LogManager.shared.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 getProgramsDto = GetProgramsDto(userId: SessionManager.main.currentLogin.user.id, + hasAired: false, + isSports: true, + limit: 9, + enableTotalRecordCount: false, + enableImageTypes: [.primary, .thumb], + fields: [.channelInfo, .primaryImageAspectRatio]) + + LiveTvAPI.getPrograms(getProgramsDto: getProgramsDto) + .trackActivity(loading) + .sink(receiveCompletion: { [weak self] completion in + self?.handleAPIRequestError(completion: completion) + }, receiveValue: { [weak self] response in + LogManager.shared.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 getProgramsDto = GetProgramsDto(userId: SessionManager.main.currentLogin.user.id, + hasAired: false, + isKids: true, + limit: 9, + enableTotalRecordCount: false, + enableImageTypes: [.primary, .thumb], + fields: [.channelInfo, .primaryImageAspectRatio]) + + LiveTvAPI.getPrograms(getProgramsDto: getProgramsDto) + .trackActivity(loading) + .sink(receiveCompletion: { [weak self] completion in + self?.handleAPIRequestError(completion: completion) + }, receiveValue: { [weak self] response in + LogManager.shared.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 getProgramsDto = GetProgramsDto(userId: SessionManager.main.currentLogin.user.id, + hasAired: false, + isNews: true, + limit: 9, + enableTotalRecordCount: false, + enableImageTypes: [.primary, .thumb], + fields: [.channelInfo, .primaryImageAspectRatio]) + + LiveTvAPI.getPrograms(getProgramsDto: getProgramsDto) + .trackActivity(loading) + .sink(receiveCompletion: { [weak self] completion in + self?.handleAPIRequestError(completion: completion) + }, receiveValue: { [weak self] response in + LogManager.shared.log.debug("Received \(String(response.items?.count ?? 0)) News Items") + guard let self = self else { return } + self.newsItems = response.items ?? [] + }) + .store(in: &cancellables) + } } diff --git a/Shared/ViewModels/MainTabViewModel.swift b/Shared/ViewModels/MainTabViewModel.swift index 0df5613d..dc80ccf7 100644 --- a/Shared/ViewModels/MainTabViewModel.swift +++ b/Shared/ViewModels/MainTabViewModel.swift @@ -1,30 +1,33 @@ // -/* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// 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 + } } diff --git a/Shared/ViewModels/MovieLibrariesViewModel.swift b/Shared/ViewModels/MovieLibrariesViewModel.swift index 80bb2838..77f84536 100644 --- a/Shared/ViewModels/MovieLibrariesViewModel.swift +++ b/Shared/ViewModels/MovieLibrariesViewModel.swift @@ -1,11 +1,10 @@ // -/* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Combine import Foundation @@ -15,81 +14,79 @@ 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 { - // show library - self.router?.route(to: \.library, 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 { + // show library + self.router?.route(to: \.library, library) + } + } + }) + .store(in: &cancellables) + } - private func calculateRows() -> [LibraryRow] { - guard libraries.count > 0 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.. String { - if server.userIDs.count == 1 { - return "1 user" - } else { - return "\(server.userIDs.count) users" - } - } + func userTextFor(server: SwiftfinStore.State.Server) -> String { + if server.userIDs.count == 1 { + return "1 user" + } else { + return "\(server.userIDs.count) users" + } + } - func remove(server: SwiftfinStore.State.Server) { - SessionManager.main.delete(server: server) - fetchServers() - } + func remove(server: SwiftfinStore.State.Server) { + SessionManager.main.delete(server: server) + fetchServers() + } - @objc private func didPurge() { - fetchServers() - } + @objc + private func didPurge() { + fetchServers() + } } diff --git a/Shared/ViewModels/SettingsViewModel.swift b/Shared/ViewModels/SettingsViewModel.swift index 131f55a4..92a6d6bd 100644 --- a/Shared/ViewModels/SettingsViewModel.swift +++ b/Shared/ViewModels/SettingsViewModel.swift @@ -1,48 +1,47 @@ // -/* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// +import Defaults import Foundation import SwiftUI -import Defaults final class SettingsViewModel: ObservableObject { - - var bitrates: [Bitrates] = [] - var langs: [TrackLanguage] = [] - - let server: SwiftfinStore.State.Server - let user: SwiftfinStore.State.User - init(server: SwiftfinStore.State.Server, user: SwiftfinStore.State.User) { - - self.server = server - self.user = user - - // Bitrates - let url = Bundle.main.url(forResource: "bitrates", withExtension: "json")! + var bitrates: [Bitrates] = [] + var langs: [TrackLanguage] = [] - do { - let jsonData = try Data(contentsOf: url, options: .mappedIfSafe) - do { - self.bitrates = try JSONDecoder().decode([Bitrates].self, from: jsonData) - } catch { - LogManager.shared.log.error("Error converting processed JSON into Swift compatible schema.") - } - } catch { - LogManager.shared.log.error("Error processing JSON file `bitrates.json`") - } + let server: SwiftfinStore.State.Server + let user: SwiftfinStore.State.User - // Track languages - self.langs = Locale.isoLanguageCodes.compactMap { - guard let name = Locale.current.localizedString(forLanguageCode: $0) else { return nil } - return TrackLanguage(name: name, isoCode: $0) - }.sorted(by: { $0.name < $1.name }) - self.langs.insert(.auto, at: 0) - } + init(server: SwiftfinStore.State.Server, user: SwiftfinStore.State.User) { + + self.server = server + self.user = user + + // Bitrates + let url = Bundle.main.url(forResource: "bitrates", withExtension: "json")! + + do { + let jsonData = try Data(contentsOf: url, options: .mappedIfSafe) + do { + self.bitrates = try JSONDecoder().decode([Bitrates].self, from: jsonData) + } catch { + LogManager.shared.log.error("Error converting processed JSON into Swift compatible schema.") + } + } catch { + LogManager.shared.log.error("Error processing JSON file `bitrates.json`") + } + + // Track languages + self.langs = Locale.isoLanguageCodes.compactMap { + guard let name = Locale.current.localizedString(forLanguageCode: $0) else { return nil } + return TrackLanguage(name: name, isoCode: $0) + }.sorted(by: { $0.name < $1.name }) + self.langs.insert(.auto, at: 0) + } } diff --git a/Shared/ViewModels/TVLibrariesViewModel.swift b/Shared/ViewModels/TVLibrariesViewModel.swift index 1d5abe81..4fc7c53c 100644 --- a/Shared/ViewModels/TVLibrariesViewModel.swift +++ b/Shared/ViewModels/TVLibrariesViewModel.swift @@ -1,11 +1,10 @@ // -/* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Combine import Foundation @@ -15,81 +14,79 @@ import SwiftUICollection final class TVLibrariesViewModel: 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: TVLibrariesCoordinator.Router? + @RouterObject + var router: TVLibrariesCoordinator.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 == "tvshows" { - self.libraries.append(library) - } - } - self.rows = self.calculateRows() - if self.libraries.count == 1, let library = self.libraries.first { - // show library - self.router?.route(to: \.library, 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 == "tvshows" { + self.libraries.append(library) + } + } + self.rows = self.calculateRows() + if self.libraries.count == 1, let library = self.libraries.first { + // show library + self.router?.route(to: \.library, library) + } + } + }) + .store(in: &cancellables) + } - private func calculateRows() -> [LibraryRow] { - guard libraries.count > 0 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..() - - // During scrubbing, many progress reports were spammed - // Send only the current report after a delay - private var progressReportTimer: Timer? - private var lastProgressReport: PlaybackProgressInfo? - - // MARK: init - - init(item: BaseItemDto, - title: String, - subtitle: String?, - streamURL: URL, - streamType: ServerStreamType, - response: PlaybackInfoResponse, - audioStreams: [MediaStream], - subtitleStreams: [MediaStream], - selectedAudioStreamIndex: Int, - selectedSubtitleStreamIndex: Int, - subtitlesEnabled: Bool, - autoplayEnabled: Bool, - overlayType: OverlayType, - shouldShowPlayPreviousItem: Bool, - shouldShowPlayNextItem: Bool, - shouldShowAutoPlay: Bool) { - self.item = item - self.title = title - self.subtitle = subtitle - self.streamURL = streamURL - self.streamType = streamType - self.response = response - self.audioStreams = audioStreams - self.subtitleStreams = subtitleStreams - self.selectedAudioStreamIndex = selectedAudioStreamIndex - self.selectedSubtitleStreamIndex = selectedSubtitleStreamIndex - self.subtitlesEnabled = subtitlesEnabled - self.autoplayEnabled = autoplayEnabled - self.overlayType = overlayType - self.shouldShowPlayPreviousItem = shouldShowPlayPreviousItem - self.shouldShowPlayNextItem = shouldShowPlayNextItem - self.shouldShowAutoPlay = shouldShowAutoPlay - - self.jumpBackwardLength = Defaults[.videoPlayerJumpBackward] - self.jumpForwardLength = Defaults[.videoPlayerJumpForward] - self.jumpGesturesEnabled = Defaults[.jumpGesturesEnabled] - self.shouldShowJumpButtonsInOverlayMenu = Defaults[.shouldShowJumpButtonsInOverlayMenu] - - self.resumeOffset = Defaults[.resumeOffset] - - self.syncSubtitleStateWithAdjacent = Defaults[.Experimental.syncSubtitleStateWithAdjacent] - - self.confirmClose = Defaults[.confirmClose] - - super.init() - - self.sliderPercentage = (item.userData?.playedPercentage ?? 0) / 100 - } - - private func sliderPercentageChanged(newValue: Double) { - let videoDuration = Double(item.runTimeTicks! / 10_000_000) - let secondsScrubbedRemaining = videoDuration - currentSeconds - - leftLabelText = calculateTimeText(from: currentSeconds) - rightLabelText = calculateTimeText(from: secondsScrubbedRemaining) - } - private func calculateTimeText(from duration: Double) -> String { - let hours = floor(duration / 3600) - let minutes = duration.truncatingRemainder(dividingBy: 3600) / 60 - let seconds = duration.truncatingRemainder(dividingBy: 3600).truncatingRemainder(dividingBy: 60) + // MARK: Published - let timeText: String + // Manually kept state because VLCKit doesn't properly set "played" + // on the VLCMediaPlayer object + @Published + var playerState: VLCMediaPlayerState = .buffering + @Published + var leftLabelText: String = "--:--" + @Published + var rightLabelText: String = "--:--" + @Published + var playbackSpeed: PlaybackSpeed = .one + @Published + var subtitlesEnabled: Bool { + didSet { + if syncSubtitleStateWithAdjacent { + previousItemVideoPlayerViewModel?.matchSubtitlesEnabled(with: self) + nextItemVideoPlayerViewModel?.matchSubtitlesEnabled(with: self) + } + } + } - if hours != 0 { - timeText = - "\(Int(hours)):\(String(Int(floor(minutes))).leftPad(toWidth: 2, withString: "0")):\(String(Int(floor(seconds))).leftPad(toWidth: 2, withString: "0"))" - } else { - timeText = - "\(String(Int(floor(minutes))).leftPad(toWidth: 2, withString: "0")):\(String(Int(floor(seconds))).leftPad(toWidth: 2, withString: "0"))" - } + @Published + var selectedAudioStreamIndex: Int + @Published + var selectedSubtitleStreamIndex: Int { + didSet { + if syncSubtitleStateWithAdjacent { + previousItemVideoPlayerViewModel?.matchSubtitleStream(with: self) + nextItemVideoPlayerViewModel?.matchSubtitleStream(with: self) + } + } + } - return timeText - } + @Published + var previousItemVideoPlayerViewModel: VideoPlayerViewModel? + @Published + var nextItemVideoPlayerViewModel: VideoPlayerViewModel? + @Published + var jumpBackwardLength: VideoPlayerJumpLength { + willSet { + Defaults[.videoPlayerJumpBackward] = newValue + } + } + + @Published + var jumpForwardLength: VideoPlayerJumpLength { + willSet { + Defaults[.videoPlayerJumpForward] = newValue + } + } + + @Published + var sliderIsScrubbing: Bool = false + @Published + var sliderPercentage: Double = 0 { + willSet { + sliderScrubbingSubject.send(self) + sliderPercentageChanged(newValue: newValue) + } + } + + @Published + var autoplayEnabled: Bool { + willSet { + previousItemVideoPlayerViewModel?.autoplayEnabled = newValue + nextItemVideoPlayerViewModel?.autoplayEnabled = newValue + Defaults[.autoplayEnabled] = newValue + } + } + + // MARK: ShouldShowItems + + let shouldShowPlayPreviousItem: Bool + let shouldShowPlayNextItem: Bool + let shouldShowAutoPlay: Bool + let shouldShowJumpButtonsInOverlayMenu: Bool + + // MARK: General + + let item: BaseItemDto + let title: String + let subtitle: String? + let streamURL: URL + let audioStreams: [MediaStream] + let subtitleStreams: [MediaStream] + let overlayType: OverlayType + let jumpGesturesEnabled: Bool + let resumeOffset: Bool + let streamType: ServerStreamType + + // MARK: Experimental + + let syncSubtitleStateWithAdjacent: Bool + + // MARK: tvOS + + let confirmClose: Bool + + // Full response kept for convenience + let response: PlaybackInfoResponse + + var playerOverlayDelegate: PlayerOverlayDelegate? + + // Ticks of the time the media began playing + private var startTimeTicks: Int64 = 0 + + // MARK: Current Time + + var currentSeconds: Double { + let videoDuration = Double(item.runTimeTicks! / 10_000_000) + return round(sliderPercentage * videoDuration) + } + + var currentSecondTicks: Int64 { + Int64(currentSeconds) * 10_000_000 + } + + // MARK: Helpers + + var currentAudioStream: MediaStream? { + audioStreams.first(where: { $0.index == selectedAudioStreamIndex }) + } + + var currentSubtitleStream: MediaStream? { + subtitleStreams.first(where: { $0.index == selectedSubtitleStreamIndex }) + } + + // Necessary PassthroughSubject to capture manual scrubbing from sliders + let sliderScrubbingSubject = PassthroughSubject() + + // During scrubbing, many progress reports were spammed + // Send only the current report after a delay + private var progressReportTimer: Timer? + private var lastProgressReport: PlaybackProgressInfo? + + // MARK: init + + init(item: BaseItemDto, + title: String, + subtitle: String?, + streamURL: URL, + streamType: ServerStreamType, + response: PlaybackInfoResponse, + audioStreams: [MediaStream], + subtitleStreams: [MediaStream], + selectedAudioStreamIndex: Int, + selectedSubtitleStreamIndex: Int, + subtitlesEnabled: Bool, + autoplayEnabled: Bool, + overlayType: OverlayType, + shouldShowPlayPreviousItem: Bool, + shouldShowPlayNextItem: Bool, + shouldShowAutoPlay: Bool) + { + self.item = item + self.title = title + self.subtitle = subtitle + self.streamURL = streamURL + self.streamType = streamType + self.response = response + self.audioStreams = audioStreams + self.subtitleStreams = subtitleStreams + self.selectedAudioStreamIndex = selectedAudioStreamIndex + self.selectedSubtitleStreamIndex = selectedSubtitleStreamIndex + self.subtitlesEnabled = subtitlesEnabled + self.autoplayEnabled = autoplayEnabled + self.overlayType = overlayType + self.shouldShowPlayPreviousItem = shouldShowPlayPreviousItem + self.shouldShowPlayNextItem = shouldShowPlayNextItem + self.shouldShowAutoPlay = shouldShowAutoPlay + + self.jumpBackwardLength = Defaults[.videoPlayerJumpBackward] + self.jumpForwardLength = Defaults[.videoPlayerJumpForward] + self.jumpGesturesEnabled = Defaults[.jumpGesturesEnabled] + self.shouldShowJumpButtonsInOverlayMenu = Defaults[.shouldShowJumpButtonsInOverlayMenu] + + self.resumeOffset = Defaults[.resumeOffset] + + self.syncSubtitleStateWithAdjacent = Defaults[.Experimental.syncSubtitleStateWithAdjacent] + + self.confirmClose = Defaults[.confirmClose] + + super.init() + + self.sliderPercentage = (item.userData?.playedPercentage ?? 0) / 100 + } + + private func sliderPercentageChanged(newValue: Double) { + let videoDuration = Double(item.runTimeTicks! / 10_000_000) + let secondsScrubbedRemaining = videoDuration - currentSeconds + + leftLabelText = calculateTimeText(from: currentSeconds) + rightLabelText = calculateTimeText(from: secondsScrubbedRemaining) + } + + private func calculateTimeText(from duration: Double) -> String { + let hours = floor(duration / 3600) + let minutes = duration.truncatingRemainder(dividingBy: 3600) / 60 + let seconds = duration.truncatingRemainder(dividingBy: 3600).truncatingRemainder(dividingBy: 60) + + let timeText: String + + if hours != 0 { + timeText = + "\(Int(hours)):\(String(Int(floor(minutes))).leftPad(toWidth: 2, withString: "0")):\(String(Int(floor(seconds))).leftPad(toWidth: 2, withString: "0"))" + } else { + timeText = + "\(String(Int(floor(minutes))).leftPad(toWidth: 2, withString: "0")):\(String(Int(floor(seconds))).leftPad(toWidth: 2, withString: "0"))" + } + + return timeText + } } // MARK: Adjacent Items + extension VideoPlayerViewModel { - - func getAdjacentEpisodes() { - guard let seriesID = item.seriesId, item.itemType == .episode else { return } - - TvShowsAPI.getEpisodes(seriesId: seriesID, - userId: SessionManager.main.currentLogin.user.id, - adjacentTo: item.id, - limit: 3) - .sink(receiveCompletion: { completion in - print(completion) - }, receiveValue: { response in - - // 4 possible states: - // 1 - only current episode - // 2 - two episodes with next episode - // 3 - two episodes with previous episode - // 4 - three episodes with current in middle - - // State 1 - guard let items = response.items, items.count > 1 else { return } - - if items.count == 2 { - if items[0].id == self.item.id { - // State 2 - let nextItem = items[1] - - nextItem.createVideoPlayerViewModel() - .sink { completion in - self.handleAPIRequestError(completion: completion) - } receiveValue: { videoPlayerViewModel in - videoPlayerViewModel.matchSubtitleStream(with: self) - videoPlayerViewModel.matchAudioStream(with: self) - - self.nextItemVideoPlayerViewModel = videoPlayerViewModel - } - .store(in: &self.cancellables) - } else { - // State 3 - let previousItem = items[0] - - previousItem.createVideoPlayerViewModel() - .sink { completion in - self.handleAPIRequestError(completion: completion) - } receiveValue: { videoPlayerViewModel in - videoPlayerViewModel.matchSubtitleStream(with: self) - videoPlayerViewModel.matchAudioStream(with: self) - - self.previousItemVideoPlayerViewModel = videoPlayerViewModel - } - .store(in: &self.cancellables) - } - } else { - // State 4 - - let previousItem = items[0] - let nextItem = items[2] - - previousItem.createVideoPlayerViewModel() - .sink { completion in - self.handleAPIRequestError(completion: completion) - } receiveValue: { videoPlayerViewModel in - videoPlayerViewModel.matchSubtitleStream(with: self) - videoPlayerViewModel.matchAudioStream(with: self) - - self.previousItemVideoPlayerViewModel = videoPlayerViewModel - } - .store(in: &self.cancellables) - - nextItem.createVideoPlayerViewModel() - .sink { completion in - self.handleAPIRequestError(completion: completion) - } receiveValue: { videoPlayerViewModel in - videoPlayerViewModel.matchSubtitleStream(with: self) - videoPlayerViewModel.matchAudioStream(with: self) - - self.nextItemVideoPlayerViewModel = videoPlayerViewModel - } - .store(in: &self.cancellables) - } - }) - .store(in: &cancellables) - } - - // Potential for experimental feature of syncing subtitle states among adjacent episodes - // when using previous & next item buttons and auto-play - - private func matchSubtitleStream(with masterViewModel: VideoPlayerViewModel) { - if !masterViewModel.subtitlesEnabled { - matchSubtitlesEnabled(with: masterViewModel) - } - - guard let masterSubtitleStream = masterViewModel.subtitleStreams.first(where: { $0.index == masterViewModel.selectedSubtitleStreamIndex }), - let matchingSubtitleStream = self.subtitleStreams.first(where: { mediaStreamAboutEqual($0, masterSubtitleStream) }), - let matchingSubtitleStreamIndex = matchingSubtitleStream.index else { return } - - self.selectedSubtitleStreamIndex = matchingSubtitleStreamIndex - } - - private func matchAudioStream(with masterViewModel: VideoPlayerViewModel) { - guard let currentAudioStream = masterViewModel.audioStreams.first(where: { $0.index == masterViewModel.selectedAudioStreamIndex }), - let matchingAudioStream = self.audioStreams.first(where: { mediaStreamAboutEqual($0, currentAudioStream) }) else { return } - - self.selectedAudioStreamIndex = matchingAudioStream.index ?? -1 - } - - private func matchSubtitlesEnabled(with masterViewModel: VideoPlayerViewModel) { - self.subtitlesEnabled = masterViewModel.subtitlesEnabled - } - - private func mediaStreamAboutEqual(_ lhs: MediaStream, _ rhs: MediaStream) -> Bool { - return lhs.displayTitle == rhs.displayTitle && lhs.language == rhs.language - } + + func getAdjacentEpisodes() { + guard let seriesID = item.seriesId, item.itemType == .episode else { return } + + TvShowsAPI.getEpisodes(seriesId: seriesID, + userId: SessionManager.main.currentLogin.user.id, + adjacentTo: item.id, + limit: 3) + .sink(receiveCompletion: { completion in + print(completion) + }, receiveValue: { response in + + // 4 possible states: + // 1 - only current episode + // 2 - two episodes with next episode + // 3 - two episodes with previous episode + // 4 - three episodes with current in middle + + // State 1 + guard let items = response.items, items.count > 1 else { return } + + if items.count == 2 { + if items[0].id == self.item.id { + // State 2 + let nextItem = items[1] + + nextItem.createVideoPlayerViewModel() + .sink { completion in + self.handleAPIRequestError(completion: completion) + } receiveValue: { videoPlayerViewModel in + videoPlayerViewModel.matchSubtitleStream(with: self) + videoPlayerViewModel.matchAudioStream(with: self) + + self.nextItemVideoPlayerViewModel = videoPlayerViewModel + } + .store(in: &self.cancellables) + } else { + // State 3 + let previousItem = items[0] + + previousItem.createVideoPlayerViewModel() + .sink { completion in + self.handleAPIRequestError(completion: completion) + } receiveValue: { videoPlayerViewModel in + videoPlayerViewModel.matchSubtitleStream(with: self) + videoPlayerViewModel.matchAudioStream(with: self) + + self.previousItemVideoPlayerViewModel = videoPlayerViewModel + } + .store(in: &self.cancellables) + } + } else { + // State 4 + + let previousItem = items[0] + let nextItem = items[2] + + previousItem.createVideoPlayerViewModel() + .sink { completion in + self.handleAPIRequestError(completion: completion) + } receiveValue: { videoPlayerViewModel in + videoPlayerViewModel.matchSubtitleStream(with: self) + videoPlayerViewModel.matchAudioStream(with: self) + + self.previousItemVideoPlayerViewModel = videoPlayerViewModel + } + .store(in: &self.cancellables) + + nextItem.createVideoPlayerViewModel() + .sink { completion in + self.handleAPIRequestError(completion: completion) + } receiveValue: { videoPlayerViewModel in + videoPlayerViewModel.matchSubtitleStream(with: self) + videoPlayerViewModel.matchAudioStream(with: self) + + self.nextItemVideoPlayerViewModel = videoPlayerViewModel + } + .store(in: &self.cancellables) + } + }) + .store(in: &cancellables) + } + + // Potential for experimental feature of syncing subtitle states among adjacent episodes + // when using previous & next item buttons and auto-play + + private func matchSubtitleStream(with masterViewModel: VideoPlayerViewModel) { + if !masterViewModel.subtitlesEnabled { + matchSubtitlesEnabled(with: masterViewModel) + } + + guard let masterSubtitleStream = masterViewModel.subtitleStreams + .first(where: { $0.index == masterViewModel.selectedSubtitleStreamIndex }), + let matchingSubtitleStream = self.subtitleStreams.first(where: { mediaStreamAboutEqual($0, masterSubtitleStream) }), + let matchingSubtitleStreamIndex = matchingSubtitleStream.index else { return } + + self.selectedSubtitleStreamIndex = matchingSubtitleStreamIndex + } + + private func matchAudioStream(with masterViewModel: VideoPlayerViewModel) { + guard let currentAudioStream = masterViewModel.audioStreams.first(where: { $0.index == masterViewModel.selectedAudioStreamIndex }), + let matchingAudioStream = self.audioStreams.first(where: { mediaStreamAboutEqual($0, currentAudioStream) }) else { return } + + self.selectedAudioStreamIndex = matchingAudioStream.index ?? -1 + } + + private func matchSubtitlesEnabled(with masterViewModel: VideoPlayerViewModel) { + self.subtitlesEnabled = masterViewModel.subtitlesEnabled + } + + private func mediaStreamAboutEqual(_ lhs: MediaStream, _ rhs: MediaStream) -> Bool { + lhs.displayTitle == rhs.displayTitle && lhs.language == rhs.language + } } // MARK: Progress Report Timer + extension VideoPlayerViewModel { - - private func sendNewProgressReportWithTimer() { - self.progressReportTimer?.invalidate() - self.progressReportTimer = Timer.scheduledTimer(timeInterval: 0.7, target: self, selector: #selector(_sendProgressReport), userInfo: nil, repeats: false) - } + + private func sendNewProgressReportWithTimer() { + self.progressReportTimer?.invalidate() + self.progressReportTimer = Timer.scheduledTimer(timeInterval: 0.7, target: self, selector: #selector(_sendProgressReport), + userInfo: nil, repeats: false) + } } // MARK: Updates + extension VideoPlayerViewModel { - - - // MARK: sendPlayReport - func sendPlayReport() { - - self.startTimeTicks = Int64(Date().timeIntervalSince1970) * 10_000_000 - - let subtitleStreamIndex = subtitlesEnabled ? selectedSubtitleStreamIndex : nil - - let startInfo = PlaybackStartInfo(canSeek: true, - item: item, - itemId: item.id, - sessionId: response.playSessionId, - mediaSourceId: item.id, - audioStreamIndex: selectedAudioStreamIndex, - subtitleStreamIndex: subtitleStreamIndex, - isPaused: false, - isMuted: false, - positionTicks: item.userData?.playbackPositionTicks, - playbackStartTimeTicks: startTimeTicks, - volumeLevel: 100, - brightness: 100, - aspectRatio: nil, - playMethod: .directPlay, - liveStreamId: nil, - playSessionId: response.playSessionId, - repeatMode: .repeatNone, - nowPlayingQueue: nil, - playlistItemId: "playlistItem0" - ) - - PlaystateAPI.reportPlaybackStart(playbackStartInfo: startInfo) - .sink { completion in - self.handleAPIRequestError(completion: completion) - } receiveValue: { _ in - LogManager.shared.log.debug("Start report sent for item: \(self.item.id ?? "No ID")") - } - .store(in: &cancellables) - } - - // MARK: sendPauseReport - func sendPauseReport(paused: Bool) { - - let subtitleStreamIndex = subtitlesEnabled ? selectedSubtitleStreamIndex : nil - - let pauseInfo = PlaybackStartInfo(canSeek: true, - item: item, - itemId: item.id, - sessionId: response.playSessionId, - mediaSourceId: item.id, - audioStreamIndex: selectedAudioStreamIndex, - subtitleStreamIndex: subtitleStreamIndex, - isPaused: paused, - isMuted: false, - positionTicks: currentSecondTicks, - playbackStartTimeTicks: startTimeTicks, - volumeLevel: 100, - brightness: 100, - aspectRatio: nil, - playMethod: .directPlay, - liveStreamId: nil, - playSessionId: response.playSessionId, - repeatMode: .repeatNone, - nowPlayingQueue: nil, - playlistItemId: "playlistItem0" - ) - - PlaystateAPI.reportPlaybackStart(playbackStartInfo: pauseInfo) - .sink { completion in - self.handleAPIRequestError(completion: completion) - } receiveValue: { _ in - LogManager.shared.log.debug("Pause report sent for item: \(self.item.id ?? "No ID")") - } - .store(in: &cancellables) - } - - // MARK: sendProgressReport - func sendProgressReport() { - - let subtitleStreamIndex = subtitlesEnabled ? selectedSubtitleStreamIndex : nil - - let progressInfo = PlaybackProgressInfo(canSeek: true, - item: item, - itemId: item.id, - sessionId: response.playSessionId, - mediaSourceId: item.id, - audioStreamIndex: selectedAudioStreamIndex, - subtitleStreamIndex: subtitleStreamIndex, - isPaused: false, - isMuted: false, - positionTicks: currentSecondTicks, - playbackStartTimeTicks: startTimeTicks, - volumeLevel: nil, - brightness: nil, - aspectRatio: nil, - playMethod: .directPlay, - liveStreamId: nil, - playSessionId: response.playSessionId, - repeatMode: .repeatNone, - nowPlayingQueue: nil, - playlistItemId: "playlistItem0") - - self.lastProgressReport = progressInfo - - self.sendNewProgressReportWithTimer() - } - - @objc private func _sendProgressReport() { - guard let lastProgressReport = lastProgressReport else { return } - - PlaystateAPI.reportPlaybackProgress(playbackProgressInfo: lastProgressReport) - .sink { completion in - self.handleAPIRequestError(completion: completion) - } receiveValue: { _ in - LogManager.shared.log.debug("Playback progress sent for item: \(self.item.id ?? "No ID")") - } - .store(in: &cancellables) - - self.lastProgressReport = nil - } - - // MARK: sendStopReport - func sendStopReport() { - - let stopInfo = PlaybackStopInfo(item: item, - itemId: item.id, - sessionId: response.playSessionId, - mediaSourceId: item.id, - positionTicks: currentSecondTicks, - liveStreamId: nil, - playSessionId: response.playSessionId, - failed: nil, - nextMediaType: nil, - playlistItemId: "playlistItem0", - nowPlayingQueue: nil) - - PlaystateAPI.reportPlaybackStopped(playbackStopInfo: stopInfo) - .sink { completion in - self.handleAPIRequestError(completion: completion) - } receiveValue: { _ in - LogManager.shared.log.debug("Stop report sent for item: \(self.item.id ?? "No ID")") - } - .store(in: &cancellables) - } + + // MARK: sendPlayReport + + func sendPlayReport() { + + self.startTimeTicks = Int64(Date().timeIntervalSince1970) * 10_000_000 + + let subtitleStreamIndex = subtitlesEnabled ? selectedSubtitleStreamIndex : nil + + let startInfo = PlaybackStartInfo(canSeek: true, + item: item, + itemId: item.id, + sessionId: response.playSessionId, + mediaSourceId: item.id, + audioStreamIndex: selectedAudioStreamIndex, + subtitleStreamIndex: subtitleStreamIndex, + isPaused: false, + isMuted: false, + positionTicks: item.userData?.playbackPositionTicks, + playbackStartTimeTicks: startTimeTicks, + volumeLevel: 100, + brightness: 100, + aspectRatio: nil, + playMethod: .directPlay, + liveStreamId: nil, + playSessionId: response.playSessionId, + repeatMode: .repeatNone, + nowPlayingQueue: nil, + playlistItemId: "playlistItem0") + + PlaystateAPI.reportPlaybackStart(playbackStartInfo: startInfo) + .sink { completion in + self.handleAPIRequestError(completion: completion) + } receiveValue: { _ in + LogManager.shared.log.debug("Start report sent for item: \(self.item.id ?? "No ID")") + } + .store(in: &cancellables) + } + + // MARK: sendPauseReport + + func sendPauseReport(paused: Bool) { + + let subtitleStreamIndex = subtitlesEnabled ? selectedSubtitleStreamIndex : nil + + let pauseInfo = PlaybackStartInfo(canSeek: true, + item: item, + itemId: item.id, + sessionId: response.playSessionId, + mediaSourceId: item.id, + audioStreamIndex: selectedAudioStreamIndex, + subtitleStreamIndex: subtitleStreamIndex, + isPaused: paused, + isMuted: false, + positionTicks: currentSecondTicks, + playbackStartTimeTicks: startTimeTicks, + volumeLevel: 100, + brightness: 100, + aspectRatio: nil, + playMethod: .directPlay, + liveStreamId: nil, + playSessionId: response.playSessionId, + repeatMode: .repeatNone, + nowPlayingQueue: nil, + playlistItemId: "playlistItem0") + + PlaystateAPI.reportPlaybackStart(playbackStartInfo: pauseInfo) + .sink { completion in + self.handleAPIRequestError(completion: completion) + } receiveValue: { _ in + LogManager.shared.log.debug("Pause report sent for item: \(self.item.id ?? "No ID")") + } + .store(in: &cancellables) + } + + // MARK: sendProgressReport + + func sendProgressReport() { + + let subtitleStreamIndex = subtitlesEnabled ? selectedSubtitleStreamIndex : nil + + let progressInfo = PlaybackProgressInfo(canSeek: true, + item: item, + itemId: item.id, + sessionId: response.playSessionId, + mediaSourceId: item.id, + audioStreamIndex: selectedAudioStreamIndex, + subtitleStreamIndex: subtitleStreamIndex, + isPaused: false, + isMuted: false, + positionTicks: currentSecondTicks, + playbackStartTimeTicks: startTimeTicks, + volumeLevel: nil, + brightness: nil, + aspectRatio: nil, + playMethod: .directPlay, + liveStreamId: nil, + playSessionId: response.playSessionId, + repeatMode: .repeatNone, + nowPlayingQueue: nil, + playlistItemId: "playlistItem0") + + self.lastProgressReport = progressInfo + + self.sendNewProgressReportWithTimer() + } + + @objc + private func _sendProgressReport() { + guard let lastProgressReport = lastProgressReport else { return } + + PlaystateAPI.reportPlaybackProgress(playbackProgressInfo: lastProgressReport) + .sink { completion in + self.handleAPIRequestError(completion: completion) + } receiveValue: { _ in + LogManager.shared.log.debug("Playback progress sent for item: \(self.item.id ?? "No ID")") + } + .store(in: &cancellables) + + self.lastProgressReport = nil + } + + // MARK: sendStopReport + + func sendStopReport() { + + let stopInfo = PlaybackStopInfo(item: item, + itemId: item.id, + sessionId: response.playSessionId, + mediaSourceId: item.id, + positionTicks: currentSecondTicks, + liveStreamId: nil, + playSessionId: response.playSessionId, + failed: nil, + nextMediaType: nil, + playlistItemId: "playlistItem0", + nowPlayingQueue: nil) + + PlaystateAPI.reportPlaybackStopped(playbackStopInfo: stopInfo) + .sink { completion in + self.handleAPIRequestError(completion: completion) + } receiveValue: { _ in + LogManager.shared.log.debug("Stop report sent for item: \(self.item.id ?? "No ID")") + } + .store(in: &cancellables) + } } // MARK: Embedded/Normal Subtitle Streams + extension VideoPlayerViewModel { - - func createEmbeddedSubtitleStream(with subtitleStream: MediaStream) -> URL { - - guard let baseURL = URLComponents(url: streamURL, resolvingAgainstBaseURL: false) else { fatalError() } - guard let queryItems = baseURL.queryItems else { fatalError() } - - var newURL = baseURL - var newQueryItems = queryItems - - newQueryItems.removeAll(where: { $0.name == "SubtitleStreamIndex" }) - newQueryItems.removeAll(where: { $0.name == "SubtitleMethod" }) - - newURL.addQueryItem(name: "SubtitleMethod", value: "Encode") - newURL.addQueryItem(name: "SubtitleStreamIndex", value: "\(subtitleStream.index ?? -1)") - - return newURL.url! - } + + func createEmbeddedSubtitleStream(with subtitleStream: MediaStream) -> URL { + + guard let baseURL = URLComponents(url: streamURL, resolvingAgainstBaseURL: false) else { fatalError() } + guard let queryItems = baseURL.queryItems else { fatalError() } + + var newURL = baseURL + var newQueryItems = queryItems + + newQueryItems.removeAll(where: { $0.name == "SubtitleStreamIndex" }) + newQueryItems.removeAll(where: { $0.name == "SubtitleMethod" }) + + newURL.addQueryItem(name: "SubtitleMethod", value: "Encode") + newURL.addQueryItem(name: "SubtitleStreamIndex", value: "\(subtitleStream.index ?? -1)") + + return newURL.url! + } } diff --git a/Shared/ViewModels/ViewModel.swift b/Shared/ViewModels/ViewModel.swift index 1795c8b6..8531106e 100644 --- a/Shared/ViewModels/ViewModel.swift +++ b/Shared/ViewModels/ViewModel.swift @@ -1,75 +1,82 @@ // -/* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// +import ActivityIndicator import Combine import Foundation -import ActivityIndicator import JellyfinAPI class ViewModel: ObservableObject { - @Published var isLoading = false - @Published var errorMessage: ErrorMessage? + @Published + var isLoading = false + @Published + var errorMessage: ErrorMessage? - let loading = ActivityIndicator() - var cancellables = Set() + let loading = ActivityIndicator() + var cancellables = Set() - init() { - loading.loading.assign(to: \.isLoading, on: self).store(in: &cancellables) - } + init() { + loading.loading.assign(to: \.isLoading, on: self).store(in: &cancellables) + } - func handleAPIRequestError(displayMessage: String? = nil, logLevel: LogLevel = .error, tag: String = "", function: String = #function, file: String = #file, line: UInt = #line, completion: Subscribers.Completion) { - switch completion { - case .finished: - self.errorMessage = nil - case .failure(let error): - let logConstructor = LogConstructor(message: "__NOTHING__", tag: tag, level: logLevel, function: function, file: file, line: line) + func handleAPIRequestError(displayMessage: String? = nil, logLevel: LogLevel = .error, tag: String = "", function: String = #function, + file: String = #file, line: UInt = #line, completion: Subscribers.Completion) + { + switch completion { + case .finished: + self.errorMessage = nil + case let .failure(error): + let logConstructor = LogConstructor(message: "__NOTHING__", tag: tag, level: logLevel, function: function, file: file, + line: line) - switch error { - case is ErrorResponse: - let networkError: NetworkError - let errorResponse = error as! ErrorResponse - switch errorResponse { - case .error(-1, _, _, _): - networkError = .URLError(response: errorResponse, displayMessage: displayMessage, logConstructor: logConstructor) - // Use the errorResponse description for debugging, rather than the user-facing friendly description which may not be implemented - LogManager.shared.log.error("Request failed: URL request failed with error \(networkError.errorMessage.code): \(errorResponse.localizedDescription)") - case .error(-2, _, _, _): - networkError = .HTTPURLError(response: errorResponse, displayMessage: displayMessage, logConstructor: logConstructor) - LogManager.shared.log.error("Request failed: HTTP URL request failed with description: \(errorResponse.localizedDescription)") - default: - networkError = .JellyfinError(response: errorResponse, displayMessage: displayMessage, logConstructor: logConstructor) - // Able to use user-facing friendly description here since just HTTP status codes - LogManager.shared.log.error("Request failed: \(networkError.errorMessage.code) - \(networkError.errorMessage.title): \(networkError.errorMessage.logConstructor.message)\n\(error.localizedDescription)") - } + switch error { + case is ErrorResponse: + let networkError: NetworkError + let errorResponse = error as! ErrorResponse + switch errorResponse { + case .error(-1, _, _, _): + networkError = .URLError(response: errorResponse, displayMessage: displayMessage, logConstructor: logConstructor) + // Use the errorResponse description for debugging, rather than the user-facing friendly description which may not be implemented + LogManager.shared.log + .error("Request failed: URL request failed with error \(networkError.errorMessage.code): \(errorResponse.localizedDescription)") + case .error(-2, _, _, _): + networkError = .HTTPURLError(response: errorResponse, displayMessage: displayMessage, logConstructor: logConstructor) + LogManager.shared.log + .error("Request failed: HTTP URL request failed with description: \(errorResponse.localizedDescription)") + default: + networkError = .JellyfinError(response: errorResponse, displayMessage: displayMessage, logConstructor: logConstructor) + // Able to use user-facing friendly description here since just HTTP status codes + LogManager.shared.log + .error("Request failed: \(networkError.errorMessage.code) - \(networkError.errorMessage.title): \(networkError.errorMessage.logConstructor.message)\n\(error.localizedDescription)") + } - self.errorMessage = networkError.errorMessage + self.errorMessage = networkError.errorMessage - networkError.logMessage() + networkError.logMessage() - case is SwiftfinStore.Errors: - let swiftfinError = error as! SwiftfinStore.Errors - let errorMessage = ErrorMessage(code: ErrorMessage.noShowErrorCode, - title: swiftfinError.title, - displayMessage: swiftfinError.errorDescription ?? "", - logConstructor: logConstructor) - self.errorMessage = errorMessage - LogManager.shared.log.error("Request failed: \(swiftfinError.errorDescription ?? "")") + case is SwiftfinStore.Errors: + let swiftfinError = error as! SwiftfinStore.Errors + let errorMessage = ErrorMessage(code: ErrorMessage.noShowErrorCode, + title: swiftfinError.title, + displayMessage: swiftfinError.errorDescription ?? "", + logConstructor: logConstructor) + self.errorMessage = errorMessage + LogManager.shared.log.error("Request failed: \(swiftfinError.errorDescription ?? "")") - default: - let genericErrorMessage = ErrorMessage(code: ErrorMessage.noShowErrorCode, - title: "Generic Error", - displayMessage: error.localizedDescription, - logConstructor: logConstructor) - self.errorMessage = genericErrorMessage - LogManager.shared.log.error("Request failed: Generic error - \(error.localizedDescription)") - } - } - } + default: + let genericErrorMessage = ErrorMessage(code: ErrorMessage.noShowErrorCode, + title: "Generic Error", + displayMessage: error.localizedDescription, + logConstructor: logConstructor) + self.errorMessage = genericErrorMessage + LogManager.shared.log.error("Request failed: Generic error - \(error.localizedDescription)") + } + } + } } diff --git a/Shared/Views/ImageView.swift b/Shared/Views/ImageView.swift index 636d9900..8fb14bdf 100644 --- a/Shared/Views/ImageView.swift +++ b/Shared/Views/ImageView.swift @@ -1,70 +1,70 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import SwiftUI struct ImageView: View { - private let source: URL - private let blurhash: String - private let failureInitials: String + private let source: URL + private let blurhash: String + private let failureInitials: String - init(src: URL, bh: String = "001fC^", failureInitials: String = "") { - self.source = src - self.blurhash = bh - self.failureInitials = failureInitials - } + init(src: URL, bh: String = "001fC^", failureInitials: String = "") { + self.source = src + self.blurhash = bh + self.failureInitials = failureInitials + } - // TODO: fix placeholder hash image - @ViewBuilder - private var placeholderImage: some View { - Image(uiImage: UIImage(blurHash: blurhash, size: CGSize(width: 8, height: 8)) ?? UIImage(blurHash: "001fC^", size: CGSize(width: 8, height: 8))!) - .resizable() - } + // TODO: fix placeholder hash image + @ViewBuilder + private var placeholderImage: some View { + Image(uiImage: UIImage(blurHash: blurhash, size: CGSize(width: 8, height: 8)) ?? + UIImage(blurHash: "001fC^", size: CGSize(width: 8, height: 8))!) + .resizable() + } - @ViewBuilder - private var failureImage: some View { - ZStack { - Rectangle() - .foregroundColor(Color(UIColor.darkGray)) + @ViewBuilder + private var failureImage: some View { + ZStack { + Rectangle() + .foregroundColor(Color(UIColor.darkGray)) - Text(failureInitials) - .font(.largeTitle) - .foregroundColor(.secondary) - } - } + Text(failureInitials) + .font(.largeTitle) + .foregroundColor(.secondary) + } + } - var body: some View { - AsyncImage(url: source, transaction: Transaction(animation: .easeInOut)) { phase in - switch phase { - case .success(let image): - image - .resizable() - .aspectRatio(contentMode: .fill) - case .failure(_): - failureImage - default: - // TODO: remove once placeholder hash image fixed + var body: some View { + AsyncImage(url: source, transaction: Transaction(animation: .easeInOut)) { phase in + switch phase { + case let .success(image): + image + .resizable() + .aspectRatio(contentMode: .fill) + case .failure: + failureImage + default: + // TODO: remove once placeholder hash image fixed - #if os(tvOS) - ZStack { - Color.black.ignoresSafeArea() + #if os(tvOS) + ZStack { + Color.black.ignoresSafeArea() - ProgressView() - } - #else - ZStack { - Color.gray.ignoresSafeArea() + ProgressView() + } + #else + ZStack { + Color.gray.ignoresSafeArea() - ProgressView() - } - #endif - } - } - } + ProgressView() + } + #endif + } + } + } } diff --git a/Shared/Views/LazyView.swift b/Shared/Views/LazyView.swift index af46b7f0..480b5f6b 100644 --- a/Shared/Views/LazyView.swift +++ b/Shared/Views/LazyView.swift @@ -1,16 +1,17 @@ -/* JellyfinPlayer/Swiftfin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Foundation import SwiftUI struct LazyView: View { - var content: () -> Content - var body: some View { - self.content() - } + var content: () -> Content + var body: some View { + self.content() + } } diff --git a/Shared/Views/LiveTVChannelItemElement.swift b/Shared/Views/LiveTVChannelItemElement.swift index b6f50e81..35fdd41d 100644 --- a/Shared/Views/LiveTVChannelItemElement.swift +++ b/Shared/Views/LiveTVChannelItemElement.swift @@ -1,80 +1,80 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// - -import SwiftUI import JellyfinAPI +import SwiftUI struct LiveTVChannelItemElement: View { - @Environment(\.isFocused) var envFocused: Bool - @State var focused: Bool = false - - var channel: BaseItemDto - var program: BaseItemDto? - var startString = " " - var endString = " " - var progressPercent = Double(0) - - var body: some View { - VStack { - HStack { - Spacer() - Text(channel.number ?? "") - .font(.footnote) - .frame(alignment: .trailing) - }.frame(alignment: .top) - ImageView(src: channel.getPrimaryImage(maxWidth: 125)) - .frame(width: 125, alignment: .center) - .offset(x: 0, y: -32) - Text(channel.name ?? "?") - .font(.footnote) - .lineLimit(1) - .frame(alignment: .center) - Text(program?.name ?? "N/A") - .font(.body) - .lineLimit(1) - .foregroundColor(.green) - VStack { - HStack { - Text(startString) - .font(.footnote) - .lineLimit(1) - .frame(alignment: .leading) - - Spacer() - - Text(endString) - .font(.footnote) - .lineLimit(1) - .frame(alignment: .trailing) - } - GeometryReader { gp in - ZStack(alignment: .leading) { - RoundedRectangle(cornerRadius: 6) - .fill(Color.gray) - .opacity(0.4) - .frame(minWidth: 100, maxWidth: .infinity, minHeight: 12, maxHeight: 12) - RoundedRectangle(cornerRadius: 6) - .fill(Color.jellyfinPurple) - .frame(width: CGFloat(progressPercent * gp.size.width), height: 12) - } - } - } - } - .padding() - .background(Color.clear) - .border(focused ? Color.blue : Color.clear, width: 4) - .onChange(of: envFocused) { envFocus in - withAnimation(.linear(duration: 0.15)) { - self.focused = envFocus - } - } - .scaleEffect(focused ? 1.1 : 1) - } + @Environment(\.isFocused) + var envFocused: Bool + @State + var focused: Bool = false + + var channel: BaseItemDto + var program: BaseItemDto? + var startString = " " + var endString = " " + var progressPercent = Double(0) + + var body: some View { + VStack { + HStack { + Spacer() + Text(channel.number ?? "") + .font(.footnote) + .frame(alignment: .trailing) + }.frame(alignment: .top) + ImageView(src: channel.getPrimaryImage(maxWidth: 125)) + .frame(width: 125, alignment: .center) + .offset(x: 0, y: -32) + Text(channel.name ?? "?") + .font(.footnote) + .lineLimit(1) + .frame(alignment: .center) + Text(program?.name ?? "N/A") + .font(.body) + .lineLimit(1) + .foregroundColor(.green) + VStack { + HStack { + Text(startString) + .font(.footnote) + .lineLimit(1) + .frame(alignment: .leading) + + Spacer() + + Text(endString) + .font(.footnote) + .lineLimit(1) + .frame(alignment: .trailing) + } + GeometryReader { gp in + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 6) + .fill(Color.gray) + .opacity(0.4) + .frame(minWidth: 100, maxWidth: .infinity, minHeight: 12, maxHeight: 12) + RoundedRectangle(cornerRadius: 6) + .fill(Color.jellyfinPurple) + .frame(width: CGFloat(progressPercent * gp.size.width), height: 12) + } + } + } + } + .padding() + .background(Color.clear) + .border(focused ? Color.blue : Color.clear, width: 4) + .onChange(of: envFocused) { envFocus in + withAnimation(.linear(duration: 0.15)) { + self.focused = envFocus + } + } + .scaleEffect(focused ? 1.1 : 1) + } } diff --git a/Shared/Views/MultiSelectorView.swift b/Shared/Views/MultiSelectorView.swift index d24af0df..8306ba40 100644 --- a/Shared/Views/MultiSelectorView.swift +++ b/Shared/Views/MultiSelectorView.swift @@ -1,73 +1,73 @@ -/* JellyfinPlayer/Swiftfin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import SwiftUI private struct MultiSelectionView: View { - let options: [Selectable] - let optionToString: (Selectable) -> String - let label: String + let options: [Selectable] + let optionToString: (Selectable) -> String + let label: String - @Binding var selected: [Selectable] + @Binding + var selected: [Selectable] - var body: some View { - List { - ForEach(options, id: \.self) { selectable in - Button(action: { toggleSelection(selectable: selectable) }) { - HStack { - Text(optionToString(selectable)).foregroundColor(Color.primary) - Spacer() - if selected.contains { $0 == selectable } { - Image(systemName: "checkmark").foregroundColor(.accentColor) - } - } - }.tag(selectable) - } - }.listStyle(GroupedListStyle()) - } + var body: some View { + List { + ForEach(options, id: \.self) { selectable in + Button(action: { toggleSelection(selectable: selectable) }) { + HStack { + Text(optionToString(selectable)).foregroundColor(Color.primary) + Spacer() + if selected.contains { $0 == selectable } { + Image(systemName: "checkmark").foregroundColor(.accentColor) + } + } + }.tag(selectable) + } + }.listStyle(GroupedListStyle()) + } - private func toggleSelection(selectable: Selectable) { - if let existingIndex = selected.firstIndex(where: { $0 == selectable }) { - selected.remove(at: existingIndex) - } else { - selected.append(selectable) - } - } + private func toggleSelection(selectable: Selectable) { + if let existingIndex = selected.firstIndex(where: { $0 == selectable }) { + selected.remove(at: existingIndex) + } else { + selected.append(selectable) + } + } } struct MultiSelector: View { - let label: String - let options: [Selectable] - let optionToString: (Selectable) -> String + let label: String + let options: [Selectable] + let optionToString: (Selectable) -> String - var selected: Binding<[Selectable]> + var selected: Binding<[Selectable]> - private var formattedSelectedListString: String { - ListFormatter.localizedString(byJoining: selected.wrappedValue.map { optionToString($0) }) - } + private var formattedSelectedListString: String { + ListFormatter.localizedString(byJoining: selected.wrappedValue.map { optionToString($0) }) + } - var body: some View { - NavigationLink(destination: multiSelectionView()) { - HStack { - Text(label) - Spacer() - Text(formattedSelectedListString) - .foregroundColor(.gray) - .multilineTextAlignment(.trailing) - } - } - } + var body: some View { + NavigationLink(destination: multiSelectionView()) { + HStack { + Text(label) + Spacer() + Text(formattedSelectedListString) + .foregroundColor(.gray) + .multilineTextAlignment(.trailing) + } + } + } - private func multiSelectionView() -> some View { - MultiSelectionView( - options: options, - optionToString: optionToString, - label: self.label, - selected: selected - ) - } + private func multiSelectionView() -> some View { + MultiSelectionView(options: options, + optionToString: optionToString, + label: self.label, + selected: selected) + } } diff --git a/Shared/Views/ParallaxHeader.swift b/Shared/Views/ParallaxHeader.swift index d16fccea..87efc553 100644 --- a/Shared/Views/ParallaxHeader.swift +++ b/Shared/Views/ParallaxHeader.swift @@ -1,43 +1,45 @@ -/* JellyfinPlayer/Swiftfin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Foundation import SwiftUI struct ParallaxHeaderScrollView: View { - var header: Header - var staticOverlayView: StaticOverlayView - var overlayAlignment: Alignment - var headerHeight: CGFloat - var content: () -> Content + var header: Header + var staticOverlayView: StaticOverlayView + var overlayAlignment: Alignment + var headerHeight: CGFloat + var content: () -> Content - init(header: Header, - staticOverlayView: StaticOverlayView, - overlayAlignment: Alignment = .center, - headerHeight: CGFloat, - content: @escaping () -> Content) { - self.header = header - self.staticOverlayView = staticOverlayView - self.overlayAlignment = overlayAlignment - self.headerHeight = headerHeight - self.content = content - } + init(header: Header, + staticOverlayView: StaticOverlayView, + overlayAlignment: Alignment = .center, + headerHeight: CGFloat, + content: @escaping () -> Content) + { + self.header = header + self.staticOverlayView = staticOverlayView + self.overlayAlignment = overlayAlignment + self.headerHeight = headerHeight + self.content = content + } - var body: some View { - ScrollView(showsIndicators: false) { - GeometryReader { proxy in - let yOffset = proxy.frame(in: .global).minY > 0 ? -proxy.frame(in: .global).minY : 0 - header - .frame(width: proxy.size.width, height: proxy.size.height - yOffset) - .overlay(staticOverlayView, alignment: overlayAlignment) - .offset(y: yOffset) - } - .frame(height: headerHeight) - content() - } - } + var body: some View { + ScrollView(showsIndicators: false) { + GeometryReader { proxy in + let yOffset = proxy.frame(in: .global).minY > 0 ? -proxy.frame(in: .global).minY : 0 + header + .frame(width: proxy.size.width, height: proxy.size.height - yOffset) + .overlay(staticOverlayView, alignment: overlayAlignment) + .offset(y: yOffset) + } + .frame(height: headerHeight) + content() + } + } } diff --git a/Shared/Views/PlainNavigationLinkButton.swift b/Shared/Views/PlainNavigationLinkButton.swift index 1d871577..f47fdc1c 100644 --- a/Shared/Views/PlainNavigationLinkButton.swift +++ b/Shared/Views/PlainNavigationLinkButton.swift @@ -1,24 +1,23 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import SwiftUI struct PlainNavigationLinkButtonStyle: ButtonStyle { - func makeBody(configuration: Self.Configuration) -> some View { - PlainNavigationLinkButton(configuration: configuration) - } + func makeBody(configuration: Self.Configuration) -> some View { + PlainNavigationLinkButton(configuration: configuration) + } } struct PlainNavigationLinkButton: View { - let configuration: ButtonStyle.Configuration + let configuration: ButtonStyle.Configuration - var body: some View { - configuration.label - } + var body: some View { + configuration.label + } } diff --git a/Shared/Views/PortraitItemSize.swift b/Shared/Views/PortraitItemSize.swift index 3af53228..b9fbad53 100644 --- a/Shared/Views/PortraitItemSize.swift +++ b/Shared/Views/PortraitItemSize.swift @@ -1,19 +1,18 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import SwiftUI extension View { - - /// Applies Portrait Poster frame with proper corner radius ratio against the width - func portraitPoster(width: CGFloat) -> some View { - self.frame(width: width, height: width * 1.5) - .cornerRadius((width * 1.5) / 40) - } + + /// Applies Portrait Poster frame with proper corner radius ratio against the width + func portraitPoster(width: CGFloat) -> some View { + self.frame(width: width, height: width * 1.5) + .cornerRadius((width * 1.5) / 40) + } } diff --git a/Shared/Views/SearchBarView.swift b/Shared/Views/SearchBarView.swift index ce6db7f9..137489ac 100644 --- a/Shared/Views/SearchBarView.swift +++ b/Shared/Views/SearchBarView.swift @@ -1,37 +1,38 @@ -/* SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - * - * Code sourced from AppCoda.com - */ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import SwiftUI struct SearchBar: View { - @Binding var text: String + @Binding + var text: String - @State private var isEditing = false + @State + private var isEditing = false - var body: some View { - HStack(spacing: 8) { - TextField(L10n.search, text: $text) - .padding(8) - .padding(.horizontal, 16) - #if os(iOS) - .background(Color(.systemGray6)) - #endif - .cornerRadius(8) - if !text.isEmpty { - Button(action: { - self.text = "" - }) { - Image(systemName: "xmark.circle.fill") - .foregroundColor(.secondary) - } - } - } - .padding(.horizontal, 16) - } + var body: some View { + HStack(spacing: 8) { + TextField(L10n.search, text: $text) + .padding(8) + .padding(.horizontal, 16) + #if os(iOS) + .background(Color(.systemGray6)) + #endif + .cornerRadius(8) + if !text.isEmpty { + Button(action: { + self.text = "" + }) { + Image(systemName: "xmark.circle.fill") + .foregroundColor(.secondary) + } + } + } + .padding(.horizontal, 16) + } } diff --git a/Shared/Views/SearchablePickerView.swift b/Shared/Views/SearchablePickerView.swift index 91550271..2800ed24 100644 --- a/Shared/Views/SearchablePickerView.swift +++ b/Shared/Views/SearchablePickerView.swift @@ -1,72 +1,75 @@ // -/* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Foundation import SwiftUI private struct SearchablePickerView: View { - @Environment(\.presentationMode) var presentationMode + @Environment(\.presentationMode) + var presentationMode - let options: [Selectable] - let optionToString: (Selectable) -> String - let label: String + let options: [Selectable] + let optionToString: (Selectable) -> String + let label: String - @State var text = "" - @Binding var selected: Selectable + @State + var text = "" + @Binding + var selected: Selectable - var body: some View { - VStack { - SearchBar(text: $text) - List(options.filter { - guard !text.isEmpty else { return true } - return optionToString($0).lowercased().contains(text.lowercased()) - }, id: \.self) { selectable in - Button(action: { - selected = selectable - presentationMode.wrappedValue.dismiss() - }) { - HStack { - Text(optionToString(selectable)).foregroundColor(Color.primary) - Spacer() - if selected == selectable { - Image(systemName: "checkmark").foregroundColor(.accentColor) - } - } - } - }.listStyle(GroupedListStyle()) - } - } + var body: some View { + VStack { + SearchBar(text: $text) + List(options.filter { + guard !text.isEmpty else { return true } + return optionToString($0).lowercased().contains(text.lowercased()) + }, id: \.self) { selectable in + Button(action: { + selected = selectable + presentationMode.wrappedValue.dismiss() + }) { + HStack { + Text(optionToString(selectable)).foregroundColor(Color.primary) + Spacer() + if selected == selectable { + Image(systemName: "checkmark").foregroundColor(.accentColor) + } + } + } + }.listStyle(GroupedListStyle()) + } + } } struct SearchablePicker: View { - let label: String - let options: [Selectable] - let optionToString: (Selectable) -> String + let label: String + let options: [Selectable] + let optionToString: (Selectable) -> String - @Binding var selected: Selectable + @Binding + var selected: Selectable - var body: some View { - NavigationLink(destination: searchablePickerView()) { - HStack { - Text(label) - Spacer() - Text(optionToString(selected)) - .foregroundColor(.gray) - .multilineTextAlignment(.trailing) - } - } - } + var body: some View { + NavigationLink(destination: searchablePickerView()) { + HStack { + Text(label) + Spacer() + Text(optionToString(selected)) + .foregroundColor(.gray) + .multilineTextAlignment(.trailing) + } + } + } - private func searchablePickerView() -> some View { - SearchablePickerView(options: options, - optionToString: optionToString, - label: label, - selected: $selected) - } + private func searchablePickerView() -> some View { + SearchablePickerView(options: options, + optionToString: optionToString, + label: label, + selected: $selected) + } } diff --git a/Swiftfin tvOS/App/JellyfinPlayer_tvOSApp.swift b/Swiftfin tvOS/App/JellyfinPlayer_tvOSApp.swift index 8f4d25bc..ffe2d417 100644 --- a/Swiftfin tvOS/App/JellyfinPlayer_tvOSApp.swift +++ b/Swiftfin tvOS/App/JellyfinPlayer_tvOSApp.swift @@ -1,9 +1,10 @@ -/* JellyfinPlayer/Swiftfin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import SwiftUI import UIKit @@ -11,18 +12,18 @@ import UIKit @main struct JellyfinPlayer_tvOSApp: App { - var body: some Scene { - WindowGroup { - MainCoordinator().view() - .onAppear { - JellyfinPlayer_tvOSApp.setupAppearance() - } - } - } - - static func setupAppearance() { - let scenes = UIApplication.shared.connectedScenes - let windowScene = scenes.first as? UIWindowScene - windowScene?.windows.first?.overrideUserInterfaceStyle = .dark - } + var body: some Scene { + WindowGroup { + MainCoordinator().view() + .onAppear { + JellyfinPlayer_tvOSApp.setupAppearance() + } + } + } + + static func setupAppearance() { + let scenes = UIApplication.shared.connectedScenes + let windowScene = scenes.first as? UIWindowScene + windowScene?.windows.first?.overrideUserInterfaceStyle = .dark + } } diff --git a/Swiftfin tvOS/Components/EpisodesRowView.swift b/Swiftfin tvOS/Components/EpisodesRowView.swift index bf8dca08..c5696110 100644 --- a/Swiftfin tvOS/Components/EpisodesRowView.swift +++ b/Swiftfin tvOS/Components/EpisodesRowView.swift @@ -1,134 +1,135 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import JellyfinAPI import SwiftUI struct EpisodesRowView: View { - - @EnvironmentObject var itemRouter: ItemCoordinator.Router - @ObservedObject var viewModel: EpisodesRowViewModel - - var body: some View { - VStack(alignment: .leading) { - - Text(viewModel.selectedSeason?.name ?? "Episodes") - .font(.title3) - .padding(.horizontal, 50) - - ScrollView(.horizontal) { - ScrollViewReader { reader in - HStack(alignment: .top) { - if viewModel.isLoading { - VStack(alignment: .leading) { - ZStack { - Color.secondary.ignoresSafeArea() - - ProgressView() - } - .mask(Rectangle().frame(width: 500, height: 280)) - .frame(width: 500, height: 280) + @EnvironmentObject + var itemRouter: ItemCoordinator.Router + @ObservedObject + var viewModel: EpisodesRowViewModel - VStack(alignment: .leading) { - Text("S-E-") - .font(.caption) - .foregroundColor(.secondary) - Text("--") - .font(.footnote) - .padding(.bottom, 1) - Text("--") - .font(.caption) - .fontWeight(.light) - .lineLimit(4) - } - .padding(.horizontal) + var body: some View { + VStack(alignment: .leading) { - Spacer() - } - .frame(width: 500) - .focusable() - } else if let selectedSeason = viewModel.selectedSeason { - if viewModel.seasonsEpisodes[selectedSeason]!.isEmpty { - VStack(alignment: .leading) { + Text(viewModel.selectedSeason?.name ?? "Episodes") + .font(.title3) + .padding(.horizontal, 50) - Color.secondary - .mask(Rectangle().frame(width: 500, height: 280)) - .frame(width: 500, height: 280) + ScrollView(.horizontal) { + ScrollViewReader { reader in + HStack(alignment: .top) { + if viewModel.isLoading { + VStack(alignment: .leading) { - VStack(alignment: .leading) { - Text("--") - .font(.caption) - .foregroundColor(.secondary) - Text("No episodes available") - .font(.footnote) - .padding(.bottom, 1) - } - .padding(.horizontal) + ZStack { + Color.secondary.ignoresSafeArea() - Spacer() - } - .frame(width: 500) - .focusable() - } else { - ForEach(viewModel.seasonsEpisodes[selectedSeason]!, id:\.self) { episode in - Button { - itemRouter.route(to: \.item, episode) - } label: { - HStack(alignment: .top) { - VStack(alignment: .leading) { + ProgressView() + } + .mask(Rectangle().frame(width: 500, height: 280)) + .frame(width: 500, height: 280) - ImageView(src: episode.getBackdropImage(maxWidth: 500), - bh: episode.getBackdropImageBlurHash()) - .mask(Rectangle().frame(width: 500, height: 280)) - .frame(width: 500, height: 280) + VStack(alignment: .leading) { + Text("S-E-") + .font(.caption) + .foregroundColor(.secondary) + Text("--") + .font(.footnote) + .padding(.bottom, 1) + Text("--") + .font(.caption) + .fontWeight(.light) + .lineLimit(4) + } + .padding(.horizontal) - VStack(alignment: .leading) { - Text(episode.getEpisodeLocator() ?? "") - .font(.caption) - .foregroundColor(.secondary) - Text(episode.name ?? "") - .font(.footnote) - .padding(.bottom, 1) - Text(episode.overview ?? "") - .font(.caption) - .fontWeight(.light) - .lineLimit(4) - } - .padding(.horizontal) + Spacer() + } + .frame(width: 500) + .focusable() + } else if let selectedSeason = viewModel.selectedSeason { + if viewModel.seasonsEpisodes[selectedSeason]!.isEmpty { + VStack(alignment: .leading) { - Spacer() - } - .frame(width: 500) - } - } - .buttonStyle(PlainButtonStyle()) - .id(episode.name) - } - } - } - } - .padding(.horizontal, 50) - .padding(.vertical) - .onChange(of: viewModel.selectedSeason) { _ in - if viewModel.selectedSeason?.id == viewModel.episodeItemViewModel.item.seasonId { - reader.scrollTo(viewModel.episodeItemViewModel.item.name) - } - } - .onChange(of: viewModel.seasonsEpisodes) { _ in - if viewModel.selectedSeason?.id == viewModel.episodeItemViewModel.item.seasonId { - reader.scrollTo(viewModel.episodeItemViewModel.item.name) - } - } - } - .edgesIgnoringSafeArea(.horizontal) - } - } - } + Color.secondary + .mask(Rectangle().frame(width: 500, height: 280)) + .frame(width: 500, height: 280) + + VStack(alignment: .leading) { + Text("--") + .font(.caption) + .foregroundColor(.secondary) + Text("No episodes available") + .font(.footnote) + .padding(.bottom, 1) + } + .padding(.horizontal) + + Spacer() + } + .frame(width: 500) + .focusable() + } else { + ForEach(viewModel.seasonsEpisodes[selectedSeason]!, id: \.self) { episode in + Button { + itemRouter.route(to: \.item, episode) + } label: { + HStack(alignment: .top) { + VStack(alignment: .leading) { + + ImageView(src: episode.getBackdropImage(maxWidth: 500), + bh: episode.getBackdropImageBlurHash()) + .mask(Rectangle().frame(width: 500, height: 280)) + .frame(width: 500, height: 280) + + VStack(alignment: .leading) { + Text(episode.getEpisodeLocator() ?? "") + .font(.caption) + .foregroundColor(.secondary) + Text(episode.name ?? "") + .font(.footnote) + .padding(.bottom, 1) + Text(episode.overview ?? "") + .font(.caption) + .fontWeight(.light) + .lineLimit(4) + } + .padding(.horizontal) + + Spacer() + } + .frame(width: 500) + } + } + .buttonStyle(PlainButtonStyle()) + .id(episode.name) + } + } + } + } + .padding(.horizontal, 50) + .padding(.vertical) + .onChange(of: viewModel.selectedSeason) { _ in + if viewModel.selectedSeason?.id == viewModel.episodeItemViewModel.item.seasonId { + reader.scrollTo(viewModel.episodeItemViewModel.item.name) + } + } + .onChange(of: viewModel.seasonsEpisodes) { _ in + if viewModel.selectedSeason?.id == viewModel.episodeItemViewModel.item.seasonId { + reader.scrollTo(viewModel.episodeItemViewModel.item.name) + } + } + } + .edgesIgnoringSafeArea(.horizontal) + } + } + } } diff --git a/Swiftfin tvOS/Components/HomeCinematicView/CinematicBackgroundView.swift b/Swiftfin tvOS/Components/HomeCinematicView/CinematicBackgroundView.swift index 91cb3c5e..884d282b 100644 --- a/Swiftfin tvOS/Components/HomeCinematicView/CinematicBackgroundView.swift +++ b/Swiftfin tvOS/Components/HomeCinematicView/CinematicBackgroundView.swift @@ -1,11 +1,10 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import JellyfinAPI import Nuke @@ -13,43 +12,46 @@ import SwiftUI import UIKit class DynamicCinematicBackgroundViewModel: ObservableObject { - - @Published var currentItem: BaseItemDto? - @Published var currentImageView: UIImageView? - - func select(item: BaseItemDto) { - - guard item.id != currentItem?.id else { return } - - currentItem = item - - let itemImageView = UIImageView() - - let backdropImage: URL - - if item.itemType == .episode { - backdropImage = item.getSeriesBackdropImage(maxWidth: 1920) - } else { - backdropImage = item.getBackdropImage(maxWidth: 1920) - } - - let options = ImageLoadingOptions(transition: .fadeIn(duration: 0.2)) - - Nuke.loadImage(with: backdropImage, options: options, into: itemImageView, completion: { _ in }) - - currentImageView = itemImageView - } + + @Published + var currentItem: BaseItemDto? + @Published + var currentImageView: UIImageView? + + func select(item: BaseItemDto) { + + guard item.id != currentItem?.id else { return } + + currentItem = item + + let itemImageView = UIImageView() + + let backdropImage: URL + + if item.itemType == .episode { + backdropImage = item.getSeriesBackdropImage(maxWidth: 1920) + } else { + backdropImage = item.getBackdropImage(maxWidth: 1920) + } + + let options = ImageLoadingOptions(transition: .fadeIn(duration: 0.2)) + + Nuke.loadImage(with: backdropImage, options: options, into: itemImageView, completion: { _ in }) + + currentImageView = itemImageView + } } struct CinematicBackgroundView: UIViewRepresentable { - - @ObservedObject var viewModel: DynamicCinematicBackgroundViewModel - - func updateUIView(_ uiView: UICinematicBackgroundView, context: Context) { - uiView.update(imageView: viewModel.currentImageView ?? UIImageView()) - } - - func makeUIView(context: Context) -> UICinematicBackgroundView { - return UICinematicBackgroundView(initialImageView: viewModel.currentImageView ?? UIImageView()) - } + + @ObservedObject + var viewModel: DynamicCinematicBackgroundViewModel + + func updateUIView(_ uiView: UICinematicBackgroundView, context: Context) { + uiView.update(imageView: viewModel.currentImageView ?? UIImageView()) + } + + func makeUIView(context: Context) -> UICinematicBackgroundView { + UICinematicBackgroundView(initialImageView: viewModel.currentImageView ?? UIImageView()) + } } diff --git a/Swiftfin tvOS/Components/HomeCinematicView/CinematicNextUpCardView.swift b/Swiftfin tvOS/Components/HomeCinematicView/CinematicNextUpCardView.swift index 414f8342..13ef8d01 100644 --- a/Swiftfin tvOS/Components/HomeCinematicView/CinematicNextUpCardView.swift +++ b/Swiftfin tvOS/Components/HomeCinematicView/CinematicNextUpCardView.swift @@ -1,62 +1,62 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import JellyfinAPI import SwiftUI struct CinematicNextUpCardView: View { - - @EnvironmentObject var homeRouter: HomeCoordinator.Router - let item: BaseItemDto - let showOverlay: Bool - - var body: some View { - VStack(alignment: .leading) { - Button { - homeRouter.route(to: \.modalItem, item) - } label: { - ZStack(alignment: .bottomLeading) { - if item.itemType == .episode { - ImageView(src: item.getSeriesBackdropImage(maxWidth: 350)) - .frame(width: 350, height: 210) - } else { - ImageView(src: item.getBackdropImage(maxWidth: 350)) - .frame(width: 350, height: 210) - } - - LinearGradient(colors: [.clear, .black], - startPoint: .top, - endPoint: .bottom) - .frame(height: 105) - .ignoresSafeArea() - - if showOverlay { - VStack(alignment: .leading, spacing: 0) { - Text("Next") - .font(.subheadline) - .padding(.vertical, 5) - .padding(.leading, 10) - .foregroundColor(.white) - - HStack { - Color.clear - .frame(width: 1, height: 7) - } - } - } - } - .frame(width: 350, height: 210) - } - .buttonStyle(CardButtonStyle()) - .padding(.top) - } - .padding(.vertical) - } + @EnvironmentObject + var homeRouter: HomeCoordinator.Router + let item: BaseItemDto + let showOverlay: Bool + + var body: some View { + VStack(alignment: .leading) { + Button { + homeRouter.route(to: \.modalItem, item) + } label: { + ZStack(alignment: .bottomLeading) { + + if item.itemType == .episode { + ImageView(src: item.getSeriesBackdropImage(maxWidth: 350)) + .frame(width: 350, height: 210) + } else { + ImageView(src: item.getBackdropImage(maxWidth: 350)) + .frame(width: 350, height: 210) + } + + LinearGradient(colors: [.clear, .black], + startPoint: .top, + endPoint: .bottom) + .frame(height: 105) + .ignoresSafeArea() + + if showOverlay { + VStack(alignment: .leading, spacing: 0) { + Text("Next") + .font(.subheadline) + .padding(.vertical, 5) + .padding(.leading, 10) + .foregroundColor(.white) + + HStack { + Color.clear + .frame(width: 1, height: 7) + } + } + } + } + .frame(width: 350, height: 210) + } + .buttonStyle(CardButtonStyle()) + .padding(.top) + } + .padding(.vertical) + } } diff --git a/Swiftfin tvOS/Components/HomeCinematicView/CinematicResumeCardView.swift b/Swiftfin tvOS/Components/HomeCinematicView/CinematicResumeCardView.swift index 9529976c..512b841b 100644 --- a/Swiftfin tvOS/Components/HomeCinematicView/CinematicResumeCardView.swift +++ b/Swiftfin tvOS/Components/HomeCinematicView/CinematicResumeCardView.swift @@ -1,61 +1,61 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import JellyfinAPI import SwiftUI struct CinematicResumeCardView: View { - - @EnvironmentObject var homeRouter: HomeCoordinator.Router - let item: BaseItemDto - - var body: some View { - VStack(alignment: .leading) { - Button { - homeRouter.route(to: \.modalItem, item) - } label: { - ZStack(alignment: .bottom) { - if item.itemType == .episode { - ImageView(src: item.getSeriesBackdropImage(maxWidth: 350)) - .frame(width: 350, height: 210) - } else { - ImageView(src: item.getBackdropImage(maxWidth: 350)) - .frame(width: 350, height: 210) - } - - LinearGradient(colors: [.clear, .black], - startPoint: .top, - endPoint: .bottom) - .frame(height: 105) - .ignoresSafeArea() + @EnvironmentObject + var homeRouter: HomeCoordinator.Router + let item: BaseItemDto - VStack(alignment: .leading, spacing: 0) { - Text(item.getItemProgressString() ?? "") - .font(.subheadline) - .padding(.vertical, 5) - .padding(.leading, 10) - .foregroundColor(.white) + var body: some View { + VStack(alignment: .leading) { + Button { + homeRouter.route(to: \.modalItem, item) + } label: { + ZStack(alignment: .bottom) { - HStack { - Color(UIColor.systemPurple) - .frame(width: 350 * (item.userData?.playedPercentage ?? 0) / 100, height: 7) + if item.itemType == .episode { + ImageView(src: item.getSeriesBackdropImage(maxWidth: 350)) + .frame(width: 350, height: 210) + } else { + ImageView(src: item.getBackdropImage(maxWidth: 350)) + .frame(width: 350, height: 210) + } - Spacer(minLength: 0) - } - } - } - .frame(width: 350, height: 210) - } - .buttonStyle(CardButtonStyle()) - .padding(.top) - } - .padding(.vertical) - } + LinearGradient(colors: [.clear, .black], + startPoint: .top, + endPoint: .bottom) + .frame(height: 105) + .ignoresSafeArea() + + VStack(alignment: .leading, spacing: 0) { + Text(item.getItemProgressString() ?? "") + .font(.subheadline) + .padding(.vertical, 5) + .padding(.leading, 10) + .foregroundColor(.white) + + HStack { + Color(UIColor.systemPurple) + .frame(width: 350 * (item.userData?.playedPercentage ?? 0) / 100, height: 7) + + Spacer(minLength: 0) + } + } + } + .frame(width: 350, height: 210) + } + .buttonStyle(CardButtonStyle()) + .padding(.top) + } + .padding(.vertical) + } } diff --git a/Swiftfin tvOS/Components/HomeCinematicView/HomeCinematicView.swift b/Swiftfin tvOS/Components/HomeCinematicView/HomeCinematicView.swift index b023dcb4..b616f260 100644 --- a/Swiftfin tvOS/Components/HomeCinematicView/HomeCinematicView.swift +++ b/Swiftfin tvOS/Components/HomeCinematicView/HomeCinematicView.swift @@ -1,125 +1,129 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// +import JellyfinAPI import SwiftUI import UIKit -import JellyfinAPI // TODO: Generalize this view such that it can be used in other contexts like for a library struct HomeCinematicViewItem: Hashable { - - enum TopRowType { - case resume - case nextUp - case plain - } - - let item: BaseItemDto - let type: TopRowType - - func hash(into hasher: inout Hasher) { - hasher.combine(item) - hasher.combine(type) - } + + enum TopRowType { + case resume + case nextUp + case plain + } + + let item: BaseItemDto + let type: TopRowType + + func hash(into hasher: inout Hasher) { + hasher.combine(item) + hasher.combine(type) + } } struct HomeCinematicView: View { - - @FocusState var selectedItem: BaseItemDto? - @State private var updatedSelectedItem: BaseItemDto? - @State private var initiallyAppeared = false - private let forcedItemSubtitle: String? - private let items: [HomeCinematicViewItem] - private let backgroundViewModel = DynamicCinematicBackgroundViewModel() - - init(items: [HomeCinematicViewItem], forcedItemSubtitle: String? = nil) { - self.items = items - self.forcedItemSubtitle = forcedItemSubtitle - } - - var body: some View { - - ZStack(alignment: .bottom) { - - CinematicBackgroundView(viewModel: backgroundViewModel) - .frame(height: UIScreen.main.bounds.height - 10) - - LinearGradient(stops: [.init(color: .clear, location: 0.5), - .init(color: .black.opacity(0.6), location: 0.7), - .init(color: .black, location: 1)], - startPoint: .top, - endPoint: .bottom) - .ignoresSafeArea() - - VStack(alignment: .leading, spacing: 0) { - - VStack(alignment: .leading, spacing: 0) { - if let forcedItemSubtitle = forcedItemSubtitle { - Text(forcedItemSubtitle) - .font(.callout) - .fontWeight(.medium) - .foregroundColor(Color.secondary) - } else { - if updatedSelectedItem?.itemType == .episode { - Text(updatedSelectedItem?.getEpisodeLocator() ?? "") - .font(.callout) - .fontWeight(.medium) - .foregroundColor(Color.secondary) - } else { - Text("") - } - } - - Text("\(updatedSelectedItem?.seriesName ?? updatedSelectedItem?.name ?? "")") - .font(.title) - .fontWeight(.semibold) - .foregroundColor(.primary) - .lineLimit(1) - .fixedSize(horizontal: false, vertical: true) - } - .padding(.horizontal, 50) - - ScrollView(.horizontal, showsIndicators: false) { - HStack { - ForEach(items, id: \.self) { item in - switch item.type { - case .nextUp: - CinematicNextUpCardView(item: item.item, showOverlay: true) - .focused($selectedItem, equals: item.item) - case .resume: - CinematicResumeCardView(item: item.item) - .focused($selectedItem, equals: item.item) - case .plain: - CinematicNextUpCardView(item: item.item, showOverlay: false) - .focused($selectedItem, equals: item.item) - } - } - } - .padding(.horizontal, 50) - .padding(.bottom) - } - .focusSection() - } - } - .onChange(of: selectedItem) { newValue in - if let newItem = newValue { - backgroundViewModel.select(item: newItem) - updatedSelectedItem = newItem - } - } - .onAppear { - guard !initiallyAppeared else { return } - selectedItem = items.first?.item - updatedSelectedItem = items.first?.item - initiallyAppeared = true - } - } + @FocusState + var selectedItem: BaseItemDto? + @State + private var updatedSelectedItem: BaseItemDto? + @State + private var initiallyAppeared = false + private let forcedItemSubtitle: String? + private let items: [HomeCinematicViewItem] + private let backgroundViewModel = DynamicCinematicBackgroundViewModel() + + init(items: [HomeCinematicViewItem], forcedItemSubtitle: String? = nil) { + self.items = items + self.forcedItemSubtitle = forcedItemSubtitle + } + + var body: some View { + + ZStack(alignment: .bottom) { + + CinematicBackgroundView(viewModel: backgroundViewModel) + .frame(height: UIScreen.main.bounds.height - 10) + + LinearGradient(stops: [ + .init(color: .clear, location: 0.5), + .init(color: .black.opacity(0.6), location: 0.7), + .init(color: .black, location: 1), + ], + startPoint: .top, + endPoint: .bottom) + .ignoresSafeArea() + + VStack(alignment: .leading, spacing: 0) { + + VStack(alignment: .leading, spacing: 0) { + + if let forcedItemSubtitle = forcedItemSubtitle { + Text(forcedItemSubtitle) + .font(.callout) + .fontWeight(.medium) + .foregroundColor(Color.secondary) + } else { + if updatedSelectedItem?.itemType == .episode { + Text(updatedSelectedItem?.getEpisodeLocator() ?? "") + .font(.callout) + .fontWeight(.medium) + .foregroundColor(Color.secondary) + } else { + Text("") + } + } + + Text("\(updatedSelectedItem?.seriesName ?? updatedSelectedItem?.name ?? "")") + .font(.title) + .fontWeight(.semibold) + .foregroundColor(.primary) + .lineLimit(1) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.horizontal, 50) + + ScrollView(.horizontal, showsIndicators: false) { + HStack { + ForEach(items, id: \.self) { item in + switch item.type { + case .nextUp: + CinematicNextUpCardView(item: item.item, showOverlay: true) + .focused($selectedItem, equals: item.item) + case .resume: + CinematicResumeCardView(item: item.item) + .focused($selectedItem, equals: item.item) + case .plain: + CinematicNextUpCardView(item: item.item, showOverlay: false) + .focused($selectedItem, equals: item.item) + } + } + } + .padding(.horizontal, 50) + .padding(.bottom) + } + .focusSection() + } + } + .onChange(of: selectedItem) { newValue in + if let newItem = newValue { + backgroundViewModel.select(item: newItem) + updatedSelectedItem = newItem + } + } + .onAppear { + guard !initiallyAppeared else { return } + selectedItem = items.first?.item + updatedSelectedItem = items.first?.item + initiallyAppeared = true + } + } } diff --git a/Swiftfin tvOS/Components/HomeCinematicView/UICinematicBackgroundView.swift b/Swiftfin tvOS/Components/HomeCinematicView/UICinematicBackgroundView.swift index 420416b9..002b402f 100644 --- a/Swiftfin tvOS/Components/HomeCinematicView/UICinematicBackgroundView.swift +++ b/Swiftfin tvOS/Components/HomeCinematicView/UICinematicBackgroundView.swift @@ -1,71 +1,71 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import SwiftUI import UIKit class UICinematicBackgroundView: UIView { - - private var currentImageView: UIView? - - private var selectDelayTimer: Timer? - - init(initialImageView: UIImageView) { - super.init(frame: .zero) - - initialImageView.translatesAutoresizingMaskIntoConstraints = false - initialImageView.alpha = 0 - - addSubview(initialImageView) - NSLayoutConstraint.activate([ - initialImageView.topAnchor.constraint(equalTo: topAnchor), - initialImageView.bottomAnchor.constraint(equalTo: bottomAnchor), - initialImageView.leftAnchor.constraint(equalTo: leftAnchor), - initialImageView.rightAnchor.constraint(equalTo: rightAnchor) - ]) - - self.currentImageView = initialImageView - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - func update(imageView: UIImageView) { - - selectDelayTimer?.invalidate() - - selectDelayTimer = Timer.scheduledTimer(timeInterval: 0.5, target: self, selector: #selector(delayTimerTimed), userInfo: imageView, repeats: false) - - } - - @objc private func delayTimerTimed(timer: Timer) { - let newImageView = timer.userInfo as! UIImageView - - newImageView.translatesAutoresizingMaskIntoConstraints = false - newImageView.alpha = 0 - - addSubview(newImageView) - NSLayoutConstraint.activate([ - newImageView.topAnchor.constraint(equalTo: topAnchor), - newImageView.bottomAnchor.constraint(equalTo: bottomAnchor), - newImageView.leftAnchor.constraint(equalTo: leftAnchor), - newImageView.rightAnchor.constraint(equalTo: rightAnchor) - ]) - - UIView.animate(withDuration: 0.2) { - newImageView.alpha = 1 - self.currentImageView?.alpha = 0 - } completion: { _ in - self.currentImageView?.removeFromSuperview() - self.currentImageView = newImageView - } - } + private var currentImageView: UIView? + + private var selectDelayTimer: Timer? + + init(initialImageView: UIImageView) { + super.init(frame: .zero) + + initialImageView.translatesAutoresizingMaskIntoConstraints = false + initialImageView.alpha = 0 + + addSubview(initialImageView) + NSLayoutConstraint.activate([ + initialImageView.topAnchor.constraint(equalTo: topAnchor), + initialImageView.bottomAnchor.constraint(equalTo: bottomAnchor), + initialImageView.leftAnchor.constraint(equalTo: leftAnchor), + initialImageView.rightAnchor.constraint(equalTo: rightAnchor), + ]) + + self.currentImageView = initialImageView + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func update(imageView: UIImageView) { + + selectDelayTimer?.invalidate() + + selectDelayTimer = Timer.scheduledTimer(timeInterval: 0.5, target: self, selector: #selector(delayTimerTimed), userInfo: imageView, + repeats: false) + } + + @objc + private func delayTimerTimed(timer: Timer) { + let newImageView = timer.userInfo as! UIImageView + + newImageView.translatesAutoresizingMaskIntoConstraints = false + newImageView.alpha = 0 + + addSubview(newImageView) + NSLayoutConstraint.activate([ + newImageView.topAnchor.constraint(equalTo: topAnchor), + newImageView.bottomAnchor.constraint(equalTo: bottomAnchor), + newImageView.leftAnchor.constraint(equalTo: leftAnchor), + newImageView.rightAnchor.constraint(equalTo: rightAnchor), + ]) + + UIView.animate(withDuration: 0.2) { + newImageView.alpha = 1 + self.currentImageView?.alpha = 0 + } completion: { _ in + self.currentImageView?.removeFromSuperview() + self.currentImageView = newImageView + } + } } diff --git a/Swiftfin tvOS/Components/ItemDetailsView.swift b/Swiftfin tvOS/Components/ItemDetailsView.swift index c235679b..d53ed148 100644 --- a/Swiftfin tvOS/Components/ItemDetailsView.swift +++ b/Swiftfin tvOS/Components/ItemDetailsView.swift @@ -1,88 +1,89 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import SwiftUI struct ItemDetailsView: View { - - @ObservedObject var viewModel: ItemViewModel - @FocusState private var focused: Bool - - var body: some View { - - ZStack(alignment: .leading) { - - Color(UIColor.darkGray).opacity(focused ? 0.2 : 0) - .cornerRadius(30, corners: [.topLeft, .topRight]) - - HStack(alignment: .top) { - VStack(alignment: .leading, spacing: 20) { - Text("Information") - .font(.title3) - .padding(.bottom, 5) - ForEach(viewModel.informationItems, id: \.self.title) { informationItem in - ItemDetail(title: informationItem.title, content: informationItem.content) - } - } - - Spacer() - - VStack(alignment: .leading, spacing: 20) { - Text("Media") - .font(.title3) - .padding(.bottom, 5) + @ObservedObject + var viewModel: ItemViewModel + @FocusState + private var focused: Bool - ForEach(viewModel.mediaItems, id: \.self.title) { mediaItem in - ItemDetail(title: mediaItem.title, content: mediaItem.content) - } - } - - Spacer() - } - .ignoresSafeArea() - .focusable() - .focused($focused) - .padding(50) - } - } + var body: some View { + + ZStack(alignment: .leading) { + + Color(UIColor.darkGray).opacity(focused ? 0.2 : 0) + .cornerRadius(30, corners: [.topLeft, .topRight]) + + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 20) { + Text("Information") + .font(.title3) + .padding(.bottom, 5) + + ForEach(viewModel.informationItems, id: \.self.title) { informationItem in + ItemDetail(title: informationItem.title, content: informationItem.content) + } + } + + Spacer() + + VStack(alignment: .leading, spacing: 20) { + Text("Media") + .font(.title3) + .padding(.bottom, 5) + + ForEach(viewModel.mediaItems, id: \.self.title) { mediaItem in + ItemDetail(title: mediaItem.title, content: mediaItem.content) + } + } + + Spacer() + } + .ignoresSafeArea() + .focusable() + .focused($focused) + .padding(50) + } + } } fileprivate struct ItemDetail: View { - - let title: String - let content: String - - var body: some View { - VStack(alignment: .leading) { - Text(title) - .font(.body) - Text(content) - .font(.footnote) - .foregroundColor(.secondary) - } - } + + let title: String + let content: String + + var body: some View { + VStack(alignment: .leading) { + Text(title) + .font(.body) + Text(content) + .font(.footnote) + .foregroundColor(.secondary) + } + } } struct RoundedCorner: Shape { - var radius: CGFloat = .infinity - var corners: UIRectCorner = .allCorners + var radius: CGFloat = .infinity + var corners: UIRectCorner = .allCorners - func path(in rect: CGRect) -> Path { - let path = UIBezierPath(roundedRect: rect, byRoundingCorners: corners, cornerRadii: CGSize(width: radius, height: radius)) - return Path(path.cgPath) - } + func path(in rect: CGRect) -> Path { + let path = UIBezierPath(roundedRect: rect, byRoundingCorners: corners, cornerRadii: CGSize(width: radius, height: radius)) + return Path(path.cgPath) + } } extension View { - func cornerRadius(_ radius: CGFloat, corners: UIRectCorner) -> some View { - clipShape( RoundedCorner(radius: radius, corners: corners) ) - } + func cornerRadius(_ radius: CGFloat, corners: UIRectCorner) -> some View { + clipShape(RoundedCorner(radius: radius, corners: corners)) + } } diff --git a/Swiftfin tvOS/Components/LandscapeItemElement.swift b/Swiftfin tvOS/Components/LandscapeItemElement.swift index d0c5caaa..350e8a93 100644 --- a/Swiftfin tvOS/Components/LandscapeItemElement.swift +++ b/Swiftfin tvOS/Components/LandscapeItemElement.swift @@ -1,125 +1,124 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// -import SwiftUI import JellyfinAPI +import SwiftUI struct CutOffShadow: Shape { - func path(in rect: CGRect) -> Path { - var path = Path() + func path(in rect: CGRect) -> Path { + var path = Path() - let tl = CGPoint(x: rect.minX, y: rect.minY) - let tr = CGPoint(x: rect.maxX, y: rect.minY) - let brs = CGPoint(x: rect.maxX, y: rect.maxY - 6) - let brc = CGPoint(x: rect.maxX - 6, y: rect.maxY - 6) - let bls = CGPoint(x: rect.minX + 6, y: rect.maxY) - let blc = CGPoint(x: rect.minX + 6, y: rect.maxY - 6) + let tl = CGPoint(x: rect.minX, y: rect.minY) + let tr = CGPoint(x: rect.maxX, y: rect.minY) + let brs = CGPoint(x: rect.maxX, y: rect.maxY - 6) + let brc = CGPoint(x: rect.maxX - 6, y: rect.maxY - 6) + let bls = CGPoint(x: rect.minX + 6, y: rect.maxY) + let blc = CGPoint(x: rect.minX + 6, y: rect.maxY - 6) - path.move(to: tl) - path.addLine(to: tr) - path.addLine(to: brs) - path.addRelativeArc(center: brc, radius: 6, - startAngle: Angle.degrees(0), delta: Angle.degrees(90)) - path.addLine(to: bls) - path.addRelativeArc(center: blc, radius: 6, - startAngle: Angle.degrees(90), delta: Angle.degrees(90)) + path.move(to: tl) + path.addLine(to: tr) + path.addLine(to: brs) + path.addRelativeArc(center: brc, radius: 6, + startAngle: Angle.degrees(0), delta: Angle.degrees(90)) + path.addLine(to: bls) + path.addRelativeArc(center: blc, radius: 6, + startAngle: Angle.degrees(90), delta: Angle.degrees(90)) - return path - } + return path + } } struct LandscapeItemElement: View { - @Environment(\.isFocused) var envFocused: Bool - @State var focused: Bool = false - @State var backgroundURL: URL? + @Environment(\.isFocused) + var envFocused: Bool + @State + var focused: Bool = false + @State + var backgroundURL: URL? - var item: BaseItemDto - var inSeasonView: Bool? + var item: BaseItemDto + var inSeasonView: Bool? - var body: some View { - VStack { - ImageView(src: (item.type == "Episode" && !(inSeasonView ?? false) ? item.getSeriesBackdropImage(maxWidth: 445) : item.getBackdropImage(maxWidth: 445)), bh: item.type == "Episode" ? item.getSeriesBackdropImageBlurHash() : item.getBackdropImageBlurHash()) - .frame(width: 445, height: 250) - .cornerRadius(10) - .ignoresSafeArea() - .overlay( - ZStack { - if item.userData?.played ?? false { - Image(systemName: "circle.fill") - .foregroundColor(.white) - Image(systemName: "checkmark.circle.fill") - .foregroundColor(Color(.systemBlue)) - } - }.padding(2) - .opacity(1), alignment: .topTrailing).opacity(1) - .overlay( - ZStack(alignment: .leading) { - if focused && item.userData?.playedPercentage != nil { - Rectangle() - .fill(LinearGradient( - gradient: Gradient(colors: [.black, .clear]), - startPoint: .bottom, - endPoint: .top - )) - .frame(width: 445, height: 90) - .mask(CutOffShadow()) - VStack(alignment: .leading) { - Text("CONTINUE • \(item.getItemProgressString() ?? "")") - .font(.caption) - .fontWeight(.medium) - .offset(y: 5) - ZStack(alignment: .leading) { - RoundedRectangle(cornerRadius: 6) - .fill(Color.gray) - .opacity(0.4) - .frame(minWidth: 100, maxWidth: .infinity, minHeight: 12, maxHeight: 12) - RoundedRectangle(cornerRadius: 6) - .fill(Color.jellyfinPurple) - .frame(width: CGFloat(item.userData?.playedPercentage ?? 0 * 4.45 - 0.16), height: 12) - } - }.padding(12) - } else { - EmptyView() - } - }, alignment: .bottomLeading - ) - .shadow(radius: focused ? 10.0 : 0, y: focused ? 10.0 : 0) - .shadow(radius: focused ? 10.0 : 0, y: focused ? 10.0 : 0) - if inSeasonView ?? false { - Text("\(item.getEpisodeLocator() ?? "") • \(item.name ?? "")") - .font(.callout) - .fontWeight(.semibold) - .lineLimit(1) - .frame(width: 445) - } else { - Text(item.type == "Episode" ? "\(item.seriesName ?? "") • \(item.getEpisodeLocator() ?? "")" : item.name ?? "") - .font(.callout) - .fontWeight(.semibold) - .lineLimit(1) - .frame(width: 445) - } - } - .onChange(of: envFocused) { envFocus in - withAnimation(.linear(duration: 0.15)) { - self.focused = envFocus - } + var body: some View { + VStack { + ImageView(src: item.type == "Episode" && !(inSeasonView ?? false) ? item.getSeriesBackdropImage(maxWidth: 445) : item + .getBackdropImage(maxWidth: 445), + bh: item.type == "Episode" ? item.getSeriesBackdropImageBlurHash() : item.getBackdropImageBlurHash()) + .frame(width: 445, height: 250) + .cornerRadius(10) + .ignoresSafeArea() + .overlay(ZStack { + if item.userData?.played ?? false { + Image(systemName: "circle.fill") + .foregroundColor(.white) + Image(systemName: "checkmark.circle.fill") + .foregroundColor(Color(.systemBlue)) + } + }.padding(2) + .opacity(1), alignment: .topTrailing).opacity(1) + .overlay(ZStack(alignment: .leading) { + if focused && item.userData?.playedPercentage != nil { + Rectangle() + .fill(LinearGradient(gradient: Gradient(colors: [.black, .clear]), + startPoint: .bottom, + endPoint: .top)) + .frame(width: 445, height: 90) + .mask(CutOffShadow()) + VStack(alignment: .leading) { + Text("CONTINUE • \(item.getItemProgressString() ?? "")") + .font(.caption) + .fontWeight(.medium) + .offset(y: 5) + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 6) + .fill(Color.gray) + .opacity(0.4) + .frame(minWidth: 100, maxWidth: .infinity, minHeight: 12, maxHeight: 12) + RoundedRectangle(cornerRadius: 6) + .fill(Color.jellyfinPurple) + .frame(width: CGFloat(item.userData?.playedPercentage ?? 0 * 4.45 - 0.16), height: 12) + } + }.padding(12) + } else { + EmptyView() + } + }, alignment: .bottomLeading) + .shadow(radius: focused ? 10.0 : 0, y: focused ? 10.0 : 0) + .shadow(radius: focused ? 10.0 : 0, y: focused ? 10.0 : 0) + if inSeasonView ?? false { + Text("\(item.getEpisodeLocator() ?? "") • \(item.name ?? "")") + .font(.callout) + .fontWeight(.semibold) + .lineLimit(1) + .frame(width: 445) + } else { + Text(item.type == "Episode" ? "\(item.seriesName ?? "") • \(item.getEpisodeLocator() ?? "")" : item.name ?? "") + .font(.callout) + .fontWeight(.semibold) + .lineLimit(1) + .frame(width: 445) + } + } + .onChange(of: envFocused) { envFocus in + withAnimation(.linear(duration: 0.15)) { + self.focused = envFocus + } - if envFocus == true { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - // your code here - if focused == true { - backgroundURL = item.getBackdropImage(maxWidth: 1080) - BackgroundManager.current.setBackground(to: backgroundURL!, hash: item.getBackdropImageBlurHash()) - } - } - } - } - .scaleEffect(focused ? 1.1 : 1) - } + if envFocus == true { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + // your code here + if focused == true { + backgroundURL = item.getBackdropImage(maxWidth: 1080) + BackgroundManager.current.setBackground(to: backgroundURL!, hash: item.getBackdropImageBlurHash()) + } + } + } + } + .scaleEffect(focused ? 1.1 : 1) + } } diff --git a/Swiftfin tvOS/Components/MediaPlayButtonRowView.swift b/Swiftfin tvOS/Components/MediaPlayButtonRowView.swift index 9e4e67e2..fc3fc5d1 100644 --- a/Swiftfin tvOS/Components/MediaPlayButtonRowView.swift +++ b/Swiftfin tvOS/Components/MediaPlayButtonRowView.swift @@ -1,50 +1,53 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import SwiftUI struct MediaPlayButtonRowView: View { - @EnvironmentObject var itemRouter: ItemCoordinator.Router - @ObservedObject var viewModel: ItemViewModel - @State var wrappedScrollView: UIScrollView? + @EnvironmentObject + var itemRouter: ItemCoordinator.Router + @ObservedObject + var viewModel: ItemViewModel + @State + var wrappedScrollView: UIScrollView? - var body: some View { - HStack { - VStack { - Button { - itemRouter.route(to: \.videoPlayer, viewModel.itemVideoPlayerViewModel!) - } label: { - MediaViewActionButton(icon: "play.fill", scrollView: $wrappedScrollView) - } + var body: some View { + HStack { + VStack { + Button { + itemRouter.route(to: \.videoPlayer, viewModel.itemVideoPlayerViewModel!) + } label: { + MediaViewActionButton(icon: "play.fill", scrollView: $wrappedScrollView) + } - Text(viewModel.item.getItemProgressString() != "" ? "\(viewModel.item.getItemProgressString() ?? "") left" : L10n.play) - .font(.caption) - } - VStack { - Button { - viewModel.updateWatchState() - } label: { - MediaViewActionButton(icon: "eye.fill", scrollView: $wrappedScrollView, iconColor: viewModel.isWatched ? .red : .white) - } - Text(viewModel.isWatched ? "Unwatch" : "Mark Watched") - .font(.caption) - } - VStack { - Button { - viewModel.updateFavoriteState() - } label: { - MediaViewActionButton(icon: "heart.fill", scrollView: $wrappedScrollView, iconColor: viewModel.isFavorited ? .red : .white) - } - Text(viewModel.isFavorited ? "Unfavorite" : "Favorite") - .font(.caption) - } - Spacer() - } - } + Text(viewModel.item.getItemProgressString() != "" ? "\(viewModel.item.getItemProgressString() ?? "") left" : L10n.play) + .font(.caption) + } + VStack { + Button { + viewModel.updateWatchState() + } label: { + MediaViewActionButton(icon: "eye.fill", scrollView: $wrappedScrollView, iconColor: viewModel.isWatched ? .red : .white) + } + Text(viewModel.isWatched ? "Unwatch" : "Mark Watched") + .font(.caption) + } + VStack { + Button { + viewModel.updateFavoriteState() + } label: { + MediaViewActionButton(icon: "heart.fill", scrollView: $wrappedScrollView, + iconColor: viewModel.isFavorited ? .red : .white) + } + Text(viewModel.isFavorited ? "Unfavorite" : "Favorite") + .font(.caption) + } + Spacer() + } + } } diff --git a/Swiftfin tvOS/Components/MediaViewActionButton.swift b/Swiftfin tvOS/Components/MediaViewActionButton.swift index a8f6135d..8ed635c4 100644 --- a/Swiftfin tvOS/Components/MediaViewActionButton.swift +++ b/Swiftfin tvOS/Components/MediaViewActionButton.swift @@ -1,37 +1,38 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import SwiftUI struct MediaViewActionButton: View { - @Environment(\.isFocused) var envFocused: Bool - @State var focused: Bool = false - var icon: String - var scrollView: Binding? - var iconColor: Color? + @Environment(\.isFocused) + var envFocused: Bool + @State + var focused: Bool = false + var icon: String + var scrollView: Binding? + var iconColor: Color? - var body: some View { - Image(systemName: icon) - .foregroundColor(focused ? .black : iconColor ?? .white) - .onChange(of: envFocused) { envFocus in - if envFocus == true { - scrollView?.wrappedValue?.scrollToTop() - DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) { - scrollView?.wrappedValue?.scrollToTop() - } - } + var body: some View { + Image(systemName: icon) + .foregroundColor(focused ? .black : iconColor ?? .white) + .onChange(of: envFocused) { envFocus in + if envFocus == true { + scrollView?.wrappedValue?.scrollToTop() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) { + scrollView?.wrappedValue?.scrollToTop() + } + } - withAnimation(.linear(duration: 0.15)) { - self.focused = envFocus - } - } - .font(.system(size: 40)) - .padding(.vertical, 12).padding(.horizontal, 20) - } + withAnimation(.linear(duration: 0.15)) { + self.focused = envFocus + } + } + .font(.system(size: 40)) + .padding(.vertical, 12).padding(.horizontal, 20) + } } diff --git a/Swiftfin tvOS/Components/PlainLinkButton.swift b/Swiftfin tvOS/Components/PlainLinkButton.swift index 5f680fc0..607433bd 100644 --- a/Swiftfin tvOS/Components/PlainLinkButton.swift +++ b/Swiftfin tvOS/Components/PlainLinkButton.swift @@ -1,29 +1,31 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// -import SwiftUI import JellyfinAPI +import SwiftUI struct PlainLinkButton: View { - @Environment(\.isFocused) var envFocused: Bool - @State var focused: Bool = false - @State var label: String + @Environment(\.isFocused) + var envFocused: Bool + @State + var focused: Bool = false + @State + var label: String - var body: some View { - Text(label) - .fontWeight(focused ? .bold : .regular) - .foregroundColor(.blue) - .onChange(of: envFocused) { envFocus in - withAnimation(.linear(duration: 0.15)) { - self.focused = envFocus - } - } - .scaleEffect(focused ? 1.1 : 1) - } + var body: some View { + Text(label) + .fontWeight(focused ? .bold : .regular) + .foregroundColor(.blue) + .onChange(of: envFocused) { envFocus in + withAnimation(.linear(duration: 0.15)) { + self.focused = envFocus + } + } + .scaleEffect(focused ? 1.1 : 1) + } } diff --git a/Swiftfin tvOS/Components/PortraitItemElement.swift b/Swiftfin tvOS/Components/PortraitItemElement.swift index 1a7ec3c6..044ca526 100644 --- a/Swiftfin tvOS/Components/PortraitItemElement.swift +++ b/Swiftfin tvOS/Components/PortraitItemElement.swift @@ -1,94 +1,95 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// -import SwiftUI import JellyfinAPI +import SwiftUI struct PortraitItemElement: View { - @Environment(\.isFocused) var envFocused: Bool - @State var focused: Bool = false - @State var backgroundURL: URL? + @Environment(\.isFocused) + var envFocused: Bool + @State + var focused: Bool = false + @State + var backgroundURL: URL? - var item: BaseItemDto + var item: BaseItemDto - var body: some View { - VStack { - ImageView(src: item.type == "Episode" ? item.getSeriesPrimaryImage(maxWidth: 200) : item.getPrimaryImage(maxWidth: 200), bh: item.type == "Episode" ? item.getSeriesPrimaryImageBlurHash() : item.getPrimaryImageBlurHash()) - .frame(width: 200, height: 300) - .cornerRadius(10) - .shadow(radius: focused ? 10.0 : 0) - .shadow(radius: focused ? 10.0 : 0) - .overlay( - ZStack { - if item.userData?.isFavorite ?? false { - Image(systemName: "circle.fill") - .foregroundColor(.white) - .opacity(0.6) - Image(systemName: "heart.fill") - .foregroundColor(Color(.systemRed)) - .font(.system(size: 10)) - } - } - .padding(2) - .opacity(1), alignment: .bottomLeading) - .overlay( - ZStack { - if item.userData?.played ?? false { - Image(systemName: "circle.fill") - .foregroundColor(.white) - Image(systemName: "checkmark.circle.fill") - .foregroundColor(Color(.systemBlue)) - } else { - if item.userData?.unplayedItemCount != nil { - Image(systemName: "circle.fill") - .foregroundColor(Color(.systemBlue)) - Text(String(item.userData!.unplayedItemCount ?? 0)) - .foregroundColor(.white) - .font(.caption2) - } - } - }.padding(2) - .opacity(1), alignment: .topTrailing).opacity(1) - Text(item.title) - .frame(width: 200, height: 30, alignment: .center) - if item.type == "Movie" || item.type == "Series" { - Text("\(String(item.productionYear ?? 0)) • \(item.officialRating ?? "N/A")") - .foregroundColor(.secondary) - .font(.caption) - .fontWeight(.medium) - } else if item.type == "Season" { - Text("\(item.name ?? "") • \(String(item.productionYear ?? 0))") - .foregroundColor(.secondary) - .font(.caption) - .fontWeight(.medium) - } else { - Text(L10n.seasonAndEpisode(String(item.parentIndexNumber ?? 0), String(item.indexNumber ?? 0))) - .foregroundColor(.secondary) - .font(.caption) - .fontWeight(.medium) - } - } - .onChange(of: envFocused) { envFocus in - withAnimation(.linear(duration: 0.15)) { - self.focused = envFocus - } + var body: some View { + VStack { + ImageView(src: item.type == "Episode" ? item.getSeriesPrimaryImage(maxWidth: 200) : item.getPrimaryImage(maxWidth: 200), + bh: item.type == "Episode" ? item.getSeriesPrimaryImageBlurHash() : item.getPrimaryImageBlurHash()) + .frame(width: 200, height: 300) + .cornerRadius(10) + .shadow(radius: focused ? 10.0 : 0) + .shadow(radius: focused ? 10.0 : 0) + .overlay(ZStack { + if item.userData?.isFavorite ?? false { + Image(systemName: "circle.fill") + .foregroundColor(.white) + .opacity(0.6) + Image(systemName: "heart.fill") + .foregroundColor(Color(.systemRed)) + .font(.system(size: 10)) + } + } + .padding(2) + .opacity(1), alignment: .bottomLeading) + .overlay(ZStack { + if item.userData?.played ?? false { + Image(systemName: "circle.fill") + .foregroundColor(.white) + Image(systemName: "checkmark.circle.fill") + .foregroundColor(Color(.systemBlue)) + } else { + if item.userData?.unplayedItemCount != nil { + Image(systemName: "circle.fill") + .foregroundColor(Color(.systemBlue)) + Text(String(item.userData!.unplayedItemCount ?? 0)) + .foregroundColor(.white) + .font(.caption2) + } + } + }.padding(2) + .opacity(1), alignment: .topTrailing).opacity(1) + Text(item.title) + .frame(width: 200, height: 30, alignment: .center) + if item.type == "Movie" || item.type == "Series" { + Text("\(String(item.productionYear ?? 0)) • \(item.officialRating ?? "N/A")") + .foregroundColor(.secondary) + .font(.caption) + .fontWeight(.medium) + } else if item.type == "Season" { + Text("\(item.name ?? "") • \(String(item.productionYear ?? 0))") + .foregroundColor(.secondary) + .font(.caption) + .fontWeight(.medium) + } else { + Text(L10n.seasonAndEpisode(String(item.parentIndexNumber ?? 0), String(item.indexNumber ?? 0))) + .foregroundColor(.secondary) + .font(.caption) + .fontWeight(.medium) + } + } + .onChange(of: envFocused) { envFocus in + withAnimation(.linear(duration: 0.15)) { + self.focused = envFocus + } - if envFocus == true { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - // your code here - if focused == true { - backgroundURL = item.getBackdropImage(maxWidth: 1080) - BackgroundManager.current.setBackground(to: backgroundURL!, hash: item.getBackdropImageBlurHash()) - } - } - } - } - .scaleEffect(focused ? 1.1 : 1) - } + if envFocus == true { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + // your code here + if focused == true { + backgroundURL = item.getBackdropImage(maxWidth: 1080) + BackgroundManager.current.setBackground(to: backgroundURL!, hash: item.getBackdropImageBlurHash()) + } + } + } + } + .scaleEffect(focused ? 1.1 : 1) + } } diff --git a/Swiftfin tvOS/Components/PortraitItemsRowView.swift b/Swiftfin tvOS/Components/PortraitItemsRowView.swift index f2711fe1..6d0c43d7 100644 --- a/Swiftfin tvOS/Components/PortraitItemsRowView.swift +++ b/Swiftfin tvOS/Components/PortraitItemsRowView.swift @@ -1,68 +1,69 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// -import SwiftUI import JellyfinAPI +import SwiftUI struct PortraitItemsRowView: View { - - @EnvironmentObject var itemRouter: ItemCoordinator.Router - - let rowTitle: String - let items: [BaseItemDto] - let showItemTitles: Bool - let selectedAction: (BaseItemDto) -> Void - - init(rowTitle: String, - items: [BaseItemDto], - showItemTitles: Bool = true, - selectedAction: @escaping (BaseItemDto) -> Void) { - self.rowTitle = rowTitle - self.items = items - self.showItemTitles = showItemTitles - self.selectedAction = selectedAction - } - - var body: some View { - VStack(alignment: .leading) { - - Text(rowTitle) - .font(.title3) - .padding(.horizontal, 50) - - ScrollView(.horizontal) { - HStack(alignment: .top) { - ForEach(items, id: \.self) { item in - - VStack(spacing: 15) { - Button { - selectedAction(item) - } label: { - ImageView(src: item.portraitHeaderViewURL(maxWidth: 257)) - .frame(width: 257, height: 380) - } - .frame(height: 380) - .buttonStyle(PlainButtonStyle()) - - if showItemTitles { - Text(item.title) - .lineLimit(2) - .frame(width: 257) - } - } - } - } - .padding(.horizontal, 50) - .padding(.vertical) - } - .edgesIgnoringSafeArea(.horizontal) - } - .focusSection() - } + + @EnvironmentObject + var itemRouter: ItemCoordinator.Router + + let rowTitle: String + let items: [BaseItemDto] + let showItemTitles: Bool + let selectedAction: (BaseItemDto) -> Void + + init(rowTitle: String, + items: [BaseItemDto], + showItemTitles: Bool = true, + selectedAction: @escaping (BaseItemDto) -> Void) + { + self.rowTitle = rowTitle + self.items = items + self.showItemTitles = showItemTitles + self.selectedAction = selectedAction + } + + var body: some View { + VStack(alignment: .leading) { + + Text(rowTitle) + .font(.title3) + .padding(.horizontal, 50) + + ScrollView(.horizontal) { + HStack(alignment: .top) { + ForEach(items, id: \.self) { item in + + VStack(spacing: 15) { + Button { + selectedAction(item) + } label: { + ImageView(src: item.portraitHeaderViewURL(maxWidth: 257)) + .frame(width: 257, height: 380) + } + .frame(height: 380) + .buttonStyle(PlainButtonStyle()) + + if showItemTitles { + Text(item.title) + .lineLimit(2) + .frame(width: 257) + } + } + } + } + .padding(.horizontal, 50) + .padding(.vertical) + } + .edgesIgnoringSafeArea(.horizontal) + } + .focusSection() + } } diff --git a/Swiftfin tvOS/Components/PublicUserButton.swift b/Swiftfin tvOS/Components/PublicUserButton.swift index b7da051e..4b07dfe9 100644 --- a/Swiftfin tvOS/Components/PublicUserButton.swift +++ b/Swiftfin tvOS/Components/PublicUserButton.swift @@ -1,45 +1,46 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// -import SwiftUI -import JellyfinAPI import CoreMedia +import JellyfinAPI +import SwiftUI struct PublicUserButton: View { - @Environment(\.isFocused) var envFocused: Bool - @State var focused: Bool = false - var publicUser: UserDto + @Environment(\.isFocused) + var envFocused: Bool + @State + var focused: Bool = false + var publicUser: UserDto - var body: some View { - VStack { - if publicUser.primaryImageTag != nil { - ImageView(src: URL(string: "\(SessionManager.main.currentLogin.server.currentURI)/Users/\(publicUser.id ?? "")/Images/Primary?width=500&quality=80&tag=\(publicUser.primaryImageTag!)")!) - .frame(width: 250, height: 250) - .cornerRadius(125.0) - } else { - Image(systemName: "person.fill") - .foregroundColor(Color(red: 1, green: 1, blue: 1).opacity(0.8)) - .font(.system(size: 35)) - .frame(width: 250, height: 250) - .background(Color(red: 98 / 255, green: 121 / 255, blue: 205 / 255)) - .cornerRadius(125.0) - .shadow(radius: 6) - } - if focused { - Text(publicUser.name ?? "").font(.headline).fontWeight(.semibold) - } else { - Spacer().frame(height: 60) - } - }.onChange(of: envFocused) { envFocus in - withAnimation(.linear(duration: 0.15)) { - self.focused = envFocus - } - }.scaleEffect(focused ? 1.1 : 1) - } + var body: some View { + VStack { + if publicUser.primaryImageTag != nil { + ImageView(src: URL(string: "\(SessionManager.main.currentLogin.server.currentURI)/Users/\(publicUser.id ?? "")/Images/Primary?width=500&quality=80&tag=\(publicUser.primaryImageTag!)")!) + .frame(width: 250, height: 250) + .cornerRadius(125.0) + } else { + Image(systemName: "person.fill") + .foregroundColor(Color(red: 1, green: 1, blue: 1).opacity(0.8)) + .font(.system(size: 35)) + .frame(width: 250, height: 250) + .background(Color(red: 98 / 255, green: 121 / 255, blue: 205 / 255)) + .cornerRadius(125.0) + .shadow(radius: 6) + } + if focused { + Text(publicUser.name ?? "").font(.headline).fontWeight(.semibold) + } else { + Spacer().frame(height: 60) + } + }.onChange(of: envFocused) { envFocus in + withAnimation(.linear(duration: 0.15)) { + self.focused = envFocus + } + }.scaleEffect(focused ? 1.1 : 1) + } } diff --git a/Swiftfin tvOS/Components/SFSymbolButton.swift b/Swiftfin tvOS/Components/SFSymbolButton.swift index e2683b3b..bffd314e 100644 --- a/Swiftfin tvOS/Components/SFSymbolButton.swift +++ b/Swiftfin tvOS/Components/SFSymbolButton.swift @@ -1,56 +1,53 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import SwiftUI import UIKit struct SFSymbolButton: UIViewRepresentable { - - let systemName: String - let action: () -> Void - private let pointSize: CGFloat - - init(systemName: String, pointSize: CGFloat = 24, action: @escaping () -> Void) { - self.systemName = systemName - self.action = action - self.pointSize = pointSize - } - - func makeUIView(context: Context) -> some UIButton { - var configuration = UIButton.Configuration.plain() - configuration.cornerStyle = .capsule - - let buttonAction = UIAction(title: "") { action in - self.action() - } - - let button = UIButton(configuration: configuration, primaryAction: buttonAction) - - let symbolImageConfig = UIImage.SymbolConfiguration(pointSize: pointSize) - let symbolImage = UIImage(systemName: systemName, withConfiguration: symbolImageConfig) - - button.setImage(symbolImage, for: .normal) - - return button - } - - func updateUIView(_ uiView: UIViewType, context: Context) { - - } + + let systemName: String + let action: () -> Void + private let pointSize: CGFloat + + init(systemName: String, pointSize: CGFloat = 24, action: @escaping () -> Void) { + self.systemName = systemName + self.action = action + self.pointSize = pointSize + } + + func makeUIView(context: Context) -> some UIButton { + var configuration = UIButton.Configuration.plain() + configuration.cornerStyle = .capsule + + let buttonAction = UIAction(title: "") { _ in + self.action() + } + + let button = UIButton(configuration: configuration, primaryAction: buttonAction) + + let symbolImageConfig = UIImage.SymbolConfiguration(pointSize: pointSize) + let symbolImage = UIImage(systemName: systemName, withConfiguration: symbolImageConfig) + + button.setImage(symbolImage, for: .normal) + + return button + } + + func updateUIView(_ uiView: UIViewType, context: Context) {} } extension SFSymbolButton: Hashable { - static func == (lhs: SFSymbolButton, rhs: SFSymbolButton) -> Bool { - return lhs.systemName == rhs.systemName - } - - func hash(into hasher: inout Hasher) { - hasher.combine(systemName) - } + static func == (lhs: SFSymbolButton, rhs: SFSymbolButton) -> Bool { + lhs.systemName == rhs.systemName + } + + func hash(into hasher: inout Hasher) { + hasher.combine(systemName) + } } diff --git a/Swiftfin tvOS/Components/SingleSeasonEpisodesRowView.swift b/Swiftfin tvOS/Components/SingleSeasonEpisodesRowView.swift index 55bba152..1d06169c 100644 --- a/Swiftfin tvOS/Components/SingleSeasonEpisodesRowView.swift +++ b/Swiftfin tvOS/Components/SingleSeasonEpisodesRowView.swift @@ -1,122 +1,123 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import JellyfinAPI import SwiftUI struct SingleSeasonEpisodesRowView: View { - - @EnvironmentObject var itemRouter: ItemCoordinator.Router - @ObservedObject var viewModel: SingleSeasonEpisodesRowViewModel - - var body: some View { - VStack(alignment: .leading) { - - Text("Episodes") - .font(.title3) - .padding(.horizontal, 50) - - ScrollView(.horizontal) { - ScrollViewReader { reader in - HStack(alignment: .top) { - if viewModel.isLoading { - VStack(alignment: .leading) { - ZStack { - Color.secondary.ignoresSafeArea() - - ProgressView() - } - .mask(Rectangle().frame(width: 500, height: 280)) - .frame(width: 500, height: 280) + @EnvironmentObject + var itemRouter: ItemCoordinator.Router + @ObservedObject + var viewModel: SingleSeasonEpisodesRowViewModel - VStack(alignment: .leading) { - Text("S-E-") - .font(.caption) - .foregroundColor(.secondary) - Text("--") - .font(.footnote) - .padding(.bottom, 1) - Text("--") - .font(.caption) - .fontWeight(.light) - .lineLimit(4) - } - .padding(.horizontal) + var body: some View { + VStack(alignment: .leading) { - Spacer() - } - .frame(width: 500) - .focusable() - } else if viewModel.episodes.isEmpty { - VStack(alignment: .leading) { + Text("Episodes") + .font(.title3) + .padding(.horizontal, 50) - Color.secondary - .mask(Rectangle().frame(width: 500, height: 280)) - .frame(width: 500, height: 280) + ScrollView(.horizontal) { + ScrollViewReader { _ in + HStack(alignment: .top) { + if viewModel.isLoading { + VStack(alignment: .leading) { - VStack(alignment: .leading) { - Text("--") - .font(.caption) - .foregroundColor(.secondary) - Text("No episodes available") - .font(.footnote) - .padding(.bottom, 1) - } - .padding(.horizontal) + ZStack { + Color.secondary.ignoresSafeArea() - Spacer() - } - .frame(width: 500) - .focusable() - } else { - ForEach(viewModel.episodes, id:\.self) { episode in - Button { - itemRouter.route(to: \.item, episode) - } label: { - HStack(alignment: .top) { - VStack(alignment: .leading) { + ProgressView() + } + .mask(Rectangle().frame(width: 500, height: 280)) + .frame(width: 500, height: 280) - ImageView(src: episode.getBackdropImage(maxWidth: 445), - bh: episode.getBackdropImageBlurHash()) - .mask(Rectangle().frame(width: 500, height: 280)) - .frame(width: 500, height: 280) + VStack(alignment: .leading) { + Text("S-E-") + .font(.caption) + .foregroundColor(.secondary) + Text("--") + .font(.footnote) + .padding(.bottom, 1) + Text("--") + .font(.caption) + .fontWeight(.light) + .lineLimit(4) + } + .padding(.horizontal) - VStack(alignment: .leading) { - Text(episode.getEpisodeLocator() ?? "") - .font(.caption) - .foregroundColor(.secondary) - Text(episode.name ?? "") - .font(.footnote) - .padding(.bottom, 1) - Text(episode.overview ?? "") - .font(.caption) - .fontWeight(.light) - .lineLimit(4) - } - .padding(.horizontal) + Spacer() + } + .frame(width: 500) + .focusable() + } else if viewModel.episodes.isEmpty { + VStack(alignment: .leading) { - Spacer() - } - .frame(width: 500) - } - } - .buttonStyle(PlainButtonStyle()) - .id(episode.name) - } - } - } - .padding(.horizontal, 50) - .padding(.vertical) - } - .edgesIgnoringSafeArea(.horizontal) - } - } - } + Color.secondary + .mask(Rectangle().frame(width: 500, height: 280)) + .frame(width: 500, height: 280) + + VStack(alignment: .leading) { + Text("--") + .font(.caption) + .foregroundColor(.secondary) + Text("No episodes available") + .font(.footnote) + .padding(.bottom, 1) + } + .padding(.horizontal) + + Spacer() + } + .frame(width: 500) + .focusable() + } else { + ForEach(viewModel.episodes, id: \.self) { episode in + Button { + itemRouter.route(to: \.item, episode) + } label: { + HStack(alignment: .top) { + VStack(alignment: .leading) { + + ImageView(src: episode.getBackdropImage(maxWidth: 445), + bh: episode.getBackdropImageBlurHash()) + .mask(Rectangle().frame(width: 500, height: 280)) + .frame(width: 500, height: 280) + + VStack(alignment: .leading) { + Text(episode.getEpisodeLocator() ?? "") + .font(.caption) + .foregroundColor(.secondary) + Text(episode.name ?? "") + .font(.footnote) + .padding(.bottom, 1) + Text(episode.overview ?? "") + .font(.caption) + .fontWeight(.light) + .lineLimit(4) + } + .padding(.horizontal) + + Spacer() + } + .frame(width: 500) + } + } + .buttonStyle(PlainButtonStyle()) + .id(episode.name) + } + } + } + .padding(.horizontal, 50) + .padding(.vertical) + } + .edgesIgnoringSafeArea(.horizontal) + } + } + } } diff --git a/Swiftfin tvOS/ImageButtonStyle.swift b/Swiftfin tvOS/ImageButtonStyle.swift index a73874ed..e60f6ce0 100644 --- a/Swiftfin tvOS/ImageButtonStyle.swift +++ b/Swiftfin tvOS/ImageButtonStyle.swift @@ -1,14 +1,21 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + struct ImageButtonStyle: ButtonStyle { - let focused: Bool - func makeBody(configuration: Configuration) -> some View { - configuration - .label - .padding(6) - .foregroundColor(Color.white) - .background(Color.blue) - .cornerRadius(100) - .shadow(color: .black, radius: self.focused ? 20 : 0, x: 0, y: 0) // 0 - - } + let focused: Bool + func makeBody(configuration: Configuration) -> some View { + configuration + .label + .padding(6) + .foregroundColor(Color.white) + .background(Color.blue) + .cornerRadius(100) + .shadow(color: .black, radius: self.focused ? 20 : 0, x: 0, y: 0) // 0 + } } diff --git a/Swiftfin tvOS/Views/BasicAppSettingsView.swift b/Swiftfin tvOS/Views/BasicAppSettingsView.swift index 328cb191..1c988e24 100644 --- a/Swiftfin tvOS/Views/BasicAppSettingsView.swift +++ b/Swiftfin tvOS/Views/BasicAppSettingsView.swift @@ -1,11 +1,10 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Defaults import Stinsen @@ -13,38 +12,42 @@ import SwiftUI struct BasicAppSettingsView: View { - @EnvironmentObject var basicAppSettingsRouter: BasicAppSettingsCoordinator.Router - @ObservedObject var viewModel: BasicAppSettingsViewModel - @State var resetTapped: Bool = false + @EnvironmentObject + var basicAppSettingsRouter: BasicAppSettingsCoordinator.Router + @ObservedObject + var viewModel: BasicAppSettingsViewModel + @State + var resetTapped: Bool = false - @Default(.appAppearance) var appAppearance + @Default(.appAppearance) + var appAppearance - var body: some View { - Form { - Section { - Picker(L10n.appearance, selection: $appAppearance) { - ForEach(self.viewModel.appearances, id: \.self) { appearance in - Text(appearance.localizedName).tag(appearance.rawValue) - } - } - } header: { - L10n.accessibility.text - } + var body: some View { + Form { + Section { + Picker(L10n.appearance, selection: $appAppearance) { + ForEach(self.viewModel.appearances, id: \.self) { appearance in + Text(appearance.localizedName).tag(appearance.rawValue) + } + } + } header: { + L10n.accessibility.text + } - Button { - resetTapped = true - } label: { - L10n.reset.text - } - } - .alert(L10n.reset, isPresented: $resetTapped, actions: { - Button(role: .destructive) { - viewModel.resetAppSettings() - basicAppSettingsRouter.dismissCoordinator() - } label: { - L10n.reset.text - } - }) - .navigationTitle("Settings") - } + Button { + resetTapped = true + } label: { + L10n.reset.text + } + } + .alert(L10n.reset, isPresented: $resetTapped, actions: { + Button(role: .destructive) { + viewModel.resetAppSettings() + basicAppSettingsRouter.dismissCoordinator() + } label: { + L10n.reset.text + } + }) + .navigationTitle("Settings") + } } diff --git a/Swiftfin tvOS/Views/ConnectToServerView.swift b/Swiftfin tvOS/Views/ConnectToServerView.swift index 74aec005..7a1319a9 100644 --- a/Swiftfin tvOS/Views/ConnectToServerView.swift +++ b/Swiftfin tvOS/Views/ConnectToServerView.swift @@ -1,82 +1,85 @@ -/* - * JellyfinPlayer/Swiftfin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Defaults -import SwiftUI import Stinsen +import SwiftUI struct ConnectToServerView: View { - @StateObject var viewModel: ConnectToServerViewModel - @State var uri = "" - - @Default(.defaultHTTPScheme) var defaultHTTPScheme + @StateObject + var viewModel: ConnectToServerViewModel + @State + var uri = "" - var body: some View { - List { - Section { - TextField(L10n.serverURL, text: $uri) - .disableAutocorrection(true) - .autocapitalization(.none) - .keyboardType(.URL) - .onAppear { - if uri == "" { - uri = "\(defaultHTTPScheme.rawValue)://" - } - } - - Button { - viewModel.connectToServer(uri: uri) - } label: { - HStack { - L10n.connect.text - Spacer() - if viewModel.isLoading { - ProgressView() - } - } - } - .disabled(viewModel.isLoading || uri.isEmpty) - } header: { - Text("Connect to a Jellyfin server") - } + @Default(.defaultHTTPScheme) + var defaultHTTPScheme - Section(header: L10n.localServers.text) { - if viewModel.searching { - ProgressView() - } - ForEach(viewModel.discoveredServers.sorted(by: { $0.name < $1.name }), id: \.id) { discoveredServer in - Button(action: { - viewModel.connectToServer(uri: discoveredServer.url.absoluteString) - }, label: { - HStack { - Text(discoveredServer.name) - .font(.headline) - Text("• \(discoveredServer.host)") - .font(.subheadline) - .foregroundColor(.secondary) - Spacer() - if viewModel.isLoading { - ProgressView() - } - } + var body: some View { + List { + Section { + TextField(L10n.serverURL, text: $uri) + .disableAutocorrection(true) + .autocapitalization(.none) + .keyboardType(.URL) + .onAppear { + if uri == "" { + uri = "\(defaultHTTPScheme.rawValue)://" + } + } - }) - } - } - .onAppear(perform: self.viewModel.discoverServers) - .headerProminence(.increased) - } - .alert(item: $viewModel.errorMessage) { _ in - Alert(title: Text(viewModel.alertTitle), - message: Text(viewModel.errorMessage?.displayMessage ?? "Unknown Error"), - dismissButton: .cancel()) - } - .navigationTitle(L10n.connect) - } + Button { + viewModel.connectToServer(uri: uri) + } label: { + HStack { + L10n.connect.text + Spacer() + if viewModel.isLoading { + ProgressView() + } + } + } + .disabled(viewModel.isLoading || uri.isEmpty) + } header: { + Text("Connect to a Jellyfin server") + } + + Section(header: L10n.localServers.text) { + if viewModel.searching { + ProgressView() + } + ForEach(viewModel.discoveredServers.sorted(by: { $0.name < $1.name }), id: \.id) { discoveredServer in + Button(action: { + viewModel.connectToServer(uri: discoveredServer.url.absoluteString) + }, label: { + HStack { + Text(discoveredServer.name) + .font(.headline) + Text("• \(discoveredServer.host)") + .font(.subheadline) + .foregroundColor(.secondary) + Spacer() + if viewModel.isLoading { + ProgressView() + } + } + + }) + } + } + .onAppear(perform: self.viewModel.discoverServers) + .headerProminence(.increased) + } + .alert(item: $viewModel.errorMessage) { _ in + Alert(title: Text(viewModel.alertTitle), + message: Text(viewModel.errorMessage?.displayMessage ?? "Unknown Error"), + dismissButton: .cancel()) + } + .navigationTitle(L10n.connect) + } } diff --git a/Swiftfin tvOS/Views/ContinueWatchingView/ContinueWatchingCard.swift b/Swiftfin tvOS/Views/ContinueWatchingView/ContinueWatchingCard.swift index 99b400ff..daac3470 100644 --- a/Swiftfin tvOS/Views/ContinueWatchingView/ContinueWatchingCard.swift +++ b/Swiftfin tvOS/Views/ContinueWatchingView/ContinueWatchingCard.swift @@ -1,80 +1,80 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import JellyfinAPI import SwiftUI struct ContinueWatchingCard: View { - - @EnvironmentObject var homeRouter: HomeCoordinator.Router - let item: BaseItemDto - - var body: some View { - VStack(alignment: .leading) { - Button { - homeRouter.route(to: \.modalItem, item) - } label: { - ZStack(alignment: .bottom) { - if item.itemType == .episode { - ImageView(src: item.getSeriesBackdropImage(maxWidth: 500)) - .frame(width: 500, height: 281.25) - } else { - ImageView(src: item.getBackdropImage(maxWidth: 500)) - .frame(width: 500, height: 281.25) - } + @EnvironmentObject + var homeRouter: HomeCoordinator.Router + let item: BaseItemDto - VStack(alignment: .leading, spacing: 0) { - Text(item.getItemProgressString() ?? "") - .font(.subheadline) - .padding(.vertical, 5) - .padding(.leading, 10) - .foregroundColor(.white) + var body: some View { + VStack(alignment: .leading) { + Button { + homeRouter.route(to: \.modalItem, item) + } label: { + ZStack(alignment: .bottom) { - HStack { - Color(UIColor.systemPurple) - .frame(width: 500 * (item.userData?.playedPercentage ?? 0) / 100, height: 13) + if item.itemType == .episode { + ImageView(src: item.getSeriesBackdropImage(maxWidth: 500)) + .frame(width: 500, height: 281.25) + } else { + ImageView(src: item.getBackdropImage(maxWidth: 500)) + .frame(width: 500, height: 281.25) + } - Spacer(minLength: 0) - } - } - .background { - LinearGradient(colors: [.clear, .black.opacity(0.5), .black.opacity(0.7)], - startPoint: .top, - endPoint: .bottom) - .ignoresSafeArea() - } - } - .frame(width: 500, height: 281.25) - } - .buttonStyle(CardButtonStyle()) - .padding(.top) + VStack(alignment: .leading, spacing: 0) { + Text(item.getItemProgressString() ?? "") + .font(.subheadline) + .padding(.vertical, 5) + .padding(.leading, 10) + .foregroundColor(.white) - VStack(alignment: .leading) { - Text("\(item.seriesName ?? item.name ?? "")") - .font(.callout) - .fontWeight(.semibold) - .foregroundColor(.primary) - .lineLimit(1) - .frame(width: 500, alignment: .leading) + HStack { + Color(UIColor.systemPurple) + .frame(width: 500 * (item.userData?.playedPercentage ?? 0) / 100, height: 13) - if item.itemType == .episode { - Text(item.getEpisodeLocator() ?? "") - .font(.callout) - .fontWeight(.medium) - .foregroundColor(.secondary) - .lineLimit(1) - } else { - Text("") - } - } - } - .padding(.vertical) - } + Spacer(minLength: 0) + } + } + .background { + LinearGradient(colors: [.clear, .black.opacity(0.5), .black.opacity(0.7)], + startPoint: .top, + endPoint: .bottom) + .ignoresSafeArea() + } + } + .frame(width: 500, height: 281.25) + } + .buttonStyle(CardButtonStyle()) + .padding(.top) + + VStack(alignment: .leading) { + Text("\(item.seriesName ?? item.name ?? "")") + .font(.callout) + .fontWeight(.semibold) + .foregroundColor(.primary) + .lineLimit(1) + .frame(width: 500, alignment: .leading) + + if item.itemType == .episode { + Text(item.getEpisodeLocator() ?? "") + .font(.callout) + .fontWeight(.medium) + .foregroundColor(.secondary) + .lineLimit(1) + } else { + Text("") + } + } + } + .padding(.vertical) + } } diff --git a/Swiftfin tvOS/Views/ContinueWatchingView/ContinueWatchingView.swift b/Swiftfin tvOS/Views/ContinueWatchingView/ContinueWatchingView.swift index af6fc2c2..107a8b9f 100644 --- a/Swiftfin tvOS/Views/ContinueWatchingView/ContinueWatchingView.swift +++ b/Swiftfin tvOS/Views/ContinueWatchingView/ContinueWatchingView.swift @@ -1,37 +1,38 @@ -/* - * JellyfinPlayer/Swiftfin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// -import SwiftUI -import JellyfinAPI import Combine +import JellyfinAPI import Stinsen +import SwiftUI struct ContinueWatchingView: View { - - @EnvironmentObject var homeRouter: HomeCoordinator.Router - let items: [BaseItemDto] - var body: some View { - VStack(alignment: .leading) { - - L10n.continueWatching.text - .font(.title3) - .fontWeight(.semibold) - .padding(.leading, 50) - - ScrollView(.horizontal, showsIndicators: false) { - LazyHStack(alignment: .top) { - ForEach(items, id: \.self) { item in - ContinueWatchingCard(item: item) - } - } - .padding(.horizontal, 50) - } - } - } + @EnvironmentObject + var homeRouter: HomeCoordinator.Router + let items: [BaseItemDto] + + var body: some View { + VStack(alignment: .leading) { + + L10n.continueWatching.text + .font(.title3) + .fontWeight(.semibold) + .padding(.leading, 50) + + ScrollView(.horizontal, showsIndicators: false) { + LazyHStack(alignment: .top) { + ForEach(items, id: \.self) { item in + ContinueWatchingCard(item: item) + } + } + .padding(.horizontal, 50) + } + } + } } diff --git a/Swiftfin tvOS/Views/HomeView.swift b/Swiftfin tvOS/Views/HomeView.swift index 17f91ca8..435eedbc 100644 --- a/Swiftfin tvOS/Views/HomeView.swift +++ b/Swiftfin tvOS/Views/HomeView.swift @@ -1,79 +1,82 @@ // -/* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Defaults import Foundation -import SwiftUI import JellyfinAPI +import SwiftUI struct HomeView: View { - - @EnvironmentObject var homeRouter: HomeCoordinator.Router - @ObservedObject var viewModel = HomeViewModel() - @Default(.showPosterLabels) var showPosterLabels - @State var showingSettings = false + @EnvironmentObject + var homeRouter: HomeCoordinator.Router + @ObservedObject + var viewModel = HomeViewModel() + @Default(.showPosterLabels) + var showPosterLabels - var body: some View { - if viewModel.isLoading { - ProgressView() - .scaleEffect(2) - } else { - ScrollView { - LazyVStack(alignment: .leading) { - - if viewModel.resumeItems.isEmpty { - HomeCinematicView(items: viewModel.latestAddedItems.map({ .init(item: $0, type: .plain) }), - forcedItemSubtitle: "Recently Added") - - if !viewModel.nextUpItems.isEmpty { - NextUpView(items: viewModel.nextUpItems) - .focusSection() - } - } else { - HomeCinematicView(items: viewModel.resumeItems.map({ .init(item: $0, type: .resume) })) - - if !viewModel.nextUpItems.isEmpty { - NextUpView(items: viewModel.nextUpItems) - .focusSection() - } - - PortraitItemsRowView(rowTitle: "Recently Added", - items: viewModel.latestAddedItems, - showItemTitles: showPosterLabels) { item in - homeRouter.route(to: \.modalItem, item) - } - } + @State + var showingSettings = false - ForEach(viewModel.libraries, id: \.self) { library in - LatestMediaView(viewModel: LatestMediaViewModel(library: library)) - .focusSection() - } - - Spacer(minLength: 100) - - HStack { - Spacer() - - Button { - viewModel.refresh() - } label: { - Text("Refresh") - } - - Spacer() - } - .focusSection() - } - } - .edgesIgnoringSafeArea(.top) - .edgesIgnoringSafeArea(.horizontal) - } - } + var body: some View { + if viewModel.isLoading { + ProgressView() + .scaleEffect(2) + } else { + ScrollView { + LazyVStack(alignment: .leading) { + + if viewModel.resumeItems.isEmpty { + HomeCinematicView(items: viewModel.latestAddedItems.map { .init(item: $0, type: .plain) }, + forcedItemSubtitle: "Recently Added") + + if !viewModel.nextUpItems.isEmpty { + NextUpView(items: viewModel.nextUpItems) + .focusSection() + } + } else { + HomeCinematicView(items: viewModel.resumeItems.map { .init(item: $0, type: .resume) }) + + if !viewModel.nextUpItems.isEmpty { + NextUpView(items: viewModel.nextUpItems) + .focusSection() + } + + PortraitItemsRowView(rowTitle: "Recently Added", + items: viewModel.latestAddedItems, + showItemTitles: showPosterLabels) { item in + homeRouter.route(to: \.modalItem, item) + } + } + + ForEach(viewModel.libraries, id: \.self) { library in + LatestMediaView(viewModel: LatestMediaViewModel(library: library)) + .focusSection() + } + + Spacer(minLength: 100) + + HStack { + Spacer() + + Button { + viewModel.refresh() + } label: { + Text("Refresh") + } + + Spacer() + } + .focusSection() + } + } + .edgesIgnoringSafeArea(.top) + .edgesIgnoringSafeArea(.horizontal) + } + } } diff --git a/Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicCollectionItemView.swift b/Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicCollectionItemView.swift index d96aa9e0..2fe6275f 100644 --- a/Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicCollectionItemView.swift +++ b/Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicCollectionItemView.swift @@ -1,69 +1,72 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Defaults import Introspect import SwiftUI struct CinematicCollectionItemView: View { - - @EnvironmentObject var itemRouter: ItemCoordinator.Router - @ObservedObject var viewModel: CollectionItemViewModel - @State var wrappedScrollView: UIScrollView? - @Default(.showPosterLabels) var showPosterLabels - - var body: some View { - ZStack { - - ImageView(src: viewModel.item.getBackdropImage(maxWidth: 1920), - bh: viewModel.item.getBackdropImageBlurHash()) - .ignoresSafeArea() - - ScrollView { - VStack(spacing: 0) { - - CinematicItemViewTopRow(viewModel: viewModel, - wrappedScrollView: wrappedScrollView, - title: viewModel.item.name ?? "", - showDetails: false) - .focusSection() - .frame(height: UIScreen.main.bounds.height - 10) - ZStack(alignment: .topLeading) { - - Color.black.ignoresSafeArea() - - VStack(alignment: .leading, spacing: 20) { - - CinematicItemAboutView(viewModel: viewModel) - - PortraitItemsRowView(rowTitle: "Items", - items: viewModel.collectionItems) { item in - itemRouter.route(to: \.item, item) - } - - if !viewModel.similarItems.isEmpty { - PortraitItemsRowView(rowTitle: "Recommended", - items: viewModel.similarItems, - showItemTitles: showPosterLabels) { item in - itemRouter.route(to: \.item, item) - } - } - } - .padding(.vertical, 50) - } - } - } - .introspectScrollView { scrollView in - wrappedScrollView = scrollView - } - .ignoresSafeArea() - } - } + @EnvironmentObject + var itemRouter: ItemCoordinator.Router + @ObservedObject + var viewModel: CollectionItemViewModel + @State + var wrappedScrollView: UIScrollView? + @Default(.showPosterLabels) + var showPosterLabels + + var body: some View { + ZStack { + + ImageView(src: viewModel.item.getBackdropImage(maxWidth: 1920), + bh: viewModel.item.getBackdropImageBlurHash()) + .ignoresSafeArea() + + ScrollView { + VStack(spacing: 0) { + + CinematicItemViewTopRow(viewModel: viewModel, + wrappedScrollView: wrappedScrollView, + title: viewModel.item.name ?? "", + showDetails: false) + .focusSection() + .frame(height: UIScreen.main.bounds.height - 10) + + ZStack(alignment: .topLeading) { + + Color.black.ignoresSafeArea() + + VStack(alignment: .leading, spacing: 20) { + + CinematicItemAboutView(viewModel: viewModel) + + PortraitItemsRowView(rowTitle: "Items", + items: viewModel.collectionItems) { item in + itemRouter.route(to: \.item, item) + } + + if !viewModel.similarItems.isEmpty { + PortraitItemsRowView(rowTitle: "Recommended", + items: viewModel.similarItems, + showItemTitles: showPosterLabels) { item in + itemRouter.route(to: \.item, item) + } + } + } + .padding(.vertical, 50) + } + } + } + .introspectScrollView { scrollView in + wrappedScrollView = scrollView + } + .ignoresSafeArea() + } + } } diff --git a/Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicEpisodeItemView.swift b/Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicEpisodeItemView.swift index 3942ec11..5fec9500 100644 --- a/Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicEpisodeItemView.swift +++ b/Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicEpisodeItemView.swift @@ -1,86 +1,89 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Defaults import Introspect import SwiftUI struct CinematicEpisodeItemView: View { - - @EnvironmentObject var itemRouter: ItemCoordinator.Router - @ObservedObject var viewModel: EpisodeItemViewModel - @State var wrappedScrollView: UIScrollView? - @Default(.showPosterLabels) var showPosterLabels - - func generateSubtitle() -> String? { - guard let seriesName = viewModel.item.seriesName, let episodeLocator = viewModel.item.getEpisodeLocator() else { - return nil - } - - return "\(seriesName) - \(episodeLocator)" - } - - var body: some View { - ZStack { - - ImageView(src: viewModel.item.getBackdropImage(maxWidth: 1920), - bh: viewModel.item.getBackdropImageBlurHash()) - .frame(height: UIScreen.main.bounds.height - 10) - .ignoresSafeArea() - - ScrollView { - VStack(spacing: 0) { - - CinematicItemViewTopRow(viewModel: viewModel, - wrappedScrollView: wrappedScrollView, - title: viewModel.item.name ?? "", - subtitle: generateSubtitle()) - .focusSection() - .frame(height: UIScreen.main.bounds.height - 10) - ZStack(alignment: .topLeading) { - - Color.black.ignoresSafeArea() - .frame(minHeight: UIScreen.main.bounds.height) - - VStack(alignment: .leading, spacing: 20) { - - CinematicItemAboutView(viewModel: viewModel) - - EpisodesRowView(viewModel: EpisodesRowViewModel(episodeItemViewModel: viewModel)) - .focusSection() - - if let seriesItem = viewModel.series { - PortraitItemsRowView(rowTitle: "Series", - items: [seriesItem]) { seriesItem in - itemRouter.route(to: \.item, seriesItem) - } - } - - if !viewModel.similarItems.isEmpty { - PortraitItemsRowView(rowTitle: "Recommended", - items: viewModel.similarItems, - showItemTitles: showPosterLabels) { item in - itemRouter.route(to: \.item, item) - } - } - - ItemDetailsView(viewModel: viewModel) - } - .padding(.top, 50) - } - } - } - .introspectScrollView { scrollView in - wrappedScrollView = scrollView - } - .ignoresSafeArea() - } - } + @EnvironmentObject + var itemRouter: ItemCoordinator.Router + @ObservedObject + var viewModel: EpisodeItemViewModel + @State + var wrappedScrollView: UIScrollView? + @Default(.showPosterLabels) + var showPosterLabels + + func generateSubtitle() -> String? { + guard let seriesName = viewModel.item.seriesName, let episodeLocator = viewModel.item.getEpisodeLocator() else { + return nil + } + + return "\(seriesName) - \(episodeLocator)" + } + + var body: some View { + ZStack { + + ImageView(src: viewModel.item.getBackdropImage(maxWidth: 1920), + bh: viewModel.item.getBackdropImageBlurHash()) + .frame(height: UIScreen.main.bounds.height - 10) + .ignoresSafeArea() + + ScrollView { + VStack(spacing: 0) { + + CinematicItemViewTopRow(viewModel: viewModel, + wrappedScrollView: wrappedScrollView, + title: viewModel.item.name ?? "", + subtitle: generateSubtitle()) + .focusSection() + .frame(height: UIScreen.main.bounds.height - 10) + + ZStack(alignment: .topLeading) { + + Color.black.ignoresSafeArea() + .frame(minHeight: UIScreen.main.bounds.height) + + VStack(alignment: .leading, spacing: 20) { + + CinematicItemAboutView(viewModel: viewModel) + + EpisodesRowView(viewModel: EpisodesRowViewModel(episodeItemViewModel: viewModel)) + .focusSection() + + if let seriesItem = viewModel.series { + PortraitItemsRowView(rowTitle: "Series", + items: [seriesItem]) { seriesItem in + itemRouter.route(to: \.item, seriesItem) + } + } + + if !viewModel.similarItems.isEmpty { + PortraitItemsRowView(rowTitle: "Recommended", + items: viewModel.similarItems, + showItemTitles: showPosterLabels) { item in + itemRouter.route(to: \.item, item) + } + } + + ItemDetailsView(viewModel: viewModel) + } + .padding(.top, 50) + } + } + } + .introspectScrollView { scrollView in + wrappedScrollView = scrollView + } + .ignoresSafeArea() + } + } } diff --git a/Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicItemAboutView.swift b/Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicItemAboutView.swift index 89977d74..6e20b039 100644 --- a/Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicItemAboutView.swift +++ b/Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicItemAboutView.swift @@ -1,42 +1,43 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import SwiftUI struct CinematicItemAboutView: View { - - @ObservedObject var viewModel: ItemViewModel - @FocusState private var focused: Bool - - var body: some View { - HStack(alignment: .top, spacing: 10) { - ImageView(src: viewModel.item.portraitHeaderViewURL(maxWidth: 257)) - .portraitPoster(width: 257) - - ZStack(alignment: .topLeading) { - Color(UIColor.darkGray).opacity(focused ? 0.2 : 0) - .cornerRadius(9.5) - .frame(height: 385.5) - - VStack(alignment: .leading) { - Text("About") - .font(.title3) - Text(viewModel.item.overview ?? "No details available") - .padding(.top, 2) - .lineLimit(7) - } - .padding() - } - } - .focusable() - .focused($focused) - .padding(.horizontal, 50) - } + @ObservedObject + var viewModel: ItemViewModel + @FocusState + private var focused: Bool + + var body: some View { + HStack(alignment: .top, spacing: 10) { + ImageView(src: viewModel.item.portraitHeaderViewURL(maxWidth: 257)) + .portraitPoster(width: 257) + + ZStack(alignment: .topLeading) { + Color(UIColor.darkGray).opacity(focused ? 0.2 : 0) + .cornerRadius(9.5) + .frame(height: 385.5) + + VStack(alignment: .leading) { + Text("About") + .font(.title3) + + Text(viewModel.item.overview ?? "No details available") + .padding(.top, 2) + .lineLimit(7) + } + .padding() + } + } + .focusable() + .focused($focused) + .padding(.horizontal, 50) + } } diff --git a/Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicItemViewTopRow.swift b/Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicItemViewTopRow.swift index 25965d4d..e49caa5c 100644 --- a/Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicItemViewTopRow.swift +++ b/Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicItemViewTopRow.swift @@ -1,154 +1,161 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import SwiftUI struct CinematicItemViewTopRow: View { - - @EnvironmentObject var itemRouter: ItemCoordinator.Router - @ObservedObject var viewModel: ItemViewModel - @Environment(\.isFocused) var envFocused: Bool - @State var focused: Bool = false - @State var wrappedScrollView: UIScrollView? - @State var title: String - @State var subtitle: String? - let showDetails: Bool - - init(viewModel: ItemViewModel, - wrappedScrollView: UIScrollView? = nil, - title: String, - subtitle: String? = nil, - showDetails: Bool = true) { - self.viewModel = viewModel - self.wrappedScrollView = wrappedScrollView - self.title = title - self.subtitle = subtitle - self.showDetails = showDetails - } - - var body: some View { - ZStack(alignment: .bottom) { - LinearGradient(gradient: Gradient(colors: [.clear, .black.opacity(0.8), .black]), - startPoint: .top, - endPoint: .bottom) - .ignoresSafeArea() - .frame(height: 210) - - VStack { - Spacer() - - HStack(alignment: .bottom) { - VStack(alignment: .leading) { - HStack(alignment: .PlayInformationAlignmentGuide) { - - // MARK: Play - Button { - if let itemVideoPlayerViewModel = viewModel.itemVideoPlayerViewModel { - itemRouter.route(to: \.videoPlayer, itemVideoPlayerViewModel) - } else { - LogManager.shared.log.error("Attempted to play item but no playback information available") - } - } label: { - HStack(spacing: 15) { - Image(systemName: "play.fill") - .foregroundColor(viewModel.playButtonItem == nil ? Color(UIColor.secondaryLabel) : Color.black) - .font(.title3) - Text(viewModel.playButtonText()) - .foregroundColor(viewModel.playButtonItem == nil ? Color(UIColor.secondaryLabel) : Color.black) - .fontWeight(.semibold) - } - .frame(width: 230, height: 100) - .background(viewModel.playButtonItem == nil ? Color.secondarySystemFill : Color.white) - .cornerRadius(10) - } - .buttonStyle(CardButtonStyle()) - } - } - - VStack(alignment: .leading, spacing: 5) { - Text(title) - .font(.title2) - .lineLimit(2) - - if let subtitle = subtitle { - Text(subtitle) - } - - HStack(alignment: .PlayInformationAlignmentGuide, spacing: 20) { - - if showDetails { - if viewModel.item.itemType == .series { - if let airTime = viewModel.item.airTime { - Text(airTime) - .font(.subheadline) - .fontWeight(.medium) - } - } else { - if let runtime = viewModel.item.getItemRuntime() { - Text(runtime) - .font(.subheadline) - .fontWeight(.medium) - } - - if let productionYear = viewModel.item.productionYear { - Text(String(productionYear)) - .font(.subheadline) - .fontWeight(.medium) - .lineLimit(1) - } - } - - if let officialRating = viewModel.item.officialRating { - Text(officialRating) - .font(.subheadline) - .fontWeight(.semibold) - .lineLimit(1) - .padding(EdgeInsets(top: 1, leading: 4, bottom: 1, trailing: 4)) - .overlay(RoundedRectangle(cornerRadius: 2) - .stroke(Color.secondary, lineWidth: 1)) - } - } else { - Text("") - } - } - .foregroundColor(.secondary) - } - - Spacer() - } - .padding(.horizontal, 50) - .padding(.bottom, 50) - } - - } - .onChange(of: envFocused) { envFocus in - if envFocus == true { - wrappedScrollView?.scrollToTop() - DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) { - wrappedScrollView?.scrollToTop() - } - } - withAnimation(.linear(duration: 0.15)) { - self.focused = envFocus - } - } - } + @EnvironmentObject + var itemRouter: ItemCoordinator.Router + @ObservedObject + var viewModel: ItemViewModel + @Environment(\.isFocused) + var envFocused: Bool + @State + var focused: Bool = false + @State + var wrappedScrollView: UIScrollView? + @State + var title: String + @State + var subtitle: String? + let showDetails: Bool + + init(viewModel: ItemViewModel, + wrappedScrollView: UIScrollView? = nil, + title: String, + subtitle: String? = nil, + showDetails: Bool = true) + { + self.viewModel = viewModel + self.wrappedScrollView = wrappedScrollView + self.title = title + self.subtitle = subtitle + self.showDetails = showDetails + } + + var body: some View { + ZStack(alignment: .bottom) { + LinearGradient(gradient: Gradient(colors: [.clear, .black.opacity(0.8), .black]), + startPoint: .top, + endPoint: .bottom) + .ignoresSafeArea() + .frame(height: 210) + + VStack { + Spacer() + + HStack(alignment: .bottom) { + VStack(alignment: .leading) { + HStack(alignment: .PlayInformationAlignmentGuide) { + + // MARK: Play + + Button { + if let itemVideoPlayerViewModel = viewModel.itemVideoPlayerViewModel { + itemRouter.route(to: \.videoPlayer, itemVideoPlayerViewModel) + } else { + LogManager.shared.log.error("Attempted to play item but no playback information available") + } + } label: { + HStack(spacing: 15) { + Image(systemName: "play.fill") + .foregroundColor(viewModel.playButtonItem == nil ? Color(UIColor.secondaryLabel) : Color.black) + .font(.title3) + Text(viewModel.playButtonText()) + .foregroundColor(viewModel.playButtonItem == nil ? Color(UIColor.secondaryLabel) : Color.black) + .fontWeight(.semibold) + } + .frame(width: 230, height: 100) + .background(viewModel.playButtonItem == nil ? Color.secondarySystemFill : Color.white) + .cornerRadius(10) + } + .buttonStyle(CardButtonStyle()) + } + } + + VStack(alignment: .leading, spacing: 5) { + Text(title) + .font(.title2) + .lineLimit(2) + + if let subtitle = subtitle { + Text(subtitle) + } + + HStack(alignment: .PlayInformationAlignmentGuide, spacing: 20) { + + if showDetails { + if viewModel.item.itemType == .series { + if let airTime = viewModel.item.airTime { + Text(airTime) + .font(.subheadline) + .fontWeight(.medium) + } + } else { + if let runtime = viewModel.item.getItemRuntime() { + Text(runtime) + .font(.subheadline) + .fontWeight(.medium) + } + + if let productionYear = viewModel.item.productionYear { + Text(String(productionYear)) + .font(.subheadline) + .fontWeight(.medium) + .lineLimit(1) + } + } + + if let officialRating = viewModel.item.officialRating { + Text(officialRating) + .font(.subheadline) + .fontWeight(.semibold) + .lineLimit(1) + .padding(EdgeInsets(top: 1, leading: 4, bottom: 1, trailing: 4)) + .overlay(RoundedRectangle(cornerRadius: 2) + .stroke(Color.secondary, lineWidth: 1)) + } + } else { + Text("") + } + } + .foregroundColor(.secondary) + } + + Spacer() + } + .padding(.horizontal, 50) + .padding(.bottom, 50) + } + } + .onChange(of: envFocused) { envFocus in + if envFocus == true { + wrappedScrollView?.scrollToTop() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) { + wrappedScrollView?.scrollToTop() + } + } + + withAnimation(.linear(duration: 0.15)) { + self.focused = envFocus + } + } + } } extension VerticalAlignment { - - private struct PlayInformationAlignment: AlignmentID { - static func defaultValue(in context: ViewDimensions) -> CGFloat { - context[VerticalAlignment.bottom] - } - } - - static let PlayInformationAlignmentGuide = VerticalAlignment(PlayInformationAlignment.self) + + private struct PlayInformationAlignment: AlignmentID { + static func defaultValue(in context: ViewDimensions) -> CGFloat { + context[VerticalAlignment.bottom] + } + } + + static let PlayInformationAlignmentGuide = VerticalAlignment(PlayInformationAlignment.self) } diff --git a/Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicItemViewTopRowButton.swift b/Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicItemViewTopRowButton.swift index bc799724..d6775d7f 100644 --- a/Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicItemViewTopRowButton.swift +++ b/Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicItemViewTopRowButton.swift @@ -1,48 +1,51 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import SwiftUI struct CinematicItemViewTopRowButton: View { - @Environment(\.isFocused) var envFocused: Bool - @State var focused: Bool = false - @State var wrappedScrollView: UIScrollView? - var content: () -> Content - - @FocusState private var buttonFocused: Bool - - var body: some View { - content() - .focused($buttonFocused) - .onChange(of: envFocused) { envFocus in - if envFocus == true { - wrappedScrollView?.scrollToTop() - DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) { - wrappedScrollView?.scrollToTop() - } - } + @Environment(\.isFocused) + var envFocused: Bool + @State + var focused: Bool = false + @State + var wrappedScrollView: UIScrollView? + var content: () -> Content - withAnimation(.linear(duration: 0.15)) { - self.focused = envFocus - } - } - .onChange(of: buttonFocused) { newValue in - if newValue { - wrappedScrollView?.scrollToTop() - DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) { - wrappedScrollView?.scrollToTop() - } - - withAnimation(.linear(duration: 0.15)) { - self.focused = newValue - } - } - } - } + @FocusState + private var buttonFocused: Bool + + var body: some View { + content() + .focused($buttonFocused) + .onChange(of: envFocused) { envFocus in + if envFocus == true { + wrappedScrollView?.scrollToTop() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) { + wrappedScrollView?.scrollToTop() + } + } + + withAnimation(.linear(duration: 0.15)) { + self.focused = envFocus + } + } + .onChange(of: buttonFocused) { newValue in + if newValue { + wrappedScrollView?.scrollToTop() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) { + wrappedScrollView?.scrollToTop() + } + + withAnimation(.linear(duration: 0.15)) { + self.focused = newValue + } + } + } + } } diff --git a/Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicMovieItemView.swift b/Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicMovieItemView.swift index 0ca3865d..445822dd 100644 --- a/Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicMovieItemView.swift +++ b/Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicMovieItemView.swift @@ -1,67 +1,69 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Defaults import Introspect import SwiftUI struct CinematicMovieItemView: View { - - @EnvironmentObject var itemRouter: ItemCoordinator.Router - @ObservedObject var viewModel: MovieItemViewModel - @State var wrappedScrollView: UIScrollView? - @Default(.showPosterLabels) var showPosterLabels - - var body: some View { - ZStack { - - ImageView(src: viewModel.item.getBackdropImage(maxWidth: 1920), - bh: viewModel.item.getBackdropImageBlurHash()) - .ignoresSafeArea() - - ScrollView { - VStack(spacing: 0) { - - CinematicItemViewTopRow(viewModel: viewModel, - wrappedScrollView: wrappedScrollView, - title: viewModel.item.name ?? "", - subtitle: nil) - .focusSection() - .frame(height: UIScreen.main.bounds.height - 10) - ZStack(alignment: .topLeading) { - - Color.black.ignoresSafeArea() - - VStack(alignment: .leading, spacing: 20) { - - CinematicItemAboutView(viewModel: viewModel) - - if !viewModel.similarItems.isEmpty { - PortraitItemsRowView(rowTitle: "Recommended", - items: viewModel.similarItems, - showItemTitles: showPosterLabels) { item in - itemRouter.route(to: \.item, item) - } - } - - ItemDetailsView(viewModel: viewModel) - } - .padding(.top, 50) - - } - } - } - .introspectScrollView { scrollView in - wrappedScrollView = scrollView - } - .ignoresSafeArea() - } - } + @EnvironmentObject + var itemRouter: ItemCoordinator.Router + @ObservedObject + var viewModel: MovieItemViewModel + @State + var wrappedScrollView: UIScrollView? + @Default(.showPosterLabels) + var showPosterLabels + + var body: some View { + ZStack { + + ImageView(src: viewModel.item.getBackdropImage(maxWidth: 1920), + bh: viewModel.item.getBackdropImageBlurHash()) + .ignoresSafeArea() + + ScrollView { + VStack(spacing: 0) { + + CinematicItemViewTopRow(viewModel: viewModel, + wrappedScrollView: wrappedScrollView, + title: viewModel.item.name ?? "", + subtitle: nil) + .focusSection() + .frame(height: UIScreen.main.bounds.height - 10) + + ZStack(alignment: .topLeading) { + + Color.black.ignoresSafeArea() + + VStack(alignment: .leading, spacing: 20) { + + CinematicItemAboutView(viewModel: viewModel) + + if !viewModel.similarItems.isEmpty { + PortraitItemsRowView(rowTitle: "Recommended", + items: viewModel.similarItems, + showItemTitles: showPosterLabels) { item in + itemRouter.route(to: \.item, item) + } + } + + ItemDetailsView(viewModel: viewModel) + } + .padding(.top, 50) + } + } + } + .introspectScrollView { scrollView in + wrappedScrollView = scrollView + } + .ignoresSafeArea() + } + } } diff --git a/Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicSeasonItemView.swift b/Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicSeasonItemView.swift index c527ce65..a3c4190a 100644 --- a/Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicSeasonItemView.swift +++ b/Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicSeasonItemView.swift @@ -1,80 +1,83 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Defaults import SwiftUI struct CinematicSeasonItemView: View { - - @EnvironmentObject var itemRouter: ItemCoordinator.Router - @ObservedObject var viewModel: SeasonItemViewModel - @State var wrappedScrollView: UIScrollView? - @Default(.showPosterLabels) var showPosterLabels - - var body: some View { - ZStack { - - ImageView(src: viewModel.item.getBackdropImage(maxWidth: 1920), bh: viewModel.item.getBackdropImageBlurHash()) - .ignoresSafeArea() - - ScrollView { - VStack(spacing: 0) { - - if let seriesItem = viewModel.seriesItem { - CinematicItemViewTopRow(viewModel: viewModel, - wrappedScrollView: wrappedScrollView, - title: viewModel.item.name ?? "", - subtitle: seriesItem.name) - .focusSection() - .frame(height: UIScreen.main.bounds.height - 10) - } else { - CinematicItemViewTopRow(viewModel: viewModel, - wrappedScrollView: wrappedScrollView, - title: viewModel.item.name ?? "") - .focusSection() - .frame(height: UIScreen.main.bounds.height - 10) - } - - ZStack(alignment: .topLeading) { - - Color.black.ignoresSafeArea() - .frame(minHeight: UIScreen.main.bounds.height) - - VStack(alignment: .leading, spacing: 20) { - - CinematicItemAboutView(viewModel: viewModel) - - SingleSeasonEpisodesRowView(viewModel: SingleSeasonEpisodesRowViewModel(seasonItemViewModel: viewModel)) - - if let seriesItem = viewModel.seriesItem { - PortraitItemsRowView(rowTitle: "Series", - items: [seriesItem]) { seriesItem in - itemRouter.route(to: \.item, seriesItem) - } - } - - if !viewModel.similarItems.isEmpty { - PortraitItemsRowView(rowTitle: "Recommended", - items: viewModel.similarItems, - showItemTitles: showPosterLabels) { item in - itemRouter.route(to: \.item, item) - } - } - } - .padding(.vertical, 50) - } - } - } - .introspectScrollView { scrollView in - wrappedScrollView = scrollView - } - .ignoresSafeArea() - } - } + + @EnvironmentObject + var itemRouter: ItemCoordinator.Router + @ObservedObject + var viewModel: SeasonItemViewModel + @State + var wrappedScrollView: UIScrollView? + @Default(.showPosterLabels) + var showPosterLabels + + var body: some View { + ZStack { + + ImageView(src: viewModel.item.getBackdropImage(maxWidth: 1920), bh: viewModel.item.getBackdropImageBlurHash()) + .ignoresSafeArea() + + ScrollView { + VStack(spacing: 0) { + + if let seriesItem = viewModel.seriesItem { + CinematicItemViewTopRow(viewModel: viewModel, + wrappedScrollView: wrappedScrollView, + title: viewModel.item.name ?? "", + subtitle: seriesItem.name) + .focusSection() + .frame(height: UIScreen.main.bounds.height - 10) + } else { + CinematicItemViewTopRow(viewModel: viewModel, + wrappedScrollView: wrappedScrollView, + title: viewModel.item.name ?? "") + .focusSection() + .frame(height: UIScreen.main.bounds.height - 10) + } + + ZStack(alignment: .topLeading) { + + Color.black.ignoresSafeArea() + .frame(minHeight: UIScreen.main.bounds.height) + + VStack(alignment: .leading, spacing: 20) { + + CinematicItemAboutView(viewModel: viewModel) + + SingleSeasonEpisodesRowView(viewModel: SingleSeasonEpisodesRowViewModel(seasonItemViewModel: viewModel)) + + if let seriesItem = viewModel.seriesItem { + PortraitItemsRowView(rowTitle: "Series", + items: [seriesItem]) { seriesItem in + itemRouter.route(to: \.item, seriesItem) + } + } + + if !viewModel.similarItems.isEmpty { + PortraitItemsRowView(rowTitle: "Recommended", + items: viewModel.similarItems, + showItemTitles: showPosterLabels) { item in + itemRouter.route(to: \.item, item) + } + } + } + .padding(.vertical, 50) + } + } + } + .introspectScrollView { scrollView in + wrappedScrollView = scrollView + } + .ignoresSafeArea() + } + } } diff --git a/Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicSeriesItemView.swift b/Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicSeriesItemView.swift index 6ff5e24e..6ce1edbd 100644 --- a/Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicSeriesItemView.swift +++ b/Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicSeriesItemView.swift @@ -1,69 +1,72 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Defaults import SwiftUI struct CinematicSeriesItemView: View { - - @EnvironmentObject var itemRouter: ItemCoordinator.Router - @ObservedObject var viewModel: SeriesItemViewModel - @State var wrappedScrollView: UIScrollView? - @Default(.showPosterLabels) var showPosterLabels - - var body: some View { - ZStack { - - ImageView(src: viewModel.item.getBackdropImage(maxWidth: 1920), bh: viewModel.item.getBackdropImageBlurHash()) - .ignoresSafeArea() - - ScrollView { - VStack(spacing: 0) { - - CinematicItemViewTopRow(viewModel: viewModel, - wrappedScrollView: wrappedScrollView, - title: viewModel.item.name ?? "", - subtitle: nil) - .focusSection() - .frame(height: UIScreen.main.bounds.height - 10) - - ZStack(alignment: .topLeading) { - - Color.black.ignoresSafeArea() - .frame(minHeight: UIScreen.main.bounds.height) - - VStack(alignment: .leading, spacing: 20) { - - CinematicItemAboutView(viewModel: viewModel) - - PortraitItemsRowView(rowTitle: "Seasons", - items: viewModel.seasons, - showItemTitles: showPosterLabels) { season in - itemRouter.route(to: \.item, season) - } - - if !viewModel.similarItems.isEmpty { - PortraitItemsRowView(rowTitle: "Recommended", - items: viewModel.similarItems, - showItemTitles: showPosterLabels) { item in - itemRouter.route(to: \.item, item) - } - } - } - .padding(.vertical, 50) - } - } - } - .introspectScrollView { scrollView in - wrappedScrollView = scrollView - } - .ignoresSafeArea() - } - } + + @EnvironmentObject + var itemRouter: ItemCoordinator.Router + @ObservedObject + var viewModel: SeriesItemViewModel + @State + var wrappedScrollView: UIScrollView? + @Default(.showPosterLabels) + var showPosterLabels + + var body: some View { + ZStack { + + ImageView(src: viewModel.item.getBackdropImage(maxWidth: 1920), bh: viewModel.item.getBackdropImageBlurHash()) + .ignoresSafeArea() + + ScrollView { + VStack(spacing: 0) { + + CinematicItemViewTopRow(viewModel: viewModel, + wrappedScrollView: wrappedScrollView, + title: viewModel.item.name ?? "", + subtitle: nil) + .focusSection() + .frame(height: UIScreen.main.bounds.height - 10) + + ZStack(alignment: .topLeading) { + + Color.black.ignoresSafeArea() + .frame(minHeight: UIScreen.main.bounds.height) + + VStack(alignment: .leading, spacing: 20) { + + CinematicItemAboutView(viewModel: viewModel) + + PortraitItemsRowView(rowTitle: "Seasons", + items: viewModel.seasons, + showItemTitles: showPosterLabels) { season in + itemRouter.route(to: \.item, season) + } + + if !viewModel.similarItems.isEmpty { + PortraitItemsRowView(rowTitle: "Recommended", + items: viewModel.similarItems, + showItemTitles: showPosterLabels) { item in + itemRouter.route(to: \.item, item) + } + } + } + .padding(.vertical, 50) + } + } + } + .introspectScrollView { scrollView in + wrappedScrollView = scrollView + } + .ignoresSafeArea() + } + } } diff --git a/Swiftfin tvOS/Views/ItemView/CompactItemView/EpisodeItemView.swift b/Swiftfin tvOS/Views/ItemView/CompactItemView/EpisodeItemView.swift index cb30a302..b6fc94b3 100644 --- a/Swiftfin tvOS/Views/ItemView/CompactItemView/EpisodeItemView.swift +++ b/Swiftfin tvOS/Views/ItemView/CompactItemView/EpisodeItemView.swift @@ -1,157 +1,161 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// -import SwiftUI import JellyfinAPI +import SwiftUI struct EpisodeItemView: View { - - @EnvironmentObject var itemRouter: ItemCoordinator.Router - @ObservedObject var viewModel: EpisodeItemViewModel - @State var actors: [BaseItemPerson] = [] - @State var studio: String? - @State var director: String? + @EnvironmentObject + var itemRouter: ItemCoordinator.Router + @ObservedObject + var viewModel: EpisodeItemViewModel - func onAppear() { - actors = [] - director = nil - studio = nil - var actor_index = 0 - viewModel.item.people?.forEach { person in - if person.type == "Actor" { - if actor_index < 4 { - actors.append(person) - } - actor_index = actor_index + 1 - } - if person.type == "Director" { - director = person.name ?? "" - } - } + @State + var actors: [BaseItemPerson] = [] + @State + var studio: String? + @State + var director: String? - studio = viewModel.item.studios?.first?.name ?? nil - } + func onAppear() { + actors = [] + director = nil + studio = nil + var actor_index = 0 + viewModel.item.people?.forEach { person in + if person.type == "Actor" { + if actor_index < 4 { + actors.append(person) + } + actor_index = actor_index + 1 + } + if person.type == "Director" { + director = person.name ?? "" + } + } - var body: some View { - ZStack { - ImageView(src: viewModel.item.getBackdropImage(maxWidth: 1920), bh: viewModel.item.getBackdropImageBlurHash()) - .opacity(0.4) - .ignoresSafeArea() - LazyVStack(alignment: .leading) { - Text(viewModel.item.name ?? "") - .font(.title) - .fontWeight(.bold) - .foregroundColor(.primary) - Text(viewModel.item.seriesName ?? "") - .fontWeight(.bold) - .foregroundColor(.primary) - HStack { - if viewModel.item.productionYear != nil { - Text(String(viewModel.item.productionYear!)).font(.subheadline) - .fontWeight(.medium) - .foregroundColor(.secondary) - .lineLimit(1) - } - if let runtime = viewModel.item.getItemRuntime() { - Text(runtime).font(.subheadline) - .fontWeight(.medium) - .foregroundColor(.secondary) - .lineLimit(1) - } - - if viewModel.item.officialRating != nil { - Text(viewModel.item.officialRating!).font(.subheadline) - .fontWeight(.semibold) - .foregroundColor(.secondary) - .lineLimit(1) - .padding(EdgeInsets(top: 1, leading: 4, bottom: 1, trailing: 4)) - .overlay(RoundedRectangle(cornerRadius: 2) - .stroke(Color.secondary, lineWidth: 1)) - } - Spacer() - }.padding(.top, -15) + studio = viewModel.item.studios?.first?.name ?? nil + } - HStack(alignment: .top) { - VStack(alignment: .trailing) { - if studio != nil { - L10n.studio.text - .font(.body) - .fontWeight(.semibold) - .foregroundColor(.primary) - Text(studio!) - .font(.body) - .fontWeight(.semibold) - .foregroundColor(.secondary) - .padding(.bottom, 40) - } + var body: some View { + ZStack { + ImageView(src: viewModel.item.getBackdropImage(maxWidth: 1920), bh: viewModel.item.getBackdropImageBlurHash()) + .opacity(0.4) + .ignoresSafeArea() + LazyVStack(alignment: .leading) { + Text(viewModel.item.name ?? "") + .font(.title) + .fontWeight(.bold) + .foregroundColor(.primary) + Text(viewModel.item.seriesName ?? "") + .fontWeight(.bold) + .foregroundColor(.primary) + HStack { + if viewModel.item.productionYear != nil { + Text(String(viewModel.item.productionYear!)).font(.subheadline) + .fontWeight(.medium) + .foregroundColor(.secondary) + .lineLimit(1) + } + if let runtime = viewModel.item.getItemRuntime() { + Text(runtime).font(.subheadline) + .fontWeight(.medium) + .foregroundColor(.secondary) + .lineLimit(1) + } - if director != nil { - L10n.director.text - .font(.body) - .fontWeight(.semibold) - .foregroundColor(.primary) - Text(director!) - .font(.body) - .fontWeight(.semibold) - .foregroundColor(.secondary) - .padding(.bottom, 40) - } + if viewModel.item.officialRating != nil { + Text(viewModel.item.officialRating!).font(.subheadline) + .fontWeight(.semibold) + .foregroundColor(.secondary) + .lineLimit(1) + .padding(EdgeInsets(top: 1, leading: 4, bottom: 1, trailing: 4)) + .overlay(RoundedRectangle(cornerRadius: 2) + .stroke(Color.secondary, lineWidth: 1)) + } + Spacer() + }.padding(.top, -15) - if !actors.isEmpty { - L10n.cast.text - .font(.body) - .fontWeight(.semibold) - .foregroundColor(.primary) - ForEach(actors, id: \.id) { person in - Text(person.name!) - .font(.body) - .fontWeight(.semibold) - .foregroundColor(.secondary) - } - } - Spacer() - } - VStack(alignment: .leading) { - Text(viewModel.item.overview ?? "") - .font(.body) - .fontWeight(.medium) - .foregroundColor(.primary) - - MediaPlayButtonRowView(viewModel: viewModel) - .environmentObject(itemRouter) - } - }.padding(.top, 50) + HStack(alignment: .top) { + VStack(alignment: .trailing) { + if studio != nil { + L10n.studio.text + .font(.body) + .fontWeight(.semibold) + .foregroundColor(.primary) + Text(studio!) + .font(.body) + .fontWeight(.semibold) + .foregroundColor(.secondary) + .padding(.bottom, 40) + } - if !viewModel.similarItems.isEmpty { - L10n.moreLikeThis.text - .font(.headline) - .fontWeight(.semibold) - ScrollView(.horizontal) { - LazyHStack { - Spacer().frame(width: 45) - ForEach(viewModel.similarItems, id: \.id) { similarItem in - Button { - itemRouter.route(to: \.item, similarItem) - } label: { - PortraitItemElement(item: similarItem) - } - .buttonStyle(PlainNavigationLinkButtonStyle()) - } - Spacer().frame(width: 45) - } - }.padding(EdgeInsets(top: -30, leading: -90, bottom: 0, trailing: -90)) - .frame(height: 360) - } - Spacer() - Spacer() - }.padding(EdgeInsets(top: 90, leading: 90, bottom: 0, trailing: 90)) - }.onAppear(perform: onAppear) - } + if director != nil { + L10n.director.text + .font(.body) + .fontWeight(.semibold) + .foregroundColor(.primary) + Text(director!) + .font(.body) + .fontWeight(.semibold) + .foregroundColor(.secondary) + .padding(.bottom, 40) + } + + if !actors.isEmpty { + L10n.cast.text + .font(.body) + .fontWeight(.semibold) + .foregroundColor(.primary) + ForEach(actors, id: \.id) { person in + Text(person.name!) + .font(.body) + .fontWeight(.semibold) + .foregroundColor(.secondary) + } + } + Spacer() + } + VStack(alignment: .leading) { + Text(viewModel.item.overview ?? "") + .font(.body) + .fontWeight(.medium) + .foregroundColor(.primary) + + MediaPlayButtonRowView(viewModel: viewModel) + .environmentObject(itemRouter) + } + }.padding(.top, 50) + + if !viewModel.similarItems.isEmpty { + L10n.moreLikeThis.text + .font(.headline) + .fontWeight(.semibold) + ScrollView(.horizontal) { + LazyHStack { + Spacer().frame(width: 45) + ForEach(viewModel.similarItems, id: \.id) { similarItem in + Button { + itemRouter.route(to: \.item, similarItem) + } label: { + PortraitItemElement(item: similarItem) + } + .buttonStyle(PlainNavigationLinkButtonStyle()) + } + Spacer().frame(width: 45) + } + }.padding(EdgeInsets(top: -30, leading: -90, bottom: 0, trailing: -90)) + .frame(height: 360) + } + Spacer() + Spacer() + }.padding(EdgeInsets(top: 90, leading: 90, bottom: 0, trailing: 90)) + }.onAppear(perform: onAppear) + } } diff --git a/Swiftfin tvOS/Views/ItemView/CompactItemView/MovieItemView.swift b/Swiftfin tvOS/Views/ItemView/CompactItemView/MovieItemView.swift index dbe04c5e..cb19aba6 100644 --- a/Swiftfin tvOS/Views/ItemView/CompactItemView/MovieItemView.swift +++ b/Swiftfin tvOS/Views/ItemView/CompactItemView/MovieItemView.swift @@ -1,172 +1,178 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// -import SwiftUI import JellyfinAPI +import SwiftUI struct MovieItemView: View { - - @EnvironmentObject var itemRouter: ItemCoordinator.Router - @ObservedObject var viewModel: MovieItemViewModel - @State var actors: [BaseItemPerson] = [] - @State var studio: String? - @State var director: String? - @State var wrappedScrollView: UIScrollView? + @EnvironmentObject + var itemRouter: ItemCoordinator.Router + @ObservedObject + var viewModel: MovieItemViewModel - @Namespace private var namespace + @State + var actors: [BaseItemPerson] = [] + @State + var studio: String? + @State + var director: String? + @State + var wrappedScrollView: UIScrollView? - func onAppear() { - actors = [] - director = nil - studio = nil - var actor_index = 0 - viewModel.item.people?.forEach { person in - if person.type == "Actor" { - if actor_index < 4 { - actors.append(person) - } - actor_index = actor_index + 1 - } - if person.type == "Director" { - director = person.name ?? "" - } - } + @Namespace + private var namespace - studio = viewModel.item.studios?.first?.name ?? nil - } + func onAppear() { + actors = [] + director = nil + studio = nil + var actor_index = 0 + viewModel.item.people?.forEach { person in + if person.type == "Actor" { + if actor_index < 4 { + actors.append(person) + } + actor_index = actor_index + 1 + } + if person.type == "Director" { + director = person.name ?? "" + } + } - var body: some View { - ZStack { - ImageView(src: viewModel.item.getBackdropImage(maxWidth: 1920), bh: viewModel.item.getBackdropImageBlurHash()) - .opacity(0.4) - .ignoresSafeArea() - ScrollView { - LazyVStack(alignment: .leading) { - Text(viewModel.item.name ?? "") - .font(.title) - .fontWeight(.bold) - .foregroundColor(.primary) - HStack { - if viewModel.item.productionYear != nil { - Text(String(viewModel.item.productionYear!)).font(.subheadline) - .fontWeight(.medium) - .foregroundColor(.secondary) - .lineLimit(1) - } - if let runtime = viewModel.item.getItemRuntime() { - Text(runtime).font(.subheadline) - .fontWeight(.medium) - .foregroundColor(.secondary) - .lineLimit(1) - } - if viewModel.item.officialRating != nil { - Text(viewModel.item.officialRating!).font(.subheadline) - .fontWeight(.semibold) - .foregroundColor(.secondary) - .lineLimit(1) - .padding(EdgeInsets(top: 1, leading: 4, bottom: 1, trailing: 4)) - .overlay(RoundedRectangle(cornerRadius: 2) - .stroke(Color.secondary, lineWidth: 1)) - } - } + studio = viewModel.item.studios?.first?.name ?? nil + } - HStack { - VStack(alignment: .trailing) { - if studio != nil { - L10n.studio.text - .font(.body) - .fontWeight(.semibold) - .foregroundColor(.primary) - Text(studio!) - .font(.body) - .fontWeight(.semibold) - .foregroundColor(.secondary) - .padding(.bottom, 40) - } + var body: some View { + ZStack { + ImageView(src: viewModel.item.getBackdropImage(maxWidth: 1920), bh: viewModel.item.getBackdropImageBlurHash()) + .opacity(0.4) + .ignoresSafeArea() + ScrollView { + LazyVStack(alignment: .leading) { + Text(viewModel.item.name ?? "") + .font(.title) + .fontWeight(.bold) + .foregroundColor(.primary) + HStack { + if viewModel.item.productionYear != nil { + Text(String(viewModel.item.productionYear!)).font(.subheadline) + .fontWeight(.medium) + .foregroundColor(.secondary) + .lineLimit(1) + } + if let runtime = viewModel.item.getItemRuntime() { + Text(runtime).font(.subheadline) + .fontWeight(.medium) + .foregroundColor(.secondary) + .lineLimit(1) + } + if viewModel.item.officialRating != nil { + Text(viewModel.item.officialRating!).font(.subheadline) + .fontWeight(.semibold) + .foregroundColor(.secondary) + .lineLimit(1) + .padding(EdgeInsets(top: 1, leading: 4, bottom: 1, trailing: 4)) + .overlay(RoundedRectangle(cornerRadius: 2) + .stroke(Color.secondary, lineWidth: 1)) + } + } - if director != nil { - L10n.director.text - .font(.body) - .fontWeight(.semibold) - .foregroundColor(.primary) - Text(director!) - .font(.body) - .fontWeight(.semibold) - .foregroundColor(.secondary) - .padding(.bottom, 40) - } + HStack { + VStack(alignment: .trailing) { + if studio != nil { + L10n.studio.text + .font(.body) + .fontWeight(.semibold) + .foregroundColor(.primary) + Text(studio!) + .font(.body) + .fontWeight(.semibold) + .foregroundColor(.secondary) + .padding(.bottom, 40) + } - if !actors.isEmpty { - L10n.cast.text - .font(.body) - .fontWeight(.semibold) - .foregroundColor(.primary) - ForEach(actors, id: \.id) { person in - Text(person.name!) - .font(.body) - .fontWeight(.semibold) - .foregroundColor(.secondary) - } - } - Spacer() - } - VStack(alignment: .leading) { - if !(viewModel.item.taglines ?? []).isEmpty { - Text(viewModel.item.taglines?.first ?? "") - .font(.body) - .italic() - .fontWeight(.medium) - .foregroundColor(.primary) - } - Text(viewModel.item.overview ?? "") - .font(.body) - .fontWeight(.medium) - .foregroundColor(.primary) + if director != nil { + L10n.director.text + .font(.body) + .fontWeight(.semibold) + .foregroundColor(.primary) + Text(director!) + .font(.body) + .fontWeight(.semibold) + .foregroundColor(.secondary) + .padding(.bottom, 40) + } - MediaPlayButtonRowView(viewModel: viewModel, wrappedScrollView: wrappedScrollView) - .padding(.top, 15) - } - }.padding(.top, 50) + if !actors.isEmpty { + L10n.cast.text + .font(.body) + .fontWeight(.semibold) + .foregroundColor(.primary) + ForEach(actors, id: \.id) { person in + Text(person.name!) + .font(.body) + .fontWeight(.semibold) + .foregroundColor(.secondary) + } + } + Spacer() + } + VStack(alignment: .leading) { + if !(viewModel.item.taglines ?? []).isEmpty { + Text(viewModel.item.taglines?.first ?? "") + .font(.body) + .italic() + .fontWeight(.medium) + .foregroundColor(.primary) + } + Text(viewModel.item.overview ?? "") + .font(.body) + .fontWeight(.medium) + .foregroundColor(.primary) - if !viewModel.similarItems.isEmpty { - L10n.moreLikeThis.text - .font(.headline) - .fontWeight(.semibold) - ScrollView(.horizontal) { - LazyHStack { - Spacer().frame(width: 45) - ForEach(viewModel.similarItems, id: \.id) { similarItem in - Button { - itemRouter.route(to: \.item, similarItem) - } label: { - PortraitItemElement(item: similarItem) - } - .buttonStyle(PlainNavigationLinkButtonStyle()) - } - Spacer().frame(width: 45) - } - }.padding(EdgeInsets(top: -30, leading: -90, bottom: 0, trailing: -90)) - .frame(height: 360) - } - }.padding(EdgeInsets(top: 90, leading: 90, bottom: 0, trailing: 90)) - }.introspectScrollView { scrollView in - wrappedScrollView = scrollView - } - }.onAppear(perform: onAppear) - .focusScope(namespace) - } + MediaPlayButtonRowView(viewModel: viewModel, wrappedScrollView: wrappedScrollView) + .padding(.top, 15) + } + }.padding(.top, 50) + + if !viewModel.similarItems.isEmpty { + L10n.moreLikeThis.text + .font(.headline) + .fontWeight(.semibold) + ScrollView(.horizontal) { + LazyHStack { + Spacer().frame(width: 45) + ForEach(viewModel.similarItems, id: \.id) { similarItem in + Button { + itemRouter.route(to: \.item, similarItem) + } label: { + PortraitItemElement(item: similarItem) + } + .buttonStyle(PlainNavigationLinkButtonStyle()) + } + Spacer().frame(width: 45) + } + }.padding(EdgeInsets(top: -30, leading: -90, bottom: 0, trailing: -90)) + .frame(height: 360) + } + }.padding(EdgeInsets(top: 90, leading: 90, bottom: 0, trailing: 90)) + }.introspectScrollView { scrollView in + wrappedScrollView = scrollView + } + }.onAppear(perform: onAppear) + .focusScope(namespace) + } } extension UIScrollView { - func scrollToTop() { - let desiredOffset = CGPoint(x: 0, y: 0) - setContentOffset(desiredOffset, animated: true) - } + func scrollToTop() { + let desiredOffset = CGPoint(x: 0, y: 0) + setContentOffset(desiredOffset, animated: true) + } } diff --git a/Swiftfin tvOS/Views/ItemView/CompactItemView/SeasonItemView.swift b/Swiftfin tvOS/Views/ItemView/CompactItemView/SeasonItemView.swift index 85631bd7..33a40736 100644 --- a/Swiftfin tvOS/Views/ItemView/CompactItemView/SeasonItemView.swift +++ b/Swiftfin tvOS/Views/ItemView/CompactItemView/SeasonItemView.swift @@ -1,125 +1,131 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// -import SwiftUI import JellyfinAPI +import SwiftUI struct SeasonItemView: View { - - @EnvironmentObject var itemRouter: ItemCoordinator.Router - @ObservedObject var viewModel: SeasonItemViewModel - @State var wrappedScrollView: UIScrollView? - @Environment(\.resetFocus) var resetFocus - @Namespace private var namespace + @EnvironmentObject + var itemRouter: ItemCoordinator.Router + @ObservedObject + var viewModel: SeasonItemViewModel + @State + var wrappedScrollView: UIScrollView? - var body: some View { - ZStack { - ImageView(src: viewModel.item.getSeriesBackdropImage(maxWidth: 1920), bh: viewModel.item.getSeriesBackdropImageBlurHash()) - .opacity(0.4) - .ignoresSafeArea() - ScrollView { - LazyVStack(alignment: .leading) { - Text("\(viewModel.item.seriesName ?? "") • \(viewModel.item.name ?? "")") - .font(.title) - .fontWeight(.bold) - .foregroundColor(.primary) - HStack { - if viewModel.item.productionYear != nil { - Text(String(viewModel.item.productionYear!)).font(.subheadline) - .fontWeight(.medium) - .foregroundColor(.secondary) - .lineLimit(1) - } - if viewModel.item.officialRating != nil { - Text(viewModel.item.officialRating!).font(.subheadline) - .fontWeight(.semibold) - .foregroundColor(.secondary) - .lineLimit(1) - .padding(EdgeInsets(top: 1, leading: 4, bottom: 1, trailing: 4)) - .overlay(RoundedRectangle(cornerRadius: 2) - .stroke(Color.secondary, lineWidth: 1)) - } - if viewModel.item.communityRating != nil { - HStack { - Image(systemName: "star.fill") - .foregroundColor(.yellow) - .font(.subheadline) - Text(String(viewModel.item.communityRating!)).font(.subheadline) - .fontWeight(.semibold) - .foregroundColor(.secondary) - .lineLimit(1) - } - } - } + @Environment(\.resetFocus) + var resetFocus + @Namespace + private var namespace - VStack(alignment: .leading) { - if !(viewModel.item.taglines ?? []).isEmpty { - Text(viewModel.item.taglines?.first ?? "") - .font(.body) - .italic() - .fontWeight(.medium) - .foregroundColor(.primary) - } - Text(viewModel.item.overview ?? "") - .font(.body) - .fontWeight(.medium) - .foregroundColor(.primary) + var body: some View { + ZStack { + ImageView(src: viewModel.item.getSeriesBackdropImage(maxWidth: 1920), bh: viewModel.item.getSeriesBackdropImageBlurHash()) + .opacity(0.4) + .ignoresSafeArea() + ScrollView { + LazyVStack(alignment: .leading) { + Text("\(viewModel.item.seriesName ?? "") • \(viewModel.item.name ?? "")") + .font(.title) + .fontWeight(.bold) + .foregroundColor(.primary) + HStack { + if viewModel.item.productionYear != nil { + Text(String(viewModel.item.productionYear!)).font(.subheadline) + .fontWeight(.medium) + .foregroundColor(.secondary) + .lineLimit(1) + } + if viewModel.item.officialRating != nil { + Text(viewModel.item.officialRating!).font(.subheadline) + .fontWeight(.semibold) + .foregroundColor(.secondary) + .lineLimit(1) + .padding(EdgeInsets(top: 1, leading: 4, bottom: 1, trailing: 4)) + .overlay(RoundedRectangle(cornerRadius: 2) + .stroke(Color.secondary, lineWidth: 1)) + } + if viewModel.item.communityRating != nil { + HStack { + Image(systemName: "star.fill") + .foregroundColor(.yellow) + .font(.subheadline) + Text(String(viewModel.item.communityRating!)).font(.subheadline) + .fontWeight(.semibold) + .foregroundColor(.secondary) + .lineLimit(1) + } + } + } - HStack { - VStack { - Button { - viewModel.updateFavoriteState() - } label: { - MediaViewActionButton(icon: "heart.fill", scrollView: $wrappedScrollView, iconColor: viewModel.isFavorited ? .red : .white) - }.prefersDefaultFocus(in: namespace) - Text(viewModel.isFavorited ? "Unfavorite" : "Favorite") - .font(.caption) - } + VStack(alignment: .leading) { + if !(viewModel.item.taglines ?? []).isEmpty { + Text(viewModel.item.taglines?.first ?? "") + .font(.body) + .italic() + .fontWeight(.medium) + .foregroundColor(.primary) + } + Text(viewModel.item.overview ?? "") + .font(.body) + .fontWeight(.medium) + .foregroundColor(.primary) - VStack { - Button { - viewModel.updateWatchState() - } label: { - MediaViewActionButton(icon: "eye.fill", scrollView: $wrappedScrollView, iconColor: viewModel.isWatched ? .red : .white) - } - Text(viewModel.isWatched ? "Unwatch" : "Mark Watched") - .font(.caption) - } - }.padding(.top, 15) - Spacer() - }.padding(.top, 50) + HStack { + VStack { + Button { + viewModel.updateFavoriteState() + } label: { + MediaViewActionButton(icon: "heart.fill", scrollView: $wrappedScrollView, + iconColor: viewModel.isFavorited ? .red : .white) + }.prefersDefaultFocus(in: namespace) + Text(viewModel.isFavorited ? "Unfavorite" : "Favorite") + .font(.caption) + } - if !viewModel.episodes.isEmpty { - L10n.episodes.text - .font(.headline) - .fontWeight(.semibold) - ScrollView(.horizontal) { - LazyHStack { - Spacer().frame(width: 45) - - ForEach(viewModel.episodes, id: \.id) { episode in - - Button { - itemRouter.route(to: \.item, episode) - } label: { - LandscapeItemElement(item: episode, inSeasonView: true) - } - .buttonStyle(PlainNavigationLinkButtonStyle()) - } - Spacer().frame(width: 45) - } - }.padding(EdgeInsets(top: -30, leading: -90, bottom: 0, trailing: -90)) - .frame(height: 360) - } - }.padding(EdgeInsets(top: 90, leading: 90, bottom: 45, trailing: 90)) - } - } - } + VStack { + Button { + viewModel.updateWatchState() + } label: { + MediaViewActionButton(icon: "eye.fill", scrollView: $wrappedScrollView, + iconColor: viewModel.isWatched ? .red : .white) + } + Text(viewModel.isWatched ? "Unwatch" : "Mark Watched") + .font(.caption) + } + }.padding(.top, 15) + Spacer() + }.padding(.top, 50) + + if !viewModel.episodes.isEmpty { + L10n.episodes.text + .font(.headline) + .fontWeight(.semibold) + ScrollView(.horizontal) { + LazyHStack { + Spacer().frame(width: 45) + + ForEach(viewModel.episodes, id: \.id) { episode in + + Button { + itemRouter.route(to: \.item, episode) + } label: { + LandscapeItemElement(item: episode, inSeasonView: true) + } + .buttonStyle(PlainNavigationLinkButtonStyle()) + } + Spacer().frame(width: 45) + } + }.padding(EdgeInsets(top: -30, leading: -90, bottom: 0, trailing: -90)) + .frame(height: 360) + } + }.padding(EdgeInsets(top: 90, leading: 90, bottom: 45, trailing: 90)) + } + } + } } diff --git a/Swiftfin tvOS/Views/ItemView/CompactItemView/SeriesItemView.swift b/Swiftfin tvOS/Views/ItemView/CompactItemView/SeriesItemView.swift index 21cb1d1b..53ac05bb 100644 --- a/Swiftfin tvOS/Views/ItemView/CompactItemView/SeriesItemView.swift +++ b/Swiftfin tvOS/Views/ItemView/CompactItemView/SeriesItemView.swift @@ -1,187 +1,192 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// -import SwiftUI import JellyfinAPI +import SwiftUI struct SeriesItemView: View { - - @EnvironmentObject var itemRouter: ItemCoordinator.Router - @ObservedObject var viewModel: SeriesItemViewModel - @State var actors: [BaseItemPerson] = [] - @State var studio: String? - @State var director: String? + @EnvironmentObject + var itemRouter: ItemCoordinator.Router + @ObservedObject + var viewModel: SeriesItemViewModel - @State var wrappedScrollView: UIScrollView? + @State + var actors: [BaseItemPerson] = [] + @State + var studio: String? + @State + var director: String? - @Environment(\.resetFocus) var resetFocus - @Namespace private var namespace + @State + var wrappedScrollView: UIScrollView? - func onAppear() { - actors = [] - director = nil - studio = nil - var actor_index = 0 - viewModel.item.people?.forEach { person in - if person.type == "Actor" { - if actor_index < 4 { - actors.append(person) - } - actor_index = actor_index + 1 - } - if person.type == "Director" { - director = person.name ?? "" - } - } + @Environment(\.resetFocus) + var resetFocus + @Namespace + private var namespace - studio = viewModel.item.studios?.first?.name ?? nil - } + func onAppear() { + actors = [] + director = nil + studio = nil + var actor_index = 0 + viewModel.item.people?.forEach { person in + if person.type == "Actor" { + if actor_index < 4 { + actors.append(person) + } + actor_index = actor_index + 1 + } + if person.type == "Director" { + director = person.name ?? "" + } + } - var body: some View { - ZStack { - ImageView(src: viewModel.item.getBackdropImage(maxWidth: 1920), bh: viewModel.item.getBackdropImageBlurHash()) - .opacity(0.4) - .ignoresSafeArea() - ScrollView { - LazyVStack(alignment: .leading) { - Text(viewModel.item.name ?? "") - .font(.title) - .fontWeight(.bold) - .foregroundColor(.primary) - HStack { - if viewModel.item.officialRating != nil { - Text(viewModel.item.officialRating!).font(.subheadline) - .fontWeight(.semibold) - .foregroundColor(.secondary) - .lineLimit(1) - .padding(EdgeInsets(top: 1, leading: 4, bottom: 1, trailing: 4)) - .overlay(RoundedRectangle(cornerRadius: 2) - .stroke(Color.secondary, lineWidth: 1)) - } - if viewModel.item.communityRating != nil { - HStack { - Image(systemName: "star.fill") - .foregroundColor(.yellow) - .font(.subheadline) - Text(String(viewModel.item.communityRating!)).font(.subheadline) - .fontWeight(.semibold) - .foregroundColor(.secondary) - .lineLimit(1) - } - } - } + studio = viewModel.item.studios?.first?.name ?? nil + } - HStack { - VStack(alignment: .trailing) { - if studio != nil { - L10n.studio.text - .font(.body) - .fontWeight(.semibold) - .foregroundColor(.primary) - Text(studio!) - .font(.body) - .fontWeight(.semibold) - .foregroundColor(.secondary) - .padding(.bottom, 40) - } + var body: some View { + ZStack { + ImageView(src: viewModel.item.getBackdropImage(maxWidth: 1920), bh: viewModel.item.getBackdropImageBlurHash()) + .opacity(0.4) + .ignoresSafeArea() + ScrollView { + LazyVStack(alignment: .leading) { + Text(viewModel.item.name ?? "") + .font(.title) + .fontWeight(.bold) + .foregroundColor(.primary) + HStack { + if viewModel.item.officialRating != nil { + Text(viewModel.item.officialRating!).font(.subheadline) + .fontWeight(.semibold) + .foregroundColor(.secondary) + .lineLimit(1) + .padding(EdgeInsets(top: 1, leading: 4, bottom: 1, trailing: 4)) + .overlay(RoundedRectangle(cornerRadius: 2) + .stroke(Color.secondary, lineWidth: 1)) + } + if viewModel.item.communityRating != nil { + HStack { + Image(systemName: "star.fill") + .foregroundColor(.yellow) + .font(.subheadline) + Text(String(viewModel.item.communityRating!)).font(.subheadline) + .fontWeight(.semibold) + .foregroundColor(.secondary) + .lineLimit(1) + } + } + } - if director != nil { - L10n.director.text - .font(.body) - .fontWeight(.semibold) - .foregroundColor(.primary) - Text(director!) - .font(.body) - .fontWeight(.semibold) - .foregroundColor(.secondary) - .padding(.bottom, 40) - } + HStack { + VStack(alignment: .trailing) { + if studio != nil { + L10n.studio.text + .font(.body) + .fontWeight(.semibold) + .foregroundColor(.primary) + Text(studio!) + .font(.body) + .fontWeight(.semibold) + .foregroundColor(.secondary) + .padding(.bottom, 40) + } - if !actors.isEmpty { - L10n.cast.text - .font(.body) - .fontWeight(.semibold) - .foregroundColor(.primary) - ForEach(actors, id: \.id) { person in - Text(person.name!) - .font(.body) - .fontWeight(.semibold) - .foregroundColor(.secondary) - } - } - Spacer() - } - VStack(alignment: .leading) { - if !(viewModel.item.taglines ?? []).isEmpty { - Text(viewModel.item.taglines?.first ?? "") - .font(.body) - .italic() - .fontWeight(.medium) - .foregroundColor(.primary) - } - Text(viewModel.item.overview ?? "") - .font(.body) - .fontWeight(.medium) - .foregroundColor(.primary) + if director != nil { + L10n.director.text + .font(.body) + .fontWeight(.semibold) + .foregroundColor(.primary) + Text(director!) + .font(.body) + .fontWeight(.semibold) + .foregroundColor(.secondary) + .padding(.bottom, 40) + } - MediaPlayButtonRowView(viewModel: viewModel, wrappedScrollView: wrappedScrollView) - .padding(.top, 15) - Spacer() - } - }.padding(.top, 50) - if !viewModel.seasons.isEmpty { - L10n.seasons.text - .font(.headline) - .fontWeight(.semibold) - ScrollView(.horizontal) { - LazyHStack { - Spacer().frame(width: 45) - - - - ForEach(viewModel.seasons, id: \.id) { season in - Button { - itemRouter.route(to: \.item, season) - } label: { - PortraitItemElement(item: season) - } - .buttonStyle(PlainNavigationLinkButtonStyle()) - } - Spacer().frame(width: 45) - } - }.padding(EdgeInsets(top: -30, leading: -90, bottom: 0, trailing: -90)) - .frame(height: 360) - } + if !actors.isEmpty { + L10n.cast.text + .font(.body) + .fontWeight(.semibold) + .foregroundColor(.primary) + ForEach(actors, id: \.id) { person in + Text(person.name!) + .font(.body) + .fontWeight(.semibold) + .foregroundColor(.secondary) + } + } + Spacer() + } + VStack(alignment: .leading) { + if !(viewModel.item.taglines ?? []).isEmpty { + Text(viewModel.item.taglines?.first ?? "") + .font(.body) + .italic() + .fontWeight(.medium) + .foregroundColor(.primary) + } + Text(viewModel.item.overview ?? "") + .font(.body) + .fontWeight(.medium) + .foregroundColor(.primary) - if !viewModel.similarItems.isEmpty { - L10n.moreLikeThis.text - .font(.headline) - .fontWeight(.semibold) - ScrollView(.horizontal) { - LazyHStack { - Spacer().frame(width: 45) - ForEach(viewModel.similarItems, id: \.id) { similarItems in - NavigationLink(destination: ItemView(item: similarItems)) { - PortraitItemElement(item: similarItems) - }.buttonStyle(PlainNavigationLinkButtonStyle()) - } - Spacer().frame(width: 45) - } - }.padding(EdgeInsets(top: -30, leading: -90, bottom: 0, trailing: -90)) - .frame(height: 360) - } - }.padding(EdgeInsets(top: 90, leading: 90, bottom: 45, trailing: 90)) - }.focusScope(namespace) - .introspectScrollView { scrollView in - wrappedScrollView = scrollView - } - }.onAppear(perform: onAppear) - } + MediaPlayButtonRowView(viewModel: viewModel, wrappedScrollView: wrappedScrollView) + .padding(.top, 15) + Spacer() + } + }.padding(.top, 50) + if !viewModel.seasons.isEmpty { + L10n.seasons.text + .font(.headline) + .fontWeight(.semibold) + ScrollView(.horizontal) { + LazyHStack { + Spacer().frame(width: 45) + + ForEach(viewModel.seasons, id: \.id) { season in + Button { + itemRouter.route(to: \.item, season) + } label: { + PortraitItemElement(item: season) + } + .buttonStyle(PlainNavigationLinkButtonStyle()) + } + Spacer().frame(width: 45) + } + }.padding(EdgeInsets(top: -30, leading: -90, bottom: 0, trailing: -90)) + .frame(height: 360) + } + + if !viewModel.similarItems.isEmpty { + L10n.moreLikeThis.text + .font(.headline) + .fontWeight(.semibold) + ScrollView(.horizontal) { + LazyHStack { + Spacer().frame(width: 45) + ForEach(viewModel.similarItems, id: \.id) { similarItems in + NavigationLink(destination: ItemView(item: similarItems)) { + PortraitItemElement(item: similarItems) + }.buttonStyle(PlainNavigationLinkButtonStyle()) + } + Spacer().frame(width: 45) + } + }.padding(EdgeInsets(top: -30, leading: -90, bottom: 0, trailing: -90)) + .frame(height: 360) + } + }.padding(EdgeInsets(top: 90, leading: 90, bottom: 45, trailing: 90)) + }.focusScope(namespace) + .introspectScrollView { scrollView in + wrappedScrollView = scrollView + } + }.onAppear(perform: onAppear) + } } diff --git a/Swiftfin tvOS/Views/ItemView/ItemView.swift b/Swiftfin tvOS/Views/ItemView/ItemView.swift index 9981506b..0d78e0dc 100644 --- a/Swiftfin tvOS/Views/ItemView/ItemView.swift +++ b/Swiftfin tvOS/Views/ItemView/ItemView.swift @@ -1,9 +1,10 @@ -/* JellyfinPlayer/Swiftfin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Defaults import Introspect @@ -12,59 +13,60 @@ import SwiftUI // Useless view necessary in tvOS because of iOS's implementation struct ItemNavigationView: View { - private let item: BaseItemDto + private let item: BaseItemDto - init(item: BaseItemDto) { - self.item = item - } + init(item: BaseItemDto) { + self.item = item + } - var body: some View { - ItemView(item: item) - } + var body: some View { + ItemView(item: item) + } } struct ItemView: View { - - @Default(.tvOSCinematicViews) var tvOSCinematicViews - - private var item: BaseItemDto - init(item: BaseItemDto) { - self.item = item - } + @Default(.tvOSCinematicViews) + var tvOSCinematicViews - var body: some View { - Group { - switch item.itemType { - case .movie: - if tvOSCinematicViews { - CinematicMovieItemView(viewModel: MovieItemViewModel(item: item)) - } else { - MovieItemView(viewModel: MovieItemViewModel(item: item)) - } - case .episode: - if tvOSCinematicViews { - CinematicEpisodeItemView(viewModel: EpisodeItemViewModel(item: item)) - } else { - EpisodeItemView(viewModel: EpisodeItemViewModel(item: item)) - } - case .season: - if tvOSCinematicViews { - CinematicSeasonItemView(viewModel: SeasonItemViewModel(item: item)) - } else { - SeasonItemView(viewModel: .init(item: item)) - } - case .series: - if tvOSCinematicViews { - CinematicSeriesItemView(viewModel: SeriesItemViewModel(item: item)) - } else { - SeriesItemView(viewModel: SeriesItemViewModel(item: item)) - } - case .boxset: - CinematicCollectionItemView(viewModel: CollectionItemViewModel(item: item)) - default: - Text(L10n.notImplementedYetWithType(item.type ?? "")) - } - } - } + private var item: BaseItemDto + + init(item: BaseItemDto) { + self.item = item + } + + var body: some View { + Group { + switch item.itemType { + case .movie: + if tvOSCinematicViews { + CinematicMovieItemView(viewModel: MovieItemViewModel(item: item)) + } else { + MovieItemView(viewModel: MovieItemViewModel(item: item)) + } + case .episode: + if tvOSCinematicViews { + CinematicEpisodeItemView(viewModel: EpisodeItemViewModel(item: item)) + } else { + EpisodeItemView(viewModel: EpisodeItemViewModel(item: item)) + } + case .season: + if tvOSCinematicViews { + CinematicSeasonItemView(viewModel: SeasonItemViewModel(item: item)) + } else { + SeasonItemView(viewModel: .init(item: item)) + } + case .series: + if tvOSCinematicViews { + CinematicSeriesItemView(viewModel: SeriesItemViewModel(item: item)) + } else { + SeriesItemView(viewModel: SeriesItemViewModel(item: item)) + } + case .boxset: + CinematicCollectionItemView(viewModel: CollectionItemViewModel(item: item)) + default: + Text(L10n.notImplementedYetWithType(item.type ?? "")) + } + } + } } diff --git a/Swiftfin tvOS/Views/LatestMediaView.swift b/Swiftfin tvOS/Views/LatestMediaView.swift index 68fbe395..c2cc6f87 100644 --- a/Swiftfin tvOS/Views/LatestMediaView.swift +++ b/Swiftfin tvOS/Views/LatestMediaView.swift @@ -1,75 +1,80 @@ -/* JellyfinPlayer/Swiftfin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Defaults import JellyfinAPI import SwiftUI struct LatestMediaView: View { - - @EnvironmentObject var homeRouter: HomeCoordinator.Router - @StateObject var viewModel: LatestMediaViewModel - @Default(.showPosterLabels) var showPosterLabels - - var body: some View { - VStack(alignment: .leading) { - - L10n.latestWithString(viewModel.library.name ?? "").text - .font(.title3) - .padding(.horizontal, 50) - - ScrollView(.horizontal) { - HStack(alignment: .top) { - ForEach(viewModel.items, id: \.self) { item in - - VStack(spacing: 15) { - Button { - homeRouter.route(to: \.modalItem, item) - } label: { - ImageView(src: item.portraitHeaderViewURL(maxWidth: 257)) - .frame(width: 257, height: 380) - } - .frame(height: 380) - .buttonStyle(PlainButtonStyle()) - - if showPosterLabels { - Text(item.title) - .lineLimit(2) - .frame(width: 257) - } - } - } - - Button { - homeRouter.route(to: \.library, (viewModel: .init(parentID: viewModel.library.id!, - filters: LibraryFilters(filters: [], sortOrder: [.descending], sortBy: [.dateAdded])), - title: viewModel.library.name ?? "")) - } label: { - ZStack { - Color(UIColor.darkGray) - .opacity(0.5) - - VStack(spacing: 20) { - Image(systemName: "chevron.right") - .font(.title) - - L10n.seeAll.text - .font(.title3) - } - } - } - .frame(width: 257, height: 380) - .buttonStyle(PlainButtonStyle()) - } - .padding(.horizontal, 50) - .padding(.vertical) - } - .edgesIgnoringSafeArea(.horizontal) - } - .focusSection() - } + + @EnvironmentObject + var homeRouter: HomeCoordinator.Router + @StateObject + var viewModel: LatestMediaViewModel + @Default(.showPosterLabels) + var showPosterLabels + + var body: some View { + VStack(alignment: .leading) { + + L10n.latestWithString(viewModel.library.name ?? "").text + .font(.title3) + .padding(.horizontal, 50) + + ScrollView(.horizontal) { + HStack(alignment: .top) { + ForEach(viewModel.items, id: \.self) { item in + + VStack(spacing: 15) { + Button { + homeRouter.route(to: \.modalItem, item) + } label: { + ImageView(src: item.portraitHeaderViewURL(maxWidth: 257)) + .frame(width: 257, height: 380) + } + .frame(height: 380) + .buttonStyle(PlainButtonStyle()) + + if showPosterLabels { + Text(item.title) + .lineLimit(2) + .frame(width: 257) + } + } + } + + Button { + homeRouter.route(to: \.library, (viewModel: .init(parentID: viewModel.library.id!, + filters: LibraryFilters(filters: [], sortOrder: [.descending], + sortBy: [.dateAdded])), + title: viewModel.library.name ?? "")) + } label: { + ZStack { + Color(UIColor.darkGray) + .opacity(0.5) + + VStack(spacing: 20) { + Image(systemName: "chevron.right") + .font(.title) + + L10n.seeAll.text + .font(.title3) + } + } + } + .frame(width: 257, height: 380) + .buttonStyle(PlainButtonStyle()) + } + .padding(.horizontal, 50) + .padding(.vertical) + } + .edgesIgnoringSafeArea(.horizontal) + } + .focusSection() + } } diff --git a/Swiftfin tvOS/Views/LibraryFilterView.swift b/Swiftfin tvOS/Views/LibraryFilterView.swift index cf77babe..50af13ab 100644 --- a/Swiftfin tvOS/Views/LibraryFilterView.swift +++ b/Swiftfin tvOS/Views/LibraryFilterView.swift @@ -1,9 +1,10 @@ -/* JellyfinPlayer/Swiftfin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import JellyfinAPI import Stinsen @@ -11,84 +12,87 @@ import SwiftUI struct LibraryFilterView: View { - @EnvironmentObject var filterRouter: FilterCoordinator.Router - @Binding var filters: LibraryFilters - var parentId: String = "" + @EnvironmentObject + var filterRouter: FilterCoordinator.Router + @Binding + var filters: LibraryFilters + var parentId: String = "" - @StateObject var viewModel: LibraryFilterViewModel + @StateObject + var viewModel: LibraryFilterViewModel - init(filters: Binding, enabledFilterType: [FilterType], parentId: String) { - _filters = filters - self.parentId = parentId - _viewModel = - StateObject(wrappedValue: .init(filters: filters.wrappedValue, enabledFilterType: enabledFilterType, parentId: parentId)) - } + init(filters: Binding, enabledFilterType: [FilterType], parentId: String) { + _filters = filters + self.parentId = parentId + _viewModel = + StateObject(wrappedValue: .init(filters: filters.wrappedValue, enabledFilterType: enabledFilterType, parentId: parentId)) + } - var body: some View { - VStack { - if viewModel.isLoading { - ProgressView() - } else { - Form { - if viewModel.enabledFilterType.contains(.genre) { - MultiSelector(label: L10n.genres, - options: viewModel.possibleGenres, - optionToString: { $0.name ?? "" }, - selected: $viewModel.modifiedFilters.withGenres) - } - if viewModel.enabledFilterType.contains(.filter) { - MultiSelector(label: L10n.filters, - options: viewModel.possibleItemFilters, - optionToString: { $0.localized }, - selected: $viewModel.modifiedFilters.filters) - } - if viewModel.enabledFilterType.contains(.tag) { - MultiSelector(label: L10n.tags, - options: viewModel.possibleTags, - optionToString: { $0 }, - selected: $viewModel.modifiedFilters.tags) - } - if viewModel.enabledFilterType.contains(.sortBy) { - Picker(selection: $viewModel.selectedSortBy, label: L10n.sortBy.text) { - ForEach(viewModel.possibleSortBys, id: \.self) { so in - Text(so.localized).tag(so) - } - } - } - if viewModel.enabledFilterType.contains(.sortOrder) { - Picker(selection: $viewModel.selectedSortOrder, label: L10n.displayOrder.text) { - ForEach(viewModel.possibleSortOrders, id: \.self) { so in - Text(so.rawValue).tag(so) - } - } - } - } - Button { - viewModel.resetFilters() - self.filters = viewModel.modifiedFilters - filterRouter.dismissCoordinator() - } label: { - L10n.reset.text - } - } - } - .toolbar { - ToolbarItemGroup(placement: .navigationBarLeading) { - Button { - filterRouter.dismissCoordinator() - } label: { - Image(systemName: "xmark") - } - } - ToolbarItemGroup(placement: .navigationBarTrailing) { - Button { - viewModel.updateModifiedFilter() - self.filters = viewModel.modifiedFilters - filterRouter.dismissCoordinator() - } label: { - L10n.apply.text - } - } - } - } + var body: some View { + VStack { + if viewModel.isLoading { + ProgressView() + } else { + Form { + if viewModel.enabledFilterType.contains(.genre) { + MultiSelector(label: L10n.genres, + options: viewModel.possibleGenres, + optionToString: { $0.name ?? "" }, + selected: $viewModel.modifiedFilters.withGenres) + } + if viewModel.enabledFilterType.contains(.filter) { + MultiSelector(label: L10n.filters, + options: viewModel.possibleItemFilters, + optionToString: { $0.localized }, + selected: $viewModel.modifiedFilters.filters) + } + if viewModel.enabledFilterType.contains(.tag) { + MultiSelector(label: L10n.tags, + options: viewModel.possibleTags, + optionToString: { $0 }, + selected: $viewModel.modifiedFilters.tags) + } + if viewModel.enabledFilterType.contains(.sortBy) { + Picker(selection: $viewModel.selectedSortBy, label: L10n.sortBy.text) { + ForEach(viewModel.possibleSortBys, id: \.self) { so in + Text(so.localized).tag(so) + } + } + } + if viewModel.enabledFilterType.contains(.sortOrder) { + Picker(selection: $viewModel.selectedSortOrder, label: L10n.displayOrder.text) { + ForEach(viewModel.possibleSortOrders, id: \.self) { so in + Text(so.rawValue).tag(so) + } + } + } + } + Button { + viewModel.resetFilters() + self.filters = viewModel.modifiedFilters + filterRouter.dismissCoordinator() + } label: { + L10n.reset.text + } + } + } + .toolbar { + ToolbarItemGroup(placement: .navigationBarLeading) { + Button { + filterRouter.dismissCoordinator() + } label: { + Image(systemName: "xmark") + } + } + ToolbarItemGroup(placement: .navigationBarTrailing) { + Button { + viewModel.updateModifiedFilter() + self.filters = viewModel.modifiedFilters + filterRouter.dismissCoordinator() + } label: { + L10n.apply.text + } + } + } + } } diff --git a/Swiftfin tvOS/Views/LibraryListView.swift b/Swiftfin tvOS/Views/LibraryListView.swift index 9a3db910..1ba40a08 100644 --- a/Swiftfin tvOS/Views/LibraryListView.swift +++ b/Swiftfin tvOS/Views/LibraryListView.swift @@ -1,111 +1,115 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Defaults import Foundation import SwiftUI struct LibraryListView: View { - @EnvironmentObject var mainCoordinator: MainCoordinator.Router - @EnvironmentObject var libraryListRouter: LibraryListCoordinator.Router - @StateObject var viewModel = LibraryListViewModel() - - @Default(.Experimental.liveTVAlphaEnabled) var liveTVAlphaEnabled + @EnvironmentObject + var mainCoordinator: MainCoordinator.Router + @EnvironmentObject + var libraryListRouter: LibraryListCoordinator.Router + @StateObject + var viewModel = LibraryListViewModel() - var body: some View { - ScrollView { - LazyVStack { - if !viewModel.isLoading { - - if let collectionLibraryItem = viewModel.libraries.first(where: { $0.collectionType == "boxsets" }) { - Button() { - self.libraryListRouter.route(to: \.library, - (viewModel: LibraryViewModel(parentID: collectionLibraryItem.id), title: collectionLibraryItem.name ?? "")) - } - label: { - ZStack { - HStack { - Spacer() - VStack { - Text(collectionLibraryItem.name ?? "") - .foregroundColor(.white) - .font(.title2) - .fontWeight(.semibold) - } - Spacer() - }.padding(32) - } - .frame(minWidth: 100, maxWidth: .infinity) - .frame(height: 100) - } - .cornerRadius(10) - .shadow(radius: 5) - .padding(.bottom, 5) - } - - ForEach(viewModel.libraries.filter({ $0.collectionType != "boxsets" }), id: \.id) { library in - if library.collectionType == "livetv" { - if liveTVAlphaEnabled { - Button() { - self.mainCoordinator.root(\.liveTV) - } - label: { - ZStack { - HStack { - Spacer() - VStack { - Text(library.name ?? "") - .foregroundColor(.white) - .font(.title2) - .fontWeight(.semibold) - } - Spacer() - }.padding(32) - } - .frame(minWidth: 100, maxWidth: .infinity) - .frame(height: 100) - } - .cornerRadius(10) - .shadow(radius: 5) - .padding(.bottom, 5) - } - } else { - Button() { - self.libraryListRouter.route(to: \.library, (viewModel: LibraryViewModel(), title: library.name ?? "")) - } - label: { - ZStack { - HStack { - Spacer() - VStack { - Text(library.name ?? "") - .foregroundColor(.white) - .font(.title2) - .fontWeight(.semibold) - } - Spacer() - }.padding(32) - } - .frame(minWidth: 100, maxWidth: .infinity) - .frame(height: 100) - } - .cornerRadius(10) - .shadow(radius: 5) - .padding(.bottom, 5) - } - } - } else { - ProgressView() - } - }.padding(.leading, 16) - .padding(.trailing, 16) - .padding(.top, 8) - } - } + @Default(.Experimental.liveTVAlphaEnabled) + var liveTVAlphaEnabled + + var body: some View { + ScrollView { + LazyVStack { + if !viewModel.isLoading { + + if let collectionLibraryItem = viewModel.libraries.first(where: { $0.collectionType == "boxsets" }) { + Button { + self.libraryListRouter.route(to: \.library, + (viewModel: LibraryViewModel(parentID: collectionLibraryItem.id), + title: collectionLibraryItem.name ?? "")) + } + label: { + ZStack { + HStack { + Spacer() + VStack { + Text(collectionLibraryItem.name ?? "") + .foregroundColor(.white) + .font(.title2) + .fontWeight(.semibold) + } + Spacer() + }.padding(32) + } + .frame(minWidth: 100, maxWidth: .infinity) + .frame(height: 100) + } + .cornerRadius(10) + .shadow(radius: 5) + .padding(.bottom, 5) + } + + ForEach(viewModel.libraries.filter { $0.collectionType != "boxsets" }, id: \.id) { library in + if library.collectionType == "livetv" { + if liveTVAlphaEnabled { + Button { + self.mainCoordinator.root(\.liveTV) + } + label: { + ZStack { + HStack { + Spacer() + VStack { + Text(library.name ?? "") + .foregroundColor(.white) + .font(.title2) + .fontWeight(.semibold) + } + Spacer() + }.padding(32) + } + .frame(minWidth: 100, maxWidth: .infinity) + .frame(height: 100) + } + .cornerRadius(10) + .shadow(radius: 5) + .padding(.bottom, 5) + } + } else { + Button { + self.libraryListRouter.route(to: \.library, (viewModel: LibraryViewModel(), title: library.name ?? "")) + } + label: { + ZStack { + HStack { + Spacer() + VStack { + Text(library.name ?? "") + .foregroundColor(.white) + .font(.title2) + .fontWeight(.semibold) + } + Spacer() + }.padding(32) + } + .frame(minWidth: 100, maxWidth: .infinity) + .frame(height: 100) + } + .cornerRadius(10) + .shadow(radius: 5) + .padding(.bottom, 5) + } + } + } else { + ProgressView() + } + }.padding(.leading, 16) + .padding(.trailing, 16) + .padding(.top, 8) + } + } } diff --git a/Swiftfin tvOS/Views/LibrarySearchView.swift b/Swiftfin tvOS/Views/LibrarySearchView.swift index 2cc5c706..4edb744a 100644 --- a/Swiftfin tvOS/Views/LibrarySearchView.swift +++ b/Swiftfin tvOS/Views/LibrarySearchView.swift @@ -1,103 +1,107 @@ -/* JellyfinPlayer/Swiftfin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Combine import JellyfinAPI import SwiftUI struct LibrarySearchView: View { - @StateObject var viewModel: LibrarySearchViewModel - @State var searchQuery = "" + @StateObject + var viewModel: LibrarySearchViewModel + @State + var searchQuery = "" - @State private var tracks: [GridItem] = Array(repeating: .init(.flexible()), count: Int(UIScreen.main.bounds.size.width) / 125) + @State + private var tracks: [GridItem] = Array(repeating: .init(.flexible()), count: Int(UIScreen.main.bounds.size.width) / 125) - func recalcTracks() { - tracks = Array(repeating: .init(.flexible()), count: Int(UIScreen.main.bounds.size.width) / 125) - } + func recalcTracks() { + tracks = Array(repeating: .init(.flexible()), count: Int(UIScreen.main.bounds.size.width) / 125) + } - var body: some View { - ZStack { - VStack { - SearchBar(text: $searchQuery) - .padding(.top, 16) - .padding(.bottom, 8) - if searchQuery.isEmpty { - suggestionsListView - } else { - resultView - } - } - if viewModel.isLoading { - ProgressView() - } - } - .onChange(of: searchQuery) { query in - viewModel.searchQuerySubject.send(query) - } - .navigationBarTitle("Search") - } + var body: some View { + ZStack { + VStack { + SearchBar(text: $searchQuery) + .padding(.top, 16) + .padding(.bottom, 8) + if searchQuery.isEmpty { + suggestionsListView + } else { + resultView + } + } + if viewModel.isLoading { + ProgressView() + } + } + .onChange(of: searchQuery) { query in + viewModel.searchQuerySubject.send(query) + } + .navigationBarTitle("Search") + } - var suggestionsListView: some View { - ScrollView { - LazyVStack(spacing: 8) { - L10n.suggestions.text - .font(.headline) - .fontWeight(.bold) - .foregroundColor(.primary) - .padding(.bottom, 8) - ForEach(viewModel.suggestions, id: \.id) { item in - Button { - searchQuery = item.name ?? "" - } label: { - Text(item.name ?? "") - .font(.body) - } - } - } - .padding(.horizontal, 16) - } - } + var suggestionsListView: some View { + ScrollView { + LazyVStack(spacing: 8) { + L10n.suggestions.text + .font(.headline) + .fontWeight(.bold) + .foregroundColor(.primary) + .padding(.bottom, 8) + ForEach(viewModel.suggestions, id: \.id) { item in + Button { + searchQuery = item.name ?? "" + } label: { + Text(item.name ?? "") + .font(.body) + } + } + } + .padding(.horizontal, 16) + } + } - var resultView: some View { - let items = items(for: viewModel.selectedItemType) - return VStack(alignment: .leading, spacing: 16) { - Picker("ItemType", selection: $viewModel.selectedItemType) { - ForEach(viewModel.supportedItemTypeList, id: \.self) { - Text($0.localized) - .tag($0) - } - } - .pickerStyle(SegmentedPickerStyle()) - .padding(.horizontal, 16) - ScrollView { - LazyVStack(alignment: .leading, spacing: 16) { - if !items.isEmpty { - LazyVGrid(columns: tracks) { - ForEach(items, id: \.id) { item in - ItemView(item: item) - } - } - .padding(.bottom, 16) - } - } - } - } - } + var resultView: some View { + let items = items(for: viewModel.selectedItemType) + return VStack(alignment: .leading, spacing: 16) { + Picker("ItemType", selection: $viewModel.selectedItemType) { + ForEach(viewModel.supportedItemTypeList, id: \.self) { + Text($0.localized) + .tag($0) + } + } + .pickerStyle(SegmentedPickerStyle()) + .padding(.horizontal, 16) + ScrollView { + LazyVStack(alignment: .leading, spacing: 16) { + if !items.isEmpty { + LazyVGrid(columns: tracks) { + ForEach(items, id: \.id) { item in + ItemView(item: item) + } + } + .padding(.bottom, 16) + } + } + } + } + } - func items(for type: ItemType) -> [BaseItemDto] { - switch type { - case .episode: - return viewModel.episodeItems - case .movie: - return viewModel.movieItems - case .series: - return viewModel.showItems - default: - return [] - } - } + func items(for type: ItemType) -> [BaseItemDto] { + switch type { + case .episode: + return viewModel.episodeItems + case .movie: + return viewModel.movieItems + case .series: + return viewModel.showItems + default: + return [] + } + } } diff --git a/Swiftfin tvOS/Views/LibraryView.swift b/Swiftfin tvOS/Views/LibraryView.swift index 0f500499..d65cb25d 100644 --- a/Swiftfin tvOS/Views/LibraryView.swift +++ b/Swiftfin tvOS/Views/LibraryView.swift @@ -1,100 +1,95 @@ -/* - * JellyfinPlayer/Swiftfin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// +import JellyfinAPI import SwiftUI import SwiftUICollection -import JellyfinAPI struct LibraryView: View { - @EnvironmentObject var libraryRouter: LibraryCoordinator.Router - @StateObject var viewModel: LibraryViewModel - var title: String + @EnvironmentObject + var libraryRouter: LibraryCoordinator.Router + @StateObject + var viewModel: LibraryViewModel + var title: String - // MARK: tracks for grid - var defaultFilters = LibraryFilters(filters: [], sortOrder: [.ascending], withGenres: [], tags: [], sortBy: [.name]) + // MARK: tracks for grid - @State var isShowingSearchView = false - @State var isShowingFilterView = false + var defaultFilters = LibraryFilters(filters: [], sortOrder: [.ascending], withGenres: [], tags: [], sortBy: [.name]) - var body: some View { - if viewModel.isLoading == true { - ProgressView() - } else if !viewModel.rows.isEmpty { - CollectionView(rows: viewModel.rows) { _, _ in - let itemSize = NSCollectionLayoutSize( - widthDimension: .fractionalWidth(1), - heightDimension: .fractionalHeight(1) - ) - let item = NSCollectionLayoutItem(layoutSize: itemSize) + @State + var isShowingSearchView = false + @State + var isShowingFilterView = false - let groupSize = NSCollectionLayoutSize( - widthDimension: .absolute(200), - heightDimension: .absolute(300) - ) - let group = NSCollectionLayoutGroup.horizontal( - layoutSize: groupSize, - subitems: [item] - ) + var body: some View { + if viewModel.isLoading == true { + ProgressView() + } else if !viewModel.rows.isEmpty { + CollectionView(rows: viewModel.rows) { _, _ in + let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), + heightDimension: .fractionalHeight(1)) + let item = NSCollectionLayoutItem(layoutSize: itemSize) - let header = - NSCollectionLayoutBoundarySupplementaryItem( - layoutSize: NSCollectionLayoutSize( - widthDimension: .fractionalWidth(1), - heightDimension: .absolute(44) - ), - elementKind: UICollectionView.elementKindSectionHeader, - alignment: .topLeading - ) + let groupSize = NSCollectionLayoutSize(widthDimension: .absolute(200), + heightDimension: .absolute(300)) + let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, + subitems: [item]) - let section = NSCollectionLayoutSection(group: group) + let header = + NSCollectionLayoutBoundarySupplementaryItem(layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), + heightDimension: .absolute(44)), + elementKind: UICollectionView.elementKindSectionHeader, + alignment: .topLeading) - section.contentInsets = NSDirectionalEdgeInsets(top: 30, leading: 0, bottom: 80, trailing: 80) - section.interGroupSpacing = 48 - section.orthogonalScrollingBehavior = .continuous - section.boundarySupplementaryItems = [header] - return section - } cell: { _, cell in - GeometryReader { _ in - if let item = cell.item { - if item.type != "Folder" { - Button { - libraryRouter.route(to: \.modalItem, item) - } label: { - PortraitItemElement(item: item) - } - .buttonStyle(PlainNavigationLinkButtonStyle()) - .onAppear { - if item == viewModel.items.last && viewModel.hasNextPage { - viewModel.requestNextPageAsync() - } - } - } - } else if cell.loadingCell { - ProgressView() - .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity, alignment: .center) - } - } - } supplementaryView: { _, indexPath in - HStack { - Spacer() - }.accessibilityIdentifier("\(indexPath.section).\(indexPath.row)") - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .ignoresSafeArea(.all) - } else { - VStack { - L10n.noResults.text - Button { } label: { - Text("Reload") - } - } - } - } + let section = NSCollectionLayoutSection(group: group) + + section.contentInsets = NSDirectionalEdgeInsets(top: 30, leading: 0, bottom: 80, trailing: 80) + section.interGroupSpacing = 48 + section.orthogonalScrollingBehavior = .continuous + section.boundarySupplementaryItems = [header] + return section + } cell: { _, cell in + GeometryReader { _ in + if let item = cell.item { + if item.type != "Folder" { + Button { + libraryRouter.route(to: \.modalItem, item) + } label: { + PortraitItemElement(item: item) + } + .buttonStyle(PlainNavigationLinkButtonStyle()) + .onAppear { + if item == viewModel.items.last && viewModel.hasNextPage { + viewModel.requestNextPageAsync() + } + } + } + } else if cell.loadingCell { + ProgressView() + .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity, alignment: .center) + } + } + } supplementaryView: { _, indexPath in + HStack { + Spacer() + }.accessibilityIdentifier("\(indexPath.section).\(indexPath.row)") + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .ignoresSafeArea(.all) + } else { + VStack { + L10n.noResults.text + Button {} label: { + Text("Reload") + } + } + } + } } // stream BM^S by nicki! diff --git a/Swiftfin tvOS/Views/LiveTVChannelsView.swift b/Swiftfin tvOS/Views/LiveTVChannelsView.swift index 69f34e51..e123ebf6 100644 --- a/Swiftfin tvOS/Views/LiveTVChannelsView.swift +++ b/Swiftfin tvOS/Views/LiveTVChannelsView.swift @@ -1,106 +1,98 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Foundation import JellyfinAPI import SwiftUI import SwiftUICollection - struct LiveTVChannelsView: View { - @EnvironmentObject var router: LiveTVChannelsCoordinator.Router - @StateObject var viewModel = LiveTVChannelsViewModel() - - var body: some View { - if viewModel.isLoading == true { - ProgressView() - } else if !viewModel.rows.isEmpty { - CollectionView(rows: viewModel.rows) { section, env in - return createGridLayout() - } cell: { indexPath, cell in - makeCellView(indexPath: indexPath, cell: cell) - } supplementaryView: { _, indexPath in - EmptyView() - .accessibilityIdentifier("\(indexPath.section).\(indexPath.row)") - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .ignoresSafeArea() - .onAppear { - viewModel.startScheduleCheckTimer() - } - .onDisappear { - viewModel.stopScheduleCheckTimer() - } - } else { - VStack { - Text("No results.") - Button { - viewModel.getChannels() - } label: { - Text("Reload") - } - } - } - } - - @ViewBuilder func makeCellView(indexPath: IndexPath, cell: LiveTVChannelRowCell) -> some View { - let item = cell.item - let channel = item.channel - if channel.type != "Folder" { - Button { - self.router.route(to: \.videoPlayer, channel) - } label: { - LiveTVChannelItemElement( - channel: channel, - program: item.program, - startString: item.program?.getLiveStartTimeString(formatter: viewModel.timeFormatter) ?? " ", - endString: item.program?.getLiveEndTimeString(formatter: viewModel.timeFormatter) ?? " ", - progressPercent: item.program?.getLiveProgressPercentage() ?? 0 - ) - } - .buttonStyle(PlainNavigationLinkButtonStyle()) - } - } - - private func createGridLayout() -> NSCollectionLayoutSection { - // I don't know why tvOS has a margin on the sides of a collection view - // But it does, even with contentInset = .zero and ignoreSafeArea. - let sideMargin = CGFloat(30) - let itemWidth = (UIScreen.main.bounds.width / 4) - (sideMargin * 2) - let itemSize = NSCollectionLayoutSize(widthDimension: .absolute(itemWidth), - heightDimension: .absolute(itemWidth)) - let item = NSCollectionLayoutItem(layoutSize: itemSize) - item.edgeSpacing = .init( - leading: .fixed(8), - top: .fixed(8), - trailing: .fixed(8), - bottom: .fixed(8) - ) - - - let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), - heightDimension: .absolute(itemWidth)) - let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, - subitems: [item]) - group.edgeSpacing = .init( - leading: .fixed(0), - top: .fixed(16), - trailing: .fixed(0), - bottom: .fixed(16) - ) - group.contentInsets = .zero - - - let section = NSCollectionLayoutSection(group: group) - section.contentInsets = .zero + @EnvironmentObject + var router: LiveTVChannelsCoordinator.Router + @StateObject + var viewModel = LiveTVChannelsViewModel() - return section - } - + var body: some View { + if viewModel.isLoading == true { + ProgressView() + } else if !viewModel.rows.isEmpty { + CollectionView(rows: viewModel.rows) { _, _ in + createGridLayout() + } cell: { indexPath, cell in + makeCellView(indexPath: indexPath, cell: cell) + } supplementaryView: { _, indexPath in + EmptyView() + .accessibilityIdentifier("\(indexPath.section).\(indexPath.row)") + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .ignoresSafeArea() + .onAppear { + viewModel.startScheduleCheckTimer() + } + .onDisappear { + viewModel.stopScheduleCheckTimer() + } + } else { + VStack { + Text("No results.") + Button { + viewModel.getChannels() + } label: { + Text("Reload") + } + } + } + } + + @ViewBuilder + func makeCellView(indexPath: IndexPath, cell: LiveTVChannelRowCell) -> some View { + let item = cell.item + let channel = item.channel + if channel.type != "Folder" { + Button { + self.router.route(to: \.videoPlayer, channel) + } label: { + LiveTVChannelItemElement(channel: channel, + program: item.program, + startString: item.program?.getLiveStartTimeString(formatter: viewModel.timeFormatter) ?? " ", + endString: item.program?.getLiveEndTimeString(formatter: viewModel.timeFormatter) ?? " ", + progressPercent: item.program?.getLiveProgressPercentage() ?? 0) + } + .buttonStyle(PlainNavigationLinkButtonStyle()) + } + } + + private func createGridLayout() -> NSCollectionLayoutSection { + // I don't know why tvOS has a margin on the sides of a collection view + // But it does, even with contentInset = .zero and ignoreSafeArea. + let sideMargin = CGFloat(30) + let itemWidth = (UIScreen.main.bounds.width / 4) - (sideMargin * 2) + let itemSize = NSCollectionLayoutSize(widthDimension: .absolute(itemWidth), + heightDimension: .absolute(itemWidth)) + let item = NSCollectionLayoutItem(layoutSize: itemSize) + item.edgeSpacing = .init(leading: .fixed(8), + top: .fixed(8), + trailing: .fixed(8), + bottom: .fixed(8)) + + let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), + heightDimension: .absolute(itemWidth)) + let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, + subitems: [item]) + group.edgeSpacing = .init(leading: .fixed(0), + top: .fixed(16), + trailing: .fixed(0), + bottom: .fixed(16)) + group.contentInsets = .zero + + let section = NSCollectionLayoutSection(group: group) + section.contentInsets = .zero + + return section + } } diff --git a/Swiftfin tvOS/Views/LiveTVHomeView.swift b/Swiftfin tvOS/Views/LiveTVHomeView.swift index 691dbc27..83780803 100644 --- a/Swiftfin tvOS/Views/LiveTVHomeView.swift +++ b/Swiftfin tvOS/Views/LiveTVHomeView.swift @@ -1,23 +1,23 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Foundation import SwiftUI struct LiveTVHomeView: View { - @EnvironmentObject var mainCoordinator: MainCoordinator.Router - - var body: some View { - Button {} label: { - Text("Return Home") - }.onAppear { - self.mainCoordinator.root(\.mainTab) - } - } + @EnvironmentObject + var mainCoordinator: MainCoordinator.Router + + var body: some View { + Button {} label: { + Text("Return Home") + }.onAppear { + self.mainCoordinator.root(\.mainTab) + } + } } diff --git a/Swiftfin tvOS/Views/LiveTVProgramsView.swift b/Swiftfin tvOS/Views/LiveTVProgramsView.swift index acefbada..8e9cbe62 100644 --- a/Swiftfin tvOS/Views/LiveTVProgramsView.swift +++ b/Swiftfin tvOS/Views/LiveTVProgramsView.swift @@ -1,167 +1,180 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Foundation import SwiftUI struct LiveTVProgramsView: View { - @EnvironmentObject var programsRouter: LiveTVProgramsCoordinator.Router - @StateObject var viewModel = LiveTVProgramsViewModel() + @EnvironmentObject + var programsRouter: LiveTVProgramsCoordinator.Router + @StateObject + var viewModel = LiveTVProgramsViewModel() - var body: some View { - ScrollView { - LazyVStack(alignment: .leading) { - if !viewModel.recommendedItems.isEmpty, - let items = viewModel.recommendedItems { - Text("On Now") - .font(.headline) - .fontWeight(.semibold) - .padding(.leading, 90) - ScrollView(.horizontal, showsIndicators: false) { - LazyHStack { - Spacer().frame(width: 45) - ForEach(items, id: \.id) { item in - Button { - if let chanId = item.channelId, - let chan = viewModel.findChannel(id: chanId) { - self.programsRouter.route(to: \.videoPlayer, chan) - } - } label: { - LandscapeItemElement(item: item) - } - .buttonStyle(PlainNavigationLinkButtonStyle()) - } - Spacer().frame(width: 45) - } - }.frame(height: 350) - } - if !viewModel.seriesItems.isEmpty, - let items = viewModel.seriesItems { - Text("Shows") - .font(.headline) - .fontWeight(.semibold) - .padding(.leading, 90) - ScrollView(.horizontal, showsIndicators: false) { - LazyHStack { - Spacer().frame(width: 45) - ForEach(items, id: \.id) { item in - Button { - if let chanId = item.channelId, - let chan = viewModel.findChannel(id: chanId) { - self.programsRouter.route(to: \.videoPlayer, chan) - } - } label: { - LandscapeItemElement(item: item) - } - .buttonStyle(PlainNavigationLinkButtonStyle()) - } - Spacer().frame(width: 45) - } - }.frame(height: 350) - } - if !viewModel.movieItems.isEmpty, - let items = viewModel.movieItems { - Text("Movies") - .font(.headline) - .fontWeight(.semibold) - .padding(.leading, 90) - ScrollView(.horizontal, showsIndicators: false) { - LazyHStack { - Spacer().frame(width: 45) - ForEach(items, id: \.id) { item in - Button { - if let chanId = item.channelId, - let chan = viewModel.findChannel(id: chanId) { - self.programsRouter.route(to: \.videoPlayer, chan) - } - } label: { - LandscapeItemElement(item: item) - } - .buttonStyle(PlainNavigationLinkButtonStyle()) - } - Spacer().frame(width: 45) - } - }.frame(height: 350) - } - if !viewModel.sportsItems.isEmpty, - let items = viewModel.sportsItems { - Text("Sports") - .font(.headline) - .fontWeight(.semibold) - .padding(.leading, 90) - ScrollView(.horizontal, showsIndicators: false) { - LazyHStack { - Spacer().frame(width: 45) - ForEach(items, id: \.id) { item in - Button { - if let chanId = item.channelId, - let chan = viewModel.findChannel(id: chanId) { - self.programsRouter.route(to: \.videoPlayer, chan) - } - } label: { - LandscapeItemElement(item: item) - } - .buttonStyle(PlainNavigationLinkButtonStyle()) - } - Spacer().frame(width: 45) - } - }.frame(height: 350) - } - if !viewModel.kidsItems.isEmpty, - let items = viewModel.kidsItems { - Text("Kids") - .font(.headline) - .fontWeight(.semibold) - .padding(.leading, 90) - ScrollView(.horizontal, showsIndicators: false) { - LazyHStack { - Spacer().frame(width: 45) - ForEach(items, id: \.id) { item in - Button { - if let chanId = item.channelId, - let chan = viewModel.findChannel(id: chanId) { - self.programsRouter.route(to: \.videoPlayer, chan) - } - } label: { - LandscapeItemElement(item: item) - } - .buttonStyle(PlainNavigationLinkButtonStyle()) - } - Spacer().frame(width: 45) - } - }.frame(height: 350) - } - if !viewModel.newsItems.isEmpty, - let items = viewModel.newsItems { - Text("News") - .font(.headline) - .fontWeight(.semibold) - .padding(.leading, 90) - ScrollView(.horizontal, showsIndicators: false) { - LazyHStack { - Spacer().frame(width: 45) - ForEach(items, id: \.id) { item in - Button { - if let chanId = item.channelId, - let chan = viewModel.findChannel(id: chanId) { - self.programsRouter.route(to: \.videoPlayer, chan) - } - } label: { - LandscapeItemElement(item: item) - } - .buttonStyle(PlainNavigationLinkButtonStyle()) - } - Spacer().frame(width: 45) - } - }.frame(height: 350) - } - } - } - } + var body: some View { + ScrollView { + LazyVStack(alignment: .leading) { + if !viewModel.recommendedItems.isEmpty, + let items = viewModel.recommendedItems + { + Text("On Now") + .font(.headline) + .fontWeight(.semibold) + .padding(.leading, 90) + ScrollView(.horizontal, showsIndicators: false) { + LazyHStack { + Spacer().frame(width: 45) + ForEach(items, id: \.id) { item in + Button { + if let chanId = item.channelId, + let chan = viewModel.findChannel(id: chanId) + { + self.programsRouter.route(to: \.videoPlayer, chan) + } + } label: { + LandscapeItemElement(item: item) + } + .buttonStyle(PlainNavigationLinkButtonStyle()) + } + Spacer().frame(width: 45) + } + }.frame(height: 350) + } + if !viewModel.seriesItems.isEmpty, + let items = viewModel.seriesItems + { + Text("Shows") + .font(.headline) + .fontWeight(.semibold) + .padding(.leading, 90) + ScrollView(.horizontal, showsIndicators: false) { + LazyHStack { + Spacer().frame(width: 45) + ForEach(items, id: \.id) { item in + Button { + if let chanId = item.channelId, + let chan = viewModel.findChannel(id: chanId) + { + self.programsRouter.route(to: \.videoPlayer, chan) + } + } label: { + LandscapeItemElement(item: item) + } + .buttonStyle(PlainNavigationLinkButtonStyle()) + } + Spacer().frame(width: 45) + } + }.frame(height: 350) + } + if !viewModel.movieItems.isEmpty, + let items = viewModel.movieItems + { + Text("Movies") + .font(.headline) + .fontWeight(.semibold) + .padding(.leading, 90) + ScrollView(.horizontal, showsIndicators: false) { + LazyHStack { + Spacer().frame(width: 45) + ForEach(items, id: \.id) { item in + Button { + if let chanId = item.channelId, + let chan = viewModel.findChannel(id: chanId) + { + self.programsRouter.route(to: \.videoPlayer, chan) + } + } label: { + LandscapeItemElement(item: item) + } + .buttonStyle(PlainNavigationLinkButtonStyle()) + } + Spacer().frame(width: 45) + } + }.frame(height: 350) + } + if !viewModel.sportsItems.isEmpty, + let items = viewModel.sportsItems + { + Text("Sports") + .font(.headline) + .fontWeight(.semibold) + .padding(.leading, 90) + ScrollView(.horizontal, showsIndicators: false) { + LazyHStack { + Spacer().frame(width: 45) + ForEach(items, id: \.id) { item in + Button { + if let chanId = item.channelId, + let chan = viewModel.findChannel(id: chanId) + { + self.programsRouter.route(to: \.videoPlayer, chan) + } + } label: { + LandscapeItemElement(item: item) + } + .buttonStyle(PlainNavigationLinkButtonStyle()) + } + Spacer().frame(width: 45) + } + }.frame(height: 350) + } + if !viewModel.kidsItems.isEmpty, + let items = viewModel.kidsItems + { + Text("Kids") + .font(.headline) + .fontWeight(.semibold) + .padding(.leading, 90) + ScrollView(.horizontal, showsIndicators: false) { + LazyHStack { + Spacer().frame(width: 45) + ForEach(items, id: \.id) { item in + Button { + if let chanId = item.channelId, + let chan = viewModel.findChannel(id: chanId) + { + self.programsRouter.route(to: \.videoPlayer, chan) + } + } label: { + LandscapeItemElement(item: item) + } + .buttonStyle(PlainNavigationLinkButtonStyle()) + } + Spacer().frame(width: 45) + } + }.frame(height: 350) + } + if !viewModel.newsItems.isEmpty, + let items = viewModel.newsItems + { + Text("News") + .font(.headline) + .fontWeight(.semibold) + .padding(.leading, 90) + ScrollView(.horizontal, showsIndicators: false) { + LazyHStack { + Spacer().frame(width: 45) + ForEach(items, id: \.id) { item in + Button { + if let chanId = item.channelId, + let chan = viewModel.findChannel(id: chanId) + { + self.programsRouter.route(to: \.videoPlayer, chan) + } + } label: { + LandscapeItemElement(item: item) + } + .buttonStyle(PlainNavigationLinkButtonStyle()) + } + Spacer().frame(width: 45) + } + }.frame(height: 350) + } + } + } + } } diff --git a/Swiftfin tvOS/Views/MovieLibrariesView.swift b/Swiftfin tvOS/Views/MovieLibrariesView.swift index ab87ce24..3e906b68 100644 --- a/Swiftfin tvOS/Views/MovieLibrariesView.swift +++ b/Swiftfin tvOS/Views/MovieLibrariesView.swift @@ -1,89 +1,81 @@ -/* - * JellyfinPlayer/Swiftfin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// +import JellyfinAPI import SwiftUI import SwiftUICollection -import JellyfinAPI struct MovieLibrariesView: View { - @EnvironmentObject var movieLibrariesRouter: MovieLibrariesCoordinator.Router - @StateObject var viewModel: MovieLibrariesViewModel - var title: String + @EnvironmentObject + var movieLibrariesRouter: MovieLibrariesCoordinator.Router + @StateObject + var viewModel: MovieLibrariesViewModel + var title: String - var body: some View { - if viewModel.isLoading == true { - ProgressView() - } else if !viewModel.rows.isEmpty { - CollectionView(rows: viewModel.rows) { _, _ in - let itemSize = NSCollectionLayoutSize( - widthDimension: .fractionalWidth(1), - heightDimension: .fractionalHeight(1) - ) - let item = NSCollectionLayoutItem(layoutSize: itemSize) + var body: some View { + if viewModel.isLoading == true { + ProgressView() + } else if !viewModel.rows.isEmpty { + CollectionView(rows: viewModel.rows) { _, _ in + let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), + heightDimension: .fractionalHeight(1)) + let item = NSCollectionLayoutItem(layoutSize: itemSize) - let groupSize = NSCollectionLayoutSize( - widthDimension: .absolute(200), - heightDimension: .absolute(300) - ) - let group = NSCollectionLayoutGroup.horizontal( - layoutSize: groupSize, - subitems: [item] - ) + let groupSize = NSCollectionLayoutSize(widthDimension: .absolute(200), + heightDimension: .absolute(300)) + let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, + subitems: [item]) - let header = - NSCollectionLayoutBoundarySupplementaryItem( - layoutSize: NSCollectionLayoutSize( - widthDimension: .fractionalWidth(1), - heightDimension: .absolute(44) - ), - elementKind: UICollectionView.elementKindSectionHeader, - alignment: .topLeading - ) + let header = + NSCollectionLayoutBoundarySupplementaryItem(layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), + heightDimension: .absolute(44)), + elementKind: UICollectionView.elementKindSectionHeader, + alignment: .topLeading) - let section = NSCollectionLayoutSection(group: group) + let section = NSCollectionLayoutSection(group: group) - section.contentInsets = NSDirectionalEdgeInsets(top: 30, leading: 0, bottom: 80, trailing: 80) - section.interGroupSpacing = 48 - section.orthogonalScrollingBehavior = .continuous - section.boundarySupplementaryItems = [header] - return section - } cell: { _, cell in - GeometryReader { _ in - if let item = cell.item { - if item.type != "Folder" { - Button { - self.movieLibrariesRouter.route(to: \.library, item) - } label: { - PortraitItemElement(item: item) - } - .buttonStyle(PlainNavigationLinkButtonStyle()) - } - } else if cell.loadingCell { - ProgressView() - .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity, alignment: .center) - } - } - } supplementaryView: { _, indexPath in - HStack { - Spacer() - }.accessibilityIdentifier("\(indexPath.section).\(indexPath.row)") - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .ignoresSafeArea(.all) - } else { - VStack { - L10n.noResults.text - Button { - print("movieLibraries reload") - } label: { - Text("Reload") - } - } - } - } + section.contentInsets = NSDirectionalEdgeInsets(top: 30, leading: 0, bottom: 80, trailing: 80) + section.interGroupSpacing = 48 + section.orthogonalScrollingBehavior = .continuous + section.boundarySupplementaryItems = [header] + return section + } cell: { _, cell in + GeometryReader { _ in + if let item = cell.item { + if item.type != "Folder" { + Button { + self.movieLibrariesRouter.route(to: \.library, item) + } label: { + PortraitItemElement(item: item) + } + .buttonStyle(PlainNavigationLinkButtonStyle()) + } + } else if cell.loadingCell { + ProgressView() + .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity, alignment: .center) + } + } + } supplementaryView: { _, indexPath in + HStack { + Spacer() + }.accessibilityIdentifier("\(indexPath.section).\(indexPath.row)") + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .ignoresSafeArea(.all) + } else { + VStack { + L10n.noResults.text + Button { + print("movieLibraries reload") + } label: { + Text("Reload") + } + } + } + } } diff --git a/Swiftfin tvOS/Views/NextUpView/NextUpCard.swift b/Swiftfin tvOS/Views/NextUpView/NextUpCard.swift index 53094ac3..8e3e9083 100644 --- a/Swiftfin tvOS/Views/NextUpView/NextUpCard.swift +++ b/Swiftfin tvOS/Views/NextUpView/NextUpCard.swift @@ -1,51 +1,51 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import JellyfinAPI import SwiftUI struct NextUpCard: View { - - @EnvironmentObject var homeRouter: HomeCoordinator.Router - let item: BaseItemDto - - var body: some View { - VStack(alignment: .leading) { - Button { - homeRouter.route(to: \.modalItem, item) - } label: { - if item.itemType == .episode { - ImageView(src: item.getSeriesBackdropImage(maxWidth: 500)) - .frame(width: 500, height: 281.25) - } else { - ImageView(src: item.getBackdropImage(maxWidth: 500)) - .frame(width: 500, height: 281.25) - } - } - .buttonStyle(CardButtonStyle()) - .padding(.top) - - VStack(alignment: .leading) { - Text("\(item.seriesName ?? item.name ?? "")") - .font(.callout) - .fontWeight(.semibold) - .foregroundColor(.primary) - .lineLimit(1) - - if item.itemType == .episode { - Text(item.getEpisodeLocator() ?? "") - .font(.callout) - .fontWeight(.medium) - .foregroundColor(.secondary) - .lineLimit(1) - } - } - } - } + + @EnvironmentObject + var homeRouter: HomeCoordinator.Router + let item: BaseItemDto + + var body: some View { + VStack(alignment: .leading) { + Button { + homeRouter.route(to: \.modalItem, item) + } label: { + if item.itemType == .episode { + ImageView(src: item.getSeriesBackdropImage(maxWidth: 500)) + .frame(width: 500, height: 281.25) + } else { + ImageView(src: item.getBackdropImage(maxWidth: 500)) + .frame(width: 500, height: 281.25) + } + } + .buttonStyle(CardButtonStyle()) + .padding(.top) + + VStack(alignment: .leading) { + Text("\(item.seriesName ?? item.name ?? "")") + .font(.callout) + .fontWeight(.semibold) + .foregroundColor(.primary) + .lineLimit(1) + + if item.itemType == .episode { + Text(item.getEpisodeLocator() ?? "") + .font(.callout) + .fontWeight(.medium) + .foregroundColor(.secondary) + .lineLimit(1) + } + } + } + } } diff --git a/Swiftfin tvOS/Views/NextUpView/NextUpView.swift b/Swiftfin tvOS/Views/NextUpView/NextUpView.swift index b4d8062c..24f3bee7 100644 --- a/Swiftfin tvOS/Views/NextUpView/NextUpView.swift +++ b/Swiftfin tvOS/Views/NextUpView/NextUpView.swift @@ -1,37 +1,37 @@ -/* - * JellyfinPlayer/Swiftfin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// -import SwiftUI -import JellyfinAPI import Combine +import JellyfinAPI import Stinsen +import SwiftUI struct NextUpView: View { - var items: [BaseItemDto] + var items: [BaseItemDto] - var homeRouter: HomeCoordinator.Router? = RouterStore.shared.retrieve() + var homeRouter: HomeCoordinator.Router? = RouterStore.shared.retrieve() - var body: some View { - VStack(alignment: .leading) { - - L10n.nextUp.text - .font(.title3) - .fontWeight(.semibold) - .padding(.leading, 50) - - ScrollView(.horizontal, showsIndicators: false) { - LazyHStack { - ForEach(items, id: \.id) { item in - NextUpCard(item: item) - } - } - .padding(.horizontal, 50) - } - } - } + var body: some View { + VStack(alignment: .leading) { + + L10n.nextUp.text + .font(.title3) + .fontWeight(.semibold) + .padding(.leading, 50) + + ScrollView(.horizontal, showsIndicators: false) { + LazyHStack { + ForEach(items, id: \.id) { item in + NextUpCard(item: item) + } + } + .padding(.horizontal, 50) + } + } + } } diff --git a/Swiftfin tvOS/Views/ServerDetailView.swift b/Swiftfin tvOS/Views/ServerDetailView.swift index dbb8e166..df096643 100644 --- a/Swiftfin tvOS/Views/ServerDetailView.swift +++ b/Swiftfin tvOS/Views/ServerDetailView.swift @@ -1,50 +1,50 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import SwiftUI struct ServerDetailView: View { - @ObservedObject var viewModel: ServerDetailViewModel + @ObservedObject + var viewModel: ServerDetailViewModel - var body: some View { - Form { - Section(header: Text("Server Details")) { - HStack { - Text("Name") - Spacer() - Text(SessionManager.main.currentLogin.server.name) - .foregroundColor(.secondary) - } - .focusable() + var body: some View { + Form { + Section(header: Text("Server Details")) { + HStack { + Text("Name") + Spacer() + Text(SessionManager.main.currentLogin.server.name) + .foregroundColor(.secondary) + } + .focusable() - HStack { - Text("URI") - Spacer() - Text(SessionManager.main.currentLogin.server.currentURI) - .foregroundColor(.secondary) - } + HStack { + Text("URI") + Spacer() + Text(SessionManager.main.currentLogin.server.currentURI) + .foregroundColor(.secondary) + } - HStack { - Text("Version") - Spacer() - Text(SessionManager.main.currentLogin.server.version) - .foregroundColor(.secondary) - } + HStack { + Text("Version") + Spacer() + Text(SessionManager.main.currentLogin.server.version) + .foregroundColor(.secondary) + } - HStack { - Text("Operating System") - Spacer() - Text(SessionManager.main.currentLogin.server.os) - .foregroundColor(.secondary) - } - } - } - } + HStack { + Text("Operating System") + Spacer() + Text(SessionManager.main.currentLogin.server.os) + .foregroundColor(.secondary) + } + } + } + } } diff --git a/Swiftfin tvOS/Views/ServerListView.swift b/Swiftfin tvOS/Views/ServerListView.swift index a62d454f..3d8c2dd4 100644 --- a/Swiftfin tvOS/Views/ServerListView.swift +++ b/Swiftfin tvOS/Views/ServerListView.swift @@ -1,129 +1,130 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import CoreStore import SwiftUI struct ServerListView: View { - @EnvironmentObject var serverListRouter: ServerListCoordinator.Router - @ObservedObject var viewModel: ServerListViewModel + @EnvironmentObject + var serverListRouter: ServerListCoordinator.Router + @ObservedObject + var viewModel: ServerListViewModel - @ViewBuilder - private var listView: some View { - ScrollView { - LazyVStack { - ForEach(viewModel.servers, id: \.id) { server in - Button { - serverListRouter.route(to: \.userList, server) - } label: { - HStack { - Image(systemName: "server.rack") - .font(.system(size: 72)) - .foregroundColor(.primary) + @ViewBuilder + private var listView: some View { + ScrollView { + LazyVStack { + ForEach(viewModel.servers, id: \.id) { server in + Button { + serverListRouter.route(to: \.userList, server) + } label: { + HStack { + Image(systemName: "server.rack") + .font(.system(size: 72)) + .foregroundColor(.primary) - VStack(alignment: .leading, spacing: 5) { - Text(server.name) - .font(.title2) - .foregroundColor(.primary) + VStack(alignment: .leading, spacing: 5) { + Text(server.name) + .font(.title2) + .foregroundColor(.primary) - Text(server.currentURI) - .font(.footnote) - .disabled(true) - .foregroundColor(.secondary) + Text(server.currentURI) + .font(.footnote) + .disabled(true) + .foregroundColor(.secondary) - Text(viewModel.userTextFor(server: server)) - .font(.footnote) - .foregroundColor(.primary) - } + Text(viewModel.userTextFor(server: server)) + .font(.footnote) + .foregroundColor(.primary) + } - Spacer() - } - } - .padding(.horizontal, 100) - .contextMenu { - Button(role: .destructive) { - viewModel.remove(server: server) - } label: { - Label("Remove", systemImage: "trash") - } - } - } - } - .padding(.top, 50) - } - .padding(.top, 50) - } + Spacer() + } + } + .padding(.horizontal, 100) + .contextMenu { + Button(role: .destructive) { + viewModel.remove(server: server) + } label: { + Label("Remove", systemImage: "trash") + } + } + } + } + .padding(.top, 50) + } + .padding(.top, 50) + } - @ViewBuilder - private var noServerView: some View { - VStack { - Text("Connect to a Jellyfin server to get started") - .frame(minWidth: 50, maxWidth: 500) - .multilineTextAlignment(.center) - .font(.body) + @ViewBuilder + private var noServerView: some View { + VStack { + Text("Connect to a Jellyfin server to get started") + .frame(minWidth: 50, maxWidth: 500) + .multilineTextAlignment(.center) + .font(.body) - Button { - serverListRouter.route(to: \.connectToServer) - } label: { - L10n.connect.text - .bold() - .font(.callout) - .padding(.vertical) - .padding(.horizontal, 30) - .background(Color.jellyfinPurple) - } - .padding(.top, 40) - .buttonStyle(CardButtonStyle()) - } - } + Button { + serverListRouter.route(to: \.connectToServer) + } label: { + L10n.connect.text + .bold() + .font(.callout) + .padding(.vertical) + .padding(.horizontal, 30) + .background(Color.jellyfinPurple) + } + .padding(.top, 40) + .buttonStyle(CardButtonStyle()) + } + } - @ViewBuilder - private var innerBody: some View { - if viewModel.servers.isEmpty { - noServerView - .offset(y: -50) - } else { - listView - } - } + @ViewBuilder + private var innerBody: some View { + if viewModel.servers.isEmpty { + noServerView + .offset(y: -50) + } else { + listView + } + } - @ViewBuilder - private var trailingToolbarContent: some View { - if viewModel.servers.isEmpty { - EmptyView() - } else { - Button { - serverListRouter.route(to: \.connectToServer) - } label: { - Image(systemName: "plus.circle.fill") - } - .contextMenu { - Button { - serverListRouter.route(to: \.basicAppSettings) - } label: { - Text("Settings") - } - } - } - } + @ViewBuilder + private var trailingToolbarContent: some View { + if viewModel.servers.isEmpty { + EmptyView() + } else { + Button { + serverListRouter.route(to: \.connectToServer) + } label: { + Image(systemName: "plus.circle.fill") + } + .contextMenu { + Button { + serverListRouter.route(to: \.basicAppSettings) + } label: { + Text("Settings") + } + } + } + } - var body: some View { - innerBody - .navigationTitle("Servers") - .toolbar { - ToolbarItemGroup(placement: .navigationBarTrailing) { - trailingToolbarContent - } - } - .onAppear { - viewModel.fetchServers() - } - } + var body: some View { + innerBody + .navigationTitle("Servers") + .toolbar { + ToolbarItemGroup(placement: .navigationBarTrailing) { + trailingToolbarContent + } + } + .onAppear { + viewModel.fetchServers() + } + } } diff --git a/Swiftfin tvOS/Views/SettingsView/ExperimentalSettingsView.swift b/Swiftfin tvOS/Views/SettingsView/ExperimentalSettingsView.swift index b79b53c0..1ec0351a 100644 --- a/Swiftfin tvOS/Views/SettingsView/ExperimentalSettingsView.swift +++ b/Swiftfin tvOS/Views/SettingsView/ExperimentalSettingsView.swift @@ -1,31 +1,32 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Defaults import SwiftUI struct ExperimentalSettingsView: View { - - @Default(.Experimental.syncSubtitleStateWithAdjacent) var syncSubtitleStateWithAdjacent - @Default(.Experimental.liveTVAlphaEnabled) var liveTVAlphaEnabled - - var body: some View { - Form { - Section { - - Toggle("Sync Subtitles with Adjacent Episodes", isOn: $syncSubtitleStateWithAdjacent) - - Toggle("Live TV (Alpha)", isOn: $liveTVAlphaEnabled) - - } header: { - Text("Experimental") - } - } - } + + @Default(.Experimental.syncSubtitleStateWithAdjacent) + var syncSubtitleStateWithAdjacent + @Default(.Experimental.liveTVAlphaEnabled) + var liveTVAlphaEnabled + + var body: some View { + Form { + Section { + + Toggle("Sync Subtitles with Adjacent Episodes", isOn: $syncSubtitleStateWithAdjacent) + + Toggle("Live TV (Alpha)", isOn: $liveTVAlphaEnabled) + + } header: { + Text("Experimental") + } + } + } } diff --git a/Swiftfin tvOS/Views/SettingsView/OverlaySettingsView.swift b/Swiftfin tvOS/Views/SettingsView/OverlaySettingsView.swift index 81f068ac..304151c2 100644 --- a/Swiftfin tvOS/Views/SettingsView/OverlaySettingsView.swift +++ b/Swiftfin tvOS/Views/SettingsView/OverlaySettingsView.swift @@ -1,29 +1,31 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Defaults import SwiftUI struct OverlaySettingsView: View { - - @Default(.shouldShowPlayPreviousItem) var shouldShowPlayPreviousItem - @Default(.shouldShowPlayNextItem) var shouldShowPlayNextItem - @Default(.shouldShowAutoPlay) var shouldShowAutoPlay - - var body: some View { - Form { - Section(header: Text("Overlay")) { - - Toggle("\(Image(systemName: "chevron.left.circle")) Play Previous Item", isOn: $shouldShowPlayPreviousItem) - Toggle("\(Image(systemName: "chevron.right.circle")) Play Next Item", isOn: $shouldShowPlayNextItem) - Toggle("\(Image(systemName: "play.circle.fill")) Auto Play", isOn: $shouldShowAutoPlay) - } - } - } + + @Default(.shouldShowPlayPreviousItem) + var shouldShowPlayPreviousItem + @Default(.shouldShowPlayNextItem) + var shouldShowPlayNextItem + @Default(.shouldShowAutoPlay) + var shouldShowAutoPlay + + var body: some View { + Form { + Section(header: Text("Overlay")) { + + Toggle("\(Image(systemName: "chevron.left.circle")) Play Previous Item", isOn: $shouldShowPlayPreviousItem) + Toggle("\(Image(systemName: "chevron.right.circle")) Play Next Item", isOn: $shouldShowPlayNextItem) + Toggle("\(Image(systemName: "play.circle.fill")) Auto Play", isOn: $shouldShowAutoPlay) + } + } + } } diff --git a/Swiftfin tvOS/Views/SettingsView/SettingsView.swift b/Swiftfin tvOS/Views/SettingsView/SettingsView.swift index 4b6d66bc..a71b97ab 100644 --- a/Swiftfin tvOS/Views/SettingsView/SettingsView.swift +++ b/Swiftfin tvOS/Views/SettingsView/SettingsView.swift @@ -1,133 +1,142 @@ -/* JellyfinPlayer/Swiftfin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import CoreData -import SwiftUI import Defaults import JellyfinAPI +import SwiftUI struct SettingsView: View { - @EnvironmentObject var settingsRouter: SettingsCoordinator.Router - @ObservedObject var viewModel: SettingsViewModel + @EnvironmentObject + var settingsRouter: SettingsCoordinator.Router + @ObservedObject + var viewModel: SettingsViewModel - @Default(.autoSelectAudioLangCode) var autoSelectAudioLangcode - @Default(.videoPlayerJumpForward) var jumpForwardLength - @Default(.videoPlayerJumpBackward) var jumpBackwardLength - @Default(.downActionShowsMenu) var downActionShowsMenu - @Default(.confirmClose) var confirmClose - @Default(.tvOSCinematicViews) var tvOSCinematicViews - @Default(.showPosterLabels) var showPosterLabels - @Default(.resumeOffset) var resumeOffset + @Default(.autoSelectAudioLangCode) + var autoSelectAudioLangcode + @Default(.videoPlayerJumpForward) + var jumpForwardLength + @Default(.videoPlayerJumpBackward) + var jumpBackwardLength + @Default(.downActionShowsMenu) + var downActionShowsMenu + @Default(.confirmClose) + var confirmClose + @Default(.tvOSCinematicViews) + var tvOSCinematicViews + @Default(.showPosterLabels) + var showPosterLabels + @Default(.resumeOffset) + var resumeOffset - var body: some View { - GeometryReader { reader in - HStack { - - Image(uiImage: UIImage(named: "App Icon")!) - .cornerRadius(30) - .scaleEffect(2) - .frame(width: reader.size.width / 2) - - Form { - Section(header: EmptyView()) { - - Button { - - } label: { - HStack { - Text("User") - Spacer() - Text(viewModel.user.username) - .foregroundColor(.jellyfinPurple) - } - } + var body: some View { + GeometryReader { reader in + HStack { - Button { - settingsRouter.route(to: \.serverDetail) - } label: { - HStack { - Text("Server") - .foregroundColor(.primary) - Spacer() - Text(viewModel.server.name) - .foregroundColor(.jellyfinPurple) + Image(uiImage: UIImage(named: "App Icon")!) + .cornerRadius(30) + .scaleEffect(2) + .frame(width: reader.size.width / 2) - Image(systemName: "chevron.right") - .foregroundColor(.jellyfinPurple) - } - } + Form { + Section(header: EmptyView()) { - Button { - SessionManager.main.logout() - } label: { - Text("Switch User") - .foregroundColor(Color.jellyfinPurple) - .font(.callout) - } - } - - Section(header: Text("Video Player")) { - Picker("Jump Forward Length", selection: $jumpForwardLength) { - ForEach(VideoPlayerJumpLength.allCases, id: \.self) { length in - Text(length.label).tag(length.rawValue) - } - } + Button {} label: { + HStack { + Text("User") + Spacer() + Text(viewModel.user.username) + .foregroundColor(.jellyfinPurple) + } + } - Picker("Jump Backward Length", selection: $jumpBackwardLength) { - ForEach(VideoPlayerJumpLength.allCases, id: \.self) { length in - Text(length.label).tag(length.rawValue) - } - } - - Toggle("Resume 5 Second Offset", isOn: $resumeOffset) - - Toggle("Press Down for Menu", isOn: $downActionShowsMenu) - - Toggle("Confirm Close", isOn: $confirmClose) - - Button { - settingsRouter.route(to: \.overlaySettings) - } label: { - HStack { - Text("Overlay") - .foregroundColor(.primary) - Spacer() - Image(systemName: "chevron.right") - } - } - - Button { - settingsRouter.route(to: \.experimentalSettings) - } label: { - HStack { - Text("Experimental") - .foregroundColor(.primary) - Spacer() - Image(systemName: "chevron.right") - } - } - } - - Section { - Toggle("Cinematic Views", isOn: $tvOSCinematicViews) - Toggle("Show Poster Labels", isOn: $showPosterLabels) - - } header: { - Text("Appearance") - } - } - } - } - } + Button { + settingsRouter.route(to: \.serverDetail) + } label: { + HStack { + Text("Server") + .foregroundColor(.primary) + Spacer() + Text(viewModel.server.name) + .foregroundColor(.jellyfinPurple) + + Image(systemName: "chevron.right") + .foregroundColor(.jellyfinPurple) + } + } + + Button { + SessionManager.main.logout() + } label: { + Text("Switch User") + .foregroundColor(Color.jellyfinPurple) + .font(.callout) + } + } + + Section(header: Text("Video Player")) { + Picker("Jump Forward Length", selection: $jumpForwardLength) { + ForEach(VideoPlayerJumpLength.allCases, id: \.self) { length in + Text(length.label).tag(length.rawValue) + } + } + + Picker("Jump Backward Length", selection: $jumpBackwardLength) { + ForEach(VideoPlayerJumpLength.allCases, id: \.self) { length in + Text(length.label).tag(length.rawValue) + } + } + + Toggle("Resume 5 Second Offset", isOn: $resumeOffset) + + Toggle("Press Down for Menu", isOn: $downActionShowsMenu) + + Toggle("Confirm Close", isOn: $confirmClose) + + Button { + settingsRouter.route(to: \.overlaySettings) + } label: { + HStack { + Text("Overlay") + .foregroundColor(.primary) + Spacer() + Image(systemName: "chevron.right") + } + } + + Button { + settingsRouter.route(to: \.experimentalSettings) + } label: { + HStack { + Text("Experimental") + .foregroundColor(.primary) + Spacer() + Image(systemName: "chevron.right") + } + } + } + + Section { + Toggle("Cinematic Views", isOn: $tvOSCinematicViews) + Toggle("Show Poster Labels", isOn: $showPosterLabels) + + } header: { + Text("Appearance") + } + } + } + } + } } struct SettingsView_Previews: PreviewProvider { - static var previews: some View { - SettingsView(viewModel: SettingsViewModel(server: .sample, user: .sample)) - } + static var previews: some View { + SettingsView(viewModel: SettingsViewModel(server: .sample, user: .sample)) + } } diff --git a/Swiftfin tvOS/Views/TVLibrariesView.swift b/Swiftfin tvOS/Views/TVLibrariesView.swift index 25c06ceb..ec5f4e9c 100644 --- a/Swiftfin tvOS/Views/TVLibrariesView.swift +++ b/Swiftfin tvOS/Views/TVLibrariesView.swift @@ -1,89 +1,81 @@ -/* - * JellyfinPlayer/Swiftfin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// +import JellyfinAPI import SwiftUI import SwiftUICollection -import JellyfinAPI struct TVLibrariesView: View { - @EnvironmentObject var tvLibrariesRouter: TVLibrariesCoordinator.Router - @StateObject var viewModel: TVLibrariesViewModel - var title: String + @EnvironmentObject + var tvLibrariesRouter: TVLibrariesCoordinator.Router + @StateObject + var viewModel: TVLibrariesViewModel + var title: String - var body: some View { - if viewModel.isLoading == true { - ProgressView() - } else if !viewModel.rows.isEmpty { - CollectionView(rows: viewModel.rows) { _, _ in - let itemSize = NSCollectionLayoutSize( - widthDimension: .fractionalWidth(1), - heightDimension: .fractionalHeight(1) - ) - let item = NSCollectionLayoutItem(layoutSize: itemSize) + var body: some View { + if viewModel.isLoading == true { + ProgressView() + } else if !viewModel.rows.isEmpty { + CollectionView(rows: viewModel.rows) { _, _ in + let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), + heightDimension: .fractionalHeight(1)) + let item = NSCollectionLayoutItem(layoutSize: itemSize) - let groupSize = NSCollectionLayoutSize( - widthDimension: .absolute(200), - heightDimension: .absolute(300) - ) - let group = NSCollectionLayoutGroup.horizontal( - layoutSize: groupSize, - subitems: [item] - ) + let groupSize = NSCollectionLayoutSize(widthDimension: .absolute(200), + heightDimension: .absolute(300)) + let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, + subitems: [item]) - let header = - NSCollectionLayoutBoundarySupplementaryItem( - layoutSize: NSCollectionLayoutSize( - widthDimension: .fractionalWidth(1), - heightDimension: .absolute(44) - ), - elementKind: UICollectionView.elementKindSectionHeader, - alignment: .topLeading - ) + let header = + NSCollectionLayoutBoundarySupplementaryItem(layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), + heightDimension: .absolute(44)), + elementKind: UICollectionView.elementKindSectionHeader, + alignment: .topLeading) - let section = NSCollectionLayoutSection(group: group) + let section = NSCollectionLayoutSection(group: group) - section.contentInsets = NSDirectionalEdgeInsets(top: 30, leading: 0, bottom: 80, trailing: 80) - section.interGroupSpacing = 48 - section.orthogonalScrollingBehavior = .continuous - section.boundarySupplementaryItems = [header] - return section - } cell: { _, cell in - GeometryReader { _ in - if let item = cell.item { - if item.type != "Folder" { - Button { - self.tvLibrariesRouter.route(to: \.library, item) - } label: { - PortraitItemElement(item: item) - } - .buttonStyle(PlainNavigationLinkButtonStyle()) - } - } else if cell.loadingCell { - ProgressView() - .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity, alignment: .center) - } - } - } supplementaryView: { _, indexPath in - HStack { - Spacer() - }.accessibilityIdentifier("\(indexPath.section).\(indexPath.row)") - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .ignoresSafeArea(.all) - } else { - VStack { - L10n.noResults.text - Button { - print("tvLibraries reload") - } label: { - Text("Reload") - } - } - } - } + section.contentInsets = NSDirectionalEdgeInsets(top: 30, leading: 0, bottom: 80, trailing: 80) + section.interGroupSpacing = 48 + section.orthogonalScrollingBehavior = .continuous + section.boundarySupplementaryItems = [header] + return section + } cell: { _, cell in + GeometryReader { _ in + if let item = cell.item { + if item.type != "Folder" { + Button { + self.tvLibrariesRouter.route(to: \.library, item) + } label: { + PortraitItemElement(item: item) + } + .buttonStyle(PlainNavigationLinkButtonStyle()) + } + } else if cell.loadingCell { + ProgressView() + .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity, alignment: .center) + } + } + } supplementaryView: { _, indexPath in + HStack { + Spacer() + }.accessibilityIdentifier("\(indexPath.section).\(indexPath.row)") + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .ignoresSafeArea(.all) + } else { + VStack { + L10n.noResults.text + Button { + print("tvLibraries reload") + } label: { + Text("Reload") + } + } + } + } } diff --git a/Swiftfin tvOS/Views/UserListView.swift b/Swiftfin tvOS/Views/UserListView.swift index bc7c93f9..983035a5 100644 --- a/Swiftfin tvOS/Views/UserListView.swift +++ b/Swiftfin tvOS/Views/UserListView.swift @@ -1,107 +1,108 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import SwiftUI struct UserListView: View { - @EnvironmentObject var userListRouter: UserListCoordinator.Router - @ObservedObject var viewModel: UserListViewModel + @EnvironmentObject + var userListRouter: UserListCoordinator.Router + @ObservedObject + var viewModel: UserListViewModel - @ViewBuilder - private var listView: some View { - ScrollView { - LazyVStack { - ForEach(viewModel.users, id: \.id) { user in - Button { - viewModel.login(user: user) - } label: { - HStack { - Text(user.username) - .font(.title2) + @ViewBuilder + private var listView: some View { + ScrollView { + LazyVStack { + ForEach(viewModel.users, id: \.id) { user in + Button { + viewModel.login(user: user) + } label: { + HStack { + Text(user.username) + .font(.title2) - Spacer() + Spacer() - if viewModel.isLoading { - ProgressView() - } - } - } - .padding(.horizontal, 100) - .contextMenu { - Button(role: .destructive) { - viewModel.remove(user: user) - } label: { - Label("Remove", systemImage: "trash") - } - } - } - } - .padding(.top, 50) - } - .padding(.top, 50) - } + if viewModel.isLoading { + ProgressView() + } + } + } + .padding(.horizontal, 100) + .contextMenu { + Button(role: .destructive) { + viewModel.remove(user: user) + } label: { + Label("Remove", systemImage: "trash") + } + } + } + } + .padding(.top, 50) + } + .padding(.top, 50) + } - @ViewBuilder - private var noUserView: some View { - VStack { - Text("Sign in to get started") - .frame(minWidth: 50, maxWidth: 500) - .multilineTextAlignment(.center) - .font(.callout) + @ViewBuilder + private var noUserView: some View { + VStack { + Text("Sign in to get started") + .frame(minWidth: 50, maxWidth: 500) + .multilineTextAlignment(.center) + .font(.callout) - Button { - userListRouter.route(to: \.userSignIn, viewModel.server) - } label: { - Text("Sign in") - .bold() - .font(.callout) - } - .padding(.top, 40) - } - } + Button { + userListRouter.route(to: \.userSignIn, viewModel.server) + } label: { + Text("Sign in") + .bold() + .font(.callout) + } + .padding(.top, 40) + } + } - @ViewBuilder - private var innerBody: some View { - if viewModel.users.isEmpty { - noUserView - .offset(y: -50) - } else { - listView - } - } + @ViewBuilder + private var innerBody: some View { + if viewModel.users.isEmpty { + noUserView + .offset(y: -50) + } else { + listView + } + } - @ViewBuilder - private var toolbarContent: some View { - if viewModel.users.isEmpty { - EmptyView() - } else { - HStack { - Button { - userListRouter.route(to: \.userSignIn, viewModel.server) - } label: { - Image(systemName: "person.crop.circle.fill.badge.plus") - } - } - } - } + @ViewBuilder + private var toolbarContent: some View { + if viewModel.users.isEmpty { + EmptyView() + } else { + HStack { + Button { + userListRouter.route(to: \.userSignIn, viewModel.server) + } label: { + Image(systemName: "person.crop.circle.fill.badge.plus") + } + } + } + } - var body: some View { - innerBody - .navigationTitle(viewModel.server.name) - .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - toolbarContent - } - } - .onAppear { - viewModel.fetchUsers() - } - } + var body: some View { + innerBody + .navigationTitle(viewModel.server.name) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + toolbarContent + } + } + .onAppear { + viewModel.fetchUsers() + } + } } diff --git a/Swiftfin tvOS/Views/UserSignInView.swift b/Swiftfin tvOS/Views/UserSignInView.swift index 2e78d2b5..4caa8717 100644 --- a/Swiftfin tvOS/Views/UserSignInView.swift +++ b/Swiftfin tvOS/Views/UserSignInView.swift @@ -1,55 +1,57 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// -import SwiftUI import Stinsen +import SwiftUI struct UserSignInView: View { - @ObservedObject var viewModel: UserSignInViewModel - @State private var username: String = "" - @State private var password: String = "" + @ObservedObject + var viewModel: UserSignInViewModel + @State + private var username: String = "" + @State + private var password: String = "" - var body: some View { - Form { + var body: some View { + Form { - Section { - TextField(L10n.username, text: $username) - .disableAutocorrection(true) - .autocapitalization(.none) + Section { + TextField(L10n.username, text: $username) + .disableAutocorrection(true) + .autocapitalization(.none) - SecureField(L10n.password, text: $password) - .disableAutocorrection(true) - .autocapitalization(.none) + SecureField(L10n.password, text: $password) + .disableAutocorrection(true) + .autocapitalization(.none) - Button { - viewModel.login(username: username, password: password) - } label: { - HStack { - L10n.connect.text - Spacer() - if viewModel.isLoading { - ProgressView() - } - } - } - .disabled(viewModel.isLoading || username.isEmpty) + Button { + viewModel.login(username: username, password: password) + } label: { + HStack { + L10n.connect.text + Spacer() + if viewModel.isLoading { + ProgressView() + } + } + } + .disabled(viewModel.isLoading || username.isEmpty) - } header: { - Text("Sign In to \(viewModel.server.name)") - } - } - .alert(item: $viewModel.errorMessage) { _ in - Alert(title: Text(viewModel.alertTitle), - message: Text(viewModel.errorMessage?.displayMessage ?? "Unknown Error"), - dismissButton: .cancel()) - } - .navigationTitle("Sign In") - } + } header: { + Text("Sign In to \(viewModel.server.name)") + } + } + .alert(item: $viewModel.errorMessage) { _ in + Alert(title: Text(viewModel.alertTitle), + message: Text(viewModel.errorMessage?.displayMessage ?? "Unknown Error"), + dismissButton: .cancel()) + } + .navigationTitle("Sign In") + } } diff --git a/Swiftfin tvOS/Views/VideoPlayer/PlayerOverlayDelegate.swift b/Swiftfin tvOS/Views/VideoPlayer/PlayerOverlayDelegate.swift index fbc91a7c..4ed7ec35 100644 --- a/Swiftfin tvOS/Views/VideoPlayer/PlayerOverlayDelegate.swift +++ b/Swiftfin tvOS/Views/VideoPlayer/PlayerOverlayDelegate.swift @@ -1,31 +1,30 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Foundation protocol PlayerOverlayDelegate { - - func didSelectClose() - func didSelectMenu() - - func didSelectBackward() - func didSelectForward() - func didSelectMain() - - func didGenerallyTap() - - func didBeginScrubbing() - func didEndScrubbing() - - func didSelectAudioStream(index: Int) - func didSelectSubtitleStream(index: Int) - - func didSelectPlayPreviousItem() - func didSelectPlayNextItem() + + func didSelectClose() + func didSelectMenu() + + func didSelectBackward() + func didSelectForward() + func didSelectMain() + + func didGenerallyTap() + + func didBeginScrubbing() + func didEndScrubbing() + + func didSelectAudioStream(index: Int) + func didSelectSubtitleStream(index: Int) + + func didSelectPlayPreviousItem() + func didSelectPlayNextItem() } diff --git a/Swiftfin tvOS/Views/VideoPlayer/VLCPlayerViewController.swift b/Swiftfin tvOS/Views/VideoPlayer/VLCPlayerViewController.swift index 26f2eb80..29672a2c 100644 --- a/Swiftfin tvOS/Views/VideoPlayer/VLCPlayerViewController.swift +++ b/Swiftfin tvOS/Views/VideoPlayer/VLCPlayerViewController.swift @@ -1,842 +1,868 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// -import AVKit import AVFoundation +import AVKit import Combine import Defaults import JellyfinAPI import MediaPlayer -import TVVLCKit import SwiftUI +import TVVLCKit import UIKit // TODO: Look at making the VLC player layer a view class VLCPlayerViewController: UIViewController { - - // MARK: variables - - private var viewModel: VideoPlayerViewModel - private var vlcMediaPlayer: VLCMediaPlayer - private var lastPlayerTicks: Int64 = 0 - private var lastProgressReportTicks: Int64 = 0 - private var viewModelListeners = Set() - private var overlayDismissTimer: Timer? - private var confirmCloseOverlayDismissTimer: Timer? - - private var currentPlayerTicks: Int64 { - return Int64(vlcMediaPlayer.time.intValue) * 100_000 - } - - private var displayingOverlay: Bool { - return currentOverlayHostingController?.view.alpha ?? 0 > 0 - } - - private var displayingContentOverlay: Bool { - return currentOverlayContentHostingController?.view.alpha ?? 0 > 0 - } - - private var displayingConfirmClose: Bool { - return currentConfirmCloseHostingController?.view.alpha ?? 0 > 0 - } - - private lazy var videoContentView = makeVideoContentView() - private lazy var jumpBackwardOverlayView = makeJumpBackwardOverlayView() - private lazy var jumpForwardOverlayView = makeJumpForwardOverlayView() - private var currentOverlayHostingController: UIHostingController? - private var currentOverlayContentHostingController: UIHostingController? - private var currentConfirmCloseHostingController: UIHostingController? - - // MARK: init - - init(viewModel: VideoPlayerViewModel) { - - self.viewModel = viewModel - self.vlcMediaPlayer = VLCMediaPlayer() - - super.init(nibName: nil, bundle: nil) - - viewModel.playerOverlayDelegate = self - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - private func setupSubviews() { - view.addSubview(videoContentView) - view.addSubview(jumpForwardOverlayView) - view.addSubview(jumpBackwardOverlayView) - - jumpBackwardOverlayView.alpha = 0 - jumpForwardOverlayView.alpha = 0 - } - - private func setupConstraints() { - NSLayoutConstraint.activate([ - videoContentView.topAnchor.constraint(equalTo: view.topAnchor), - videoContentView.bottomAnchor.constraint(equalTo: view.bottomAnchor), - videoContentView.leftAnchor.constraint(equalTo: view.leftAnchor), - videoContentView.rightAnchor.constraint(equalTo: view.rightAnchor) - ]) - NSLayoutConstraint.activate([ - jumpBackwardOverlayView.leftAnchor.constraint(equalTo: view.leftAnchor, constant: 300), - jumpBackwardOverlayView.centerYAnchor.constraint(equalTo: view.centerYAnchor) - ]) - NSLayoutConstraint.activate([ - jumpForwardOverlayView.rightAnchor.constraint(equalTo: view.rightAnchor, constant: -300), - jumpForwardOverlayView.centerYAnchor.constraint(equalTo: view.centerYAnchor) - ]) - } - - // MARK: viewWillDisappear - - override func viewWillDisappear(_ animated: Bool) { - super.viewWillDisappear(animated) - - didSelectClose() - - let defaultNotificationCenter = NotificationCenter.default - defaultNotificationCenter.removeObserver(self, name: UIApplication.willTerminateNotification, object: nil) - defaultNotificationCenter.removeObserver(self, name: UIApplication.willResignActiveNotification, object: nil) - defaultNotificationCenter.removeObserver(self, name: UIApplication.didEnterBackgroundNotification, object: nil) - } - - // MARK: viewDidLoad - - override func viewDidLoad() { - super.viewDidLoad() - - setupSubviews() - setupConstraints() - - view.backgroundColor = .black - - setupMediaPlayer(newViewModel: viewModel) - - setupPanGestureRecognizer() - - addButtonPressRecognizer(pressType: .menu, action: #selector(didPressMenu)) - - let defaultNotificationCenter = NotificationCenter.default - defaultNotificationCenter.addObserver(self, selector: #selector(appWillTerminate), name: UIApplication.willTerminateNotification, object: nil) - defaultNotificationCenter.addObserver(self, selector: #selector(appWillResignActive), name: UIApplication.willResignActiveNotification, object: nil) - defaultNotificationCenter.addObserver(self, selector: #selector(appWillResignActive), name: UIApplication.didEnterBackgroundNotification, object: nil) - } - - @objc private func appWillTerminate() { - viewModel.sendStopReport() - } - - @objc private func appWillResignActive() { - showOverlay() - - stopOverlayDismissTimer() - - vlcMediaPlayer.pause() - - viewModel.sendPauseReport(paused: true) - } - - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - - startPlayback() - } - - // MARK: subviews - - private func makeVideoContentView() -> UIView { - let view = UIView() - view.translatesAutoresizingMaskIntoConstraints = false - view.backgroundColor = .black - - return view - } - - private func makeJumpBackwardOverlayView() -> UIImageView { - let symbolConfig = UIImage.SymbolConfiguration(pointSize: 72) - let forwardSymbolImage = UIImage(systemName: viewModel.jumpBackwardLength.backwardImageLabel, withConfiguration: symbolConfig) - let imageView = UIImageView(image: forwardSymbolImage) - imageView.translatesAutoresizingMaskIntoConstraints = false - - return imageView - } - - private func makeJumpForwardOverlayView() -> UIImageView { - let symbolConfig = UIImage.SymbolConfiguration(pointSize: 72) - let forwardSymbolImage = UIImage(systemName: viewModel.jumpForwardLength.forwardImageLabel, withConfiguration: symbolConfig) - let imageView = UIImageView(image: forwardSymbolImage) - imageView.translatesAutoresizingMaskIntoConstraints = false - - return imageView - } - - private func setupPanGestureRecognizer() { - let panGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(userPanned(panGestureRecognizer:))) - view.addGestureRecognizer(panGestureRecognizer) - } - - // MARK: pressesBegan - override func pressesBegan(_ presses: Set, with event: UIPressesEvent?) { - guard let buttonPress = presses.first?.type else { return } - - switch(buttonPress) { - case .menu: () // Captured by other recognizer - case .playPause: - hideConfirmCloseOverlay() - - didSelectMain() - case .select: - hideConfirmCloseOverlay() - - didGenerallyTap() - case .upArrow: - hideConfirmCloseOverlay() - case .downArrow: - hideConfirmCloseOverlay() - - if Defaults[.downActionShowsMenu] { - if !displayingContentOverlay && !displayingOverlay { - didSelectMenu() - } - } - case .leftArrow: - hideConfirmCloseOverlay() - - if !displayingContentOverlay && !displayingOverlay { - didSelectBackward() - } - case .rightArrow: - hideConfirmCloseOverlay() - - if !displayingContentOverlay && !displayingOverlay { - didSelectForward() - } - case .pageUp: () - case .pageDown: () - @unknown default: () - } - } - - private func addButtonPressRecognizer(pressType: UIPress.PressType, action: Selector) { - let pressRecognizer = UITapGestureRecognizer() - pressRecognizer.addTarget(self, action: action) - pressRecognizer.allowedPressTypes = [NSNumber(value: pressType.rawValue)] - view.addGestureRecognizer(pressRecognizer) - } - - // MARK: didPressMenu - @objc private func didPressMenu() { - if displayingOverlay { - hideOverlay() - } else if displayingContentOverlay { - hideOverlayContent() - } else if viewModel.confirmClose && !displayingConfirmClose { - - showConfirmCloseOverlay() - restartConfirmCloseDismissTimer() - - } else { - vlcMediaPlayer.pause() - - dismiss(animated: true, completion: nil) - } - } - - @objc private func userPanned(panGestureRecognizer: UIPanGestureRecognizer) { - if displayingOverlay { - restartOverlayDismissTimer() - } - } - - // MARK: setupOverlayHostingController - private func setupOverlayHostingController(viewModel: VideoPlayerViewModel) { - // TODO: Look at injecting viewModel into the environment so it updates the current overlay - - // Main overlay - if let currentOverlayHostingController = currentOverlayHostingController { - // UX fade-out - UIView.animate(withDuration: 0.5) { - currentOverlayHostingController.view.alpha = 0 - } completion: { _ in - currentOverlayHostingController.view.isHidden = true + // MARK: variables - currentOverlayHostingController.view.removeFromSuperview() - currentOverlayHostingController.removeFromParent() - } - } + private var viewModel: VideoPlayerViewModel + private var vlcMediaPlayer: VLCMediaPlayer + private var lastPlayerTicks: Int64 = 0 + private var lastProgressReportTicks: Int64 = 0 + private var viewModelListeners = Set() + private var overlayDismissTimer: Timer? + private var confirmCloseOverlayDismissTimer: Timer? - let newOverlayView = tvOSVLCOverlay(viewModel: viewModel) - let newOverlayHostingController = UIHostingController(rootView: newOverlayView) + private var currentPlayerTicks: Int64 { + Int64(vlcMediaPlayer.time.intValue) * 100_000 + } - newOverlayHostingController.view.translatesAutoresizingMaskIntoConstraints = false - newOverlayHostingController.view.backgroundColor = UIColor.clear + private var displayingOverlay: Bool { + currentOverlayHostingController?.view.alpha ?? 0 > 0 + } - // UX fade-in - newOverlayHostingController.view.alpha = 0 + private var displayingContentOverlay: Bool { + currentOverlayContentHostingController?.view.alpha ?? 0 > 0 + } - addChild(newOverlayHostingController) - view.addSubview(newOverlayHostingController.view) - newOverlayHostingController.didMove(toParent: self) + private var displayingConfirmClose: Bool { + currentConfirmCloseHostingController?.view.alpha ?? 0 > 0 + } - NSLayoutConstraint.activate([ - newOverlayHostingController.view.topAnchor.constraint(equalTo: videoContentView.topAnchor), - newOverlayHostingController.view.bottomAnchor.constraint(equalTo: videoContentView.bottomAnchor), - newOverlayHostingController.view.leftAnchor.constraint(equalTo: videoContentView.leftAnchor), - newOverlayHostingController.view.rightAnchor.constraint(equalTo: videoContentView.rightAnchor) - ]) + private lazy var videoContentView = makeVideoContentView() + private lazy var jumpBackwardOverlayView = makeJumpBackwardOverlayView() + private lazy var jumpForwardOverlayView = makeJumpForwardOverlayView() + private var currentOverlayHostingController: UIHostingController? + private var currentOverlayContentHostingController: UIHostingController? + private var currentConfirmCloseHostingController: UIHostingController? - // UX fade-in - UIView.animate(withDuration: 0.5) { - newOverlayHostingController.view.alpha = 1 - } + // MARK: init - self.currentOverlayHostingController = newOverlayHostingController - - // Media Stream selection - if let currentOverlayContentHostingController = currentOverlayContentHostingController { - currentOverlayContentHostingController.view.isHidden = true + init(viewModel: VideoPlayerViewModel) { - currentOverlayContentHostingController.view.removeFromSuperview() - currentOverlayContentHostingController.removeFromParent() - } - - let newSmallMenuOverlayView = SmallMediaStreamSelectionView(viewModel: viewModel) - - let newOverlayContentHostingController = UIHostingController(rootView: newSmallMenuOverlayView) - - newOverlayContentHostingController.view.translatesAutoresizingMaskIntoConstraints = false - newOverlayContentHostingController.view.backgroundColor = UIColor.clear + self.viewModel = viewModel + self.vlcMediaPlayer = VLCMediaPlayer() - newOverlayContentHostingController.view.alpha = 0 + super.init(nibName: nil, bundle: nil) - addChild(newOverlayContentHostingController) - view.addSubview(newOverlayContentHostingController.view) - newOverlayContentHostingController.didMove(toParent: self) + viewModel.playerOverlayDelegate = self + } - NSLayoutConstraint.activate([ - newOverlayContentHostingController.view.topAnchor.constraint(equalTo: videoContentView.topAnchor), - newOverlayContentHostingController.view.bottomAnchor.constraint(equalTo: videoContentView.bottomAnchor), - newOverlayContentHostingController.view.leftAnchor.constraint(equalTo: videoContentView.leftAnchor), - newOverlayContentHostingController.view.rightAnchor.constraint(equalTo: videoContentView.rightAnchor) - ]) - - self.currentOverlayContentHostingController = newOverlayContentHostingController - - // Confirm close - if let currentConfirmCloseHostingController = currentConfirmCloseHostingController { - currentConfirmCloseHostingController.view.isHidden = true + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } - currentConfirmCloseHostingController.view.removeFromSuperview() - currentConfirmCloseHostingController.removeFromParent() - } - - let newConfirmCloseOverlay = ConfirmCloseOverlay() - - let newConfirmCloseHostingController = UIHostingController(rootView: newConfirmCloseOverlay) - - newConfirmCloseHostingController.view.translatesAutoresizingMaskIntoConstraints = false - newConfirmCloseHostingController.view.backgroundColor = UIColor.clear + private func setupSubviews() { + view.addSubview(videoContentView) + view.addSubview(jumpForwardOverlayView) + view.addSubview(jumpBackwardOverlayView) - newConfirmCloseHostingController.view.alpha = 0 + jumpBackwardOverlayView.alpha = 0 + jumpForwardOverlayView.alpha = 0 + } - addChild(newConfirmCloseHostingController) - view.addSubview(newConfirmCloseHostingController.view) - newConfirmCloseHostingController.didMove(toParent: self) + private func setupConstraints() { + NSLayoutConstraint.activate([ + videoContentView.topAnchor.constraint(equalTo: view.topAnchor), + videoContentView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + videoContentView.leftAnchor.constraint(equalTo: view.leftAnchor), + videoContentView.rightAnchor.constraint(equalTo: view.rightAnchor), + ]) + NSLayoutConstraint.activate([ + jumpBackwardOverlayView.leftAnchor.constraint(equalTo: view.leftAnchor, constant: 300), + jumpBackwardOverlayView.centerYAnchor.constraint(equalTo: view.centerYAnchor), + ]) + NSLayoutConstraint.activate([ + jumpForwardOverlayView.rightAnchor.constraint(equalTo: view.rightAnchor, constant: -300), + jumpForwardOverlayView.centerYAnchor.constraint(equalTo: view.centerYAnchor), + ]) + } - NSLayoutConstraint.activate([ - newConfirmCloseHostingController.view.topAnchor.constraint(equalTo: videoContentView.topAnchor), - newConfirmCloseHostingController.view.bottomAnchor.constraint(equalTo: videoContentView.bottomAnchor), - newConfirmCloseHostingController.view.leftAnchor.constraint(equalTo: videoContentView.leftAnchor), - newConfirmCloseHostingController.view.rightAnchor.constraint(equalTo: videoContentView.rightAnchor) - ]) - - self.currentConfirmCloseHostingController = newConfirmCloseHostingController + // MARK: viewWillDisappear - // There is a behavior when setting this that the navigation bar - // on the current navigation controller pops up, re-hide it - self.navigationController?.isNavigationBarHidden = true - } + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + + didSelectClose() + + let defaultNotificationCenter = NotificationCenter.default + defaultNotificationCenter.removeObserver(self, name: UIApplication.willTerminateNotification, object: nil) + defaultNotificationCenter.removeObserver(self, name: UIApplication.willResignActiveNotification, object: nil) + defaultNotificationCenter.removeObserver(self, name: UIApplication.didEnterBackgroundNotification, object: nil) + } + + // MARK: viewDidLoad + + override func viewDidLoad() { + super.viewDidLoad() + + setupSubviews() + setupConstraints() + + view.backgroundColor = .black + + setupMediaPlayer(newViewModel: viewModel) + + setupPanGestureRecognizer() + + addButtonPressRecognizer(pressType: .menu, action: #selector(didPressMenu)) + + let defaultNotificationCenter = NotificationCenter.default + defaultNotificationCenter.addObserver(self, selector: #selector(appWillTerminate), name: UIApplication.willTerminateNotification, + object: nil) + defaultNotificationCenter.addObserver(self, selector: #selector(appWillResignActive), + name: UIApplication.willResignActiveNotification, object: nil) + defaultNotificationCenter.addObserver(self, selector: #selector(appWillResignActive), + name: UIApplication.didEnterBackgroundNotification, object: nil) + } + + @objc + private func appWillTerminate() { + viewModel.sendStopReport() + } + + @objc + private func appWillResignActive() { + showOverlay() + + stopOverlayDismissTimer() + + vlcMediaPlayer.pause() + + viewModel.sendPauseReport(paused: true) + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + startPlayback() + } + + // MARK: subviews + + private func makeVideoContentView() -> UIView { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + view.backgroundColor = .black + + return view + } + + private func makeJumpBackwardOverlayView() -> UIImageView { + let symbolConfig = UIImage.SymbolConfiguration(pointSize: 72) + let forwardSymbolImage = UIImage(systemName: viewModel.jumpBackwardLength.backwardImageLabel, withConfiguration: symbolConfig) + let imageView = UIImageView(image: forwardSymbolImage) + imageView.translatesAutoresizingMaskIntoConstraints = false + + return imageView + } + + private func makeJumpForwardOverlayView() -> UIImageView { + let symbolConfig = UIImage.SymbolConfiguration(pointSize: 72) + let forwardSymbolImage = UIImage(systemName: viewModel.jumpForwardLength.forwardImageLabel, withConfiguration: symbolConfig) + let imageView = UIImageView(image: forwardSymbolImage) + imageView.translatesAutoresizingMaskIntoConstraints = false + + return imageView + } + + private func setupPanGestureRecognizer() { + let panGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(userPanned(panGestureRecognizer:))) + view.addGestureRecognizer(panGestureRecognizer) + } + + // MARK: pressesBegan + + override func pressesBegan(_ presses: Set, with event: UIPressesEvent?) { + guard let buttonPress = presses.first?.type else { return } + + switch buttonPress { + case .menu: () // Captured by other recognizer + case .playPause: + hideConfirmCloseOverlay() + + didSelectMain() + case .select: + hideConfirmCloseOverlay() + + didGenerallyTap() + case .upArrow: + hideConfirmCloseOverlay() + case .downArrow: + hideConfirmCloseOverlay() + + if Defaults[.downActionShowsMenu] { + if !displayingContentOverlay && !displayingOverlay { + didSelectMenu() + } + } + case .leftArrow: + hideConfirmCloseOverlay() + + if !displayingContentOverlay && !displayingOverlay { + didSelectBackward() + } + case .rightArrow: + hideConfirmCloseOverlay() + + if !displayingContentOverlay && !displayingOverlay { + didSelectForward() + } + case .pageUp: () + case .pageDown: () + @unknown default: () + } + } + + private func addButtonPressRecognizer(pressType: UIPress.PressType, action: Selector) { + let pressRecognizer = UITapGestureRecognizer() + pressRecognizer.addTarget(self, action: action) + pressRecognizer.allowedPressTypes = [NSNumber(value: pressType.rawValue)] + view.addGestureRecognizer(pressRecognizer) + } + + // MARK: didPressMenu + + @objc + private func didPressMenu() { + if displayingOverlay { + hideOverlay() + } else if displayingContentOverlay { + hideOverlayContent() + } else if viewModel.confirmClose && !displayingConfirmClose { + + showConfirmCloseOverlay() + restartConfirmCloseDismissTimer() + + } else { + vlcMediaPlayer.pause() + + dismiss(animated: true, completion: nil) + } + } + + @objc + private func userPanned(panGestureRecognizer: UIPanGestureRecognizer) { + if displayingOverlay { + restartOverlayDismissTimer() + } + } + + // MARK: setupOverlayHostingController + + private func setupOverlayHostingController(viewModel: VideoPlayerViewModel) { + + // TODO: Look at injecting viewModel into the environment so it updates the current overlay + + // Main overlay + if let currentOverlayHostingController = currentOverlayHostingController { + // UX fade-out + UIView.animate(withDuration: 0.5) { + currentOverlayHostingController.view.alpha = 0 + } completion: { _ in + currentOverlayHostingController.view.isHidden = true + + currentOverlayHostingController.view.removeFromSuperview() + currentOverlayHostingController.removeFromParent() + } + } + + let newOverlayView = tvOSVLCOverlay(viewModel: viewModel) + let newOverlayHostingController = UIHostingController(rootView: newOverlayView) + + newOverlayHostingController.view.translatesAutoresizingMaskIntoConstraints = false + newOverlayHostingController.view.backgroundColor = UIColor.clear + + // UX fade-in + newOverlayHostingController.view.alpha = 0 + + addChild(newOverlayHostingController) + view.addSubview(newOverlayHostingController.view) + newOverlayHostingController.didMove(toParent: self) + + NSLayoutConstraint.activate([ + newOverlayHostingController.view.topAnchor.constraint(equalTo: videoContentView.topAnchor), + newOverlayHostingController.view.bottomAnchor.constraint(equalTo: videoContentView.bottomAnchor), + newOverlayHostingController.view.leftAnchor.constraint(equalTo: videoContentView.leftAnchor), + newOverlayHostingController.view.rightAnchor.constraint(equalTo: videoContentView.rightAnchor), + ]) + + // UX fade-in + UIView.animate(withDuration: 0.5) { + newOverlayHostingController.view.alpha = 1 + } + + self.currentOverlayHostingController = newOverlayHostingController + + // Media Stream selection + if let currentOverlayContentHostingController = currentOverlayContentHostingController { + currentOverlayContentHostingController.view.isHidden = true + + currentOverlayContentHostingController.view.removeFromSuperview() + currentOverlayContentHostingController.removeFromParent() + } + + let newSmallMenuOverlayView = SmallMediaStreamSelectionView(viewModel: viewModel) + + let newOverlayContentHostingController = UIHostingController(rootView: newSmallMenuOverlayView) + + newOverlayContentHostingController.view.translatesAutoresizingMaskIntoConstraints = false + newOverlayContentHostingController.view.backgroundColor = UIColor.clear + + newOverlayContentHostingController.view.alpha = 0 + + addChild(newOverlayContentHostingController) + view.addSubview(newOverlayContentHostingController.view) + newOverlayContentHostingController.didMove(toParent: self) + + NSLayoutConstraint.activate([ + newOverlayContentHostingController.view.topAnchor.constraint(equalTo: videoContentView.topAnchor), + newOverlayContentHostingController.view.bottomAnchor.constraint(equalTo: videoContentView.bottomAnchor), + newOverlayContentHostingController.view.leftAnchor.constraint(equalTo: videoContentView.leftAnchor), + newOverlayContentHostingController.view.rightAnchor.constraint(equalTo: videoContentView.rightAnchor), + ]) + + self.currentOverlayContentHostingController = newOverlayContentHostingController + + // Confirm close + if let currentConfirmCloseHostingController = currentConfirmCloseHostingController { + currentConfirmCloseHostingController.view.isHidden = true + + currentConfirmCloseHostingController.view.removeFromSuperview() + currentConfirmCloseHostingController.removeFromParent() + } + + let newConfirmCloseOverlay = ConfirmCloseOverlay() + + let newConfirmCloseHostingController = UIHostingController(rootView: newConfirmCloseOverlay) + + newConfirmCloseHostingController.view.translatesAutoresizingMaskIntoConstraints = false + newConfirmCloseHostingController.view.backgroundColor = UIColor.clear + + newConfirmCloseHostingController.view.alpha = 0 + + addChild(newConfirmCloseHostingController) + view.addSubview(newConfirmCloseHostingController.view) + newConfirmCloseHostingController.didMove(toParent: self) + + NSLayoutConstraint.activate([ + newConfirmCloseHostingController.view.topAnchor.constraint(equalTo: videoContentView.topAnchor), + newConfirmCloseHostingController.view.bottomAnchor.constraint(equalTo: videoContentView.bottomAnchor), + newConfirmCloseHostingController.view.leftAnchor.constraint(equalTo: videoContentView.leftAnchor), + newConfirmCloseHostingController.view.rightAnchor.constraint(equalTo: videoContentView.rightAnchor), + ]) + + self.currentConfirmCloseHostingController = newConfirmCloseHostingController + + // There is a behavior when setting this that the navigation bar + // on the current navigation controller pops up, re-hide it + self.navigationController?.isNavigationBarHidden = true + } } // MARK: setupMediaPlayer -extension VLCPlayerViewController { - - /// Main function that handles setting up the media player with the current VideoPlayerViewModel - /// and also takes the role of setting the 'viewModel' property with the given viewModel - /// - /// Use case for this is setting new media within the same VLCPlayerViewController - func setupMediaPlayer(newViewModel: VideoPlayerViewModel) { - - // remove old player - - if vlcMediaPlayer.media != nil { - viewModelListeners.forEach({ $0.cancel() }) - - vlcMediaPlayer.stop() - viewModel.sendStopReport() - viewModel.playerOverlayDelegate = nil - } - - vlcMediaPlayer = VLCMediaPlayer() - - // setup with new player and view model - - vlcMediaPlayer = VLCMediaPlayer() - - vlcMediaPlayer.delegate = self - vlcMediaPlayer.drawable = videoContentView - - // TODO: Custom subtitle sizes - vlcMediaPlayer.perform(Selector(("setTextRendererFontSize:")), with: 16) - - stopOverlayDismissTimer() - - // Stop current media if there is one - if vlcMediaPlayer.media != nil { - viewModelListeners.forEach({ $0.cancel() }) - - vlcMediaPlayer.stop() - viewModel.sendStopReport() - viewModel.playerOverlayDelegate = nil - } - - lastPlayerTicks = newViewModel.item.userData?.playbackPositionTicks ?? 0 - lastProgressReportTicks = newViewModel.item.userData?.playbackPositionTicks ?? 0 - // TODO: Custom buffer/cache amounts - - let media = VLCMedia(url: newViewModel.streamURL) - media.addOption("--prefetch-buffer-size=1048576") - media.addOption("--network-caching=5000") - - vlcMediaPlayer.media = media - - setupOverlayHostingController(viewModel: newViewModel) - setupViewModelListeners(viewModel: newViewModel) - - newViewModel.getAdjacentEpisodes() - newViewModel.playerOverlayDelegate = self - - let startPercentage = newViewModel.item.userData?.playedPercentage ?? 0 - - if startPercentage > 0 { - if viewModel.resumeOffset { - let videoDurationSeconds = Double(viewModel.item.runTimeTicks! / 10_000_000) - var startSeconds = round((startPercentage / 100) * videoDurationSeconds) - startSeconds = startSeconds.subtract(5, floor: 0) - let newStartPercentage = startSeconds / videoDurationSeconds - newViewModel.sliderPercentage = newStartPercentage - } else { - newViewModel.sliderPercentage = startPercentage / 100 - } - } - - viewModel = newViewModel - } - - // MARK: startPlayback - func startPlayback() { - vlcMediaPlayer.play() - - // Setup external subtitles - for externalSubtitle in viewModel.subtitleStreams.filter({ $0.deliveryMethod == .external }) { - if let deliveryURL = externalSubtitle.externalURL(base: SessionManager.main.currentLogin.server.currentURI) { - vlcMediaPlayer.addPlaybackSlave(deliveryURL, type: .subtitle, enforce: false) - } - } - - setMediaPlayerTimeAtCurrentSlider() - - viewModel.sendPlayReport() - - restartOverlayDismissTimer(interval: 5) - } - - // MARK: setupViewModelListeners - - private func setupViewModelListeners(viewModel: VideoPlayerViewModel) { - viewModel.$playbackSpeed.sink { newSpeed in - self.vlcMediaPlayer.rate = Float(newSpeed.rawValue) - }.store(in: &viewModelListeners) - - viewModel.$sliderIsScrubbing.sink { sliderIsScrubbing in - if sliderIsScrubbing { - self.didBeginScrubbing() - } else { - self.didEndScrubbing() - } - }.store(in: &viewModelListeners) - - viewModel.$selectedAudioStreamIndex.sink { newAudioStreamIndex in - self.didSelectAudioStream(index: newAudioStreamIndex) - }.store(in: &viewModelListeners) - - viewModel.$selectedSubtitleStreamIndex.sink { newSubtitleStreamIndex in - self.didSelectSubtitleStream(index: newSubtitleStreamIndex) - }.store(in: &viewModelListeners) - - viewModel.$subtitlesEnabled.sink { newSubtitlesEnabled in - self.didToggleSubtitles(newValue: newSubtitlesEnabled) - }.store(in: &viewModelListeners) - } - - func setMediaPlayerTimeAtCurrentSlider() { - // Necessary math as VLCMediaPlayer doesn't work well - // by just setting the position - let videoPosition = Double(vlcMediaPlayer.time.intValue / 1000) - let videoDuration = Double(viewModel.item.runTimeTicks! / 10_000_000) - let secondsScrubbedTo = round(viewModel.sliderPercentage * videoDuration) - let newPositionOffset = secondsScrubbedTo - videoPosition - - if newPositionOffset > 0 { - vlcMediaPlayer.jumpForward(Int32(newPositionOffset)) - } else { - vlcMediaPlayer.jumpBackward(Int32(abs(newPositionOffset))) - } - } +extension VLCPlayerViewController { + + /// Main function that handles setting up the media player with the current VideoPlayerViewModel + /// and also takes the role of setting the 'viewModel' property with the given viewModel + /// + /// Use case for this is setting new media within the same VLCPlayerViewController + func setupMediaPlayer(newViewModel: VideoPlayerViewModel) { + + // remove old player + + if vlcMediaPlayer.media != nil { + viewModelListeners.forEach { $0.cancel() } + + vlcMediaPlayer.stop() + viewModel.sendStopReport() + viewModel.playerOverlayDelegate = nil + } + + vlcMediaPlayer = VLCMediaPlayer() + + // setup with new player and view model + + vlcMediaPlayer = VLCMediaPlayer() + + vlcMediaPlayer.delegate = self + vlcMediaPlayer.drawable = videoContentView + + // TODO: Custom subtitle sizes + vlcMediaPlayer.perform(Selector(("setTextRendererFontSize:")), with: 16) + + stopOverlayDismissTimer() + + // Stop current media if there is one + if vlcMediaPlayer.media != nil { + viewModelListeners.forEach { $0.cancel() } + + vlcMediaPlayer.stop() + viewModel.sendStopReport() + viewModel.playerOverlayDelegate = nil + } + + lastPlayerTicks = newViewModel.item.userData?.playbackPositionTicks ?? 0 + lastProgressReportTicks = newViewModel.item.userData?.playbackPositionTicks ?? 0 + + // TODO: Custom buffer/cache amounts + + let media = VLCMedia(url: newViewModel.streamURL) + media.addOption("--prefetch-buffer-size=1048576") + media.addOption("--network-caching=5000") + + vlcMediaPlayer.media = media + + setupOverlayHostingController(viewModel: newViewModel) + setupViewModelListeners(viewModel: newViewModel) + + newViewModel.getAdjacentEpisodes() + newViewModel.playerOverlayDelegate = self + + let startPercentage = newViewModel.item.userData?.playedPercentage ?? 0 + + if startPercentage > 0 { + if viewModel.resumeOffset { + let videoDurationSeconds = Double(viewModel.item.runTimeTicks! / 10_000_000) + var startSeconds = round((startPercentage / 100) * videoDurationSeconds) + startSeconds = startSeconds.subtract(5, floor: 0) + let newStartPercentage = startSeconds / videoDurationSeconds + newViewModel.sliderPercentage = newStartPercentage + } else { + newViewModel.sliderPercentage = startPercentage / 100 + } + } + + viewModel = newViewModel + } + + // MARK: startPlayback + + func startPlayback() { + vlcMediaPlayer.play() + + // Setup external subtitles + for externalSubtitle in viewModel.subtitleStreams.filter({ $0.deliveryMethod == .external }) { + if let deliveryURL = externalSubtitle.externalURL(base: SessionManager.main.currentLogin.server.currentURI) { + vlcMediaPlayer.addPlaybackSlave(deliveryURL, type: .subtitle, enforce: false) + } + } + + setMediaPlayerTimeAtCurrentSlider() + + viewModel.sendPlayReport() + + restartOverlayDismissTimer(interval: 5) + } + + // MARK: setupViewModelListeners + + private func setupViewModelListeners(viewModel: VideoPlayerViewModel) { + viewModel.$playbackSpeed.sink { newSpeed in + self.vlcMediaPlayer.rate = Float(newSpeed.rawValue) + }.store(in: &viewModelListeners) + + viewModel.$sliderIsScrubbing.sink { sliderIsScrubbing in + if sliderIsScrubbing { + self.didBeginScrubbing() + } else { + self.didEndScrubbing() + } + }.store(in: &viewModelListeners) + + viewModel.$selectedAudioStreamIndex.sink { newAudioStreamIndex in + self.didSelectAudioStream(index: newAudioStreamIndex) + }.store(in: &viewModelListeners) + + viewModel.$selectedSubtitleStreamIndex.sink { newSubtitleStreamIndex in + self.didSelectSubtitleStream(index: newSubtitleStreamIndex) + }.store(in: &viewModelListeners) + + viewModel.$subtitlesEnabled.sink { newSubtitlesEnabled in + self.didToggleSubtitles(newValue: newSubtitlesEnabled) + }.store(in: &viewModelListeners) + } + + func setMediaPlayerTimeAtCurrentSlider() { + // Necessary math as VLCMediaPlayer doesn't work well + // by just setting the position + let videoPosition = Double(vlcMediaPlayer.time.intValue / 1000) + let videoDuration = Double(viewModel.item.runTimeTicks! / 10_000_000) + let secondsScrubbedTo = round(viewModel.sliderPercentage * videoDuration) + let newPositionOffset = secondsScrubbedTo - videoPosition + + if newPositionOffset > 0 { + vlcMediaPlayer.jumpForward(Int32(newPositionOffset)) + } else { + vlcMediaPlayer.jumpBackward(Int32(abs(newPositionOffset))) + } + } } // MARK: Show/Hide Overlay + extension VLCPlayerViewController { - - private func showOverlay() { - guard let overlayHostingController = currentOverlayHostingController else { return } - - guard overlayHostingController.view.alpha != 1 else { return } - - UIView.animate(withDuration: 0.2) { - overlayHostingController.view.alpha = 1 - } - } - - private func hideOverlay() { - guard let overlayHostingController = currentOverlayHostingController else { return } - - guard overlayHostingController.view.alpha != 0 else { return } - - UIView.animate(withDuration: 0.2) { - overlayHostingController.view.alpha = 0 - } - } - - private func toggleOverlay() { - if displayingOverlay { - hideOverlay() - } else { - showOverlay() - } - } - - private func showOverlayContent() { - guard let currentOverlayContentHostingController = currentOverlayContentHostingController else { return } - - guard currentOverlayContentHostingController.view.alpha != 1 else { return } - - currentOverlayContentHostingController.view.setNeedsFocusUpdate() - currentOverlayContentHostingController.setNeedsFocusUpdate() - setNeedsFocusUpdate() - - UIView.animate(withDuration: 0.2) { - currentOverlayContentHostingController.view.alpha = 1 - } - } - - private func hideOverlayContent() { - guard let currentOverlayContentHostingController = currentOverlayContentHostingController else { return } - - guard currentOverlayContentHostingController.view.alpha != 0 else { return } - - setNeedsFocusUpdate() - - UIView.animate(withDuration: 0.2) { - currentOverlayContentHostingController.view.alpha = 0 - } - } + + private func showOverlay() { + guard let overlayHostingController = currentOverlayHostingController else { return } + + guard overlayHostingController.view.alpha != 1 else { return } + + UIView.animate(withDuration: 0.2) { + overlayHostingController.view.alpha = 1 + } + } + + private func hideOverlay() { + guard let overlayHostingController = currentOverlayHostingController else { return } + + guard overlayHostingController.view.alpha != 0 else { return } + + UIView.animate(withDuration: 0.2) { + overlayHostingController.view.alpha = 0 + } + } + + private func toggleOverlay() { + if displayingOverlay { + hideOverlay() + } else { + showOverlay() + } + } + + private func showOverlayContent() { + guard let currentOverlayContentHostingController = currentOverlayContentHostingController else { return } + + guard currentOverlayContentHostingController.view.alpha != 1 else { return } + + currentOverlayContentHostingController.view.setNeedsFocusUpdate() + currentOverlayContentHostingController.setNeedsFocusUpdate() + setNeedsFocusUpdate() + + UIView.animate(withDuration: 0.2) { + currentOverlayContentHostingController.view.alpha = 1 + } + } + + private func hideOverlayContent() { + guard let currentOverlayContentHostingController = currentOverlayContentHostingController else { return } + + guard currentOverlayContentHostingController.view.alpha != 0 else { return } + + setNeedsFocusUpdate() + + UIView.animate(withDuration: 0.2) { + currentOverlayContentHostingController.view.alpha = 0 + } + } } // MARK: Show/Hide Jump + extension VLCPlayerViewController { - - private func flashJumpBackwardOverlay() { - jumpBackwardOverlayView.layer.removeAllAnimations() - - UIView.animate(withDuration: 0.1) { - self.jumpBackwardOverlayView.alpha = 1 - } completion: { _ in - self.hideJumpBackwardOverlay() - } - } - - private func hideJumpBackwardOverlay() { - UIView.animate(withDuration: 0.3) { - self.jumpBackwardOverlayView.alpha = 0 - } - } - - private func flashJumpFowardOverlay() { - jumpForwardOverlayView.layer.removeAllAnimations() - - UIView.animate(withDuration: 0.1) { - self.jumpForwardOverlayView.alpha = 1 - } completion: { _ in - self.hideJumpForwardOverlay() - } - } - - private func hideJumpForwardOverlay() { - UIView.animate(withDuration: 0.3) { - self.jumpForwardOverlayView.alpha = 0 - } - } + + private func flashJumpBackwardOverlay() { + jumpBackwardOverlayView.layer.removeAllAnimations() + + UIView.animate(withDuration: 0.1) { + self.jumpBackwardOverlayView.alpha = 1 + } completion: { _ in + self.hideJumpBackwardOverlay() + } + } + + private func hideJumpBackwardOverlay() { + UIView.animate(withDuration: 0.3) { + self.jumpBackwardOverlayView.alpha = 0 + } + } + + private func flashJumpFowardOverlay() { + jumpForwardOverlayView.layer.removeAllAnimations() + + UIView.animate(withDuration: 0.1) { + self.jumpForwardOverlayView.alpha = 1 + } completion: { _ in + self.hideJumpForwardOverlay() + } + } + + private func hideJumpForwardOverlay() { + UIView.animate(withDuration: 0.3) { + self.jumpForwardOverlayView.alpha = 0 + } + } } // MARK: Show/Hide Confirm close + extension VLCPlayerViewController { - - private func showConfirmCloseOverlay() { - guard let currentConfirmCloseHostingController = currentConfirmCloseHostingController else { return } - - UIView.animate(withDuration: 0.2) { - currentConfirmCloseHostingController.view.alpha = 1 - } - } - - private func hideConfirmCloseOverlay() { - guard let currentConfirmCloseHostingController = currentConfirmCloseHostingController else { return } - - UIView.animate(withDuration: 0.5) { - currentConfirmCloseHostingController.view.alpha = 0 - } - } + + private func showConfirmCloseOverlay() { + guard let currentConfirmCloseHostingController = currentConfirmCloseHostingController else { return } + + UIView.animate(withDuration: 0.2) { + currentConfirmCloseHostingController.view.alpha = 1 + } + } + + private func hideConfirmCloseOverlay() { + guard let currentConfirmCloseHostingController = currentConfirmCloseHostingController else { return } + + UIView.animate(withDuration: 0.5) { + currentConfirmCloseHostingController.view.alpha = 0 + } + } } // MARK: OverlayTimer + extension VLCPlayerViewController { - - private func restartOverlayDismissTimer(interval: Double = 5) { - self.overlayDismissTimer?.invalidate() - self.overlayDismissTimer = Timer.scheduledTimer(timeInterval: interval, target: self, selector: #selector(dismissTimerFired), userInfo: nil, repeats: false) - } - - @objc private func dismissTimerFired() { - hideOverlay() - } - - private func stopOverlayDismissTimer() { - overlayDismissTimer?.invalidate() - } + + private func restartOverlayDismissTimer(interval: Double = 5) { + self.overlayDismissTimer?.invalidate() + self.overlayDismissTimer = Timer.scheduledTimer(timeInterval: interval, target: self, selector: #selector(dismissTimerFired), + userInfo: nil, repeats: false) + } + + @objc + private func dismissTimerFired() { + hideOverlay() + } + + private func stopOverlayDismissTimer() { + overlayDismissTimer?.invalidate() + } } // MARK: Confirm Close Overlay Timer + extension VLCPlayerViewController { - - private func restartConfirmCloseDismissTimer() { - self.confirmCloseOverlayDismissTimer?.invalidate() - self.confirmCloseOverlayDismissTimer = Timer.scheduledTimer(timeInterval: 5, target: self, selector: #selector(confirmCloseTimerFired), userInfo: nil, repeats: false) - } - - @objc private func confirmCloseTimerFired() { - hideConfirmCloseOverlay() - } - - private func stopConfirmCloseDismissTimer() { - confirmCloseOverlayDismissTimer?.invalidate() - } + + private func restartConfirmCloseDismissTimer() { + self.confirmCloseOverlayDismissTimer?.invalidate() + self.confirmCloseOverlayDismissTimer = Timer.scheduledTimer(timeInterval: 5, target: self, + selector: #selector(confirmCloseTimerFired), userInfo: nil, + repeats: false) + } + + @objc + private func confirmCloseTimerFired() { + hideConfirmCloseOverlay() + } + + private func stopConfirmCloseDismissTimer() { + confirmCloseOverlayDismissTimer?.invalidate() + } } // MARK: VLCMediaPlayerDelegate + extension VLCPlayerViewController: VLCMediaPlayerDelegate { - - - // MARK: mediaPlayerStateChanged - func mediaPlayerStateChanged(_ aNotification: Notification!) { - - // Don't show buffering if paused, usually here while scrubbing - if vlcMediaPlayer.state == .buffering && viewModel.playerState == .paused { - return - } - - viewModel.playerState = vlcMediaPlayer.state - - if vlcMediaPlayer.state == VLCMediaPlayerState.ended { - if viewModel.autoplayEnabled && viewModel.nextItemVideoPlayerViewModel != nil { - didSelectPlayNextItem() - } else { - didSelectClose() - } - } - } - - // MARK: mediaPlayerTimeChanged - func mediaPlayerTimeChanged(_ aNotification: Notification!) { - - if !viewModel.sliderIsScrubbing { - viewModel.sliderPercentage = Double(vlcMediaPlayer.position) - } - - // Have to manually set playing because VLCMediaPlayer doesn't - // properly set it itself - if abs(currentPlayerTicks - lastPlayerTicks) >= 10_000 { - viewModel.playerState = VLCMediaPlayerState.playing - } - - // If needing to fix subtitle streams during playback - if vlcMediaPlayer.currentVideoSubTitleIndex != viewModel.selectedSubtitleStreamIndex && - viewModel.subtitlesEnabled { - didSelectSubtitleStream(index: viewModel.selectedSubtitleStreamIndex) - } - - if vlcMediaPlayer.currentAudioTrackIndex != viewModel.selectedAudioStreamIndex { - didSelectAudioStream(index: viewModel.selectedAudioStreamIndex) - } - - lastPlayerTicks = currentPlayerTicks - - // Send progress report every 5 seconds - if abs(lastProgressReportTicks - currentPlayerTicks) >= 500_000_000 { - viewModel.sendProgressReport() - - lastProgressReportTicks = currentPlayerTicks - } - } + + // MARK: mediaPlayerStateChanged + + func mediaPlayerStateChanged(_ aNotification: Notification!) { + + // Don't show buffering if paused, usually here while scrubbing + if vlcMediaPlayer.state == .buffering && viewModel.playerState == .paused { + return + } + + viewModel.playerState = vlcMediaPlayer.state + + if vlcMediaPlayer.state == VLCMediaPlayerState.ended { + if viewModel.autoplayEnabled && viewModel.nextItemVideoPlayerViewModel != nil { + didSelectPlayNextItem() + } else { + didSelectClose() + } + } + } + + // MARK: mediaPlayerTimeChanged + + func mediaPlayerTimeChanged(_ aNotification: Notification!) { + + if !viewModel.sliderIsScrubbing { + viewModel.sliderPercentage = Double(vlcMediaPlayer.position) + } + + // Have to manually set playing because VLCMediaPlayer doesn't + // properly set it itself + if abs(currentPlayerTicks - lastPlayerTicks) >= 10000 { + viewModel.playerState = VLCMediaPlayerState.playing + } + + // If needing to fix subtitle streams during playback + if vlcMediaPlayer.currentVideoSubTitleIndex != viewModel.selectedSubtitleStreamIndex && + viewModel.subtitlesEnabled + { + didSelectSubtitleStream(index: viewModel.selectedSubtitleStreamIndex) + } + + if vlcMediaPlayer.currentAudioTrackIndex != viewModel.selectedAudioStreamIndex { + didSelectAudioStream(index: viewModel.selectedAudioStreamIndex) + } + + lastPlayerTicks = currentPlayerTicks + + // Send progress report every 5 seconds + if abs(lastProgressReportTicks - currentPlayerTicks) >= 500_000_000 { + viewModel.sendProgressReport() + + lastProgressReportTicks = currentPlayerTicks + } + } } // MARK: PlayerOverlayDelegate -extension VLCPlayerViewController: PlayerOverlayDelegate { - - func didSelectAudioStream(index: Int) { - vlcMediaPlayer.currentAudioTrackIndex = Int32(index) - - viewModel.sendProgressReport() - - lastProgressReportTicks = currentPlayerTicks - } - - /// Do not call when setting to index -1 - func didSelectSubtitleStream(index: Int) { - - viewModel.subtitlesEnabled = true - vlcMediaPlayer.currentVideoSubTitleIndex = Int32(index) - - viewModel.sendProgressReport() - - lastProgressReportTicks = currentPlayerTicks - } - - func didSelectClose() { - vlcMediaPlayer.stop() - - viewModel.sendStopReport() - - dismiss(animated: true, completion: nil) - } - - func didToggleSubtitles(newValue: Bool) { - if newValue { - vlcMediaPlayer.currentVideoSubTitleIndex = Int32(viewModel.selectedSubtitleStreamIndex) - } else { - vlcMediaPlayer.currentVideoSubTitleIndex = -1 - } - } - - func didSelectMenu() { - stopOverlayDismissTimer() - hideOverlay() - showOverlayContent() - } - - func didSelectBackward() { - - flashJumpBackwardOverlay() - - vlcMediaPlayer.jumpBackward(viewModel.jumpBackwardLength.rawValue) - - if displayingOverlay { - restartOverlayDismissTimer() - } - - viewModel.sendProgressReport() - - lastProgressReportTicks = currentPlayerTicks - } - - func didSelectForward() { - - flashJumpFowardOverlay() - - vlcMediaPlayer.jumpForward(viewModel.jumpForwardLength.rawValue) - - if displayingOverlay { - restartOverlayDismissTimer() - } - - viewModel.sendProgressReport() - - lastProgressReportTicks = currentPlayerTicks - } - - func didSelectMain() { - - switch viewModel.playerState { - case .buffering: - vlcMediaPlayer.play() - restartOverlayDismissTimer() - case .playing: - viewModel.sendPauseReport(paused: true) - vlcMediaPlayer.pause() - - showOverlay() - restartOverlayDismissTimer(interval: 5) - case .paused: - viewModel.sendPauseReport(paused: false) - vlcMediaPlayer.play() - restartOverlayDismissTimer() - default: () - } - } - - func didGenerallyTap() { - toggleOverlay() - - restartOverlayDismissTimer(interval: 5) - } - - func didBeginScrubbing() { - stopOverlayDismissTimer() - } - - func didEndScrubbing() { - setMediaPlayerTimeAtCurrentSlider() - - restartOverlayDismissTimer() - - viewModel.sendProgressReport() - - lastProgressReportTicks = currentPlayerTicks - } - - func didSelectPlayPreviousItem() { - if let previousItemVideoPlayerViewModel = viewModel.previousItemVideoPlayerViewModel { - setupMediaPlayer(newViewModel: previousItemVideoPlayerViewModel) - startPlayback() - } - } - - func didSelectPlayNextItem() { - if let nextItemVideoPlayerViewModel = viewModel.nextItemVideoPlayerViewModel { - setupMediaPlayer(newViewModel: nextItemVideoPlayerViewModel) - startPlayback() - } - } +extension VLCPlayerViewController: PlayerOverlayDelegate { + + func didSelectAudioStream(index: Int) { + vlcMediaPlayer.currentAudioTrackIndex = Int32(index) + + viewModel.sendProgressReport() + + lastProgressReportTicks = currentPlayerTicks + } + + /// Do not call when setting to index -1 + func didSelectSubtitleStream(index: Int) { + + viewModel.subtitlesEnabled = true + vlcMediaPlayer.currentVideoSubTitleIndex = Int32(index) + + viewModel.sendProgressReport() + + lastProgressReportTicks = currentPlayerTicks + } + + func didSelectClose() { + vlcMediaPlayer.stop() + + viewModel.sendStopReport() + + dismiss(animated: true, completion: nil) + } + + func didToggleSubtitles(newValue: Bool) { + if newValue { + vlcMediaPlayer.currentVideoSubTitleIndex = Int32(viewModel.selectedSubtitleStreamIndex) + } else { + vlcMediaPlayer.currentVideoSubTitleIndex = -1 + } + } + + func didSelectMenu() { + stopOverlayDismissTimer() + + hideOverlay() + showOverlayContent() + } + + func didSelectBackward() { + + flashJumpBackwardOverlay() + + vlcMediaPlayer.jumpBackward(viewModel.jumpBackwardLength.rawValue) + + if displayingOverlay { + restartOverlayDismissTimer() + } + + viewModel.sendProgressReport() + + lastProgressReportTicks = currentPlayerTicks + } + + func didSelectForward() { + + flashJumpFowardOverlay() + + vlcMediaPlayer.jumpForward(viewModel.jumpForwardLength.rawValue) + + if displayingOverlay { + restartOverlayDismissTimer() + } + + viewModel.sendProgressReport() + + lastProgressReportTicks = currentPlayerTicks + } + + func didSelectMain() { + + switch viewModel.playerState { + case .buffering: + vlcMediaPlayer.play() + restartOverlayDismissTimer() + case .playing: + viewModel.sendPauseReport(paused: true) + vlcMediaPlayer.pause() + + showOverlay() + restartOverlayDismissTimer(interval: 5) + case .paused: + viewModel.sendPauseReport(paused: false) + vlcMediaPlayer.play() + restartOverlayDismissTimer() + default: () + } + } + + func didGenerallyTap() { + toggleOverlay() + + restartOverlayDismissTimer(interval: 5) + } + + func didBeginScrubbing() { + stopOverlayDismissTimer() + } + + func didEndScrubbing() { + setMediaPlayerTimeAtCurrentSlider() + + restartOverlayDismissTimer() + + viewModel.sendProgressReport() + + lastProgressReportTicks = currentPlayerTicks + } + + func didSelectPlayPreviousItem() { + if let previousItemVideoPlayerViewModel = viewModel.previousItemVideoPlayerViewModel { + setupMediaPlayer(newViewModel: previousItemVideoPlayerViewModel) + startPlayback() + } + } + + func didSelectPlayNextItem() { + if let nextItemVideoPlayerViewModel = viewModel.nextItemVideoPlayerViewModel { + setupMediaPlayer(newViewModel: nextItemVideoPlayerViewModel) + startPlayback() + } + } } diff --git a/Swiftfin tvOS/Views/VideoPlayer/VideoPlayerView.swift b/Swiftfin tvOS/Views/VideoPlayer/VideoPlayerView.swift index 099d0838..a9684d8f 100644 --- a/Swiftfin tvOS/Views/VideoPlayer/VideoPlayerView.swift +++ b/Swiftfin tvOS/Views/VideoPlayer/VideoPlayerView.swift @@ -1,27 +1,24 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// -import UIKit import SwiftUI +import UIKit struct VLCPlayerView: UIViewControllerRepresentable { - - let viewModel: VideoPlayerViewModel - - typealias UIViewControllerType = VLCPlayerViewController - - func makeUIViewController(context: Context) -> VLCPlayerViewController { - - return VLCPlayerViewController(viewModel: viewModel) - } - - func updateUIViewController(_ uiViewController: VLCPlayerViewController, context: Context) { - - } + + let viewModel: VideoPlayerViewModel + + typealias UIViewControllerType = VLCPlayerViewController + + func makeUIViewController(context: Context) -> VLCPlayerViewController { + + VLCPlayerViewController(viewModel: viewModel) + } + + func updateUIViewController(_ uiViewController: VLCPlayerViewController, context: Context) {} } diff --git a/Swiftfin tvOS/Views/VideoPlayer/tvOSOverlay/ConfirmCloseOverlay.swift b/Swiftfin tvOS/Views/VideoPlayer/tvOSOverlay/ConfirmCloseOverlay.swift index b9e2b493..d115e5f4 100644 --- a/Swiftfin tvOS/Views/VideoPlayer/tvOSOverlay/ConfirmCloseOverlay.swift +++ b/Swiftfin tvOS/Views/VideoPlayer/tvOSOverlay/ConfirmCloseOverlay.swift @@ -1,40 +1,39 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import SwiftUI struct ConfirmCloseOverlay: View { - var body: some View { - VStack { - HStack { - Image(systemName: "chevron.left.circle.fill") - .font(.system(size: 96)) - .padding(3) - .background(Color.black.opacity(0.4).mask(Circle())) - - Spacer() - } - .padding() - - Spacer() - } - .padding() - } + var body: some View { + VStack { + HStack { + Image(systemName: "chevron.left.circle.fill") + .font(.system(size: 96)) + .padding(3) + .background(Color.black.opacity(0.4).mask(Circle())) + + Spacer() + } + .padding() + + Spacer() + } + .padding() + } } struct ConfirmCloseOverlay_Previews: PreviewProvider { - static var previews: some View { - ZStack { - Color.red.ignoresSafeArea() - - ConfirmCloseOverlay() - .ignoresSafeArea() - } - } + static var previews: some View { + ZStack { + Color.red.ignoresSafeArea() + + ConfirmCloseOverlay() + .ignoresSafeArea() + } + } } diff --git a/Swiftfin tvOS/Views/VideoPlayer/tvOSOverlay/SmallMenuOverlay.swift b/Swiftfin tvOS/Views/VideoPlayer/tvOSOverlay/SmallMenuOverlay.swift index 4734cec9..c754d994 100644 --- a/Swiftfin tvOS/Views/VideoPlayer/tvOSOverlay/SmallMenuOverlay.swift +++ b/Swiftfin tvOS/Views/VideoPlayer/tvOSOverlay/SmallMenuOverlay.swift @@ -1,254 +1,260 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import JellyfinAPI import SwiftUI // TODO: Needs replacement/reworking struct SmallMediaStreamSelectionView: View { - - enum Layer: Hashable { - case subtitles - case audio - case playbackSpeed - } - - enum MediaSection: Hashable { - case titles - case items - } - - @ObservedObject var viewModel: VideoPlayerViewModel - - @State private var updateFocusedLayer: Layer = .subtitles - - @FocusState private var subtitlesFocused: Bool - @FocusState private var audioFocused: Bool - @FocusState private var playbackSpeedFocused: Bool - @FocusState private var focusedSection: MediaSection? - @FocusState private var focusedLayer: Layer? { - willSet { - updateFocusedLayer = newValue! - - if focusedSection == .titles { - lastFocusedLayer = newValue! - } - } - } - - @State private var lastFocusedLayer: Layer = .subtitles - - var body: some View { - ZStack(alignment: .bottom) { - LinearGradient(gradient: Gradient(colors: [.clear, .black.opacity(0.8), .black]), - startPoint: .top, - endPoint: .bottom) - .ignoresSafeArea() - .frame(height: 300) - VStack { + enum Layer: Hashable { + case subtitles + case audio + case playbackSpeed + } - Spacer() + enum MediaSection: Hashable { + case titles + case items + } - HStack { - - // MARK: Subtitle Header - Button { - updateFocusedLayer = .subtitles - focusedLayer = .subtitles - } label: { - if updateFocusedLayer == .subtitles { - HStack(spacing: 15) { - Image(systemName: "captions.bubble") - Text("Subtitles") - } - .padding() - .background(Color.white) - .foregroundColor(.black) - } else { - HStack(spacing: 15) { - Image(systemName: "captions.bubble") - Text("Subtitles") - } - .padding() - } - } - .buttonStyle(PlainButtonStyle()) - .background(Color.clear) - .focused($focusedLayer, equals: .subtitles) - .focused($subtitlesFocused) - .onChange(of: subtitlesFocused) { isFocused in - if isFocused { - focusedLayer = .subtitles - } - } - - // MARK: Audio Header - Button { - updateFocusedLayer = .audio - focusedLayer = .audio - } label: { - if updateFocusedLayer == .audio { - HStack(spacing: 15) { - Image(systemName: "speaker.wave.3") - Text("Audio") - } - .padding() - .background(Color.white) - .foregroundColor(.black) - } else { - HStack(spacing: 15) { - Image(systemName: "speaker.wave.3") - Text("Audio") - } - .padding() - } - } - .buttonStyle(PlainButtonStyle()) - .background(Color.clear) - .focused($focusedLayer, equals: .audio) - .focused($audioFocused) - .onChange(of: audioFocused) { isFocused in - if isFocused { - focusedLayer = .audio - } - } - - // MARK: Playback Speed Header - Button { - updateFocusedLayer = .playbackSpeed - focusedLayer = .playbackSpeed - } label: { - if updateFocusedLayer == .playbackSpeed { - HStack(spacing: 15) { - Image(systemName: "speedometer") - Text("Playback Speed") - } - .padding() - .background(Color.white) - .foregroundColor(.black) - } else { - HStack(spacing: 15) { - Image(systemName: "speedometer") - Text("Playback Speed") - } - .padding() - } - } - .buttonStyle(PlainButtonStyle()) - .background(Color.clear) - .focused($focusedLayer, equals: .playbackSpeed) - .focused($playbackSpeedFocused) - .onChange(of: playbackSpeedFocused) { isFocused in - if isFocused { - focusedLayer = .playbackSpeed - } - } - - Spacer() - } - .padding() - .focusSection() - .focused($focusedSection, equals: .titles) - .onChange(of: focusedSection) { newSection in - if focusedSection == .titles { - if lastFocusedLayer == .subtitles { - subtitlesFocused = true - } else if lastFocusedLayer == .audio { - audioFocused = true - } else if lastFocusedLayer == .playbackSpeed { - playbackSpeedFocused = true - } - } - } - - if updateFocusedLayer == .subtitles && lastFocusedLayer == .subtitles { - // MARK: Subtitles - - ScrollView(.horizontal) { - HStack { - if viewModel.subtitleStreams.isEmpty { - Button { - - } label: { - Text("None") - } - } else { - ForEach(viewModel.subtitleStreams, id: \.self) { subtitleStream in - Button { - viewModel.selectedSubtitleStreamIndex = subtitleStream.index ?? -1 - } label: { - if subtitleStream.index == viewModel.selectedSubtitleStreamIndex { - Label(subtitleStream.displayTitle ?? "No Title", systemImage: "checkmark") - } else { - Text(subtitleStream.displayTitle ?? "No Title") - } - } - } - } - } - .padding(.vertical) - .focusSection() - .focused($focusedSection, equals: .items) - } - } else if updateFocusedLayer == .audio && lastFocusedLayer == .audio { - // MARK: Audio - - ScrollView(.horizontal) { - HStack { - if viewModel.audioStreams.isEmpty { - Button { - - } label: { - Text("None") - } - } else { - ForEach(viewModel.audioStreams, id: \.self) { audioStream in - Button { - viewModel.selectedAudioStreamIndex = audioStream.index ?? -1 - } label: { - if audioStream.index == viewModel.selectedAudioStreamIndex { - Label(audioStream.displayTitle ?? "No Title", systemImage: "checkmark") - } else { - Text(audioStream.displayTitle ?? "No Title") - } - } - } - } - } - .padding(.vertical) - .focusSection() - .focused($focusedSection, equals: .items) - } - } else if updateFocusedLayer == .playbackSpeed && lastFocusedLayer == .playbackSpeed { - // MARK: Rates - - ScrollView(.horizontal) { - HStack { - ForEach(PlaybackSpeed.allCases, id: \.self) { playbackSpeed in - Button { - viewModel.playbackSpeed = playbackSpeed - } label: { - if playbackSpeed == viewModel.playbackSpeed { - Label(playbackSpeed.displayTitle, systemImage: "checkmark") - } else { - Text(playbackSpeed.displayTitle) - } - } - } - } - .padding(.vertical) - .focusSection() - .focused($focusedSection, equals: .items) - } - } - } - } - } + @ObservedObject + var viewModel: VideoPlayerViewModel + + @State + private var updateFocusedLayer: Layer = .subtitles + + @FocusState + private var subtitlesFocused: Bool + @FocusState + private var audioFocused: Bool + @FocusState + private var playbackSpeedFocused: Bool + @FocusState + private var focusedSection: MediaSection? + @FocusState + private var focusedLayer: Layer? { + willSet { + updateFocusedLayer = newValue! + + if focusedSection == .titles { + lastFocusedLayer = newValue! + } + } + } + + @State + private var lastFocusedLayer: Layer = .subtitles + + var body: some View { + ZStack(alignment: .bottom) { + LinearGradient(gradient: Gradient(colors: [.clear, .black.opacity(0.8), .black]), + startPoint: .top, + endPoint: .bottom) + .ignoresSafeArea() + .frame(height: 300) + + VStack { + + Spacer() + + HStack { + + // MARK: Subtitle Header + + Button { + updateFocusedLayer = .subtitles + focusedLayer = .subtitles + } label: { + if updateFocusedLayer == .subtitles { + HStack(spacing: 15) { + Image(systemName: "captions.bubble") + Text("Subtitles") + } + .padding() + .background(Color.white) + .foregroundColor(.black) + } else { + HStack(spacing: 15) { + Image(systemName: "captions.bubble") + Text("Subtitles") + } + .padding() + } + } + .buttonStyle(PlainButtonStyle()) + .background(Color.clear) + .focused($focusedLayer, equals: .subtitles) + .focused($subtitlesFocused) + .onChange(of: subtitlesFocused) { isFocused in + if isFocused { + focusedLayer = .subtitles + } + } + + // MARK: Audio Header + + Button { + updateFocusedLayer = .audio + focusedLayer = .audio + } label: { + if updateFocusedLayer == .audio { + HStack(spacing: 15) { + Image(systemName: "speaker.wave.3") + Text("Audio") + } + .padding() + .background(Color.white) + .foregroundColor(.black) + } else { + HStack(spacing: 15) { + Image(systemName: "speaker.wave.3") + Text("Audio") + } + .padding() + } + } + .buttonStyle(PlainButtonStyle()) + .background(Color.clear) + .focused($focusedLayer, equals: .audio) + .focused($audioFocused) + .onChange(of: audioFocused) { isFocused in + if isFocused { + focusedLayer = .audio + } + } + + // MARK: Playback Speed Header + + Button { + updateFocusedLayer = .playbackSpeed + focusedLayer = .playbackSpeed + } label: { + if updateFocusedLayer == .playbackSpeed { + HStack(spacing: 15) { + Image(systemName: "speedometer") + Text("Playback Speed") + } + .padding() + .background(Color.white) + .foregroundColor(.black) + } else { + HStack(spacing: 15) { + Image(systemName: "speedometer") + Text("Playback Speed") + } + .padding() + } + } + .buttonStyle(PlainButtonStyle()) + .background(Color.clear) + .focused($focusedLayer, equals: .playbackSpeed) + .focused($playbackSpeedFocused) + .onChange(of: playbackSpeedFocused) { isFocused in + if isFocused { + focusedLayer = .playbackSpeed + } + } + + Spacer() + } + .padding() + .focusSection() + .focused($focusedSection, equals: .titles) + .onChange(of: focusedSection) { _ in + if focusedSection == .titles { + if lastFocusedLayer == .subtitles { + subtitlesFocused = true + } else if lastFocusedLayer == .audio { + audioFocused = true + } else if lastFocusedLayer == .playbackSpeed { + playbackSpeedFocused = true + } + } + } + + if updateFocusedLayer == .subtitles && lastFocusedLayer == .subtitles { + // MARK: Subtitles + + ScrollView(.horizontal) { + HStack { + if viewModel.subtitleStreams.isEmpty { + Button {} label: { + Text("None") + } + } else { + ForEach(viewModel.subtitleStreams, id: \.self) { subtitleStream in + Button { + viewModel.selectedSubtitleStreamIndex = subtitleStream.index ?? -1 + } label: { + if subtitleStream.index == viewModel.selectedSubtitleStreamIndex { + Label(subtitleStream.displayTitle ?? "No Title", systemImage: "checkmark") + } else { + Text(subtitleStream.displayTitle ?? "No Title") + } + } + } + } + } + .padding(.vertical) + .focusSection() + .focused($focusedSection, equals: .items) + } + } else if updateFocusedLayer == .audio && lastFocusedLayer == .audio { + // MARK: Audio + + ScrollView(.horizontal) { + HStack { + if viewModel.audioStreams.isEmpty { + Button {} label: { + Text("None") + } + } else { + ForEach(viewModel.audioStreams, id: \.self) { audioStream in + Button { + viewModel.selectedAudioStreamIndex = audioStream.index ?? -1 + } label: { + if audioStream.index == viewModel.selectedAudioStreamIndex { + Label(audioStream.displayTitle ?? "No Title", systemImage: "checkmark") + } else { + Text(audioStream.displayTitle ?? "No Title") + } + } + } + } + } + .padding(.vertical) + .focusSection() + .focused($focusedSection, equals: .items) + } + } else if updateFocusedLayer == .playbackSpeed && lastFocusedLayer == .playbackSpeed { + // MARK: Rates + + ScrollView(.horizontal) { + HStack { + ForEach(PlaybackSpeed.allCases, id: \.self) { playbackSpeed in + Button { + viewModel.playbackSpeed = playbackSpeed + } label: { + if playbackSpeed == viewModel.playbackSpeed { + Label(playbackSpeed.displayTitle, systemImage: "checkmark") + } else { + Text(playbackSpeed.displayTitle) + } + } + } + } + .padding(.vertical) + .focusSection() + .focused($focusedSection, equals: .items) + } + } + } + } + } } diff --git a/Swiftfin tvOS/Views/VideoPlayer/tvOSOverlay/tvOSVLCOverlay.swift b/Swiftfin tvOS/Views/VideoPlayer/tvOSOverlay/tvOSVLCOverlay.swift index bcffe81b..21e427d2 100644 --- a/Swiftfin tvOS/Views/VideoPlayer/tvOSOverlay/tvOSVLCOverlay.swift +++ b/Swiftfin tvOS/Views/VideoPlayer/tvOSOverlay/tvOSVLCOverlay.swift @@ -1,164 +1,166 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Defaults import JellyfinAPI import SwiftUI struct tvOSVLCOverlay: View { - - @ObservedObject var viewModel: VideoPlayerViewModel - @Default(.downActionShowsMenu) var downActionShowsMenu - - @ViewBuilder - private var mainButtonView: some View { - switch viewModel.playerState { - case .stopped, .paused: - Image(systemName: "play.circle") - case .playing: - Image(systemName: "pause.circle") - default: - ProgressView() - } - } - - var body: some View { - ZStack(alignment: .bottom) { - - LinearGradient(gradient: Gradient(colors: [.clear, .black.opacity(0.8), .black]), - startPoint: .top, - endPoint: .bottom) - .ignoresSafeArea() - .frame(height: viewModel.subtitle == nil ? 180 : 210) - - VStack { - - Spacer() - - HStack(alignment: .bottom) { - - VStack(alignment: .leading) { - if let subtitle = viewModel.subtitle { - Text(subtitle) - .font(.subheadline) - .foregroundColor(.white) - } - - Text(viewModel.title) - .font(.title3) - .fontWeight(.bold) - } - Spacer() - - if viewModel.shouldShowPlayPreviousItem { - SFSymbolButton(systemName: "chevron.left.circle", action: { - viewModel.playerOverlayDelegate?.didSelectPlayPreviousItem() - }) - .frame(maxWidth: 30, maxHeight: 30) - .disabled(viewModel.previousItemVideoPlayerViewModel == nil) - .foregroundColor(viewModel.nextItemVideoPlayerViewModel == nil ? .gray : .white) - } - - if viewModel.shouldShowPlayNextItem { - SFSymbolButton(systemName: "chevron.right.circle", action: { - viewModel.playerOverlayDelegate?.didSelectPlayNextItem() - }) - .frame(maxWidth: 30, maxHeight: 30) - .disabled(viewModel.nextItemVideoPlayerViewModel == nil) - .foregroundColor(viewModel.nextItemVideoPlayerViewModel == nil ? .gray : .white) - } - - if viewModel.shouldShowAutoPlay { - if viewModel.autoplayEnabled { - SFSymbolButton(systemName: "play.circle.fill") { - viewModel.autoplayEnabled.toggle() - } - .frame(maxWidth: 30, maxHeight: 30) - } else { - SFSymbolButton(systemName: "stop.circle") { - viewModel.autoplayEnabled.toggle() - } - .frame(maxWidth: 30, maxHeight: 30) - } - } - - if !viewModel.subtitleStreams.isEmpty { - if viewModel.subtitlesEnabled { - SFSymbolButton(systemName: "captions.bubble.fill") { - viewModel.subtitlesEnabled.toggle() - } - .frame(maxWidth: 30, maxHeight: 30) - } else { - SFSymbolButton(systemName: "captions.bubble") { - viewModel.subtitlesEnabled.toggle() - } - .frame(maxWidth: 30, maxHeight: 30) - } - } - - if !downActionShowsMenu { - SFSymbolButton(systemName: "ellipsis.circle") { - viewModel.playerOverlayDelegate?.didSelectMenu() - } - .frame(maxWidth: 30, maxHeight: 30) - } } - .offset(x: 0, y: 10) - - SliderView(viewModel: viewModel) - .frame(maxHeight: 40) - - HStack { - - HStack(spacing: 10) { - mainButtonView - .frame(maxWidth: 40, maxHeight: 40) - - Text(viewModel.leftLabelText) - } - - Spacer() - - Text(viewModel.rightLabelText) - } - .offset(x: 0, y: -10) - } - } - .foregroundColor(.white) - } + @ObservedObject + var viewModel: VideoPlayerViewModel + @Default(.downActionShowsMenu) + var downActionShowsMenu + + @ViewBuilder + private var mainButtonView: some View { + switch viewModel.playerState { + case .stopped, .paused: + Image(systemName: "play.circle") + case .playing: + Image(systemName: "pause.circle") + default: + ProgressView() + } + } + + var body: some View { + ZStack(alignment: .bottom) { + + LinearGradient(gradient: Gradient(colors: [.clear, .black.opacity(0.8), .black]), + startPoint: .top, + endPoint: .bottom) + .ignoresSafeArea() + .frame(height: viewModel.subtitle == nil ? 180 : 210) + + VStack { + + Spacer() + + HStack(alignment: .bottom) { + + VStack(alignment: .leading) { + if let subtitle = viewModel.subtitle { + Text(subtitle) + .font(.subheadline) + .foregroundColor(.white) + } + + Text(viewModel.title) + .font(.title3) + .fontWeight(.bold) + } + + Spacer() + + if viewModel.shouldShowPlayPreviousItem { + SFSymbolButton(systemName: "chevron.left.circle", action: { + viewModel.playerOverlayDelegate?.didSelectPlayPreviousItem() + }) + .frame(maxWidth: 30, maxHeight: 30) + .disabled(viewModel.previousItemVideoPlayerViewModel == nil) + .foregroundColor(viewModel.nextItemVideoPlayerViewModel == nil ? .gray : .white) + } + + if viewModel.shouldShowPlayNextItem { + SFSymbolButton(systemName: "chevron.right.circle", action: { + viewModel.playerOverlayDelegate?.didSelectPlayNextItem() + }) + .frame(maxWidth: 30, maxHeight: 30) + .disabled(viewModel.nextItemVideoPlayerViewModel == nil) + .foregroundColor(viewModel.nextItemVideoPlayerViewModel == nil ? .gray : .white) + } + + if viewModel.shouldShowAutoPlay { + if viewModel.autoplayEnabled { + SFSymbolButton(systemName: "play.circle.fill") { + viewModel.autoplayEnabled.toggle() + } + .frame(maxWidth: 30, maxHeight: 30) + } else { + SFSymbolButton(systemName: "stop.circle") { + viewModel.autoplayEnabled.toggle() + } + .frame(maxWidth: 30, maxHeight: 30) + } + } + + if !viewModel.subtitleStreams.isEmpty { + if viewModel.subtitlesEnabled { + SFSymbolButton(systemName: "captions.bubble.fill") { + viewModel.subtitlesEnabled.toggle() + } + .frame(maxWidth: 30, maxHeight: 30) + } else { + SFSymbolButton(systemName: "captions.bubble") { + viewModel.subtitlesEnabled.toggle() + } + .frame(maxWidth: 30, maxHeight: 30) + } + } + + if !downActionShowsMenu { + SFSymbolButton(systemName: "ellipsis.circle") { + viewModel.playerOverlayDelegate?.didSelectMenu() + } + .frame(maxWidth: 30, maxHeight: 30) + } + } + .offset(x: 0, y: 10) + + SliderView(viewModel: viewModel) + .frame(maxHeight: 40) + + HStack { + + HStack(spacing: 10) { + mainButtonView + .frame(maxWidth: 40, maxHeight: 40) + + Text(viewModel.leftLabelText) + } + + Spacer() + + Text(viewModel.rightLabelText) + } + .offset(x: 0, y: -10) + } + } + .foregroundColor(.white) + } } struct tvOSVLCOverlay_Previews: PreviewProvider { - - static let videoPlayerViewModel = VideoPlayerViewModel(item: BaseItemDto(), - title: "Glorious Purpose", - subtitle: "Loki - S1E1", - streamURL: URL(string: "www.apple.com")!, - streamType: .direct, - response: PlaybackInfoResponse(), - audioStreams: [MediaStream(displayTitle: "English", index: -1)], - subtitleStreams: [MediaStream(displayTitle: "None", index: -1)], - selectedAudioStreamIndex: -1, - selectedSubtitleStreamIndex: -1, - subtitlesEnabled: true, - autoplayEnabled: false, - overlayType: .compact, - shouldShowPlayPreviousItem: true, - shouldShowPlayNextItem: true, - shouldShowAutoPlay: true) - - static var previews: some View { - ZStack { - Color.red - .ignoresSafeArea() - - tvOSVLCOverlay(viewModel: videoPlayerViewModel) - } - } + + static let videoPlayerViewModel = VideoPlayerViewModel(item: BaseItemDto(), + title: "Glorious Purpose", + subtitle: "Loki - S1E1", + streamURL: URL(string: "www.apple.com")!, + streamType: .direct, + response: PlaybackInfoResponse(), + audioStreams: [MediaStream(displayTitle: "English", index: -1)], + subtitleStreams: [MediaStream(displayTitle: "None", index: -1)], + selectedAudioStreamIndex: -1, + selectedSubtitleStreamIndex: -1, + subtitlesEnabled: true, + autoplayEnabled: false, + overlayType: .compact, + shouldShowPlayPreviousItem: true, + shouldShowPlayNextItem: true, + shouldShowAutoPlay: true) + + static var previews: some View { + ZStack { + Color.red + .ignoresSafeArea() + + tvOSVLCOverlay(viewModel: videoPlayerViewModel) + } + } } diff --git a/Swiftfin tvOS/Views/VideoPlayer/tvOSSLider/SliderView.swift b/Swiftfin tvOS/Views/VideoPlayer/tvOSSLider/SliderView.swift index cb7f3535..b5a8b0e5 100644 --- a/Swiftfin tvOS/Views/VideoPlayer/tvOSSLider/SliderView.swift +++ b/Swiftfin tvOS/Views/VideoPlayer/tvOSSLider/SliderView.swift @@ -1,39 +1,39 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import SwiftUI struct SliderView: UIViewRepresentable { - - @ObservedObject var viewModel: VideoPlayerViewModel - - // TODO: look at adjusting value dependent on item runtime - private let maxValue: Double = 1000 - - func updateUIView(_ uiView: TvOSSlider, context: Context) { - guard !viewModel.sliderIsScrubbing else { return } - uiView.value = Float(maxValue * viewModel.sliderPercentage) - } - - func makeUIView(context: Context) -> TvOSSlider { - let slider = TvOSSlider(viewModel: viewModel) - - slider.minimumValue = 0 - slider.maximumValue = Float(maxValue) - slider.value = Float(maxValue * viewModel.sliderPercentage) - slider.thumbSize = 25 - slider.thumbTintColor = .white - slider.minimumTrackTintColor = .white - slider.focusScaleFactor = 1.4 - slider.panDampingValue = 50 - slider.fineTunningVelocityThreshold = 1000 - - return slider - } + + @ObservedObject + var viewModel: VideoPlayerViewModel + + // TODO: look at adjusting value dependent on item runtime + private let maxValue: Double = 1000 + + func updateUIView(_ uiView: TvOSSlider, context: Context) { + guard !viewModel.sliderIsScrubbing else { return } + uiView.value = Float(maxValue * viewModel.sliderPercentage) + } + + func makeUIView(context: Context) -> TvOSSlider { + let slider = TvOSSlider(viewModel: viewModel) + + slider.minimumValue = 0 + slider.maximumValue = Float(maxValue) + slider.value = Float(maxValue * viewModel.sliderPercentage) + slider.thumbSize = 25 + slider.thumbTintColor = .white + slider.minimumTrackTintColor = .white + slider.focusScaleFactor = 1.4 + slider.panDampingValue = 50 + slider.fineTunningVelocityThreshold = 1000 + + return slider + } } diff --git a/Swiftfin tvOS/Views/VideoPlayer/tvOSSLider/tvOSSlider.swift b/Swiftfin tvOS/Views/VideoPlayer/tvOSSLider/tvOSSlider.swift index 8405ffa8..7c531348 100644 --- a/Swiftfin tvOS/Views/VideoPlayer/tvOSSLider/tvOSSlider.swift +++ b/Swiftfin tvOS/Views/VideoPlayer/tvOSSLider/tvOSSlider.swift @@ -1,23 +1,22 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// // Modification of https://github.com/zattoo/TvOSSlider -import UIKit import GameController +import UIKit enum DPadState { - case select - case right - case left - case up - case down + case select + case right + case left + case up + case down } private let trackViewHeight: CGFloat = 5 @@ -36,523 +35,524 @@ private let decelerationMaxVelocity: Float = 1000 /// A control used to select a single value from a continuous range of values. public final class TvOSSlider: UIControl { - - // MARK: - Public - - /// The slider’s current value. - @IBInspectable - public var value: Float { - get { - return storedValue - } - set { - storedValue = min(maximumValue, newValue) - storedValue = max(minimumValue, storedValue) - - var offset = trackView.bounds.width * CGFloat((storedValue - minimumValue) / (maximumValue - minimumValue)) - offset = min(trackView.bounds.width, offset) - thumbViewCenterXConstraint.constant = offset - } - } - - /// The minimum value of the slider. - @IBInspectable - public var minimumValue: Float = defaultMinimumValue { - didSet { - value = max(value, minimumValue) - } - } - - /// The maximum value of the slider. - @IBInspectable - public var maximumValue: Float = defaultMaximumValue { - didSet { - value = min(value, maximumValue) - } - } - - /// A Boolean value indicating whether changes in the slider’s value generate continuous update events. - @IBInspectable - public var isContinuous: Bool = defaultIsContinuous - - /// The color used to tint the default minimum track images. - @IBInspectable - public var minimumTrackTintColor: UIColor? = defaultMininumTrackTintColor { - didSet { - minimumTrackView.backgroundColor = minimumTrackTintColor - } - } - - /// The color used to tint the default maximum track images. - @IBInspectable - public var maximumTrackTintColor: UIColor? { - didSet { - maximumTrackView.backgroundColor = maximumTrackTintColor - } - } - - /// The color used to tint the default thumb images. - @IBInspectable - public var thumbTintColor: UIColor = defaultThumbTintColor { - didSet { - thumbView.backgroundColor = thumbTintColor - } - } - - /// Scale factor applied to the slider when receiving the focus - @IBInspectable - public var focusScaleFactor: CGFloat = defaultFocusScaleFactor { - didSet { - updateStateDependantViews() - } - } - - /// Value added or subtracted from the current value on steps left or right updates - public var stepValue: Float = defaultStepValue - - /// Damping value for panning gestures - public var panDampingValue: Float = 5 - - // Size for thumb view - public var thumbSize: CGFloat = 30 - - public var fineTunningVelocityThreshold: Float = 600 - - /** - Sets the slider’s current value, allowing you to animate the change visually. - - - Parameters: - - value: The new value to assign to the value property - - animated: Specify true to animate the change in value; otherwise, specify false to update the slider’s appearance immediately. Animations are performed asynchronously and do not block the calling thread. - */ - public func setValue(_ value: Float, animated: Bool) { - self.value = value - stopDeceleratingTimer() - - if animated { - UIView.animate(withDuration: animationDuration) { - self.setNeedsLayout() - self.layoutIfNeeded() - } - } - } - - /** - Assigns a minimum track image to the specified control states. - - - Parameters: - - image: The minimum track image to associate with the specified states. - - state: The control state with which to associate the image. - */ - public func setMinimumTrackImage(_ image: UIImage?, for state: UIControl.State) { - minimumTrackViewImages[state.rawValue] = image - updateStateDependantViews() - } - - /** - Assigns a maximum track image to the specified control states. - - - Parameters: - - image: The maximum track image to associate with the specified states. - - state: The control state with which to associate the image. - */ - public func setMaximumTrackImage(_ image: UIImage?, for state: UIControl.State) { - maximumTrackViewImages[state.rawValue] = image - updateStateDependantViews() - } - - /** - Assigns a thumb image to the specified control states. - - - Parameters: - - image: The thumb image to associate with the specified states. - - state: The control state with which to associate the image. - */ - public func setThumbImage(_ image: UIImage?, for state: UIControl.State) { - thumbViewImages[state.rawValue] = image - updateStateDependantViews() - } - - /// The minimum track image currently being used to render the slider. - public var currentMinimumTrackImage: UIImage? { - return minimumTrackView.image - } - - /// Contains the maximum track image currently being used to render the slider. - public var currentMaximumTrackImage: UIImage? { - return maximumTrackView.image - } - - /// The thumb image currently being used to render the slider. - public var currentThumbImage: UIImage? { - return thumbView.image - } - - /** - Returns the minimum track image associated with the specified control state. - - - Parameters: - - state: The control state whose minimum track image you want to use. Specify a single control state value for this parameter. - - - Returns: The minimum track image associated with the specified state, or nil if no image has been set. This method might also return nil if you specify multiple control states in the state parameter. For a description of track images, see Customizing the Slider’s Appearance. - */ - public func minimumTrackImage(for state: UIControl.State) -> UIImage? { - return minimumTrackViewImages[state.rawValue] - } - - /** - Returns the maximum track image associated with the specified control state. - - - Parameters: - - state: The control state whose maximum track image you want to use. Specify a single control state value for this parameter. - - - Returns: The maximum track image associated with the specified state, or nil if an appropriate image could not be retrieved. This method might return nil if you specify multiple control states in the state parameter. For a description of track images, see Customizing the Slider’s Appearance. - */ - public func maximumTrackImage(for state: UIControl.State) -> UIImage? { - return maximumTrackViewImages[state.rawValue] - } - - /** - Returns the thumb image associated with the specified control state. - - - Parameters: - - state: The control state whose thumb image you want to use. Specify a single control state value for this parameter. - - - Returns: The thumb image associated with the specified state, or nil if an appropriate image could not be retrieved. This method might return nil if you specify multiple control states in the state parameter. For a description of track and thumb images, see Customizing the Slider’s Appearance. - */ - public func thumbImage(for state: UIControl.State) -> UIImage? { - return thumbViewImages[state.rawValue] - } - - // MARK: - Initializers - - /// :nodoc: -// public override init(frame: CGRect) { -// super.init(frame: frame) -// setUpView() -// } - - /// :nodoc: -// public required init?(coder aDecoder: NSCoder) { -// super.init(coder: aDecoder) -// setUpView() -// } - - // MARK: VideoPlayerVieModel init - init(viewModel: VideoPlayerViewModel) { - self.viewModel = viewModel - super.init(frame: .zero) - setUpView() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - deinit { - NotificationCenter.default.removeObserver(self) - } - - // MARK: - UIControlStates - - /// :nodoc: - public override var isEnabled: Bool { - didSet { - panGestureRecognizer.isEnabled = isEnabled - updateStateDependantViews() - } - } - - /// :nodoc: - public override var isSelected: Bool { - didSet { - updateStateDependantViews() - } - } - - /// :nodoc: - public override var isHighlighted: Bool { - didSet { - updateStateDependantViews() - } - } - - /// :nodoc: - public override func didUpdateFocus(in context: UIFocusUpdateContext, with coordinator: UIFocusAnimationCoordinator) { - coordinator.addCoordinatedAnimations({ - self.updateStateDependantViews() - }, completion: nil) - } - - // MARK: - Private - - private let viewModel: VideoPlayerViewModel! - - private typealias ControlState = UInt - - public var storedValue: Float = defaultValue - - private var thumbViewImages: [ControlState: UIImage] = [:] - private var thumbView: UIImageView! - - private var trackViewImages: [ControlState: UIImage] = [:] - private var trackView: UIImageView! - - private var minimumTrackViewImages: [ControlState: UIImage] = [:] - private var minimumTrackView: UIImageView! - - private var maximumTrackViewImages: [ControlState: UIImage] = [:] - private var maximumTrackView: UIImageView! - - private var panGestureRecognizer: UIPanGestureRecognizer! - private var leftTapGestureRecognizer: UITapGestureRecognizer! - private var rightTapGestureRecognizer: UITapGestureRecognizer! - - private var thumbViewCenterXConstraint: NSLayoutConstraint! - - private var dPadState: DPadState = .select - - private weak var deceleratingTimer: Timer? - private var deceleratingVelocity: Float = 0 - - private var thumbViewCenterXConstraintConstant: Float = 0 - - private func setUpView() { - setUpTrackView() - setUpMinimumTrackView() - setUpMaximumTrackView() - setUpThumbView() - - setUpTrackViewConstraints() - setUpMinimumTrackViewConstraints() - setUpMaximumTrackViewConstraints() - setUpThumbViewConstraints() - - setUpGestures() - - NotificationCenter.default.addObserver(self, selector: #selector(controllerConnected(note:)), name: .GCControllerDidConnect, object: nil) - updateStateDependantViews() - } - - private func setUpThumbView() { - thumbView = UIImageView() - thumbView.layer.cornerRadius = thumbSize / 6 - thumbView.backgroundColor = thumbTintColor - addSubview(thumbView) - } - - private func setUpTrackView() { - trackView = UIImageView() - trackView.layer.cornerRadius = trackViewHeight/2 - trackView.backgroundColor = defaultTrackColor.withAlphaComponent(0.3) - addSubview(trackView) - } - - private func setUpMinimumTrackView() { - minimumTrackView = UIImageView() - minimumTrackView.layer.cornerRadius = trackViewHeight / 2 - minimumTrackView.backgroundColor = minimumTrackTintColor - addSubview(minimumTrackView) - } - - private func setUpMaximumTrackView() { - maximumTrackView = UIImageView() - maximumTrackView.layer.cornerRadius = trackViewHeight / 2 - maximumTrackView.backgroundColor = maximumTrackTintColor - addSubview(maximumTrackView) - } - - private func setUpTrackViewConstraints() { - trackView.translatesAutoresizingMaskIntoConstraints = false - trackView.leadingAnchor.constraint(equalTo: leadingAnchor).isActive = true - trackView.trailingAnchor.constraint(equalTo: trailingAnchor).isActive = true - trackView.centerYAnchor.constraint(equalTo: centerYAnchor).isActive = true - trackView.heightAnchor.constraint(equalToConstant: trackViewHeight).isActive = true - } - - private func setUpMinimumTrackViewConstraints() { - minimumTrackView.translatesAutoresizingMaskIntoConstraints = false - minimumTrackView.leadingAnchor.constraint(equalTo: trackView.leadingAnchor).isActive = true - minimumTrackView.trailingAnchor.constraint(equalTo: thumbView.centerXAnchor).isActive = true - minimumTrackView.centerYAnchor.constraint(equalTo:trackView.centerYAnchor).isActive = true - minimumTrackView.heightAnchor.constraint(equalToConstant: trackViewHeight).isActive = true - } - - private func setUpMaximumTrackViewConstraints() { - maximumTrackView.translatesAutoresizingMaskIntoConstraints = false - maximumTrackView.leadingAnchor.constraint(equalTo: thumbView.centerXAnchor).isActive = true - maximumTrackView.trailingAnchor.constraint(equalTo: trackView.trailingAnchor).isActive = true - maximumTrackView.centerYAnchor.constraint(equalTo:trackView.centerYAnchor).isActive = true - maximumTrackView.heightAnchor.constraint(equalToConstant: trackViewHeight).isActive = true - } - - private func setUpThumbViewConstraints() { - thumbView.translatesAutoresizingMaskIntoConstraints = false - thumbView.centerYAnchor.constraint(equalTo: centerYAnchor).isActive = true - thumbView.widthAnchor.constraint(equalToConstant: thumbSize / 3).isActive = true - thumbView.heightAnchor.constraint(equalToConstant: thumbSize).isActive = true - thumbViewCenterXConstraint = thumbView.centerXAnchor.constraint(equalTo: trackView.leadingAnchor, constant: CGFloat(value)) - thumbViewCenterXConstraint.isActive = true - } - - private func setUpGestures() { - panGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(panGestureWasTriggered(panGestureRecognizer:))) - addGestureRecognizer(panGestureRecognizer) - - leftTapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(leftTapWasTriggered)) - leftTapGestureRecognizer.allowedPressTypes = [NSNumber(value: UIPress.PressType.leftArrow.rawValue)] - leftTapGestureRecognizer.allowedTouchTypes = [NSNumber(value: UITouch.TouchType.indirect.rawValue)] - addGestureRecognizer(leftTapGestureRecognizer) - - rightTapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(rightTapWasTriggered)) - rightTapGestureRecognizer.allowedPressTypes = [NSNumber(value: UIPress.PressType.rightArrow.rawValue)] - rightTapGestureRecognizer.allowedTouchTypes = [NSNumber(value: UITouch.TouchType.indirect.rawValue)] - addGestureRecognizer(rightTapGestureRecognizer) - } - - private func updateStateDependantViews() { - thumbView.image = thumbViewImages[state.rawValue] ?? thumbViewImages[UIControl.State.normal.rawValue] - - if isFocused { - thumbView.transform = CGAffineTransform(scaleX: focusScaleFactor, y: focusScaleFactor) - } - else { - thumbView.transform = CGAffineTransform.identity - } - } - - @objc private func controllerConnected(note: NSNotification) { - guard let controller = note.object as? GCController else { return } - guard let micro = controller.microGamepad else { return } - - let threshold: Float = 0.7 - micro.reportsAbsoluteDpadValues = true - micro.dpad.valueChangedHandler = { - [weak self] (pad, x, y) in - - if x < -threshold { - self?.dPadState = .left - } - else if x > threshold { - self?.dPadState = .right - } - else { - self?.dPadState = .select - } - } - } - - @objc - private func handleDeceleratingTimer(timer: Timer) { - let centerX = thumbViewCenterXConstraintConstant + deceleratingVelocity * 0.01 - let percent = centerX / Float(trackView.frame.width) - value = minimumValue + ((maximumValue - minimumValue) * percent) - - if isContinuous { - sendActions(for: .valueChanged) - } - - thumbViewCenterXConstraintConstant = Float(thumbViewCenterXConstraint.constant) - - deceleratingVelocity *= decelerationRate - if !isFocused || abs(deceleratingVelocity) < 1 { - stopDeceleratingTimer() - } - - viewModel.sliderPercentage = Double(percent) - viewModel.sliderIsScrubbing = false - } - - private func stopDeceleratingTimer() { - deceleratingTimer?.invalidate() - deceleratingTimer = nil - deceleratingVelocity = 0 - sendActions(for: .valueChanged) - } - - private func isVerticalGesture(_ recognizer: UIPanGestureRecognizer) -> Bool { - let translation = recognizer.translation(in: self) - if abs(translation.y) > abs(translation.x) { - return true - } - return false - } - - // MARK: - Actions - - @objc - private func panGestureWasTriggered(panGestureRecognizer: UIPanGestureRecognizer) { - - if self.isVerticalGesture(panGestureRecognizer) { - return - } - - let translation = Float(panGestureRecognizer.translation(in: self).x) - let velocity = Float(panGestureRecognizer.velocity(in: self).x) - - switch panGestureRecognizer.state { - case .began: - viewModel.sliderIsScrubbing = true - - stopDeceleratingTimer() - thumbViewCenterXConstraintConstant = Float(thumbViewCenterXConstraint.constant) - case .changed: - viewModel.sliderIsScrubbing = true - - let centerX = thumbViewCenterXConstraintConstant + translation / panDampingValue - let percent = centerX / Float(trackView.frame.width) - value = minimumValue + ((maximumValue - minimumValue) * percent) - if isContinuous { - sendActions(for: .valueChanged) - } - - viewModel.sliderPercentage = Double(percent) - case .ended, .cancelled: - - thumbViewCenterXConstraintConstant = Float(thumbViewCenterXConstraint.constant) - - if abs(velocity) > fineTunningVelocityThreshold { - let direction: Float = velocity > 0 ? 1 : -1 - deceleratingVelocity = abs(velocity) > decelerationMaxVelocity ? decelerationMaxVelocity * direction : velocity - deceleratingTimer = Timer.scheduledTimer(timeInterval: 0.01, target: self, selector: #selector(handleDeceleratingTimer(timer:)), userInfo: nil, repeats: true) - } - else { - viewModel.sliderIsScrubbing = false - stopDeceleratingTimer() - } - default: - break - } - } - - @objc - private func leftTapWasTriggered() { -// setValue(value-stepValue, animated: true) - viewModel.playerOverlayDelegate?.didSelectBackward() - } - - @objc - private func rightTapWasTriggered() { -// setValue(value+stepValue, animated: true) - viewModel.playerOverlayDelegate?.didSelectForward() - } - - public override func pressesBegan(_ presses: Set, with event: UIPressesEvent?) { - for press in presses { - switch press.type { - case .select where dPadState == .left: - panGestureRecognizer.isEnabled = false - leftTapWasTriggered() - case .select where dPadState == .right: - panGestureRecognizer.isEnabled = false - rightTapWasTriggered() - case .select: - panGestureRecognizer.isEnabled = false - default: - break - } - } - panGestureRecognizer.isEnabled = true - super.pressesBegan(presses, with: event) - } + + // MARK: - Public + + /// The slider’s current value. + @IBInspectable + public var value: Float { + get { + storedValue + } + set { + storedValue = min(maximumValue, newValue) + storedValue = max(minimumValue, storedValue) + + var offset = trackView.bounds.width * CGFloat((storedValue - minimumValue) / (maximumValue - minimumValue)) + offset = min(trackView.bounds.width, offset) + thumbViewCenterXConstraint.constant = offset + } + } + + /// The minimum value of the slider. + @IBInspectable + public var minimumValue: Float = defaultMinimumValue { + didSet { + value = max(value, minimumValue) + } + } + + /// The maximum value of the slider. + @IBInspectable + public var maximumValue: Float = defaultMaximumValue { + didSet { + value = min(value, maximumValue) + } + } + + /// A Boolean value indicating whether changes in the slider’s value generate continuous update events. + @IBInspectable + public var isContinuous: Bool = defaultIsContinuous + + /// The color used to tint the default minimum track images. + @IBInspectable + public var minimumTrackTintColor: UIColor? = defaultMininumTrackTintColor { + didSet { + minimumTrackView.backgroundColor = minimumTrackTintColor + } + } + + /// The color used to tint the default maximum track images. + @IBInspectable + public var maximumTrackTintColor: UIColor? { + didSet { + maximumTrackView.backgroundColor = maximumTrackTintColor + } + } + + /// The color used to tint the default thumb images. + @IBInspectable + public var thumbTintColor: UIColor = defaultThumbTintColor { + didSet { + thumbView.backgroundColor = thumbTintColor + } + } + + /// Scale factor applied to the slider when receiving the focus + @IBInspectable + public var focusScaleFactor: CGFloat = defaultFocusScaleFactor { + didSet { + updateStateDependantViews() + } + } + + /// Value added or subtracted from the current value on steps left or right updates + public var stepValue: Float = defaultStepValue + + /// Damping value for panning gestures + public var panDampingValue: Float = 5 + + // Size for thumb view + public var thumbSize: CGFloat = 30 + + public var fineTunningVelocityThreshold: Float = 600 + + /** + Sets the slider’s current value, allowing you to animate the change visually. + + - Parameters: + - value: The new value to assign to the value property + - animated: Specify true to animate the change in value; otherwise, specify false to update the slider’s appearance immediately. Animations are performed asynchronously and do not block the calling thread. + */ + public func setValue(_ value: Float, animated: Bool) { + self.value = value + stopDeceleratingTimer() + + if animated { + UIView.animate(withDuration: animationDuration) { + self.setNeedsLayout() + self.layoutIfNeeded() + } + } + } + + /** + Assigns a minimum track image to the specified control states. + + - Parameters: + - image: The minimum track image to associate with the specified states. + - state: The control state with which to associate the image. + */ + public func setMinimumTrackImage(_ image: UIImage?, for state: UIControl.State) { + minimumTrackViewImages[state.rawValue] = image + updateStateDependantViews() + } + + /** + Assigns a maximum track image to the specified control states. + + - Parameters: + - image: The maximum track image to associate with the specified states. + - state: The control state with which to associate the image. + */ + public func setMaximumTrackImage(_ image: UIImage?, for state: UIControl.State) { + maximumTrackViewImages[state.rawValue] = image + updateStateDependantViews() + } + + /** + Assigns a thumb image to the specified control states. + + - Parameters: + - image: The thumb image to associate with the specified states. + - state: The control state with which to associate the image. + */ + public func setThumbImage(_ image: UIImage?, for state: UIControl.State) { + thumbViewImages[state.rawValue] = image + updateStateDependantViews() + } + + /// The minimum track image currently being used to render the slider. + public var currentMinimumTrackImage: UIImage? { + minimumTrackView.image + } + + /// Contains the maximum track image currently being used to render the slider. + public var currentMaximumTrackImage: UIImage? { + maximumTrackView.image + } + + /// The thumb image currently being used to render the slider. + public var currentThumbImage: UIImage? { + thumbView.image + } + + /** + Returns the minimum track image associated with the specified control state. + + - Parameters: + - state: The control state whose minimum track image you want to use. Specify a single control state value for this parameter. + + - Returns: The minimum track image associated with the specified state, or nil if no image has been set. This method might also return nil if you specify multiple control states in the state parameter. For a description of track images, see Customizing the Slider’s Appearance. + */ + public func minimumTrackImage(for state: UIControl.State) -> UIImage? { + minimumTrackViewImages[state.rawValue] + } + + /** + Returns the maximum track image associated with the specified control state. + + - Parameters: + - state: The control state whose maximum track image you want to use. Specify a single control state value for this parameter. + + - Returns: The maximum track image associated with the specified state, or nil if an appropriate image could not be retrieved. This method might return nil if you specify multiple control states in the state parameter. For a description of track images, see Customizing the Slider’s Appearance. + */ + public func maximumTrackImage(for state: UIControl.State) -> UIImage? { + maximumTrackViewImages[state.rawValue] + } + + /** + Returns the thumb image associated with the specified control state. + + - Parameters: + - state: The control state whose thumb image you want to use. Specify a single control state value for this parameter. + + - Returns: The thumb image associated with the specified state, or nil if an appropriate image could not be retrieved. This method might return nil if you specify multiple control states in the state parameter. For a description of track and thumb images, see Customizing the Slider’s Appearance. + */ + public func thumbImage(for state: UIControl.State) -> UIImage? { + thumbViewImages[state.rawValue] + } + + // MARK: - Initializers + + /// :nodoc: + // public override init(frame: CGRect) { + // super.init(frame: frame) + // setUpView() + // } + + /// :nodoc: + // public required init?(coder aDecoder: NSCoder) { + // super.init(coder: aDecoder) + // setUpView() + // } + + // MARK: VideoPlayerVieModel init + + init(viewModel: VideoPlayerViewModel) { + self.viewModel = viewModel + super.init(frame: .zero) + setUpView() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + // MARK: - UIControlStates + + /// :nodoc: + override public var isEnabled: Bool { + didSet { + panGestureRecognizer.isEnabled = isEnabled + updateStateDependantViews() + } + } + + /// :nodoc: + override public var isSelected: Bool { + didSet { + updateStateDependantViews() + } + } + + /// :nodoc: + override public var isHighlighted: Bool { + didSet { + updateStateDependantViews() + } + } + + /// :nodoc: + override public func didUpdateFocus(in context: UIFocusUpdateContext, with coordinator: UIFocusAnimationCoordinator) { + coordinator.addCoordinatedAnimations({ + self.updateStateDependantViews() + }, completion: nil) + } + + // MARK: - Private + + private let viewModel: VideoPlayerViewModel! + + private typealias ControlState = UInt + + public var storedValue: Float = defaultValue + + private var thumbViewImages: [ControlState: UIImage] = [:] + private var thumbView: UIImageView! + + private var trackViewImages: [ControlState: UIImage] = [:] + private var trackView: UIImageView! + + private var minimumTrackViewImages: [ControlState: UIImage] = [:] + private var minimumTrackView: UIImageView! + + private var maximumTrackViewImages: [ControlState: UIImage] = [:] + private var maximumTrackView: UIImageView! + + private var panGestureRecognizer: UIPanGestureRecognizer! + private var leftTapGestureRecognizer: UITapGestureRecognizer! + private var rightTapGestureRecognizer: UITapGestureRecognizer! + + private var thumbViewCenterXConstraint: NSLayoutConstraint! + + private var dPadState: DPadState = .select + + private weak var deceleratingTimer: Timer? + private var deceleratingVelocity: Float = 0 + + private var thumbViewCenterXConstraintConstant: Float = 0 + + private func setUpView() { + setUpTrackView() + setUpMinimumTrackView() + setUpMaximumTrackView() + setUpThumbView() + + setUpTrackViewConstraints() + setUpMinimumTrackViewConstraints() + setUpMaximumTrackViewConstraints() + setUpThumbViewConstraints() + + setUpGestures() + + NotificationCenter.default.addObserver(self, selector: #selector(controllerConnected(note:)), name: .GCControllerDidConnect, + object: nil) + updateStateDependantViews() + } + + private func setUpThumbView() { + thumbView = UIImageView() + thumbView.layer.cornerRadius = thumbSize / 6 + thumbView.backgroundColor = thumbTintColor + addSubview(thumbView) + } + + private func setUpTrackView() { + trackView = UIImageView() + trackView.layer.cornerRadius = trackViewHeight / 2 + trackView.backgroundColor = defaultTrackColor.withAlphaComponent(0.3) + addSubview(trackView) + } + + private func setUpMinimumTrackView() { + minimumTrackView = UIImageView() + minimumTrackView.layer.cornerRadius = trackViewHeight / 2 + minimumTrackView.backgroundColor = minimumTrackTintColor + addSubview(minimumTrackView) + } + + private func setUpMaximumTrackView() { + maximumTrackView = UIImageView() + maximumTrackView.layer.cornerRadius = trackViewHeight / 2 + maximumTrackView.backgroundColor = maximumTrackTintColor + addSubview(maximumTrackView) + } + + private func setUpTrackViewConstraints() { + trackView.translatesAutoresizingMaskIntoConstraints = false + trackView.leadingAnchor.constraint(equalTo: leadingAnchor).isActive = true + trackView.trailingAnchor.constraint(equalTo: trailingAnchor).isActive = true + trackView.centerYAnchor.constraint(equalTo: centerYAnchor).isActive = true + trackView.heightAnchor.constraint(equalToConstant: trackViewHeight).isActive = true + } + + private func setUpMinimumTrackViewConstraints() { + minimumTrackView.translatesAutoresizingMaskIntoConstraints = false + minimumTrackView.leadingAnchor.constraint(equalTo: trackView.leadingAnchor).isActive = true + minimumTrackView.trailingAnchor.constraint(equalTo: thumbView.centerXAnchor).isActive = true + minimumTrackView.centerYAnchor.constraint(equalTo: trackView.centerYAnchor).isActive = true + minimumTrackView.heightAnchor.constraint(equalToConstant: trackViewHeight).isActive = true + } + + private func setUpMaximumTrackViewConstraints() { + maximumTrackView.translatesAutoresizingMaskIntoConstraints = false + maximumTrackView.leadingAnchor.constraint(equalTo: thumbView.centerXAnchor).isActive = true + maximumTrackView.trailingAnchor.constraint(equalTo: trackView.trailingAnchor).isActive = true + maximumTrackView.centerYAnchor.constraint(equalTo: trackView.centerYAnchor).isActive = true + maximumTrackView.heightAnchor.constraint(equalToConstant: trackViewHeight).isActive = true + } + + private func setUpThumbViewConstraints() { + thumbView.translatesAutoresizingMaskIntoConstraints = false + thumbView.centerYAnchor.constraint(equalTo: centerYAnchor).isActive = true + thumbView.widthAnchor.constraint(equalToConstant: thumbSize / 3).isActive = true + thumbView.heightAnchor.constraint(equalToConstant: thumbSize).isActive = true + thumbViewCenterXConstraint = thumbView.centerXAnchor.constraint(equalTo: trackView.leadingAnchor, constant: CGFloat(value)) + thumbViewCenterXConstraint.isActive = true + } + + private func setUpGestures() { + panGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(panGestureWasTriggered(panGestureRecognizer:))) + addGestureRecognizer(panGestureRecognizer) + + leftTapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(leftTapWasTriggered)) + leftTapGestureRecognizer.allowedPressTypes = [NSNumber(value: UIPress.PressType.leftArrow.rawValue)] + leftTapGestureRecognizer.allowedTouchTypes = [NSNumber(value: UITouch.TouchType.indirect.rawValue)] + addGestureRecognizer(leftTapGestureRecognizer) + + rightTapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(rightTapWasTriggered)) + rightTapGestureRecognizer.allowedPressTypes = [NSNumber(value: UIPress.PressType.rightArrow.rawValue)] + rightTapGestureRecognizer.allowedTouchTypes = [NSNumber(value: UITouch.TouchType.indirect.rawValue)] + addGestureRecognizer(rightTapGestureRecognizer) + } + + private func updateStateDependantViews() { + thumbView.image = thumbViewImages[state.rawValue] ?? thumbViewImages[UIControl.State.normal.rawValue] + + if isFocused { + thumbView.transform = CGAffineTransform(scaleX: focusScaleFactor, y: focusScaleFactor) + } else { + thumbView.transform = CGAffineTransform.identity + } + } + + @objc + private func controllerConnected(note: NSNotification) { + guard let controller = note.object as? GCController else { return } + guard let micro = controller.microGamepad else { return } + + let threshold: Float = 0.7 + micro.reportsAbsoluteDpadValues = true + micro.dpad.valueChangedHandler = { + [weak self] _, x, _ in + + if x < -threshold { + self?.dPadState = .left + } else if x > threshold { + self?.dPadState = .right + } else { + self?.dPadState = .select + } + } + } + + @objc + private func handleDeceleratingTimer(timer: Timer) { + let centerX = thumbViewCenterXConstraintConstant + deceleratingVelocity * 0.01 + let percent = centerX / Float(trackView.frame.width) + value = minimumValue + ((maximumValue - minimumValue) * percent) + + if isContinuous { + sendActions(for: .valueChanged) + } + + thumbViewCenterXConstraintConstant = Float(thumbViewCenterXConstraint.constant) + + deceleratingVelocity *= decelerationRate + if !isFocused || abs(deceleratingVelocity) < 1 { + stopDeceleratingTimer() + } + + viewModel.sliderPercentage = Double(percent) + viewModel.sliderIsScrubbing = false + } + + private func stopDeceleratingTimer() { + deceleratingTimer?.invalidate() + deceleratingTimer = nil + deceleratingVelocity = 0 + sendActions(for: .valueChanged) + } + + private func isVerticalGesture(_ recognizer: UIPanGestureRecognizer) -> Bool { + let translation = recognizer.translation(in: self) + if abs(translation.y) > abs(translation.x) { + return true + } + return false + } + + // MARK: - Actions + + @objc + private func panGestureWasTriggered(panGestureRecognizer: UIPanGestureRecognizer) { + + if self.isVerticalGesture(panGestureRecognizer) { + return + } + + let translation = Float(panGestureRecognizer.translation(in: self).x) + let velocity = Float(panGestureRecognizer.velocity(in: self).x) + + switch panGestureRecognizer.state { + case .began: + viewModel.sliderIsScrubbing = true + + stopDeceleratingTimer() + thumbViewCenterXConstraintConstant = Float(thumbViewCenterXConstraint.constant) + case .changed: + viewModel.sliderIsScrubbing = true + + let centerX = thumbViewCenterXConstraintConstant + translation / panDampingValue + let percent = centerX / Float(trackView.frame.width) + value = minimumValue + ((maximumValue - minimumValue) * percent) + if isContinuous { + sendActions(for: .valueChanged) + } + + viewModel.sliderPercentage = Double(percent) + case .ended, .cancelled: + + thumbViewCenterXConstraintConstant = Float(thumbViewCenterXConstraint.constant) + + if abs(velocity) > fineTunningVelocityThreshold { + let direction: Float = velocity > 0 ? 1 : -1 + deceleratingVelocity = abs(velocity) > decelerationMaxVelocity ? decelerationMaxVelocity * direction : velocity + deceleratingTimer = Timer.scheduledTimer(timeInterval: 0.01, target: self, + selector: #selector(handleDeceleratingTimer(timer:)), userInfo: nil, repeats: true) + } else { + viewModel.sliderIsScrubbing = false + stopDeceleratingTimer() + } + default: + break + } + } + + @objc + private func leftTapWasTriggered() { + // setValue(value-stepValue, animated: true) + viewModel.playerOverlayDelegate?.didSelectBackward() + } + + @objc + private func rightTapWasTriggered() { + // setValue(value+stepValue, animated: true) + viewModel.playerOverlayDelegate?.didSelectForward() + } + + override public func pressesBegan(_ presses: Set, with event: UIPressesEvent?) { + for press in presses { + switch press.type { + case .select where dPadState == .left: + panGestureRecognizer.isEnabled = false + leftTapWasTriggered() + case .select where dPadState == .right: + panGestureRecognizer.isEnabled = false + rightTapWasTriggered() + case .select: + panGestureRecognizer.isEnabled = false + default: + break + } + } + panGestureRecognizer.isEnabled = true + super.pressesBegan(presses, with: event) + } } diff --git a/Swiftfin.xcodeproj/project.pbxproj b/Swiftfin.xcodeproj/project.pbxproj index 698d1745..789ccdab 100644 --- a/Swiftfin.xcodeproj/project.pbxproj +++ b/Swiftfin.xcodeproj/project.pbxproj @@ -974,6 +974,7 @@ E1AA33212782648000F6439C /* OverlaySliderColor.swift */, E1AA331E2782639D00F6439C /* OverlayType.swift */, E193D4DA27193CCA00900D82 /* PillStackable.swift */, + E1C812B4277A8E5D00918266 /* PlaybackSpeed.swift */, E193D4D727193CAC00900D82 /* PortraitImageStackable.swift */, E10D87DD278510E300BD264C /* PosterSize.swift */, E1D4BF832719D25A00A11E64 /* TrackLanguage.swift */, @@ -1527,7 +1528,6 @@ E193D5452719418B00900D82 /* VideoPlayer */ = { isa = PBXGroup; children = ( - E1C812B4277A8E5D00918266 /* PlaybackSpeed.swift */, E1C812B5277A8E5D00918266 /* PlayerOverlayDelegate.swift */, E1C812BB277A8E5D00918266 /* VLCPlayerOverlayView.swift */, E1C812B8277A8E5D00918266 /* VLCPlayerView.swift */, diff --git a/Swiftfin.xcodeproj/xcshareddata/IDETemplateMacros.plist b/Swiftfin.xcodeproj/xcshareddata/IDETemplateMacros.plist index bfa5b38b..86da1185 100644 --- a/Swiftfin.xcodeproj/xcshareddata/IDETemplateMacros.plist +++ b/Swiftfin.xcodeproj/xcshareddata/IDETemplateMacros.plist @@ -4,12 +4,11 @@ FILEHEADER - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) ___YEAR___ Jellyfin & Jellyfin Contributors +// diff --git a/Swiftfin/App/AppDelegate.swift b/Swiftfin/App/AppDelegate.swift index 6f120c94..f4284e0a 100644 --- a/Swiftfin/App/AppDelegate.swift +++ b/Swiftfin/App/AppDelegate.swift @@ -1,35 +1,36 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import AVFAudio import SwiftUI import UIKit class AppDelegate: NSObject, UIApplicationDelegate { - static var orientationLock = UIInterfaceOrientationMask.all + static var orientationLock = UIInterfaceOrientationMask.all - func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { + func application(_ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool + { - // Lazily initialize datastack - _ = SwiftfinStore.dataStack - - let audioSession = AVAudioSession.sharedInstance() - do { - try audioSession.setCategory(.playback) - } catch { - print("setting category AVAudioSessionCategoryPlayback failed") - } + // Lazily initialize datastack + _ = SwiftfinStore.dataStack - return true - } + let audioSession = AVAudioSession.sharedInstance() + do { + try audioSession.setCategory(.playback) + } catch { + print("setting category AVAudioSessionCategoryPlayback failed") + } - func application(_ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask { - AppDelegate.orientationLock - } + return true + } + + func application(_ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask { + AppDelegate.orientationLock + } } diff --git a/Swiftfin/App/JellyfinPlayerApp.swift b/Swiftfin/App/JellyfinPlayerApp.swift index e87b3234..c9453e09 100644 --- a/Swiftfin/App/JellyfinPlayerApp.swift +++ b/Swiftfin/App/JellyfinPlayerApp.swift @@ -1,9 +1,10 @@ -/* JellyfinPlayer/Swiftfin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Defaults import MessageUI @@ -11,51 +12,54 @@ import Stinsen import SwiftUI // MARK: JellyfinPlayerApp + @main struct JellyfinPlayerApp: App { - @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate + @UIApplicationDelegateAdaptor(AppDelegate.self) + var appDelegate - var body: some Scene { - WindowGroup { - EmptyView() - .ignoresSafeArea() - .withHostingWindow({ window in - window?.rootViewController = PreferenceUIHostingController(wrappedView: MainCoordinator().view()) - }) - .onAppear { - JellyfinPlayerApp.setupAppearance() - } - .onOpenURL { url in - AppURLHandler.shared.processDeepLink(url: url) - } - } - } + var body: some Scene { + WindowGroup { + EmptyView() + .ignoresSafeArea() + .withHostingWindow { window in + window?.rootViewController = PreferenceUIHostingController(wrappedView: MainCoordinator().view()) + } + .onAppear { + JellyfinPlayerApp.setupAppearance() + } + .onOpenURL { url in + AppURLHandler.shared.processDeepLink(url: url) + } + } + } - static func setupAppearance() { - let scenes = UIApplication.shared.connectedScenes - let windowScene = scenes.first as? UIWindowScene - windowScene?.windows.first?.overrideUserInterfaceStyle = Defaults[.appAppearance].style - } + static func setupAppearance() { + let scenes = UIApplication.shared.connectedScenes + let windowScene = scenes.first as? UIWindowScene + windowScene?.windows.first?.overrideUserInterfaceStyle = Defaults[.appAppearance].style + } } // MARK: Hosting Window + struct HostingWindowFinder: UIViewRepresentable { - var callback: (UIWindow?) -> Void + var callback: (UIWindow?) -> Void - func makeUIView(context: Context) -> UIView { - let view = UIView() - DispatchQueue.main.async { [weak view] in - callback(view?.window) - } - return view - } + func makeUIView(context: Context) -> UIView { + let view = UIView() + DispatchQueue.main.async { [weak view] in + callback(view?.window) + } + return view + } - func updateUIView(_ uiView: UIView, context: Context) {} + func updateUIView(_ uiView: UIView, context: Context) {} } extension View { - func withHostingWindow(_ callback: @escaping (UIWindow?) -> Void) -> some View { - background(HostingWindowFinder(callback: callback)) - } + func withHostingWindow(_ callback: @escaping (UIWindow?) -> Void) -> some View { + background(HostingWindowFinder(callback: callback)) + } } diff --git a/Swiftfin/App/PreferenceUIHosting/PreferenceUIHostingController.swift b/Swiftfin/App/PreferenceUIHosting/PreferenceUIHostingController.swift index d6146c75..7aba3ae3 100644 --- a/Swiftfin/App/PreferenceUIHosting/PreferenceUIHostingController.swift +++ b/Swiftfin/App/PreferenceUIHosting/PreferenceUIHostingController.swift @@ -1,118 +1,121 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// -import UIKit import SwiftUI +import UIKit // MARK: PreferenceUIHostingController + class PreferenceUIHostingController: UIHostingController { - init(wrappedView: V) { - let box = Box() - super.init(rootView: AnyView(wrappedView - .onPreferenceChange(PrefersHomeIndicatorAutoHiddenPreferenceKey.self) { - box.value?._prefersHomeIndicatorAutoHidden = $0 - }.onPreferenceChange(SupportedOrientationsPreferenceKey.self) { - box.value?._orientations = $0 - }.onPreferenceChange(ViewPreferenceKey.self) { - box.value?._viewPreference = $0 - })) - box.value = self - } + init(wrappedView: V) { + let box = Box() + super.init(rootView: AnyView(wrappedView + .onPreferenceChange(PrefersHomeIndicatorAutoHiddenPreferenceKey.self) { + box.value?._prefersHomeIndicatorAutoHidden = $0 + }.onPreferenceChange(SupportedOrientationsPreferenceKey.self) { + box.value?._orientations = $0 + }.onPreferenceChange(ViewPreferenceKey.self) { + box.value?._viewPreference = $0 + })) + box.value = self + } - @objc dynamic required init?(coder aDecoder: NSCoder) { - super.init(coder: aDecoder) - super.modalPresentationStyle = .fullScreen - } + @objc + dynamic required init?(coder aDecoder: NSCoder) { + super.init(coder: aDecoder) + super.modalPresentationStyle = .fullScreen + } - private class Box { - weak var value: PreferenceUIHostingController? - init() {} - } + private class Box { + weak var value: PreferenceUIHostingController? + init() {} + } - // MARK: Prefers Home Indicator Auto Hidden + // MARK: Prefers Home Indicator Auto Hidden - public var _prefersHomeIndicatorAutoHidden = false { - didSet { setNeedsUpdateOfHomeIndicatorAutoHidden() } - } + public var _prefersHomeIndicatorAutoHidden = false { + didSet { setNeedsUpdateOfHomeIndicatorAutoHidden() } + } - override var prefersHomeIndicatorAutoHidden: Bool { - _prefersHomeIndicatorAutoHidden - } + override var prefersHomeIndicatorAutoHidden: Bool { + _prefersHomeIndicatorAutoHidden + } - // MARK: Lock orientation + // MARK: Lock orientation - public var _orientations: UIInterfaceOrientationMask = .allButUpsideDown { - didSet { - if _orientations == .landscape { - let value = UIInterfaceOrientation.landscapeRight.rawValue - UIDevice.current.setValue(value, forKey: "orientation") - UIViewController.attemptRotationToDeviceOrientation() - } - } - } + public var _orientations: UIInterfaceOrientationMask = .allButUpsideDown { + didSet { + if _orientations == .landscape { + let value = UIInterfaceOrientation.landscapeRight.rawValue + UIDevice.current.setValue(value, forKey: "orientation") + UIViewController.attemptRotationToDeviceOrientation() + } + } + } - override var supportedInterfaceOrientations: UIInterfaceOrientationMask { - _orientations - } + override var supportedInterfaceOrientations: UIInterfaceOrientationMask { + _orientations + } - public var _viewPreference: UIUserInterfaceStyle = .unspecified { - didSet { - overrideUserInterfaceStyle = _viewPreference - } - } + public var _viewPreference: UIUserInterfaceStyle = .unspecified { + didSet { + overrideUserInterfaceStyle = _viewPreference + } + } } // MARK: Preference Keys + struct PrefersHomeIndicatorAutoHiddenPreferenceKey: PreferenceKey { - typealias Value = Bool + typealias Value = Bool - static var defaultValue: Value = false + static var defaultValue: Value = false - static func reduce(value: inout Value, nextValue: () -> Value) { - value = nextValue() || value - } + static func reduce(value: inout Value, nextValue: () -> Value) { + value = nextValue() || value + } } struct ViewPreferenceKey: PreferenceKey { - typealias Value = UIUserInterfaceStyle + typealias Value = UIUserInterfaceStyle - static var defaultValue: UIUserInterfaceStyle = .unspecified + static var defaultValue: UIUserInterfaceStyle = .unspecified - static func reduce(value: inout UIUserInterfaceStyle, nextValue: () -> UIUserInterfaceStyle) { - value = nextValue() - } + static func reduce(value: inout UIUserInterfaceStyle, nextValue: () -> UIUserInterfaceStyle) { + value = nextValue() + } } struct SupportedOrientationsPreferenceKey: PreferenceKey { - typealias Value = UIInterfaceOrientationMask - static var defaultValue: UIInterfaceOrientationMask = .allButUpsideDown + typealias Value = UIInterfaceOrientationMask + static var defaultValue: UIInterfaceOrientationMask = .allButUpsideDown - static func reduce(value: inout UIInterfaceOrientationMask, nextValue: () -> UIInterfaceOrientationMask) { - // use the most restrictive set from the stack - value.formIntersection(nextValue()) - } + static func reduce(value: inout UIInterfaceOrientationMask, nextValue: () -> UIInterfaceOrientationMask) { + // use the most restrictive set from the stack + value.formIntersection(nextValue()) + } } // MARK: Preference Key View Extension + extension View { - // Controls the application's preferred home indicator auto-hiding when this view is shown. - func prefersHomeIndicatorAutoHidden(_ value: Bool) -> some View { - preference(key: PrefersHomeIndicatorAutoHiddenPreferenceKey.self, value: value) - } + // Controls the application's preferred home indicator auto-hiding when this view is shown. + func prefersHomeIndicatorAutoHidden(_ value: Bool) -> some View { + preference(key: PrefersHomeIndicatorAutoHiddenPreferenceKey.self, value: value) + } - func supportedOrientations(_ supportedOrientations: UIInterfaceOrientationMask) -> some View { - // When rendered, export the requested orientations upward to Root - preference(key: SupportedOrientationsPreferenceKey.self, value: supportedOrientations) - } + func supportedOrientations(_ supportedOrientations: UIInterfaceOrientationMask) -> some View { + // When rendered, export the requested orientations upward to Root + preference(key: SupportedOrientationsPreferenceKey.self, value: supportedOrientations) + } - func overrideViewPreference(_ viewPreference: UIUserInterfaceStyle) -> some View { - // When rendered, export the requested orientations upward to Root - preference(key: ViewPreferenceKey.self, value: viewPreference) - } + func overrideViewPreference(_ viewPreference: UIUserInterfaceStyle) -> some View { + // When rendered, export the requested orientations upward to Root + preference(key: ViewPreferenceKey.self, value: viewPreference) + } } diff --git a/Swiftfin/App/PreferenceUIHosting/PreferenceUIHostingSwizzling.swift b/Swiftfin/App/PreferenceUIHosting/PreferenceUIHostingSwizzling.swift index 7bb70a11..304dcd1e 100644 --- a/Swiftfin/App/PreferenceUIHosting/PreferenceUIHostingSwizzling.swift +++ b/Swiftfin/App/PreferenceUIHosting/PreferenceUIHostingSwizzling.swift @@ -1,15 +1,14 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// -import UIKit import SwiftUI import SwizzleSwift +import UIKit // MARK: - wrapper view @@ -18,60 +17,63 @@ import SwizzleSwift /// /// Source: https://gist.github.com/Amzd/01e1f69ecbc4c82c8586dcd292b1d30d struct PreferenceUIHostingControllerView: UIViewControllerRepresentable { - init(@ViewBuilder wrappedView: @escaping () -> Wrapped) { - _ = UIViewController.preferenceSwizzling - self.wrappedView = wrappedView - } + init(@ViewBuilder wrappedView: @escaping () -> Wrapped) { + _ = UIViewController.preferenceSwizzling + self.wrappedView = wrappedView + } - var wrappedView: () -> Wrapped + var wrappedView: () -> Wrapped - func makeUIViewController(context: Context) -> PreferenceUIHostingController { - PreferenceUIHostingController(wrappedView: wrappedView()) - } + func makeUIViewController(context: Context) -> PreferenceUIHostingController { + PreferenceUIHostingController(wrappedView: wrappedView()) + } - func updateUIViewController(_ uiViewController: PreferenceUIHostingController, context: Context) {} + func updateUIViewController(_ uiViewController: PreferenceUIHostingController, context: Context) {} } // MARK: - swizzling uiviewcontroller extensions extension UIViewController { - static var preferenceSwizzling: Void = { - Swizzle(UIViewController.self) { - #selector(getter: childForScreenEdgesDeferringSystemGestures) <-> #selector(swizzled_childForScreenEdgesDeferringSystemGestures) - #selector(getter: childForHomeIndicatorAutoHidden) <-> #selector(swizzled_childForHomeIndicatorAutoHidden) - } - }() + static var preferenceSwizzling: Void = { + Swizzle(UIViewController.self) { + #selector(getter: childForScreenEdgesDeferringSystemGestures) <-> #selector(swizzled_childForScreenEdgesDeferringSystemGestures) + #selector(getter: childForHomeIndicatorAutoHidden) <-> #selector(swizzled_childForHomeIndicatorAutoHidden) + } + }() } extension UIViewController { - @objc func swizzled_childForScreenEdgesDeferringSystemGestures() -> UIViewController? { - if self is PreferenceUIHostingController { - // dont continue searching - return nil - } else { - return search() - } - } - @objc func swizzled_childForHomeIndicatorAutoHidden() -> UIViewController? { - if self is PreferenceUIHostingController { - // dont continue searching - return nil - } else { - return search() - } - } + @objc + func swizzled_childForScreenEdgesDeferringSystemGestures() -> UIViewController? { + if self is PreferenceUIHostingController { + // dont continue searching + return nil + } else { + return search() + } + } - private func search() -> PreferenceUIHostingController? { - if let result = children.compactMap({ $0 as? PreferenceUIHostingController }).first { - return result - } + @objc + func swizzled_childForHomeIndicatorAutoHidden() -> UIViewController? { + if self is PreferenceUIHostingController { + // dont continue searching + return nil + } else { + return search() + } + } - for child in children { - if let result = child.search() { - return result - } - } + private func search() -> PreferenceUIHostingController? { + if let result = children.compactMap({ $0 as? PreferenceUIHostingController }).first { + return result + } - return nil - } + for child in children { + if let result = child.search() { + return result + } + } + + return nil + } } diff --git a/Swiftfin/AppURLHandler/AppURLHandler.swift b/Swiftfin/AppURLHandler/AppURLHandler.swift index 7f745487..412a4c84 100644 --- a/Swiftfin/AppURLHandler/AppURLHandler.swift +++ b/Swiftfin/AppURLHandler/AppURLHandler.swift @@ -1,11 +1,10 @@ // -/* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Combine import Foundation @@ -13,97 +12,98 @@ import JellyfinAPI import Stinsen final class AppURLHandler { - static let deepLinkScheme = "jellyfin" + static let deepLinkScheme = "jellyfin" - enum AppURLState { - case launched - case allowedInLogin - case allowed + enum AppURLState { + case launched + case allowedInLogin + case allowed - func allowedScheme(with url: URL) -> Bool { - switch self { - case .launched: - return false - case .allowed: - return true - case .allowedInLogin: - return false - } - } - } + func allowedScheme(with url: URL) -> Bool { + switch self { + case .launched: + return false + case .allowed: + return true + case .allowedInLogin: + return false + } + } + } - static let shared = AppURLHandler() + static let shared = AppURLHandler() - var cancellables = Set() + var cancellables = Set() - var appURLState: AppURLState = .launched - var launchURL: URL? + var appURLState: AppURLState = .launched + var launchURL: URL? } extension AppURLHandler { - @discardableResult - func processDeepLink(url: URL) -> Bool { - guard url.scheme == Self.deepLinkScheme || url.scheme == "widget-extension" else { - return false - } - if AppURLHandler.shared.appURLState.allowedScheme(with: url) { - return processURL(url) - } else { - launchURL = url - } - return true - } + @discardableResult + func processDeepLink(url: URL) -> Bool { + guard url.scheme == Self.deepLinkScheme || url.scheme == "widget-extension" else { + return false + } + if AppURLHandler.shared.appURLState.allowedScheme(with: url) { + return processURL(url) + } else { + launchURL = url + } + return true + } - func processLaunchedURLIfNeeded() { - guard let launchURL = launchURL, - !launchURL.absoluteString.isEmpty else { return } - if processDeepLink(url: launchURL) { - self.launchURL = nil - } - } + func processLaunchedURLIfNeeded() { + guard let launchURL = launchURL, + !launchURL.absoluteString.isEmpty else { return } + if processDeepLink(url: launchURL) { + self.launchURL = nil + } + } - private func processURL(_ url: URL) -> Bool { - if processURLForUser(url: url) { - return true - } + private func processURL(_ url: URL) -> Bool { + if processURLForUser(url: url) { + return true + } - return false - } + return false + } - private func processURLForUser(url: URL) -> Bool { - guard url.host?.lowercased() == "users", - url.pathComponents[safe: 1]?.isEmpty == false else { return false } + private func processURLForUser(url: URL) -> Bool { + guard url.host?.lowercased() == "users", + url.pathComponents[safe: 1]?.isEmpty == false else { return false } - // /Users/{UserID}/Items/{ItemID} - if url.pathComponents[safe: 2]?.lowercased() == "items", - let userID = url.pathComponents[safe: 1], - let itemID = url.pathComponents[safe: 3] { - // It would be nice if the ItemViewModel could be initialized to id later. - getItem(userID: userID, itemID: itemID) { item in - guard let item = item else { return } - SwiftfinNotificationCenter.main.post(name: SwiftfinNotificationCenter.Keys.processDeepLink, object: DeepLink.item(item)) - } + // /Users/{UserID}/Items/{ItemID} + if url.pathComponents[safe: 2]?.lowercased() == "items", + let userID = url.pathComponents[safe: 1], + let itemID = url.pathComponents[safe: 3] + { + // It would be nice if the ItemViewModel could be initialized to id later. + getItem(userID: userID, itemID: itemID) { item in + guard let item = item else { return } + SwiftfinNotificationCenter.main.post(name: SwiftfinNotificationCenter.Keys.processDeepLink, object: DeepLink.item(item)) + } - return true - } + return true + } - return false - } + return false + } } extension AppURLHandler { - func getItem(userID: String, itemID: String, completion: @escaping (BaseItemDto?) -> Void) { - UserLibraryAPI.getItem(userId: userID, itemId: itemID) - .sink(receiveCompletion: { innerCompletion in - switch innerCompletion { - case .failure: - completion(nil) - default: - break - } - }, receiveValue: { item in - completion(item) - }) - .store(in: &cancellables) - } + func getItem(userID: String, itemID: String, completion: @escaping (BaseItemDto?) -> Void) { + UserLibraryAPI.getItem(userId: userID, itemId: itemID) + .sink(receiveCompletion: { innerCompletion in + switch innerCompletion { + case .failure: + completion(nil) + default: + break + } + }, receiveValue: { item in + completion(item) + }) + .store(in: &cancellables) + } } diff --git a/Swiftfin/AppURLHandler/DeepLink.swift b/Swiftfin/AppURLHandler/DeepLink.swift index 9270682f..ae224f54 100644 --- a/Swiftfin/AppURLHandler/DeepLink.swift +++ b/Swiftfin/AppURLHandler/DeepLink.swift @@ -1,15 +1,14 @@ // -/* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Foundation import JellyfinAPI enum DeepLink { - case item(BaseItemDto) + case item(BaseItemDto) } diff --git a/Swiftfin/Components/EpisodeCardVStackView.swift b/Swiftfin/Components/EpisodeCardVStackView.swift index 4df40dbd..8e959ab1 100644 --- a/Swiftfin/Components/EpisodeCardVStackView.swift +++ b/Swiftfin/Components/EpisodeCardVStackView.swift @@ -1,108 +1,108 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// -import SwiftUI import JellyfinAPI +import SwiftUI struct EpisodeCardVStackView: View { - let items: [BaseItemDto] - let selectedAction: (BaseItemDto) -> Void + let items: [BaseItemDto] + let selectedAction: (BaseItemDto) -> Void - private func buildCardOverlayView(item: BaseItemDto) -> some View { - HStack { - ZStack { - if item.userData?.isFavorite ?? false { - Image(systemName: "circle.fill") - .foregroundColor(.white) - .opacity(0.6) - Image(systemName: "heart.fill") - .foregroundColor(Color(.systemRed)) - .font(.system(size: 10)) - } - } - .padding(.leading, 2) - .padding(.bottom, item.userData?.playedPercentage == nil ? 2 : 9) - .opacity(1) + private func buildCardOverlayView(item: BaseItemDto) -> some View { + HStack { + ZStack { + if item.userData?.isFavorite ?? false { + Image(systemName: "circle.fill") + .foregroundColor(.white) + .opacity(0.6) + Image(systemName: "heart.fill") + .foregroundColor(Color(.systemRed)) + .font(.system(size: 10)) + } + } + .padding(.leading, 2) + .padding(.bottom, item.userData?.playedPercentage == nil ? 2 : 9) + .opacity(1) - ZStack { - if item.userData?.played ?? false { - Image(systemName: "circle.fill") - .foregroundColor(.white) - Image(systemName: "checkmark.circle.fill") - .foregroundColor(.jellyfinPurple) - } - }.padding(2) - .opacity(1) - } - } + ZStack { + if item.userData?.played ?? false { + Image(systemName: "circle.fill") + .foregroundColor(.white) + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.jellyfinPurple) + } + }.padding(2) + .opacity(1) + } + } - var body: some View { - VStack { - ForEach(items, id: \.id) { item in - Button { - selectedAction(item) - } label: { - HStack { + var body: some View { + VStack { + ForEach(items, id: \.id) { item in + Button { + selectedAction(item) + } label: { + HStack { - // MARK: Image - ImageView(src: item.getPrimaryImage(maxWidth: 150), - bh: item.getPrimaryImageBlurHash(), - failureInitials: item.failureInitials) - .frame(width: 150, height: 100) - .cornerRadius(10) - .overlay( - Rectangle() - .fill(Color.jellyfinPurple) - .frame(width: CGFloat(item.userData?.playedPercentage ?? 0 * 1.5), height: 7) - .padding(0), alignment: .bottomLeading - ) - .overlay(buildCardOverlayView(item: item), alignment: .topTrailing) + // MARK: Image - VStack(alignment: .leading) { + ImageView(src: item.getPrimaryImage(maxWidth: 150), + bh: item.getPrimaryImageBlurHash(), + failureInitials: item.failureInitials) + .frame(width: 150, height: 100) + .cornerRadius(10) + .overlay(Rectangle() + .fill(Color.jellyfinPurple) + .frame(width: CGFloat(item.userData?.playedPercentage ?? 0 * 1.5), height: 7) + .padding(0), alignment: .bottomLeading) + .overlay(buildCardOverlayView(item: item), alignment: .topTrailing) - // MARK: Title - Text(item.title) - .font(.subheadline) - .fontWeight(.medium) - .foregroundColor(.primary) - .lineLimit(2) + VStack(alignment: .leading) { - HStack { - Text(item.getEpisodeLocator() ?? "") - .font(.subheadline) - .fontWeight(.medium) - .foregroundColor(.secondary) + // MARK: Title - if let runtime = item.getItemRuntime() { - Text(runtime) - .font(.subheadline) - .fontWeight(.medium) - .foregroundColor(.secondary) - } + Text(item.title) + .font(.subheadline) + .fontWeight(.medium) + .foregroundColor(.primary) + .lineLimit(2) - Spacer() - } + HStack { + Text(item.getEpisodeLocator() ?? "") + .font(.subheadline) + .fontWeight(.medium) + .foregroundColor(.secondary) - // MARK: Overview - Text(item.overview ?? "") - .font(.footnote) - .foregroundColor(.primary) - .fixedSize(horizontal: false, vertical: true) - .lineLimit(4) + if let runtime = item.getItemRuntime() { + Text(runtime) + .font(.subheadline) + .fontWeight(.medium) + .foregroundColor(.secondary) + } - Spacer() - } - } - .padding(.horizontal, 16) - } - } - } - } + Spacer() + } + + // MARK: Overview + + Text(item.overview ?? "") + .font(.footnote) + .foregroundColor(.primary) + .fixedSize(horizontal: false, vertical: true) + .lineLimit(4) + + Spacer() + } + } + .padding(.horizontal, 16) + } + } + } + } } diff --git a/Swiftfin/Components/EpisodesRowView.swift b/Swiftfin/Components/EpisodesRowView.swift index aee0e7c7..fa8e962a 100644 --- a/Swiftfin/Components/EpisodesRowView.swift +++ b/Swiftfin/Components/EpisodesRowView.swift @@ -1,160 +1,161 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// -import SwiftUI import JellyfinAPI +import SwiftUI struct EpisodesRowView: View { - - @EnvironmentObject var itemRouter: ItemCoordinator.Router - @ObservedObject var viewModel: EpisodesRowViewModel - - var body: some View { - VStack(alignment: .leading, spacing: 0) { - - HStack { - Menu { - ForEach(Array(viewModel.seasonsEpisodes.keys).sorted(by: { $0.name ?? "" < $1.name ?? ""}), id:\.self) { season in - Button { - viewModel.selectedSeason = season - } label: { - if season.id == viewModel.selectedSeason?.id { - Label(season.name ?? "Season", systemImage: "checkmark") - } else { - Text(season.name ?? "Season") - } - } - } - } label: { - HStack(spacing: 5) { - Text(viewModel.selectedSeason?.name ?? "Unknown") - .fontWeight(.semibold) - .fixedSize() - Image(systemName: "chevron.down") - } - } - - Spacer() - } - .padding() - - ScrollView(.horizontal, showsIndicators: false) { - ScrollViewReader { reader in - HStack(alignment: .top, spacing: 15) { - if viewModel.isLoading { - VStack(alignment: .leading) { - ZStack { - Color.gray.ignoresSafeArea() - - ProgressView() - } - .mask(Rectangle().frame(width: 200, height: 112).cornerRadius(10)) - .frame(width: 200, height: 112) + @EnvironmentObject + var itemRouter: ItemCoordinator.Router + @ObservedObject + var viewModel: EpisodesRowViewModel - VStack(alignment: .leading) { - Text("S-E-") - .font(.footnote) - .foregroundColor(.secondary) - Text("--") - .font(.body) - .padding(.bottom, 1) - .lineLimit(2) - } + var body: some View { + VStack(alignment: .leading, spacing: 0) { - Spacer() - } - .frame(width: 200) - .shadow(radius: 4, y: 2) - } else if let selectedSeason = viewModel.selectedSeason { - if viewModel.seasonsEpisodes[selectedSeason]!.isEmpty { - VStack(alignment: .leading) { + HStack { + Menu { + ForEach(Array(viewModel.seasonsEpisodes.keys).sorted(by: { $0.name ?? "" < $1.name ?? "" }), id: \.self) { season in + Button { + viewModel.selectedSeason = season + } label: { + if season.id == viewModel.selectedSeason?.id { + Label(season.name ?? "Season", systemImage: "checkmark") + } else { + Text(season.name ?? "Season") + } + } + } + } label: { + HStack(spacing: 5) { + Text(viewModel.selectedSeason?.name ?? "Unknown") + .fontWeight(.semibold) + .fixedSize() + Image(systemName: "chevron.down") + } + } - Color.gray.ignoresSafeArea() - .mask(Rectangle().frame(width: 200, height: 112).cornerRadius(10)) - .frame(width: 200, height: 112) + Spacer() + } + .padding() - VStack(alignment: .leading) { - Text("--") - .font(.footnote) - .foregroundColor(.secondary) - Text("No episodes available") - .font(.body) - .padding(.bottom, 1) - .lineLimit(2) - } + ScrollView(.horizontal, showsIndicators: false) { + ScrollViewReader { reader in + HStack(alignment: .top, spacing: 15) { + if viewModel.isLoading { + VStack(alignment: .leading) { - Spacer() - } - .frame(width: 200) - .shadow(radius: 4, y: 2) - } else { - ForEach(viewModel.seasonsEpisodes[selectedSeason]!, id:\.self) { episode in - Button { - itemRouter.route(to: \.item, episode) - } label: { - HStack(alignment: .top) { - VStack(alignment: .leading) { - - ImageView(src: episode.getBackdropImage(maxWidth: 200), - bh: episode.getBackdropImageBlurHash()) - .mask(Rectangle().frame(width: 200, height: 112).cornerRadius(10)) - .frame(width: 200, height: 112) - .overlay { - if episode.id == viewModel.episodeItemViewModel.item.id { - RoundedRectangle(cornerRadius: 6) - .stroke(Color.jellyfinPurple, lineWidth: 4) - } - } - .padding(.top) - - VStack(alignment: .leading) { - Text(episode.getEpisodeLocator() ?? "") - .font(.footnote) - .foregroundColor(.secondary) - Text(episode.name ?? "") - .font(.body) - .padding(.bottom, 1) - .lineLimit(2) - Text(episode.overview ?? "") - .font(.caption) - .foregroundColor(.secondary) - .fontWeight(.light) - .lineLimit(3) - } - - Spacer() - } - .frame(width: 200) - .shadow(radius: 4, y: 2) - } - } - .buttonStyle(PlainButtonStyle()) - .id(episode.name) - } - } - } - } - .padding(.horizontal) - .onChange(of: viewModel.selectedSeason) { _ in - if viewModel.selectedSeason?.id == viewModel.episodeItemViewModel.item.seasonId { - reader.scrollTo(viewModel.episodeItemViewModel.item.name) - } - } - .onChange(of: viewModel.seasonsEpisodes) { _ in - if viewModel.selectedSeason?.id == viewModel.episodeItemViewModel.item.seasonId { - reader.scrollTo(viewModel.episodeItemViewModel.item.name) - } - } - } - .edgesIgnoringSafeArea(.horizontal) - } - } - } + ZStack { + Color.gray.ignoresSafeArea() + + ProgressView() + } + .mask(Rectangle().frame(width: 200, height: 112).cornerRadius(10)) + .frame(width: 200, height: 112) + + VStack(alignment: .leading) { + Text("S-E-") + .font(.footnote) + .foregroundColor(.secondary) + Text("--") + .font(.body) + .padding(.bottom, 1) + .lineLimit(2) + } + + Spacer() + } + .frame(width: 200) + .shadow(radius: 4, y: 2) + } else if let selectedSeason = viewModel.selectedSeason { + if viewModel.seasonsEpisodes[selectedSeason]!.isEmpty { + VStack(alignment: .leading) { + + Color.gray.ignoresSafeArea() + .mask(Rectangle().frame(width: 200, height: 112).cornerRadius(10)) + .frame(width: 200, height: 112) + + VStack(alignment: .leading) { + Text("--") + .font(.footnote) + .foregroundColor(.secondary) + Text("No episodes available") + .font(.body) + .padding(.bottom, 1) + .lineLimit(2) + } + + Spacer() + } + .frame(width: 200) + .shadow(radius: 4, y: 2) + } else { + ForEach(viewModel.seasonsEpisodes[selectedSeason]!, id: \.self) { episode in + Button { + itemRouter.route(to: \.item, episode) + } label: { + HStack(alignment: .top) { + VStack(alignment: .leading) { + + ImageView(src: episode.getBackdropImage(maxWidth: 200), + bh: episode.getBackdropImageBlurHash()) + .mask(Rectangle().frame(width: 200, height: 112).cornerRadius(10)) + .frame(width: 200, height: 112) + .overlay { + if episode.id == viewModel.episodeItemViewModel.item.id { + RoundedRectangle(cornerRadius: 6) + .stroke(Color.jellyfinPurple, lineWidth: 4) + } + } + .padding(.top) + + VStack(alignment: .leading) { + Text(episode.getEpisodeLocator() ?? "") + .font(.footnote) + .foregroundColor(.secondary) + Text(episode.name ?? "") + .font(.body) + .padding(.bottom, 1) + .lineLimit(2) + Text(episode.overview ?? "") + .font(.caption) + .foregroundColor(.secondary) + .fontWeight(.light) + .lineLimit(3) + } + + Spacer() + } + .frame(width: 200) + .shadow(radius: 4, y: 2) + } + } + .buttonStyle(PlainButtonStyle()) + .id(episode.name) + } + } + } + } + .padding(.horizontal) + .onChange(of: viewModel.selectedSeason) { _ in + if viewModel.selectedSeason?.id == viewModel.episodeItemViewModel.item.seasonId { + reader.scrollTo(viewModel.episodeItemViewModel.item.name) + } + } + .onChange(of: viewModel.seasonsEpisodes) { _ in + if viewModel.selectedSeason?.id == viewModel.episodeItemViewModel.item.seasonId { + reader.scrollTo(viewModel.episodeItemViewModel.item.name) + } + } + } + .edgesIgnoringSafeArea(.horizontal) + } + } + } } diff --git a/Swiftfin/Components/PillHStackView.swift b/Swiftfin/Components/PillHStackView.swift index f89bd307..9b066663 100644 --- a/Swiftfin/Components/PillHStackView.swift +++ b/Swiftfin/Components/PillHStackView.swift @@ -1,56 +1,55 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import SwiftUI struct PillHStackView: View { - let title: String - let items: [ItemType] - let selectedAction: (ItemType) -> Void + let title: String + let items: [ItemType] + let selectedAction: (ItemType) -> Void - var body: some View { - VStack(alignment: .leading) { - Text(title) - .font(.callout) - .fontWeight(.semibold) - .padding(.top, 3) - .padding(.leading, 16) + var body: some View { + VStack(alignment: .leading) { + Text(title) + .font(.callout) + .fontWeight(.semibold) + .padding(.top, 3) + .padding(.leading, 16) - ScrollView(.horizontal, showsIndicators: false) { - HStack { - ForEach(items, id: \.title) { item in - Button { - selectedAction(item) - } label: { - ZStack { - Color(UIColor.systemFill) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .cornerRadius(10) + ScrollView(.horizontal, showsIndicators: false) { + HStack { + ForEach(items, id: \.title) { item in + Button { + selectedAction(item) + } label: { + ZStack { + Color(UIColor.systemFill) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .cornerRadius(10) - Text(item.title) - .font(.caption) - .fontWeight(.semibold) - .foregroundColor(.primary) - .fixedSize() - .padding(.leading, 10) - .padding(.trailing, 10) - .padding(.top, 10) - .padding(.bottom, 10) - } - .fixedSize() - } - } - } - .padding(.leading, 16) - .padding(.trailing, UIDevice.current.userInterfaceIdiom == .pad ? 16 : 55) - } - } - } + Text(item.title) + .font(.caption) + .fontWeight(.semibold) + .foregroundColor(.primary) + .fixedSize() + .padding(.leading, 10) + .padding(.trailing, 10) + .padding(.top, 10) + .padding(.bottom, 10) + } + .fixedSize() + } + } + } + .padding(.leading, 16) + .padding(.trailing, UIDevice.current.userInterfaceIdiom == .pad ? 16 : 55) + } + } + } } diff --git a/Swiftfin/Components/PortraitHStackView.swift b/Swiftfin/Components/PortraitHStackView.swift index 30324336..06276d2c 100644 --- a/Swiftfin/Components/PortraitHStackView.swift +++ b/Swiftfin/Components/PortraitHStackView.swift @@ -1,81 +1,81 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import SwiftUI struct PortraitImageHStackView: View { - let items: [ItemType] - let maxWidth: CGFloat - let horizontalAlignment: HorizontalAlignment - let textAlignment: TextAlignment - let topBarView: () -> TopBarView - let selectedAction: (ItemType) -> Void + let items: [ItemType] + let maxWidth: CGFloat + let horizontalAlignment: HorizontalAlignment + let textAlignment: TextAlignment + let topBarView: () -> TopBarView + let selectedAction: (ItemType) -> Void - init(items: [ItemType], - maxWidth: CGFloat = 110, - horizontalAlignment: HorizontalAlignment = .leading, - textAlignment: TextAlignment = .leading, - topBarView: @escaping () -> TopBarView, - selectedAction: @escaping (ItemType) -> Void) { - self.items = items - self.maxWidth = maxWidth - self.horizontalAlignment = horizontalAlignment - self.textAlignment = textAlignment - self.topBarView = topBarView - self.selectedAction = selectedAction - } + init(items: [ItemType], + maxWidth: CGFloat = 110, + horizontalAlignment: HorizontalAlignment = .leading, + textAlignment: TextAlignment = .leading, + topBarView: @escaping () -> TopBarView, + selectedAction: @escaping (ItemType) -> Void) + { + self.items = items + self.maxWidth = maxWidth + self.horizontalAlignment = horizontalAlignment + self.textAlignment = textAlignment + self.topBarView = topBarView + self.selectedAction = selectedAction + } - var body: some View { - VStack(alignment: .leading, spacing: 0) { - topBarView() + var body: some View { + VStack(alignment: .leading, spacing: 0) { + topBarView() - ScrollView(.horizontal, showsIndicators: false) { - HStack(alignment: .top, spacing: 15) { - ForEach(items, id: \.self.portraitImageID) { item in - Button { - selectedAction(item) - } label: { - VStack(alignment: horizontalAlignment) { - ImageView(src: item.imageURLContsructor(maxWidth: Int(maxWidth)), - bh: item.blurHash, - failureInitials: item.failureInitials) - .portraitPoster(width: maxWidth) - .shadow(radius: 4, y: 2) + ScrollView(.horizontal, showsIndicators: false) { + HStack(alignment: .top, spacing: 15) { + ForEach(items, id: \.self.portraitImageID) { item in + Button { + selectedAction(item) + } label: { + VStack(alignment: horizontalAlignment) { + ImageView(src: item.imageURLContsructor(maxWidth: Int(maxWidth)), + bh: item.blurHash, + failureInitials: item.failureInitials) + .portraitPoster(width: maxWidth) + .shadow(radius: 4, y: 2) - if item.showTitle { - Text(item.title) - .font(.footnote) - .fontWeight(.regular) - .foregroundColor(.primary) - .multilineTextAlignment(textAlignment) - .fixedSize(horizontal: false, vertical: true) - .lineLimit(2) - } + if item.showTitle { + Text(item.title) + .font(.footnote) + .fontWeight(.regular) + .foregroundColor(.primary) + .multilineTextAlignment(textAlignment) + .fixedSize(horizontal: false, vertical: true) + .lineLimit(2) + } - if let description = item.subtitle { - Text(description) - .font(.caption) - .fontWeight(.medium) - .foregroundColor(.secondary) - .multilineTextAlignment(textAlignment) - .fixedSize(horizontal: false, vertical: true) - .lineLimit(2) - } - } - .frame(width: maxWidth) - } - .padding(.bottom) - } - } - .padding(.horizontal) - } - } - } + if let description = item.subtitle { + Text(description) + .font(.caption) + .fontWeight(.medium) + .foregroundColor(.secondary) + .multilineTextAlignment(textAlignment) + .fixedSize(horizontal: false, vertical: true) + .lineLimit(2) + } + } + .frame(width: maxWidth) + } + .padding(.bottom) + } + } + .padding(.horizontal) + } + } + } } diff --git a/Swiftfin/Components/PortraitItemElement.swift b/Swiftfin/Components/PortraitItemElement.swift index 7da002ad..f2e767c2 100644 --- a/Swiftfin/Components/PortraitItemElement.swift +++ b/Swiftfin/Components/PortraitItemElement.swift @@ -1,20 +1,19 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// -import SwiftUI import JellyfinAPI +import SwiftUI -// Not implemented on iOS, but used by a shared Coordinator. +// Not implemented on iOS, but used by a shared Coordinator. struct PortraitItemElement: View { - var item: BaseItemDto + var item: BaseItemDto - var body: some View { - EmptyView() - } + var body: some View { + EmptyView() + } } diff --git a/Swiftfin/Components/PortraitItemView.swift b/Swiftfin/Components/PortraitItemView.swift index c677a92f..0f67464c 100644 --- a/Swiftfin/Components/PortraitItemView.swift +++ b/Swiftfin/Components/PortraitItemView.swift @@ -1,84 +1,83 @@ // -/* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import JellyfinAPI import SwiftUI struct PortraitItemView: View { - var item: BaseItemDto + var item: BaseItemDto - var body: some View { - VStack(alignment: .leading) { - ImageView(src: item.type != "Episode" ? item.getPrimaryImage(maxWidth: 100) : item.getSeriesPrimaryImage(maxWidth: 100), - bh: item.type != "Episode" ? item.getPrimaryImageBlurHash() : item.getSeriesPrimaryImageBlurHash()) - .frame(width: 100, height: 150) - .cornerRadius(10) - .shadow(radius: 4, y: 2) - .shadow(radius: 4, y: 2) - .overlay(Rectangle() - .fill(Color.jellyfinPurple) - .frame(width: CGFloat(item.userData?.playedPercentage ?? 0), height: 7) - .padding(0), alignment: .bottomLeading) - .overlay(ZStack { - if item.userData?.isFavorite ?? false { - Image(systemName: "circle.fill") - .foregroundColor(.white) - .opacity(0.6) - Image(systemName: "heart.fill") - .foregroundColor(Color(.systemRed)) - .font(.system(size: 10)) - } - } - .padding(.leading, 2) - .padding(.bottom, item.userData?.playedPercentage == nil ? 2 : 9) - .opacity(1), alignment: .bottomLeading) - .overlay(ZStack { - if item.userData?.played ?? false { - Image(systemName: "checkmark.circle.fill") - .foregroundColor(.accentColor) - .background(Color(.white)) - .clipShape(Circle().scale(0.8)) - } else { - if item.userData?.unplayedItemCount != nil { - Capsule() - .fill(Color.accentColor) - .frame(minWidth: 20, minHeight: 20, maxHeight: 20) - Text(String(item.userData!.unplayedItemCount ?? 0)) - .foregroundColor(.white) - .font(.caption2) - .padding(2) - } - } - }.padding(2) - .fixedSize() - .opacity(1), alignment: .topTrailing).opacity(1) - Text(item.seriesName ?? item.name ?? "") - .font(.caption) - .fontWeight(.semibold) - .foregroundColor(.primary) - .lineLimit(1) - if item.type == "Movie" || item.type == "Series" { - Text("\(String(item.productionYear ?? 0)) • \(item.officialRating ?? "N/A")") - .foregroundColor(.secondary) - .font(.caption) - .fontWeight(.medium) - } else if item.type == "Season" { - Text("\(item.name ?? "") • \(String(item.productionYear ?? 0))") - .foregroundColor(.secondary) - .font(.caption) - .fontWeight(.medium) - } else { - Text(L10n.seasonAndEpisode(String(item.parentIndexNumber ?? 0), String(item.indexNumber ?? 0))) - .foregroundColor(.secondary) - .font(.caption) - .fontWeight(.medium) - } - }.frame(width: 100) - } + var body: some View { + VStack(alignment: .leading) { + ImageView(src: item.type != "Episode" ? item.getPrimaryImage(maxWidth: 100) : item.getSeriesPrimaryImage(maxWidth: 100), + bh: item.type != "Episode" ? item.getPrimaryImageBlurHash() : item.getSeriesPrimaryImageBlurHash()) + .frame(width: 100, height: 150) + .cornerRadius(10) + .shadow(radius: 4, y: 2) + .shadow(radius: 4, y: 2) + .overlay(Rectangle() + .fill(Color.jellyfinPurple) + .frame(width: CGFloat(item.userData?.playedPercentage ?? 0), height: 7) + .padding(0), alignment: .bottomLeading) + .overlay(ZStack { + if item.userData?.isFavorite ?? false { + Image(systemName: "circle.fill") + .foregroundColor(.white) + .opacity(0.6) + Image(systemName: "heart.fill") + .foregroundColor(Color(.systemRed)) + .font(.system(size: 10)) + } + } + .padding(.leading, 2) + .padding(.bottom, item.userData?.playedPercentage == nil ? 2 : 9) + .opacity(1), alignment: .bottomLeading) + .overlay(ZStack { + if item.userData?.played ?? false { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.accentColor) + .background(Color(.white)) + .clipShape(Circle().scale(0.8)) + } else { + if item.userData?.unplayedItemCount != nil { + Capsule() + .fill(Color.accentColor) + .frame(minWidth: 20, minHeight: 20, maxHeight: 20) + Text(String(item.userData!.unplayedItemCount ?? 0)) + .foregroundColor(.white) + .font(.caption2) + .padding(2) + } + } + }.padding(2) + .fixedSize() + .opacity(1), alignment: .topTrailing).opacity(1) + Text(item.seriesName ?? item.name ?? "") + .font(.caption) + .fontWeight(.semibold) + .foregroundColor(.primary) + .lineLimit(1) + if item.type == "Movie" || item.type == "Series" { + Text("\(String(item.productionYear ?? 0)) • \(item.officialRating ?? "N/A")") + .foregroundColor(.secondary) + .font(.caption) + .fontWeight(.medium) + } else if item.type == "Season" { + Text("\(item.name ?? "") • \(String(item.productionYear ?? 0))") + .foregroundColor(.secondary) + .font(.caption) + .fontWeight(.medium) + } else { + Text(L10n.seasonAndEpisode(String(item.parentIndexNumber ?? 0), String(item.indexNumber ?? 0))) + .foregroundColor(.secondary) + .font(.caption) + .fontWeight(.medium) + } + }.frame(width: 100) + } } diff --git a/Swiftfin/Components/PrimaryButtonView.swift b/Swiftfin/Components/PrimaryButtonView.swift index 7f33ff9a..7595bf37 100644 --- a/Swiftfin/Components/PrimaryButtonView.swift +++ b/Swiftfin/Components/PrimaryButtonView.swift @@ -1,41 +1,40 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import SwiftUI struct PrimaryButtonView: View { - - private let title: String - private let action: () -> Void - - init(title: String, _ action: @escaping () -> Void) { - self.title = title - self.action = action - } - - var body: some View { - Button { - action() - } label: { - ZStack { - Rectangle() - .foregroundColor(Color(UIColor.systemPurple)) - .frame(maxWidth: 400, maxHeight: 50) - .frame(height: 50) - .cornerRadius(10) - .padding(.horizontal, 30) - .padding([.top, .bottom], 20) - Text(title) - .foregroundColor(Color.white) - .bold() - } - } - } + private let title: String + private let action: () -> Void + + init(title: String, _ action: @escaping () -> Void) { + self.title = title + self.action = action + } + + var body: some View { + Button { + action() + } label: { + ZStack { + Rectangle() + .foregroundColor(Color(UIColor.systemPurple)) + .frame(maxWidth: 400, maxHeight: 50) + .frame(height: 50) + .cornerRadius(10) + .padding(.horizontal, 30) + .padding([.top, .bottom], 20) + + Text(title) + .foregroundColor(Color.white) + .bold() + } + } + } } diff --git a/Swiftfin/Components/TruncatedTextView.swift b/Swiftfin/Components/TruncatedTextView.swift index a61f49d6..71e389b5 100644 --- a/Swiftfin/Components/TruncatedTextView.swift +++ b/Swiftfin/Components/TruncatedTextView.swift @@ -1,106 +1,113 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import SwiftUI struct TruncatedTextView: View { - - @State private var truncated: Bool = false - @State private var shrinkText: String - private var text: String - let font: UIFont - let lineLimit: Int - let seeMoreAction: () -> Void - - private var moreLessText: String { - if !truncated { - return "" - } else { - return "See More" - } - } - - init(_ text: String, - lineLimit: Int, - font: UIFont = UIFont.preferredFont(forTextStyle: UIFont.TextStyle.body), - seeMoreAction: @escaping () -> Void) { - self.text = text - self.lineLimit = lineLimit - _shrinkText = State(wrappedValue: text) - self.font = font - self.seeMoreAction = seeMoreAction - } - - var body: some View { - VStack(alignment: .center) { - Group { - Text(shrinkText) - .overlay { - if truncated { - LinearGradient(stops: [.init(color: .systemBackground.opacity(0), location: 0.5), - .init(color: .systemBackground.opacity(0.8), location: 0.7), - .init(color: .systemBackground, location: 1)], - startPoint: .top, - endPoint: .bottom) - } - } - } - .lineLimit(lineLimit) - .background { - // Render the limited text and measure its size - Text(text) - .lineLimit(lineLimit + 2) - .background { - GeometryReader { visibleTextGeometry in - Color.clear - .onAppear { - let size = CGSize(width: visibleTextGeometry.size.width, height: .greatestFiniteMagnitude) - let attributes:[NSAttributedString.Key:Any] = [NSAttributedString.Key.font: font] - var low = 0 - var heigh = shrinkText.count - var mid = heigh - while ((heigh - low) > 1) { - let attributedText = NSAttributedString(string: shrinkText, attributes: attributes) - let boundingRect = attributedText.boundingRect(with: size, options: NSStringDrawingOptions.usesLineFragmentOrigin, context: nil) - if boundingRect.size.height > visibleTextGeometry.size.height { - truncated = true - heigh = mid - mid = (heigh + low)/2 - - } else { - if mid == text.count { - break - } else { - low = mid - mid = (low + heigh)/2 - } - } - shrinkText = String(text.prefix(mid)) - } - - if truncated { - shrinkText = String(shrinkText.prefix(shrinkText.count - 2)) - } - } - } - } - .hidden() - } - .font(Font(font)) - - if truncated { - Button { - seeMoreAction() - } label: { - Text(moreLessText) - } - } - } - } + + @State + private var truncated: Bool = false + @State + private var shrinkText: String + private var text: String + let font: UIFont + let lineLimit: Int + let seeMoreAction: () -> Void + + private var moreLessText: String { + if !truncated { + return "" + } else { + return "See More" + } + } + + init(_ text: String, + lineLimit: Int, + font: UIFont = UIFont.preferredFont(forTextStyle: UIFont.TextStyle.body), + seeMoreAction: @escaping () -> Void) + { + self.text = text + self.lineLimit = lineLimit + _shrinkText = State(wrappedValue: text) + self.font = font + self.seeMoreAction = seeMoreAction + } + + var body: some View { + VStack(alignment: .center) { + Group { + Text(shrinkText) + .overlay { + if truncated { + LinearGradient(stops: [ + .init(color: .systemBackground.opacity(0), location: 0.5), + .init(color: .systemBackground.opacity(0.8), location: 0.7), + .init(color: .systemBackground, location: 1), + ], + startPoint: .top, + endPoint: .bottom) + } + } + } + .lineLimit(lineLimit) + .background { + // Render the limited text and measure its size + Text(text) + .lineLimit(lineLimit + 2) + .background { + GeometryReader { visibleTextGeometry in + Color.clear + .onAppear { + let size = CGSize(width: visibleTextGeometry.size.width, height: .greatestFiniteMagnitude) + let attributes: [NSAttributedString.Key: Any] = [NSAttributedString.Key.font: font] + var low = 0 + var heigh = shrinkText.count + var mid = heigh + while (heigh - low) > 1 { + let attributedText = NSAttributedString(string: shrinkText, attributes: attributes) + let boundingRect = attributedText.boundingRect(with: size, + options: NSStringDrawingOptions + .usesLineFragmentOrigin, + context: nil) + if boundingRect.size.height > visibleTextGeometry.size.height { + truncated = true + heigh = mid + mid = (heigh + low) / 2 + + } else { + if mid == text.count { + break + } else { + low = mid + mid = (low + heigh) / 2 + } + } + shrinkText = String(text.prefix(mid)) + } + + if truncated { + shrinkText = String(shrinkText.prefix(shrinkText.count - 2)) + } + } + } + } + .hidden() + } + .font(Font(font)) + + if truncated { + Button { + seeMoreAction() + } label: { + Text(moreLessText) + } + } + } + } } diff --git a/Swiftfin/Objects/RefreshHelper.swift b/Swiftfin/Objects/RefreshHelper.swift index d3947853..9a5c95b0 100644 --- a/Swiftfin/Objects/RefreshHelper.swift +++ b/Swiftfin/Objects/RefreshHelper.swift @@ -1,40 +1,40 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import UIKit // A more general derivative of // https://stackoverflow.com/questions/65812080/introspect-library-uirefreshcontrol-with-swiftui-not-working final class RefreshHelper { - var refreshControl: UIRefreshControl? - var refreshAction: (() -> Void)? - private var lastAutomaticRefresh = Date() + var refreshControl: UIRefreshControl? + var refreshAction: (() -> Void)? + private var lastAutomaticRefresh = Date() - @objc func didRefresh() { - guard let refreshControl = refreshControl else { return } - refreshAction?() - refreshControl.endRefreshing() - } + @objc + func didRefresh() { + guard let refreshControl = refreshControl else { return } + refreshAction?() + refreshControl.endRefreshing() + } } // MARK: - automatic refreshing extension RefreshHelper { - private static let timeUntilStale = TimeInterval(60) + private static let timeUntilStale = TimeInterval(60) - func refreshStaleData() { - guard isStale else { return } - lastAutomaticRefresh = .now - refreshAction?() - } + func refreshStaleData() { + guard isStale else { return } + lastAutomaticRefresh = .now + refreshAction?() + } - private var isStale: Bool { - lastAutomaticRefresh.addingTimeInterval(Self.timeUntilStale) < .now - } + private var isStale: Bool { + lastAutomaticRefresh.addingTimeInterval(Self.timeUntilStale) < .now + } } diff --git a/Swiftfin/Views/BasicAppSettingsView.swift b/Swiftfin/Views/BasicAppSettingsView.swift index ef441842..7da5a76b 100644 --- a/Swiftfin/Views/BasicAppSettingsView.swift +++ b/Swiftfin/Views/BasicAppSettingsView.swift @@ -1,11 +1,10 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Defaults import Stinsen @@ -13,85 +12,92 @@ import SwiftUI struct BasicAppSettingsView: View { - @EnvironmentObject var basicAppSettingsRouter: BasicAppSettingsCoordinator.Router - @ObservedObject var viewModel: BasicAppSettingsViewModel - @State var resetUserSettingsTapped: Bool = false - @State var resetAppSettingsTapped: Bool = false - @State var removeAllUsersTapped: Bool = false + @EnvironmentObject + var basicAppSettingsRouter: BasicAppSettingsCoordinator.Router + @ObservedObject + var viewModel: BasicAppSettingsViewModel + @State + var resetUserSettingsTapped: Bool = false + @State + var resetAppSettingsTapped: Bool = false + @State + var removeAllUsersTapped: Bool = false - @Default(.appAppearance) var appAppearance - @Default(.defaultHTTPScheme) var defaultHTTPScheme + @Default(.appAppearance) + var appAppearance + @Default(.defaultHTTPScheme) + var defaultHTTPScheme - var body: some View { - Form { - Section { - Picker(L10n.appearance, selection: $appAppearance) { - ForEach(self.viewModel.appearances, id: \.self) { appearance in - Text(appearance.localizedName).tag(appearance.rawValue) - } - } - } header: { - L10n.accessibility.text - } + var body: some View { + Form { + Section { + Picker(L10n.appearance, selection: $appAppearance) { + ForEach(self.viewModel.appearances, id: \.self) { appearance in + Text(appearance.localizedName).tag(appearance.rawValue) + } + } + } header: { + L10n.accessibility.text + } - Section { - Picker("Default Scheme", selection: $defaultHTTPScheme) { - ForEach(HTTPScheme.allCases, id: \.self) { scheme in - Text("\(scheme.rawValue)") - } - } - } header: { - Text("Networking") - } + Section { + Picker("Default Scheme", selection: $defaultHTTPScheme) { + ForEach(HTTPScheme.allCases, id: \.self) { scheme in + Text("\(scheme.rawValue)") + } + } + } header: { + Text("Networking") + } - Button { - resetUserSettingsTapped = true - } label: { - Text("Reset User Settings") - } - - Button { - resetAppSettingsTapped = true - } label: { - Text("Reset App Settings") - } - - Button { - removeAllUsersTapped = true - } label: { - Text("Remove All Users") - } - } - .alert("Reset User Settings", isPresented: $resetUserSettingsTapped, actions: { - Button(role: .destructive) { - viewModel.resetUserSettings() - } label: { - L10n.reset.text - } - }) - .alert("Reset App Settings", isPresented: $resetAppSettingsTapped, actions: { - Button(role: .destructive) { - viewModel.resetAppSettings() - } label: { - L10n.reset.text - } - }) - .alert("Remove All Users", isPresented: $removeAllUsersTapped, actions: { - Button(role: .destructive) { - viewModel.removeAllUsers() - } label: { - L10n.reset.text - } - }) - .navigationBarTitle("Settings", displayMode: .inline) - .toolbar { - ToolbarItemGroup(placement: .navigationBarLeading) { - Button { - basicAppSettingsRouter.dismissCoordinator() - } label: { - Image(systemName: "xmark.circle.fill") - } - } - } - } + Button { + resetUserSettingsTapped = true + } label: { + Text("Reset User Settings") + } + + Button { + resetAppSettingsTapped = true + } label: { + Text("Reset App Settings") + } + + Button { + removeAllUsersTapped = true + } label: { + Text("Remove All Users") + } + } + .alert("Reset User Settings", isPresented: $resetUserSettingsTapped, actions: { + Button(role: .destructive) { + viewModel.resetUserSettings() + } label: { + L10n.reset.text + } + }) + .alert("Reset App Settings", isPresented: $resetAppSettingsTapped, actions: { + Button(role: .destructive) { + viewModel.resetAppSettings() + } label: { + L10n.reset.text + } + }) + .alert("Remove All Users", isPresented: $removeAllUsersTapped, actions: { + Button(role: .destructive) { + viewModel.removeAllUsers() + } label: { + L10n.reset.text + } + }) + .navigationBarTitle("Settings", displayMode: .inline) + .toolbar { + ToolbarItemGroup(placement: .navigationBarLeading) { + Button { + basicAppSettingsRouter.dismissCoordinator() + } label: { + Image(systemName: "xmark.circle.fill") + } + } + } + } } diff --git a/Swiftfin/Views/ConnectToServerView.swift b/Swiftfin/Views/ConnectToServerView.swift index 044755e8..6b12462c 100644 --- a/Swiftfin/Views/ConnectToServerView.swift +++ b/Swiftfin/Views/ConnectToServerView.swift @@ -1,10 +1,10 @@ -/* - * JellyfinPlayer/Swiftfin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Defaults import Stinsen @@ -12,113 +12,116 @@ import SwiftUI struct ConnectToServerView: View { - @ObservedObject var viewModel: ConnectToServerViewModel - @State var uri = "" + @ObservedObject + var viewModel: ConnectToServerViewModel + @State + var uri = "" - @Default(.defaultHTTPScheme) var defaultHTTPScheme + @Default(.defaultHTTPScheme) + var defaultHTTPScheme - var body: some View { - List { - Section { - TextField(L10n.serverURL, text: $uri) - .disableAutocorrection(true) - .autocapitalization(.none) - .keyboardType(.URL) - .onAppear { - if uri == "" { - uri = "\(defaultHTTPScheme.rawValue)://" - } - } + var body: some View { + List { + Section { + TextField(L10n.serverURL, text: $uri) + .disableAutocorrection(true) + .autocapitalization(.none) + .keyboardType(.URL) + .onAppear { + if uri == "" { + uri = "\(defaultHTTPScheme.rawValue)://" + } + } - if viewModel.isLoading { - Button(role: .destructive) { - viewModel.cancelConnection() - } label: { - Text("Cancel") - } - } else { - Button { - viewModel.connectToServer(uri: uri) - } label: { - L10n.connect.text - } - .disabled(uri.isEmpty) - } - } header: { - Text("Connect to a Jellyfin server") - } + if viewModel.isLoading { + Button(role: .destructive) { + viewModel.cancelConnection() + } label: { + Text("Cancel") + } + } else { + Button { + viewModel.connectToServer(uri: uri) + } label: { + L10n.connect.text + } + .disabled(uri.isEmpty) + } + } header: { + Text("Connect to a Jellyfin server") + } - Section { - if viewModel.searching { - HStack(alignment: .center, spacing: 5) { - Spacer() - // Oct. 15, 2021 - // There is a bug where ProgressView() won't appear sometimes when searching, - // dots were used instead but ProgressView() is preferred - Text("Searching...") - .foregroundColor(.secondary) - Spacer() - } - } else { - if viewModel.discoveredServers.isEmpty { - HStack(alignment: .center) { - Spacer() - Text("No local servers found") - .font(.callout) - .foregroundColor(.secondary) - Spacer() - } - } else { - ForEach(viewModel.discoveredServers.sorted(by: { $0.name < $1.name }), id: \.id) { discoveredServer in - Button { - uri = discoveredServer.url.absoluteString - viewModel.connectToServer(uri: discoveredServer.url.absoluteString) - } label: { - VStack(alignment: .leading, spacing: 5) { - Text(discoveredServer.name) - .font(.title3) - Text(discoveredServer.host) - .font(.subheadline) - .foregroundColor(.secondary) - } - } - .disabled(viewModel.isLoading) - } - } - } - } header: { - HStack { - L10n.localServers.text - Spacer() + Section { + if viewModel.searching { + HStack(alignment: .center, spacing: 5) { + Spacer() + // Oct. 15, 2021 + // There is a bug where ProgressView() won't appear sometimes when searching, + // dots were used instead but ProgressView() is preferred + Text("Searching...") + .foregroundColor(.secondary) + Spacer() + } + } else { + if viewModel.discoveredServers.isEmpty { + HStack(alignment: .center) { + Spacer() + Text("No local servers found") + .font(.callout) + .foregroundColor(.secondary) + Spacer() + } + } else { + ForEach(viewModel.discoveredServers.sorted(by: { $0.name < $1.name }), id: \.id) { discoveredServer in + Button { + uri = discoveredServer.url.absoluteString + viewModel.connectToServer(uri: discoveredServer.url.absoluteString) + } label: { + VStack(alignment: .leading, spacing: 5) { + Text(discoveredServer.name) + .font(.title3) + Text(discoveredServer.host) + .font(.subheadline) + .foregroundColor(.secondary) + } + } + .disabled(viewModel.isLoading) + } + } + } + } header: { + HStack { + L10n.localServers.text + Spacer() - Button { - viewModel.discoverServers() - } label: { - Image(systemName: "arrow.clockwise.circle.fill") - } - .disabled(viewModel.searching || viewModel.isLoading) - } - } - .headerProminence(.increased) - } - .alert(item: $viewModel.errorMessage) { _ in - Alert(title: Text(viewModel.alertTitle), - message: Text(viewModel.errorMessage?.displayMessage ?? "Unknown Error"), - dismissButton: .cancel()) - } - .alert(item: $viewModel.addServerURIPayload) { _ in - Alert(title: L10n.existingServer.text, - message: L10n.serverAlreadyExistsPrompt(viewModel.addServerURIPayload?.server.name ?? "").text, - primaryButton: .default(L10n.addURL.text, action: { - viewModel.addURIToServer(addServerURIPayload: viewModel.backAddServerURIPayload!) - }), - secondaryButton: .cancel()) - } - .navigationTitle(L10n.connect) - .onAppear { - viewModel.discoverServers() - AppURLHandler.shared.appURLState = .allowedInLogin - } - .navigationBarBackButtonHidden(viewModel.isLoading) - } + Button { + viewModel.discoverServers() + } label: { + Image(systemName: "arrow.clockwise.circle.fill") + } + .disabled(viewModel.searching || viewModel.isLoading) + } + } + .headerProminence(.increased) + } + .alert(item: $viewModel.errorMessage) { _ in + Alert(title: Text(viewModel.alertTitle), + message: Text(viewModel.errorMessage?.displayMessage ?? "Unknown Error"), + dismissButton: .cancel()) + } + .alert(item: $viewModel.addServerURIPayload) { _ in + Alert(title: L10n.existingServer.text, + message: L10n.serverAlreadyExistsPrompt(viewModel.addServerURIPayload?.server.name ?? "").text, + primaryButton: .default(L10n.addURL.text, action: { + viewModel.addURIToServer(addServerURIPayload: viewModel.backAddServerURIPayload!) + }), + secondaryButton: .cancel()) + } + .navigationTitle(L10n.connect) + .onAppear { + viewModel.discoverServers() + AppURLHandler.shared.appURLState = .allowedInLogin + } + .navigationBarBackButtonHidden(viewModel.isLoading) + } } diff --git a/Swiftfin/Views/ContinueWatchingView.swift b/Swiftfin/Views/ContinueWatchingView.swift index 25e8e3e8..9f4d3f3e 100644 --- a/Swiftfin/Views/ContinueWatchingView.swift +++ b/Swiftfin/Views/ContinueWatchingView.swift @@ -1,87 +1,89 @@ -/* - * JellyfinPlayer/Swiftfin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import JellyfinAPI import SwiftUI struct ContinueWatchingView: View { - - @EnvironmentObject var homeRouter: HomeCoordinator.Router - @ObservedObject var viewModel: HomeViewModel - var body: some View { - ScrollView(.horizontal, showsIndicators: false) { - HStack(alignment: .top, spacing: 20) { - ForEach(viewModel.resumeItems, id: \.id) { item in - - Button { - homeRouter.route(to: \.item, item) - } label: { - VStack(alignment: .leading) { - - ZStack { - ImageView(src: item.getBackdropImage(maxWidth: 320), bh: item.getBackdropImageBlurHash()) - .frame(width: 320, height: 180) - - HStack { - VStack{ - - Spacer() - - ZStack(alignment: .bottom) { - - LinearGradient(colors: [.clear, .black.opacity(0.5), .black.opacity(0.7)], - startPoint: .top, - endPoint: .bottom) - .frame(height: 35) - - VStack(alignment: .leading, spacing: 0) { - Text(item.getItemProgressString() ?? "Continue") - .font(.subheadline) - .padding(.bottom, 5) - .padding(.leading, 10) - .foregroundColor(.white) - - HStack { - Color.jellyfinPurple - .frame(width: 320 * (item.userData?.playedPercentage ?? 0) / 100, height: 7) - - Spacer(minLength: 0) - } - } - } - } - } - } - .frame(width: 320, height: 180) - .mask(Rectangle().cornerRadius(10)) - .shadow(radius: 4, y: 2) - - VStack(alignment: .leading) { - Text("\(item.seriesName ?? item.name ?? "")") - .font(.callout) - .fontWeight(.semibold) - .foregroundColor(.primary) - .lineLimit(1) - - if item.itemType == .episode { - Text(item.getEpisodeLocator() ?? "") - .font(.callout) - .fontWeight(.medium) - .foregroundColor(.secondary) - .lineLimit(1) - } - } - } - } - } - } - .padding(.horizontal) - } - } + @EnvironmentObject + var homeRouter: HomeCoordinator.Router + @ObservedObject + var viewModel: HomeViewModel + + var body: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(alignment: .top, spacing: 20) { + ForEach(viewModel.resumeItems, id: \.id) { item in + + Button { + homeRouter.route(to: \.item, item) + } label: { + VStack(alignment: .leading) { + + ZStack { + ImageView(src: item.getBackdropImage(maxWidth: 320), bh: item.getBackdropImageBlurHash()) + .frame(width: 320, height: 180) + + HStack { + VStack { + + Spacer() + + ZStack(alignment: .bottom) { + + LinearGradient(colors: [.clear, .black.opacity(0.5), .black.opacity(0.7)], + startPoint: .top, + endPoint: .bottom) + .frame(height: 35) + + VStack(alignment: .leading, spacing: 0) { + Text(item.getItemProgressString() ?? "Continue") + .font(.subheadline) + .padding(.bottom, 5) + .padding(.leading, 10) + .foregroundColor(.white) + + HStack { + Color.jellyfinPurple + .frame(width: 320 * (item.userData?.playedPercentage ?? 0) / 100, height: 7) + + Spacer(minLength: 0) + } + } + } + } + } + } + .frame(width: 320, height: 180) + .mask(Rectangle().cornerRadius(10)) + .shadow(radius: 4, y: 2) + + VStack(alignment: .leading) { + Text("\(item.seriesName ?? item.name ?? "")") + .font(.callout) + .fontWeight(.semibold) + .foregroundColor(.primary) + .lineLimit(1) + + if item.itemType == .episode { + Text(item.getEpisodeLocator() ?? "") + .font(.callout) + .fontWeight(.medium) + .foregroundColor(.secondary) + .lineLimit(1) + } + } + } + } + } + } + .padding(.horizontal) + } + } } diff --git a/Swiftfin/Views/HomeView.swift b/Swiftfin/Views/HomeView.swift index b3c5e00c..4e6ddacb 100644 --- a/Swiftfin/Views/HomeView.swift +++ b/Swiftfin/Views/HomeView.swift @@ -1,11 +1,10 @@ // -/* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Foundation import Introspect @@ -13,125 +12,126 @@ import SwiftUI struct HomeView: View { - @EnvironmentObject var homeRouter: HomeCoordinator.Router - @StateObject var viewModel = HomeViewModel() + @EnvironmentObject + var homeRouter: HomeCoordinator.Router + @StateObject + var viewModel = HomeViewModel() - private let refreshHelper = RefreshHelper() + private let refreshHelper = RefreshHelper() - @ViewBuilder - var innerBody: some View { - if let errorMessage = viewModel.errorMessage { - VStack(spacing: 5) { - if viewModel.isLoading { - ProgressView() - .frame(width: 100, height: 100) - .scaleEffect(2) - } else { - Image(systemName: "xmark.circle.fill") - .font(.system(size: 72)) - .foregroundColor(Color.red) - .frame(width: 100, height: 100) - } - - Text("\(errorMessage.code)") - Text(errorMessage.displayMessage) - .frame(minWidth: 50, maxWidth: 240) - .multilineTextAlignment(.center) - - PrimaryButtonView(title: "Retry") { - viewModel.refresh() - } - } - .offset(y: -50) - } else if viewModel.isLoading { - ProgressView() - .frame(width: 100, height: 100) - .scaleEffect(2) - } else { - ScrollView { - VStack(alignment: .leading) { - if !viewModel.resumeItems.isEmpty { - ContinueWatchingView(viewModel: viewModel) - } - - if !viewModel.nextUpItems.isEmpty { - PortraitImageHStackView(items: viewModel.nextUpItems, - horizontalAlignment: .leading) { - L10n.nextUp.text - .font(.title2) - .fontWeight(.bold) - .padding() - } selectedAction: { item in - homeRouter.route(to: \.item, item) - } - } - - if !viewModel.latestAddedItems.isEmpty { - PortraitImageHStackView(items: viewModel.latestAddedItems) { - Text("Recently Added") - .font(.title2) - .fontWeight(.bold) - .padding() - } selectedAction: { item in - homeRouter.route(to: \.item, item) - } - } - - ForEach(viewModel.libraries, id: \.self) { library in - - LatestMediaView(viewModel: LatestMediaViewModel(library: library)) { - HStack { - Text(L10n.latestWithString(library.name ?? "")) - .font(.title2) - .fontWeight(.bold) - - Spacer() - - Button { - homeRouter - .route(to: \.library, (viewModel: .init(parentID: library.id!, - filters: viewModel.recentFilterSet), - title: library.name ?? "")) - } label: { - HStack { - L10n.seeAll.text.font(.subheadline).fontWeight(.bold) - Image(systemName: "chevron.right").font(Font.subheadline.bold()) - } - } - } - .padding() - } - - } - } - .padding(.bottom, 50) - } - .introspectScrollView { scrollView in - let control = UIRefreshControl() + @ViewBuilder + var innerBody: some View { + if let errorMessage = viewModel.errorMessage { + VStack(spacing: 5) { + if viewModel.isLoading { + ProgressView() + .frame(width: 100, height: 100) + .scaleEffect(2) + } else { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 72)) + .foregroundColor(Color.red) + .frame(width: 100, height: 100) + } - refreshHelper.refreshControl = control - refreshHelper.refreshAction = viewModel.refresh + Text("\(errorMessage.code)") + Text(errorMessage.displayMessage) + .frame(minWidth: 50, maxWidth: 240) + .multilineTextAlignment(.center) - control.addTarget(refreshHelper, action: #selector(RefreshHelper.didRefresh), for: .valueChanged) - scrollView.refreshControl = control - } - } - } + PrimaryButtonView(title: "Retry") { + viewModel.refresh() + } + } + .offset(y: -50) + } else if viewModel.isLoading { + ProgressView() + .frame(width: 100, height: 100) + .scaleEffect(2) + } else { + ScrollView { + VStack(alignment: .leading) { + if !viewModel.resumeItems.isEmpty { + ContinueWatchingView(viewModel: viewModel) + } - var body: some View { - innerBody - .navigationTitle(L10n.home) - .toolbar { - ToolbarItemGroup(placement: .navigationBarTrailing) { - Button { - homeRouter.route(to: \.settings) - } label: { - Image(systemName: "gearshape.fill") - } - } - } - .onAppear { - refreshHelper.refreshStaleData() - } - } + if !viewModel.nextUpItems.isEmpty { + PortraitImageHStackView(items: viewModel.nextUpItems, + horizontalAlignment: .leading) { + L10n.nextUp.text + .font(.title2) + .fontWeight(.bold) + .padding() + } selectedAction: { item in + homeRouter.route(to: \.item, item) + } + } + + if !viewModel.latestAddedItems.isEmpty { + PortraitImageHStackView(items: viewModel.latestAddedItems) { + Text("Recently Added") + .font(.title2) + .fontWeight(.bold) + .padding() + } selectedAction: { item in + homeRouter.route(to: \.item, item) + } + } + + ForEach(viewModel.libraries, id: \.self) { library in + + LatestMediaView(viewModel: LatestMediaViewModel(library: library)) { + HStack { + Text(L10n.latestWithString(library.name ?? "")) + .font(.title2) + .fontWeight(.bold) + + Spacer() + + Button { + homeRouter + .route(to: \.library, (viewModel: .init(parentID: library.id!, + filters: viewModel.recentFilterSet), + title: library.name ?? "")) + } label: { + HStack { + L10n.seeAll.text.font(.subheadline).fontWeight(.bold) + Image(systemName: "chevron.right").font(Font.subheadline.bold()) + } + } + } + .padding() + } + } + } + .padding(.bottom, 50) + } + .introspectScrollView { scrollView in + let control = UIRefreshControl() + + refreshHelper.refreshControl = control + refreshHelper.refreshAction = viewModel.refresh + + control.addTarget(refreshHelper, action: #selector(RefreshHelper.didRefresh), for: .valueChanged) + scrollView.refreshControl = control + } + } + } + + var body: some View { + innerBody + .navigationTitle(L10n.home) + .toolbar { + ToolbarItemGroup(placement: .navigationBarTrailing) { + Button { + homeRouter.route(to: \.settings) + } label: { + Image(systemName: "gearshape.fill") + } + } + } + .onAppear { + refreshHelper.refreshStaleData() + } + } } diff --git a/Swiftfin/Views/ItemOverviewView.swift b/Swiftfin/Views/ItemOverviewView.swift index f155ae34..b9850270 100644 --- a/Swiftfin/Views/ItemOverviewView.swift +++ b/Swiftfin/Views/ItemOverviewView.swift @@ -1,35 +1,35 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import JellyfinAPI import SwiftUI struct ItemOverviewView: View { - - @EnvironmentObject var itemOverviewRouter: ItemOverviewCoordinator.Router - let item: BaseItemDto - - var body: some View { - ScrollView(showsIndicators: false) { - Text(item.overview ?? "") - .font(.footnote) - .padding() - } - .navigationBarTitle("Overview", displayMode: .inline) - .toolbar { - ToolbarItemGroup(placement: .navigationBarLeading) { - Button { - itemOverviewRouter.dismissCoordinator() - } label: { - Image(systemName: "xmark.circle.fill") - } - } - } - } + + @EnvironmentObject + var itemOverviewRouter: ItemOverviewCoordinator.Router + let item: BaseItemDto + + var body: some View { + ScrollView(showsIndicators: false) { + Text(item.overview ?? "") + .font(.footnote) + .padding() + } + .navigationBarTitle("Overview", displayMode: .inline) + .toolbar { + ToolbarItemGroup(placement: .navigationBarLeading) { + Button { + itemOverviewRouter.dismissCoordinator() + } label: { + Image(systemName: "xmark.circle.fill") + } + } + } + } } diff --git a/Swiftfin/Views/ItemView/ItemView.swift b/Swiftfin/Views/ItemView/ItemView.swift index a18eed1d..2433b1dc 100644 --- a/Swiftfin/Views/ItemView/ItemView.swift +++ b/Swiftfin/Views/ItemView/ItemView.swift @@ -1,9 +1,10 @@ -/* JellyfinPlayer/Swiftfin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Introspect import JellyfinAPI @@ -11,57 +12,61 @@ import SwiftUI // Intermediary view for ItemView to set navigation bar settings struct ItemNavigationView: View { - private let item: BaseItemDto + private let item: BaseItemDto - init(item: BaseItemDto) { - self.item = item - } + init(item: BaseItemDto) { + self.item = item + } - var body: some View { - ItemView(item: item) - .navigationBarTitle(item.name ?? "", displayMode: .inline) - .introspectNavigationController { navigationController in - let textAttributes = [NSAttributedString.Key.foregroundColor: UIColor.clear] - navigationController.navigationBar.titleTextAttributes = textAttributes - } - } + var body: some View { + ItemView(item: item) + .navigationBarTitle(item.name ?? "", displayMode: .inline) + .introspectNavigationController { navigationController in + let textAttributes = [NSAttributedString.Key.foregroundColor: UIColor.clear] + navigationController.navigationBar.titleTextAttributes = textAttributes + } + } } private struct ItemView: View { - @EnvironmentObject var itemRouter: ItemCoordinator.Router + @EnvironmentObject + var itemRouter: ItemCoordinator.Router - @State private var orientation: UIDeviceOrientation = .unknown - @Environment(\.horizontalSizeClass) private var hSizeClass - @Environment(\.verticalSizeClass) private var vSizeClass + @State + private var orientation: UIDeviceOrientation = .unknown + @Environment(\.horizontalSizeClass) + private var hSizeClass + @Environment(\.verticalSizeClass) + private var vSizeClass - private let viewModel: ItemViewModel + private let viewModel: ItemViewModel - init(item: BaseItemDto) { - switch item.itemType { - case .movie: - self.viewModel = MovieItemViewModel(item: item) - case .season: - self.viewModel = SeasonItemViewModel(item: item) - case .episode: - self.viewModel = EpisodeItemViewModel(item: item) - case .series: - self.viewModel = SeriesItemViewModel(item: item) - case .boxset: - self.viewModel = CollectionItemViewModel(item: item) - default: - self.viewModel = ItemViewModel(item: item) - } - } + init(item: BaseItemDto) { + switch item.itemType { + case .movie: + self.viewModel = MovieItemViewModel(item: item) + case .season: + self.viewModel = SeasonItemViewModel(item: item) + case .episode: + self.viewModel = EpisodeItemViewModel(item: item) + case .series: + self.viewModel = SeriesItemViewModel(item: item) + case .boxset: + self.viewModel = CollectionItemViewModel(item: item) + default: + self.viewModel = ItemViewModel(item: item) + } + } - var body: some View { - Group { - if hSizeClass == .compact && vSizeClass == .regular { - ItemPortraitMainView() - .environmentObject(viewModel) - } else { - ItemLandscapeMainView() - .environmentObject(viewModel) - } - } - } + var body: some View { + Group { + if hSizeClass == .compact && vSizeClass == .regular { + ItemPortraitMainView() + .environmentObject(viewModel) + } else { + ItemLandscapeMainView() + .environmentObject(viewModel) + } + } + } } diff --git a/Swiftfin/Views/ItemView/ItemViewBody.swift b/Swiftfin/Views/ItemView/ItemViewBody.swift index a7ed4e8d..7d8dadc5 100644 --- a/Swiftfin/Views/ItemView/ItemViewBody.swift +++ b/Swiftfin/Views/ItemView/ItemViewBody.swift @@ -1,158 +1,162 @@ // -/* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Defaults import JellyfinAPI import SwiftUI struct ItemViewBody: View { - - @Environment(\.horizontalSizeClass) private var hSizeClass - @Environment(\.verticalSizeClass) private var vSizeClass - @EnvironmentObject var itemRouter: ItemCoordinator.Router - @EnvironmentObject private var viewModel: ItemViewModel - @Default(.showCastAndCrew) var showCastAndCrew - var body: some View { - VStack(alignment: .leading) { - // MARK: Overview - - if let itemOverview = viewModel.item.overview { - if hSizeClass == .compact && vSizeClass == .regular { - TruncatedTextView(itemOverview, - lineLimit: 5, - font: UIFont.preferredFont(forTextStyle: .footnote)) { - itemRouter.route(to: \.itemOverview, viewModel.item) - } - .padding(.horizontal) - .padding(.top) - } else { - Text(itemOverview) - .font(.footnote) - .padding() - } - } else { - Text("No overview available") - .font(.footnote) - .padding() - } + @Environment(\.horizontalSizeClass) + private var hSizeClass + @Environment(\.verticalSizeClass) + private var vSizeClass + @EnvironmentObject + var itemRouter: ItemCoordinator.Router + @EnvironmentObject + private var viewModel: ItemViewModel + @Default(.showCastAndCrew) + var showCastAndCrew - // MARK: Seasons + var body: some View { + VStack(alignment: .leading) { + // MARK: Overview - if let seriesViewModel = viewModel as? SeriesItemViewModel { - PortraitImageHStackView(items: seriesViewModel.seasons, - topBarView: { - L10n.seasons.text - .fontWeight(.semibold) - .padding(.bottom) - .padding(.horizontal) - }, selectedAction: { season in - itemRouter.route(to: \.item, season) - }) - } + if let itemOverview = viewModel.item.overview { + if hSizeClass == .compact && vSizeClass == .regular { + TruncatedTextView(itemOverview, + lineLimit: 5, + font: UIFont.preferredFont(forTextStyle: .footnote)) { + itemRouter.route(to: \.itemOverview, viewModel.item) + } + .padding(.horizontal) + .padding(.top) + } else { + Text(itemOverview) + .font(.footnote) + .padding() + } + } else { + Text("No overview available") + .font(.footnote) + .padding() + } - // MARK: Genres + // MARK: Seasons - PillHStackView(title: L10n.genres, - items: viewModel.item.genreItems ?? [], - selectedAction: { genre in - itemRouter.route(to: \.library, (viewModel: .init(genre: genre), title: genre.title)) - }) - .padding(.bottom) + if let seriesViewModel = viewModel as? SeriesItemViewModel { + PortraitImageHStackView(items: seriesViewModel.seasons, + topBarView: { + L10n.seasons.text + .fontWeight(.semibold) + .padding(.bottom) + .padding(.horizontal) + }, selectedAction: { season in + itemRouter.route(to: \.item, season) + }) + } - // MARK: Studios + // MARK: Genres - if let studios = viewModel.item.studios { - PillHStackView(title: L10n.studios, - items: studios) { studio in - itemRouter.route(to: \.library, (viewModel: .init(studio: studio), title: studio.name ?? "")) - } - .padding(.bottom) - } - - // MARK: Episodes - - if let episodeViewModel = viewModel as? EpisodeItemViewModel { - EpisodesRowView(viewModel: EpisodesRowViewModel(episodeItemViewModel: episodeViewModel)) - } - - // MARK: Series - - if let episodeViewModel = viewModel as? EpisodeItemViewModel { - if let seriesItem = episodeViewModel.series { - let a = [seriesItem] - PortraitImageHStackView(items: a) { - Text("Series") - .fontWeight(.semibold) - .padding(.bottom) - .padding(.horizontal) - } selectedAction: { seriesItem in - itemRouter.route(to: \.item, seriesItem) - } - } - } - - // MARK: Collection Items - - if let collectionViewModel = viewModel as? CollectionItemViewModel { - PortraitImageHStackView(items: collectionViewModel.collectionItems) { - Text("Items") - .fontWeight(.semibold) - .padding(.bottom) - .padding(.horizontal) - } selectedAction: { collectionItem in - itemRouter.route(to: \.item, collectionItem) - } - } + PillHStackView(title: L10n.genres, + items: viewModel.item.genreItems ?? [], + selectedAction: { genre in + itemRouter.route(to: \.library, (viewModel: .init(genre: genre), title: genre.title)) + }) + .padding(.bottom) - // MARK: Cast & Crew + // MARK: Studios - if showCastAndCrew { - if let castAndCrew = viewModel.item.people { - PortraitImageHStackView(items: castAndCrew.filter { BaseItemPerson.DisplayedType.allCasesRaw.contains($0.type ?? "") }, - topBarView: { - Text("Cast & Crew") - .fontWeight(.semibold) - .padding(.bottom) - .padding(.horizontal) - }, - selectedAction: { person in - itemRouter.route(to: \.library, (viewModel: .init(person: person), title: person.title)) - }) - } - } + if let studios = viewModel.item.studios { + PillHStackView(title: L10n.studios, + items: studios) { studio in + itemRouter.route(to: \.library, (viewModel: .init(studio: studio), title: studio.name ?? "")) + } + .padding(.bottom) + } - // MARK: More Like This + // MARK: Episodes - if !viewModel.similarItems.isEmpty { - PortraitImageHStackView(items: viewModel.similarItems, - topBarView: { - L10n.moreLikeThis.text - .fontWeight(.semibold) - .padding(.bottom) - .padding(.horizontal) - }, - selectedAction: { item in - itemRouter.route(to: \.item, item) - }) - } - - // MARK: Details - - switch viewModel.item.itemType { - case .movie, .episode: - ItemViewDetailsView(viewModel: viewModel) - .padding() - default: - EmptyView() - .frame(height: 50) - } - } - } + if let episodeViewModel = viewModel as? EpisodeItemViewModel { + EpisodesRowView(viewModel: EpisodesRowViewModel(episodeItemViewModel: episodeViewModel)) + } + + // MARK: Series + + if let episodeViewModel = viewModel as? EpisodeItemViewModel { + if let seriesItem = episodeViewModel.series { + let a = [seriesItem] + PortraitImageHStackView(items: a) { + Text("Series") + .fontWeight(.semibold) + .padding(.bottom) + .padding(.horizontal) + } selectedAction: { seriesItem in + itemRouter.route(to: \.item, seriesItem) + } + } + } + + // MARK: Collection Items + + if let collectionViewModel = viewModel as? CollectionItemViewModel { + PortraitImageHStackView(items: collectionViewModel.collectionItems) { + Text("Items") + .fontWeight(.semibold) + .padding(.bottom) + .padding(.horizontal) + } selectedAction: { collectionItem in + itemRouter.route(to: \.item, collectionItem) + } + } + + // MARK: Cast & Crew + + if showCastAndCrew { + if let castAndCrew = viewModel.item.people { + PortraitImageHStackView(items: castAndCrew.filter { BaseItemPerson.DisplayedType.allCasesRaw.contains($0.type ?? "") }, + topBarView: { + Text("Cast & Crew") + .fontWeight(.semibold) + .padding(.bottom) + .padding(.horizontal) + }, + selectedAction: { person in + itemRouter.route(to: \.library, (viewModel: .init(person: person), title: person.title)) + }) + } + } + + // MARK: More Like This + + if !viewModel.similarItems.isEmpty { + PortraitImageHStackView(items: viewModel.similarItems, + topBarView: { + L10n.moreLikeThis.text + .fontWeight(.semibold) + .padding(.bottom) + .padding(.horizontal) + }, + selectedAction: { item in + itemRouter.route(to: \.item, item) + }) + } + + // MARK: Details + + switch viewModel.item.itemType { + case .movie, .episode: + ItemViewDetailsView(viewModel: viewModel) + .padding() + default: + EmptyView() + .frame(height: 50) + } + } + } } diff --git a/Swiftfin/Views/ItemView/ItemViewDetailsView.swift b/Swiftfin/Views/ItemView/ItemViewDetailsView.swift index 63e76eef..ed221407 100644 --- a/Swiftfin/Views/ItemView/ItemViewDetailsView.swift +++ b/Swiftfin/Views/ItemView/ItemViewDetailsView.swift @@ -1,58 +1,58 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import JellyfinAPI import SwiftUI struct ItemViewDetailsView: View { - - @ObservedObject var viewModel: ItemViewModel - - var body: some View { - VStack(alignment: .leading) { - - if !viewModel.informationItems.isEmpty { - VStack(alignment: .leading, spacing: 20) { - Text("Information") - .font(.title3) - .fontWeight(.bold) - - ForEach(viewModel.informationItems, id: \.self.title) { informationItem in - VStack(alignment: .leading, spacing: 2) { - Text(informationItem.title) - .font(.subheadline) - Text(informationItem.content) - .font(.subheadline) - .foregroundColor(Color.secondary) - } - } - } - .padding(.bottom, 20) - } - - if !viewModel.mediaItems.isEmpty { - VStack(alignment: .leading, spacing: 20) { - Text("Media") - .font(.title3) - .fontWeight(.bold) - - ForEach(viewModel.mediaItems, id: \.self.title) { mediaItem in - VStack(alignment: .leading, spacing: 2) { - Text(mediaItem.title) - .font(.subheadline) - Text(mediaItem.content) - .font(.subheadline) - .foregroundColor(Color.secondary) - } - } - } - } - } - } + + @ObservedObject + var viewModel: ItemViewModel + + var body: some View { + VStack(alignment: .leading) { + + if !viewModel.informationItems.isEmpty { + VStack(alignment: .leading, spacing: 20) { + Text("Information") + .font(.title3) + .fontWeight(.bold) + + ForEach(viewModel.informationItems, id: \.self.title) { informationItem in + VStack(alignment: .leading, spacing: 2) { + Text(informationItem.title) + .font(.subheadline) + Text(informationItem.content) + .font(.subheadline) + .foregroundColor(Color.secondary) + } + } + } + .padding(.bottom, 20) + } + + if !viewModel.mediaItems.isEmpty { + VStack(alignment: .leading, spacing: 20) { + Text("Media") + .font(.title3) + .fontWeight(.bold) + + ForEach(viewModel.mediaItems, id: \.self.title) { mediaItem in + VStack(alignment: .leading, spacing: 2) { + Text(mediaItem.title) + .font(.subheadline) + Text(mediaItem.content) + .font(.subheadline) + .foregroundColor(Color.secondary) + } + } + } + } + } + } } diff --git a/Swiftfin/Views/ItemView/Landscape/ItemLandscapeMainView.swift b/Swiftfin/Views/ItemView/Landscape/ItemLandscapeMainView.swift index a01b1f87..a1fa20e1 100644 --- a/Swiftfin/Views/ItemView/Landscape/ItemLandscapeMainView.swift +++ b/Swiftfin/Views/ItemView/Landscape/ItemLandscapeMainView.swift @@ -1,99 +1,101 @@ // -/* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Stinsen import SwiftUI struct ItemLandscapeMainView: View { - @EnvironmentObject var itemRouter: ItemCoordinator.Router - @EnvironmentObject private var viewModel: ItemViewModel + @EnvironmentObject + var itemRouter: ItemCoordinator.Router + @EnvironmentObject + private var viewModel: ItemViewModel - // MARK: innerBody + // MARK: innerBody - private var innerBody: some View { - HStack { - // MARK: Sidebar Image + private var innerBody: some View { + HStack { + // MARK: Sidebar Image - VStack { - ImageView(src: viewModel.item.portraitHeaderViewURL(maxWidth: 130), - bh: viewModel.item.getPrimaryImageBlurHash()) - .frame(width: 130, height: 195) - .cornerRadius(10) + VStack { + ImageView(src: viewModel.item.portraitHeaderViewURL(maxWidth: 130), + bh: viewModel.item.getPrimaryImageBlurHash()) + .frame(width: 130, height: 195) + .cornerRadius(10) - Spacer().frame(height: 15) + Spacer().frame(height: 15) - // MARK: Play - Button { - self.itemRouter.route(to: \.videoPlayer, viewModel.itemVideoPlayerViewModel!) - } label: { - HStack { - Image(systemName: "play.fill") - .foregroundColor(viewModel.playButtonItem == nil ? Color(UIColor.secondaryLabel) : Color.white) - .font(.system(size: 20)) - Text(viewModel.playButtonText()) - .foregroundColor(viewModel.playButtonItem == nil ? Color(UIColor.secondaryLabel) : Color.white) - .font(.callout) - .fontWeight(.semibold) - } - .frame(width: 130, height: 40) - .background(viewModel.playButtonItem == nil ? Color(UIColor.secondarySystemFill) : Color.jellyfinPurple) - .cornerRadius(10) - } - .disabled(viewModel.playButtonItem == nil || viewModel.itemVideoPlayerViewModel == nil) + // MARK: Play - Spacer() - } + Button { + self.itemRouter.route(to: \.videoPlayer, viewModel.itemVideoPlayerViewModel!) + } label: { + HStack { + Image(systemName: "play.fill") + .foregroundColor(viewModel.playButtonItem == nil ? Color(UIColor.secondaryLabel) : Color.white) + .font(.system(size: 20)) + Text(viewModel.playButtonText()) + .foregroundColor(viewModel.playButtonItem == nil ? Color(UIColor.secondaryLabel) : Color.white) + .font(.callout) + .fontWeight(.semibold) + } + .frame(width: 130, height: 40) + .background(viewModel.playButtonItem == nil ? Color(UIColor.secondarySystemFill) : Color.jellyfinPurple) + .cornerRadius(10) + } + .disabled(viewModel.playButtonItem == nil || viewModel.itemVideoPlayerViewModel == nil) - ScrollView { - VStack(alignment: .leading) { - // MARK: ItemLandscapeTopBarView + Spacer() + } - ItemLandscapeTopBarView() - .environmentObject(viewModel) + ScrollView { + VStack(alignment: .leading) { + // MARK: ItemLandscapeTopBarView - // MARK: ItemViewBody + ItemLandscapeTopBarView() + .environmentObject(viewModel) - if let episodeViewModel = viewModel as? SeasonItemViewModel { - EpisodeCardVStackView(items: episodeViewModel.episodes) { episode in - itemRouter.route(to: \.item, episode) - } - } else { - ItemViewBody() - .environmentObject(viewModel) - } - } - } - } - } + // MARK: ItemViewBody - // MARK: body + if let episodeViewModel = viewModel as? SeasonItemViewModel { + EpisodeCardVStackView(items: episodeViewModel.episodes) { episode in + itemRouter.route(to: \.item, episode) + } + } else { + ItemViewBody() + .environmentObject(viewModel) + } + } + } + } + } - var body: some View { - VStack { - ZStack { - // MARK: Backdrop + // MARK: body - ImageView(src: viewModel.item.getBackdropImage(maxWidth: 200), - bh: viewModel.item.getBackdropImageBlurHash()) - .opacity(0.3) - .edgesIgnoringSafeArea(.all) - .blur(radius: 8) - .layoutPriority(-1) + var body: some View { + VStack { + ZStack { + // MARK: Backdrop - // iPadOS is making the view go all the way to the edge. - // We have to accomodate this here - if UIDevice.current.userInterfaceIdiom == .pad { - innerBody.padding(.horizontal, 25) - } else { - innerBody - } - } - } - } + ImageView(src: viewModel.item.getBackdropImage(maxWidth: 200), + bh: viewModel.item.getBackdropImageBlurHash()) + .opacity(0.3) + .edgesIgnoringSafeArea(.all) + .blur(radius: 8) + .layoutPriority(-1) + + // iPadOS is making the view go all the way to the edge. + // We have to accomodate this here + if UIDevice.current.userInterfaceIdiom == .pad { + innerBody.padding(.horizontal, 25) + } else { + innerBody + } + } + } + } } diff --git a/Swiftfin/Views/ItemView/Landscape/ItemLandscapeTopBarView.swift b/Swiftfin/Views/ItemView/Landscape/ItemLandscapeTopBarView.swift index f94aa923..50c18e47 100644 --- a/Swiftfin/Views/ItemView/Landscape/ItemLandscapeTopBarView.swift +++ b/Swiftfin/Views/ItemView/Landscape/ItemLandscapeTopBarView.swift @@ -1,95 +1,99 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import SwiftUI struct ItemLandscapeTopBarView: View { - @EnvironmentObject private var viewModel: ItemViewModel + @EnvironmentObject + private var viewModel: ItemViewModel - var body: some View { - HStack { - VStack(alignment: .leading) { + var body: some View { + HStack { + VStack(alignment: .leading) { - // MARK: Name + // MARK: Name - Text(viewModel.getItemDisplayName()) - .font(.title) - .fontWeight(.semibold) - .foregroundColor(.primary) - .padding(.leading, 16) - .padding(.bottom, 10) + Text(viewModel.getItemDisplayName()) + .font(.title) + .fontWeight(.semibold) + .foregroundColor(.primary) + .padding(.leading, 16) + .padding(.bottom, 10) - if viewModel.item.itemType.showDetails { - // MARK: Runtime - if let runtime = viewModel.item.getItemRuntime() { - Text(runtime) - .font(.subheadline) - .fontWeight(.medium) - .foregroundColor(.secondary) - .padding(.leading, 16) - } - } + if viewModel.item.itemType.showDetails { + // MARK: Runtime - // MARK: Details - HStack { - if viewModel.item.productionYear != nil { - Text(String(viewModel.item.productionYear ?? 0)) - .font(.subheadline) - .fontWeight(.medium) - .foregroundColor(.secondary) - } + if let runtime = viewModel.item.getItemRuntime() { + Text(runtime) + .font(.subheadline) + .fontWeight(.medium) + .foregroundColor(.secondary) + .padding(.leading, 16) + } + } - if viewModel.item.officialRating != nil { - Text(viewModel.item.officialRating!) - .font(.subheadline) - .fontWeight(.semibold) - .foregroundColor(.secondary) - .padding(EdgeInsets(top: 1, leading: 4, bottom: 1, trailing: 4)) - .overlay(RoundedRectangle(cornerRadius: 2) - .stroke(Color.secondary, lineWidth: 1)) - } + // MARK: Details - Spacer() + HStack { + if viewModel.item.productionYear != nil { + Text(String(viewModel.item.productionYear ?? 0)) + .font(.subheadline) + .fontWeight(.medium) + .foregroundColor(.secondary) + } - if viewModel.item.itemType.showDetails { - // MARK: Favorite - Button { - viewModel.updateFavoriteState() - } label: { - if viewModel.isFavorited { - Image(systemName: "heart.fill").foregroundColor(Color(UIColor.systemRed)) - .font(.system(size: 20)) - } else { - Image(systemName: "heart").foregroundColor(Color.primary) - .font(.system(size: 20)) - } - } - .disabled(viewModel.isLoading) + if viewModel.item.officialRating != nil { + Text(viewModel.item.officialRating!) + .font(.subheadline) + .fontWeight(.semibold) + .foregroundColor(.secondary) + .padding(EdgeInsets(top: 1, leading: 4, bottom: 1, trailing: 4)) + .overlay(RoundedRectangle(cornerRadius: 2) + .stroke(Color.secondary, lineWidth: 1)) + } - // MARK: Watched - Button { - viewModel.updateWatchState() - } label: { - if viewModel.isWatched { - Image(systemName: "checkmark.circle.fill").foregroundColor(Color.primary) - .font(.system(size: 20)) - } else { - Image(systemName: "checkmark.circle").foregroundColor(Color.primary) - .font(.system(size: 20)) - } - } - .disabled(viewModel.isLoading) - } - } - .padding(.leading, 16) - } - } - } + Spacer() + + if viewModel.item.itemType.showDetails { + // MARK: Favorite + + Button { + viewModel.updateFavoriteState() + } label: { + if viewModel.isFavorited { + Image(systemName: "heart.fill").foregroundColor(Color(UIColor.systemRed)) + .font(.system(size: 20)) + } else { + Image(systemName: "heart").foregroundColor(Color.primary) + .font(.system(size: 20)) + } + } + .disabled(viewModel.isLoading) + + // MARK: Watched + + Button { + viewModel.updateWatchState() + } label: { + if viewModel.isWatched { + Image(systemName: "checkmark.circle.fill").foregroundColor(Color.primary) + .font(.system(size: 20)) + } else { + Image(systemName: "checkmark.circle").foregroundColor(Color.primary) + .font(.system(size: 20)) + } + } + .disabled(viewModel.isLoading) + } + } + .padding(.leading, 16) + } + } + } } diff --git a/Swiftfin/Views/ItemView/Portrait/ItemPortraitHeaderOverlayView.swift b/Swiftfin/Views/ItemView/Portrait/ItemPortraitHeaderOverlayView.swift index b8f5046b..f3bb1663 100644 --- a/Swiftfin/Views/ItemView/Portrait/ItemPortraitHeaderOverlayView.swift +++ b/Swiftfin/Views/ItemView/Portrait/ItemPortraitHeaderOverlayView.swift @@ -1,135 +1,143 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// -import SwiftUI import JellyfinAPI +import SwiftUI struct PortraitHeaderOverlayView: View { - @EnvironmentObject var itemRouter: ItemCoordinator.Router - @EnvironmentObject private var viewModel: ItemViewModel + @EnvironmentObject + var itemRouter: ItemCoordinator.Router + @EnvironmentObject + private var viewModel: ItemViewModel - var body: some View { - VStack(alignment: .leading) { - HStack(alignment: .bottom, spacing: 12) { + var body: some View { + VStack(alignment: .leading) { + HStack(alignment: .bottom, spacing: 12) { - // MARK: Portrait Image - ImageView(src: viewModel.item.portraitHeaderViewURL(maxWidth: 130)) - .portraitPoster(width: 130) + // MARK: Portrait Image - VStack(alignment: .leading, spacing: 1) { - Spacer() + ImageView(src: viewModel.item.portraitHeaderViewURL(maxWidth: 130)) + .portraitPoster(width: 130) - // MARK: Name - Text(viewModel.getItemDisplayName()) - .font(.title2) - .fontWeight(.semibold) - .foregroundColor(.primary) - .fixedSize(horizontal: false, vertical: true) - .padding(.bottom, 10) + VStack(alignment: .leading, spacing: 1) { + Spacer() - if viewModel.item.itemType.showDetails { - // MARK: Runtime - if viewModel.shouldDisplayRuntime() { - if let runtime = viewModel.item.getItemRuntime() { - Text(runtime) - .font(.subheadline) - .fontWeight(.medium) - .foregroundColor(.secondary) - .lineLimit(1) - } - } - } + // MARK: Name - // MARK: Details - HStack { - if let productionYear = viewModel.item.productionYear { - Text(String(productionYear)) - .font(.subheadline) - .fontWeight(.medium) - .foregroundColor(.secondary) - .lineLimit(1) - } + Text(viewModel.getItemDisplayName()) + .font(.title2) + .fontWeight(.semibold) + .foregroundColor(.primary) + .fixedSize(horizontal: false, vertical: true) + .padding(.bottom, 10) - if let officialRating = viewModel.item.officialRating { - Text(officialRating) - .font(.subheadline) - .fontWeight(.semibold) - .foregroundColor(.secondary) - .lineLimit(1) - .padding(EdgeInsets(top: 1, leading: 4, bottom: 1, trailing: 4)) - .overlay(RoundedRectangle(cornerRadius: 2) - .stroke(Color.secondary, lineWidth: 1)) - } - } - } - .padding(.bottom, UIDevice.current.userInterfaceIdiom == .pad ? 98 : 30) - } + if viewModel.item.itemType.showDetails { + // MARK: Runtime - HStack { + if viewModel.shouldDisplayRuntime() { + if let runtime = viewModel.item.getItemRuntime() { + Text(runtime) + .font(.subheadline) + .fontWeight(.medium) + .foregroundColor(.secondary) + .lineLimit(1) + } + } + } - // MARK: Play - Button { - self.itemRouter.route(to: \.videoPlayer, viewModel.itemVideoPlayerViewModel!) - } label: { - HStack { - Image(systemName: "play.fill") - .foregroundColor(viewModel.playButtonItem == nil ? Color(UIColor.secondaryLabel) : Color.white) - .font(.system(size: 20)) - Text(viewModel.playButtonText()) - .foregroundColor(viewModel.playButtonItem == nil ? Color(UIColor.secondaryLabel) : Color.white) - .font(.callout) - .fontWeight(.semibold) - } - .frame(width: 130, height: 40) - .background(viewModel.playButtonItem == nil ? Color(UIColor.secondarySystemFill) : Color.jellyfinPurple) - .cornerRadius(10) - }.disabled(viewModel.playButtonItem == nil) + // MARK: Details - Spacer() + HStack { + if let productionYear = viewModel.item.productionYear { + Text(String(productionYear)) + .font(.subheadline) + .fontWeight(.medium) + .foregroundColor(.secondary) + .lineLimit(1) + } - if viewModel.item.itemType.showDetails { - // MARK: Favorite - Button { - viewModel.updateFavoriteState() - } label: { - if viewModel.isFavorited { - Image(systemName: "heart.fill") - .foregroundColor(Color(UIColor.systemRed)) - .font(.system(size: 20)) - } else { - Image(systemName: "heart") - .foregroundColor(Color.primary) - .font(.system(size: 20)) - } - } - .disabled(viewModel.isLoading) + if let officialRating = viewModel.item.officialRating { + Text(officialRating) + .font(.subheadline) + .fontWeight(.semibold) + .foregroundColor(.secondary) + .lineLimit(1) + .padding(EdgeInsets(top: 1, leading: 4, bottom: 1, trailing: 4)) + .overlay(RoundedRectangle(cornerRadius: 2) + .stroke(Color.secondary, lineWidth: 1)) + } + } + } + .padding(.bottom, UIDevice.current.userInterfaceIdiom == .pad ? 98 : 30) + } - // MARK: Watched - Button { - viewModel.updateWatchState() - } label: { - if viewModel.isWatched { - Image(systemName: "checkmark.circle.fill") - .foregroundColor(Color.jellyfinPurple) - .font(.system(size: 20)) - } else { - Image(systemName: "checkmark.circle") - .foregroundColor(Color.primary) - .font(.system(size: 20)) - } - } - .disabled(viewModel.isLoading) - } - }.padding(.top, 8) - } - .padding(.horizontal, 16) - .padding(.bottom, UIDevice.current.userInterfaceIdiom == .pad ? -189 : -64) - } + HStack { + + // MARK: Play + + Button { + self.itemRouter.route(to: \.videoPlayer, viewModel.itemVideoPlayerViewModel!) + } label: { + HStack { + Image(systemName: "play.fill") + .foregroundColor(viewModel.playButtonItem == nil ? Color(UIColor.secondaryLabel) : Color.white) + .font(.system(size: 20)) + Text(viewModel.playButtonText()) + .foregroundColor(viewModel.playButtonItem == nil ? Color(UIColor.secondaryLabel) : Color.white) + .font(.callout) + .fontWeight(.semibold) + } + .frame(width: 130, height: 40) + .background(viewModel.playButtonItem == nil ? Color(UIColor.secondarySystemFill) : Color.jellyfinPurple) + .cornerRadius(10) + }.disabled(viewModel.playButtonItem == nil) + + Spacer() + + if viewModel.item.itemType.showDetails { + // MARK: Favorite + + Button { + viewModel.updateFavoriteState() + } label: { + if viewModel.isFavorited { + Image(systemName: "heart.fill") + .foregroundColor(Color(UIColor.systemRed)) + .font(.system(size: 20)) + } else { + Image(systemName: "heart") + .foregroundColor(Color.primary) + .font(.system(size: 20)) + } + } + .disabled(viewModel.isLoading) + + // MARK: Watched + + Button { + viewModel.updateWatchState() + } label: { + if viewModel.isWatched { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(Color.jellyfinPurple) + .font(.system(size: 20)) + } else { + Image(systemName: "checkmark.circle") + .foregroundColor(Color.primary) + .font(.system(size: 20)) + } + } + .disabled(viewModel.isLoading) + } + }.padding(.top, 8) + } + .padding(.horizontal, 16) + .padding(.bottom, UIDevice.current.userInterfaceIdiom == .pad ? -189 : -64) + } } diff --git a/Swiftfin/Views/ItemView/Portrait/ItemPortraitMainView.swift b/Swiftfin/Views/ItemView/Portrait/ItemPortraitMainView.swift index 6e588c02..2b3ecc71 100644 --- a/Swiftfin/Views/ItemView/Portrait/ItemPortraitMainView.swift +++ b/Swiftfin/Views/ItemView/Portrait/ItemPortraitMainView.swift @@ -1,62 +1,63 @@ // -/* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import JellyfinAPI import SwiftUI struct ItemPortraitMainView: View { - - @EnvironmentObject var itemRouter: ItemCoordinator.Router - @EnvironmentObject private var viewModel: ItemViewModel - // MARK: portraitHeaderView + @EnvironmentObject + var itemRouter: ItemCoordinator.Router + @EnvironmentObject + private var viewModel: ItemViewModel - var portraitHeaderView: some View { - ImageView(src: viewModel.item.getBackdropImage(maxWidth: Int(UIScreen.main.bounds.width)), - bh: viewModel.item.getBackdropImageBlurHash()) - .opacity(0.4) - .blur(radius: 2.0) - } + // MARK: portraitHeaderView - // MARK: portraitStaticOverlayView + var portraitHeaderView: some View { + ImageView(src: viewModel.item.getBackdropImage(maxWidth: Int(UIScreen.main.bounds.width)), + bh: viewModel.item.getBackdropImageBlurHash()) + .opacity(0.4) + .blur(radius: 2.0) + } - var portraitStaticOverlayView: some View { - PortraitHeaderOverlayView() - .environmentObject(viewModel) - } + // MARK: portraitStaticOverlayView - // MARK: body + var portraitStaticOverlayView: some View { + PortraitHeaderOverlayView() + .environmentObject(viewModel) + } - var body: some View { - VStack(alignment: .leading) { - // MARK: ParallaxScrollView + // MARK: body - ParallaxHeaderScrollView(header: portraitHeaderView, - staticOverlayView: portraitStaticOverlayView, - overlayAlignment: .bottomLeading, - headerHeight: UIScreen.main.bounds.width * 0.5625) { - VStack { - Spacer() - .frame(height: 70) + var body: some View { + VStack(alignment: .leading) { + // MARK: ParallaxScrollView - if let episodeViewModel = viewModel as? SeasonItemViewModel { - Spacer() - EpisodeCardVStackView(items: episodeViewModel.episodes) { episode in - itemRouter.route(to: \.item, episode) - } - .padding(.top, 5) - } else { - ItemViewBody() - .environmentObject(viewModel) - } - } - } - } - } + ParallaxHeaderScrollView(header: portraitHeaderView, + staticOverlayView: portraitStaticOverlayView, + overlayAlignment: .bottomLeading, + headerHeight: UIScreen.main.bounds.width * 0.5625) { + VStack { + Spacer() + .frame(height: 70) + + if let episodeViewModel = viewModel as? SeasonItemViewModel { + Spacer() + EpisodeCardVStackView(items: episodeViewModel.episodes) { episode in + itemRouter.route(to: \.item, episode) + } + .padding(.top, 5) + } else { + ItemViewBody() + .environmentObject(viewModel) + } + } + } + } + } } diff --git a/Swiftfin/Views/LatestMediaView.swift b/Swiftfin/Views/LatestMediaView.swift index 3672fef2..5c1be150 100644 --- a/Swiftfin/Views/LatestMediaView.swift +++ b/Swiftfin/Views/LatestMediaView.swift @@ -1,25 +1,28 @@ -/* JellyfinPlayer/Swiftfin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Stinsen import SwiftUI struct LatestMediaView: View { - - @EnvironmentObject var homeRouter: HomeCoordinator.Router - @StateObject var viewModel: LatestMediaViewModel - var topBarView: () -> TopBarView - var body: some View { - PortraitImageHStackView(items: viewModel.items, - horizontalAlignment: .leading) { - topBarView() - } selectedAction: { item in - homeRouter.route(to: \.item, item) - } - } + @EnvironmentObject + var homeRouter: HomeCoordinator.Router + @StateObject + var viewModel: LatestMediaViewModel + var topBarView: () -> TopBarView + + var body: some View { + PortraitImageHStackView(items: viewModel.items, + horizontalAlignment: .leading) { + topBarView() + } selectedAction: { item in + homeRouter.route(to: \.item, item) + } + } } diff --git a/Swiftfin/Views/LibraryFilterView.swift b/Swiftfin/Views/LibraryFilterView.swift index 5864a6b1..280ee180 100644 --- a/Swiftfin/Views/LibraryFilterView.swift +++ b/Swiftfin/Views/LibraryFilterView.swift @@ -1,9 +1,10 @@ -/* JellyfinPlayer/Swiftfin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import JellyfinAPI import Stinsen @@ -11,85 +12,88 @@ import SwiftUI struct LibraryFilterView: View { - @EnvironmentObject var filterRouter: FilterCoordinator.Router - @Binding var filters: LibraryFilters - var parentId: String = "" + @EnvironmentObject + var filterRouter: FilterCoordinator.Router + @Binding + var filters: LibraryFilters + var parentId: String = "" - @StateObject var viewModel: LibraryFilterViewModel + @StateObject + var viewModel: LibraryFilterViewModel - init(filters: Binding, enabledFilterType: [FilterType], parentId: String) { - _filters = filters - self.parentId = parentId - _viewModel = - StateObject(wrappedValue: .init(filters: filters.wrappedValue, enabledFilterType: enabledFilterType, parentId: parentId)) - } + init(filters: Binding, enabledFilterType: [FilterType], parentId: String) { + _filters = filters + self.parentId = parentId + _viewModel = + StateObject(wrappedValue: .init(filters: filters.wrappedValue, enabledFilterType: enabledFilterType, parentId: parentId)) + } - var body: some View { - VStack { - if viewModel.isLoading { - ProgressView() - } else { - Form { - if viewModel.enabledFilterType.contains(.genre) { - MultiSelector(label: L10n.genres, - options: viewModel.possibleGenres, - optionToString: { $0.name ?? "" }, - selected: $viewModel.modifiedFilters.withGenres) - } - if viewModel.enabledFilterType.contains(.filter) { - MultiSelector(label: L10n.filters, - options: viewModel.possibleItemFilters, - optionToString: { $0.localized }, - selected: $viewModel.modifiedFilters.filters) - } - if viewModel.enabledFilterType.contains(.tag) { - MultiSelector(label: L10n.tags, - options: viewModel.possibleTags, - optionToString: { $0 }, - selected: $viewModel.modifiedFilters.tags) - } - if viewModel.enabledFilterType.contains(.sortBy) { - Picker(selection: $viewModel.selectedSortBy, label: L10n.sortBy.text) { - ForEach(viewModel.possibleSortBys, id: \.self) { so in - Text(so.localized).tag(so) - } - } - } - if viewModel.enabledFilterType.contains(.sortOrder) { - Picker(selection: $viewModel.selectedSortOrder, label: L10n.displayOrder.text) { - ForEach(viewModel.possibleSortOrders, id: \.self) { so in - Text(so.rawValue).tag(so) - } - } - } - } - Button { - viewModel.resetFilters() - self.filters = viewModel.modifiedFilters - filterRouter.dismissCoordinator() - } label: { - L10n.reset.text - } - } - } - .navigationBarTitle(L10n.filterResults, displayMode: .inline) - .toolbar { - ToolbarItemGroup(placement: .navigationBarLeading) { - Button { - filterRouter.dismissCoordinator() - } label: { - Image(systemName: "xmark") - } - } - ToolbarItemGroup(placement: .navigationBarTrailing) { - Button { - viewModel.updateModifiedFilter() - self.filters = viewModel.modifiedFilters - filterRouter.dismissCoordinator() - } label: { - L10n.apply.text - } - } - } - } + var body: some View { + VStack { + if viewModel.isLoading { + ProgressView() + } else { + Form { + if viewModel.enabledFilterType.contains(.genre) { + MultiSelector(label: L10n.genres, + options: viewModel.possibleGenres, + optionToString: { $0.name ?? "" }, + selected: $viewModel.modifiedFilters.withGenres) + } + if viewModel.enabledFilterType.contains(.filter) { + MultiSelector(label: L10n.filters, + options: viewModel.possibleItemFilters, + optionToString: { $0.localized }, + selected: $viewModel.modifiedFilters.filters) + } + if viewModel.enabledFilterType.contains(.tag) { + MultiSelector(label: L10n.tags, + options: viewModel.possibleTags, + optionToString: { $0 }, + selected: $viewModel.modifiedFilters.tags) + } + if viewModel.enabledFilterType.contains(.sortBy) { + Picker(selection: $viewModel.selectedSortBy, label: L10n.sortBy.text) { + ForEach(viewModel.possibleSortBys, id: \.self) { so in + Text(so.localized).tag(so) + } + } + } + if viewModel.enabledFilterType.contains(.sortOrder) { + Picker(selection: $viewModel.selectedSortOrder, label: L10n.displayOrder.text) { + ForEach(viewModel.possibleSortOrders, id: \.self) { so in + Text(so.rawValue).tag(so) + } + } + } + } + Button { + viewModel.resetFilters() + self.filters = viewModel.modifiedFilters + filterRouter.dismissCoordinator() + } label: { + L10n.reset.text + } + } + } + .navigationBarTitle(L10n.filterResults, displayMode: .inline) + .toolbar { + ToolbarItemGroup(placement: .navigationBarLeading) { + Button { + filterRouter.dismissCoordinator() + } label: { + Image(systemName: "xmark") + } + } + ToolbarItemGroup(placement: .navigationBarTrailing) { + Button { + viewModel.updateModifiedFilter() + self.filters = viewModel.modifiedFilters + filterRouter.dismissCoordinator() + } label: { + L10n.apply.text + } + } + } + } } diff --git a/Swiftfin/Views/LibraryListView.swift b/Swiftfin/Views/LibraryListView.swift index 2df53f8d..3cea0fcb 100644 --- a/Swiftfin/Views/LibraryListView.swift +++ b/Swiftfin/Views/LibraryListView.swift @@ -1,121 +1,124 @@ -/* JellyfinPlayer/Swiftfin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Foundation import Stinsen import SwiftUI struct LibraryListView: View { - @EnvironmentObject var libraryListRouter: LibraryListCoordinator.Router - @StateObject var viewModel = LibraryListViewModel() + @EnvironmentObject + var libraryListRouter: LibraryListCoordinator.Router + @StateObject + var viewModel = LibraryListViewModel() - var body: some View { - ScrollView { - LazyVStack { - Button { - libraryListRouter.route(to: \.library, - (viewModel: LibraryViewModel(filters: viewModel.withFavorites), title: "Favorites")) - } label: { - ZStack { - HStack { - Spacer() - L10n.yourFavorites.text - .foregroundColor(.black) - .font(.subheadline) - .fontWeight(.semibold) - Spacer() - } - } - .padding(16) - .background(Color.white) - .frame(minWidth: 100, maxWidth: .infinity) - } - .cornerRadius(10) - .shadow(radius: 5) - .padding(.bottom, 5) + var body: some View { + ScrollView { + LazyVStack { + Button { + libraryListRouter.route(to: \.library, + (viewModel: LibraryViewModel(filters: viewModel.withFavorites), title: "Favorites")) + } label: { + ZStack { + HStack { + Spacer() + L10n.yourFavorites.text + .foregroundColor(.black) + .font(.subheadline) + .fontWeight(.semibold) + Spacer() + } + } + .padding(16) + .background(Color.white) + .frame(minWidth: 100, maxWidth: .infinity) + } + .cornerRadius(10) + .shadow(radius: 5) + .padding(.bottom, 5) - if !viewModel.isLoading { - - if let collectionsLibraryItem = viewModel.libraries.first(where: { $0.collectionType == "boxsets" }) { - Button { - libraryListRouter.route(to: \.library, - (viewModel: LibraryViewModel(parentID: collectionsLibraryItem.id), - title: collectionsLibraryItem.name ?? "")) - } label: { - ZStack { - ImageView(src: collectionsLibraryItem.getPrimaryImage(maxWidth: 500), - bh: collectionsLibraryItem.getPrimaryImageBlurHash()) - .opacity(0.4) - HStack { - Spacer() - VStack { - Text(collectionsLibraryItem.name ?? "") - .foregroundColor(.white) - .font(.title2) - .fontWeight(.semibold) - } - Spacer() - }.padding(32) - }.background(Color.black) - .frame(minWidth: 100, maxWidth: .infinity) - .frame(height: 100) - } - .cornerRadius(10) - .shadow(radius: 5) - .padding(.bottom, 5) - } - - ForEach(viewModel.libraries, id: \.id) { library in - if library.collectionType ?? "" == "movies" || library.collectionType ?? "" == "tvshows" { - Button { - libraryListRouter.route(to: \.library, - (viewModel: LibraryViewModel(parentID: library.id), - title: library.name ?? "")) - } label: { - ZStack { - ImageView(src: library.getPrimaryImage(maxWidth: 500), bh: library.getPrimaryImageBlurHash()) - .opacity(0.4) - HStack { - Spacer() - VStack { - Text(library.name ?? "") - .foregroundColor(.white) - .font(.title2) - .fontWeight(.semibold) - } - Spacer() - }.padding(32) - }.background(Color.black) - .frame(minWidth: 100, maxWidth: .infinity) - .frame(height: 100) - } - .cornerRadius(10) - .shadow(radius: 5) - .padding(.bottom, 5) - } else { - EmptyView() - } - } - } else { - ProgressView() - } - }.padding(.leading, 16) - .padding(.trailing, 16) - .padding(.top, 8) - } - .navigationTitle(L10n.allMedia) - .toolbar { - ToolbarItemGroup(placement: .navigationBarTrailing) { - Button { - libraryListRouter.route(to: \.search, LibrarySearchViewModel(parentID: nil)) - } label: { - Image(systemName: "magnifyingglass") - } - } - } - } + if !viewModel.isLoading { + + if let collectionsLibraryItem = viewModel.libraries.first(where: { $0.collectionType == "boxsets" }) { + Button { + libraryListRouter.route(to: \.library, + (viewModel: LibraryViewModel(parentID: collectionsLibraryItem.id), + title: collectionsLibraryItem.name ?? "")) + } label: { + ZStack { + ImageView(src: collectionsLibraryItem.getPrimaryImage(maxWidth: 500), + bh: collectionsLibraryItem.getPrimaryImageBlurHash()) + .opacity(0.4) + HStack { + Spacer() + VStack { + Text(collectionsLibraryItem.name ?? "") + .foregroundColor(.white) + .font(.title2) + .fontWeight(.semibold) + } + Spacer() + }.padding(32) + }.background(Color.black) + .frame(minWidth: 100, maxWidth: .infinity) + .frame(height: 100) + } + .cornerRadius(10) + .shadow(radius: 5) + .padding(.bottom, 5) + } + + ForEach(viewModel.libraries, id: \.id) { library in + if library.collectionType ?? "" == "movies" || library.collectionType ?? "" == "tvshows" { + Button { + libraryListRouter.route(to: \.library, + (viewModel: LibraryViewModel(parentID: library.id), + title: library.name ?? "")) + } label: { + ZStack { + ImageView(src: library.getPrimaryImage(maxWidth: 500), bh: library.getPrimaryImageBlurHash()) + .opacity(0.4) + HStack { + Spacer() + VStack { + Text(library.name ?? "") + .foregroundColor(.white) + .font(.title2) + .fontWeight(.semibold) + } + Spacer() + }.padding(32) + }.background(Color.black) + .frame(minWidth: 100, maxWidth: .infinity) + .frame(height: 100) + } + .cornerRadius(10) + .shadow(radius: 5) + .padding(.bottom, 5) + } else { + EmptyView() + } + } + } else { + ProgressView() + } + }.padding(.leading, 16) + .padding(.trailing, 16) + .padding(.top, 8) + } + .navigationTitle(L10n.allMedia) + .toolbar { + ToolbarItemGroup(placement: .navigationBarTrailing) { + Button { + libraryListRouter.route(to: \.search, LibrarySearchViewModel(parentID: nil)) + } label: { + Image(systemName: "magnifyingglass") + } + } + } + } } diff --git a/Swiftfin/Views/LibrarySearchView.swift b/Swiftfin/Views/LibrarySearchView.swift index 632a77ef..4d942af7 100644 --- a/Swiftfin/Views/LibrarySearchView.swift +++ b/Swiftfin/Views/LibrarySearchView.swift @@ -1,9 +1,10 @@ -/* JellyfinPlayer/Swiftfin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Combine import JellyfinAPI @@ -11,102 +12,106 @@ import Stinsen import SwiftUI struct LibrarySearchView: View { - @EnvironmentObject var searchRouter: SearchCoordinator.Router - @StateObject var viewModel: LibrarySearchViewModel - @State private var searchQuery = "" + @EnvironmentObject + var searchRouter: SearchCoordinator.Router + @StateObject + var viewModel: LibrarySearchViewModel + @State + private var searchQuery = "" - @State private var tracks: [GridItem] = Array(repeating: .init(.flexible()), count: Int(UIScreen.main.bounds.size.width) / 125) + @State + private var tracks: [GridItem] = Array(repeating: .init(.flexible()), count: Int(UIScreen.main.bounds.size.width) / 125) - func recalcTracks() { - tracks = Array(repeating: .init(.flexible()), count: Int(UIScreen.main.bounds.size.width) / 125) - } + func recalcTracks() { + tracks = Array(repeating: .init(.flexible()), count: Int(UIScreen.main.bounds.size.width) / 125) + } - var body: some View { - ZStack { - VStack { - SearchBar(text: $searchQuery) - .padding(.top, 16) - .padding(.bottom, 8) - if searchQuery.isEmpty { - suggestionsListView - } else { - resultView - } - } - if viewModel.isLoading { - ProgressView() - } - } - .onChange(of: searchQuery) { query in - viewModel.searchQuerySubject.send(query) - } - .navigationBarTitle("Search", displayMode: .inline) - } + var body: some View { + ZStack { + VStack { + SearchBar(text: $searchQuery) + .padding(.top, 16) + .padding(.bottom, 8) + if searchQuery.isEmpty { + suggestionsListView + } else { + resultView + } + } + if viewModel.isLoading { + ProgressView() + } + } + .onChange(of: searchQuery) { query in + viewModel.searchQuerySubject.send(query) + } + .navigationBarTitle("Search", displayMode: .inline) + } - var suggestionsListView: some View { - ScrollView { - LazyVStack(spacing: 8) { - L10n.suggestions.text - .font(.headline) - .fontWeight(.bold) - .foregroundColor(.primary) - .padding(.bottom, 8) - ForEach(viewModel.suggestions, id: \.id) { item in - Button { - searchQuery = item.name ?? "" - } label: { - Text(item.name ?? "") - .font(.body) - } - } - } - .padding(.horizontal, 16) - } - } + var suggestionsListView: some View { + ScrollView { + LazyVStack(spacing: 8) { + L10n.suggestions.text + .font(.headline) + .fontWeight(.bold) + .foregroundColor(.primary) + .padding(.bottom, 8) + ForEach(viewModel.suggestions, id: \.id) { item in + Button { + searchQuery = item.name ?? "" + } label: { + Text(item.name ?? "") + .font(.body) + } + } + } + .padding(.horizontal, 16) + } + } - var resultView: some View { - let items = items(for: viewModel.selectedItemType) - return VStack(alignment: .leading, spacing: 16) { - Picker("ItemType", selection: $viewModel.selectedItemType) { - ForEach(viewModel.supportedItemTypeList, id: \.self) { - Text($0.localized) - .tag($0) - } - } - .pickerStyle(SegmentedPickerStyle()) - .padding(.horizontal, 16) - ScrollView { - LazyVStack(alignment: .leading, spacing: 16) { - if !items.isEmpty { - LazyVGrid(columns: tracks) { - ForEach(items, id: \.id) { item in - Button { - searchRouter.route(to: \.item, item) - } label: { - PortraitItemView(item: item) - } - } - } - .padding(.bottom, 16) - } - } - } - } - .onRotate { _ in - recalcTracks() - } - } + var resultView: some View { + let items = items(for: viewModel.selectedItemType) + return VStack(alignment: .leading, spacing: 16) { + Picker("ItemType", selection: $viewModel.selectedItemType) { + ForEach(viewModel.supportedItemTypeList, id: \.self) { + Text($0.localized) + .tag($0) + } + } + .pickerStyle(SegmentedPickerStyle()) + .padding(.horizontal, 16) + ScrollView { + LazyVStack(alignment: .leading, spacing: 16) { + if !items.isEmpty { + LazyVGrid(columns: tracks) { + ForEach(items, id: \.id) { item in + Button { + searchRouter.route(to: \.item, item) + } label: { + PortraitItemView(item: item) + } + } + } + .padding(.bottom, 16) + } + } + } + } + .onRotate { _ in + recalcTracks() + } + } - func items(for type: ItemType) -> [BaseItemDto] { - switch type { - case .episode: - return viewModel.episodeItems - case .movie: - return viewModel.movieItems - case .series: - return viewModel.showItems - default: - return [] - } - } + func items(for type: ItemType) -> [BaseItemDto] { + switch type { + case .episode: + return viewModel.episodeItems + case .movie: + return viewModel.movieItems + case .series: + return viewModel.showItems + default: + return [] + } + } } diff --git a/Swiftfin/Views/LibraryView.swift b/Swiftfin/Views/LibraryView.swift index 62883cf0..c5c37a92 100644 --- a/Swiftfin/Views/LibraryView.swift +++ b/Swiftfin/Views/LibraryView.swift @@ -1,112 +1,115 @@ -/* - * JellyfinPlayer/Swiftfin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Stinsen import SwiftUI struct LibraryView: View { - @EnvironmentObject var libraryRouter: LibraryCoordinator.Router - @StateObject var viewModel: LibraryViewModel - var title: String + @EnvironmentObject + var libraryRouter: LibraryCoordinator.Router + @StateObject + var viewModel: LibraryViewModel + var title: String - // MARK: tracks for grid + // MARK: tracks for grid - var defaultFilters = LibraryFilters(filters: [], sortOrder: [.ascending], withGenres: [], tags: [], sortBy: [.name]) + var defaultFilters = LibraryFilters(filters: [], sortOrder: [.ascending], withGenres: [], tags: [], sortBy: [.name]) - @State private var tracks: [GridItem] = Array(repeating: .init(.flexible()), count: Int(UIScreen.main.bounds.size.width) / 125) + @State + private var tracks: [GridItem] = Array(repeating: .init(.flexible()), count: Int(UIScreen.main.bounds.size.width) / 125) - func recalcTracks() { - tracks = Array(repeating: .init(.flexible()), count: Int(UIScreen.main.bounds.size.width) / 125) - } + func recalcTracks() { + tracks = Array(repeating: .init(.flexible()), count: Int(UIScreen.main.bounds.size.width) / 125) + } - var body: some View { - Group { - if viewModel.isLoading == true { - ProgressView() - } else if !viewModel.items.isEmpty { - VStack { - ScrollView(.vertical) { - Spacer().frame(height: 16) - LazyVGrid(columns: tracks) { - ForEach(viewModel.items, id: \.id) { item in - if item.type != "Folder" { - Button { - libraryRouter.route(to: \.item, item) - } label: { - PortraitItemView(item: item) - } - } - } - }.onRotate { _ in - recalcTracks() - } - if viewModel.hasNextPage || viewModel.hasPreviousPage { - HStack { - Spacer() - HStack { - Button { - viewModel.requestPreviousPage() - } label: { - Image(systemName: "chevron.left") - .font(.system(size: 25)) - }.disabled(!viewModel.hasPreviousPage) - Text(L10n.pageOfWithNumbers(String(viewModel.currentPage + 1), String(viewModel.totalPages))) - .font(.subheadline) - .fontWeight(.medium) - Button { - viewModel.requestNextPage() - } label: { - Image(systemName: "chevron.right") - .font(.system(size: 25)) - }.disabled(!viewModel.hasNextPage) - } - Spacer() - } - } - Spacer().frame(height: 16) - } - } - } else { - L10n.noResults.text - } - } - .navigationBarTitle(title, displayMode: .inline) - .toolbar { - ToolbarItemGroup(placement: .navigationBarTrailing) { - if viewModel.hasPreviousPage { - Button { - viewModel.requestPreviousPage() - } label: { - Image(systemName: "chevron.left") - }.disabled(viewModel.isLoading) - } - if viewModel.hasNextPage { - Button { - viewModel.requestNextPage() - } label: { - Image(systemName: "chevron.right") - }.disabled(viewModel.isLoading) - } - Label("Icon One", systemImage: "line.horizontal.3.decrease.circle") - .foregroundColor(viewModel.filters == defaultFilters ? .accentColor : Color(UIColor.systemOrange)) - .onTapGesture { - libraryRouter - .route(to: \.filter, (filters: $viewModel.filters, enabledFilterType: viewModel.enabledFilterType, - parentId: viewModel.parentID ?? "")) - } - Button { - libraryRouter.route(to: \.search, .init(parentID: viewModel.parentID)) - } label: { - Image(systemName: "magnifyingglass") - } - } - } - } + var body: some View { + Group { + if viewModel.isLoading == true { + ProgressView() + } else if !viewModel.items.isEmpty { + VStack { + ScrollView(.vertical) { + Spacer().frame(height: 16) + LazyVGrid(columns: tracks) { + ForEach(viewModel.items, id: \.id) { item in + if item.type != "Folder" { + Button { + libraryRouter.route(to: \.item, item) + } label: { + PortraitItemView(item: item) + } + } + } + }.onRotate { _ in + recalcTracks() + } + if viewModel.hasNextPage || viewModel.hasPreviousPage { + HStack { + Spacer() + HStack { + Button { + viewModel.requestPreviousPage() + } label: { + Image(systemName: "chevron.left") + .font(.system(size: 25)) + }.disabled(!viewModel.hasPreviousPage) + Text(L10n.pageOfWithNumbers(String(viewModel.currentPage + 1), String(viewModel.totalPages))) + .font(.subheadline) + .fontWeight(.medium) + Button { + viewModel.requestNextPage() + } label: { + Image(systemName: "chevron.right") + .font(.system(size: 25)) + }.disabled(!viewModel.hasNextPage) + } + Spacer() + } + } + Spacer().frame(height: 16) + } + } + } else { + L10n.noResults.text + } + } + .navigationBarTitle(title, displayMode: .inline) + .toolbar { + ToolbarItemGroup(placement: .navigationBarTrailing) { + if viewModel.hasPreviousPage { + Button { + viewModel.requestPreviousPage() + } label: { + Image(systemName: "chevron.left") + }.disabled(viewModel.isLoading) + } + if viewModel.hasNextPage { + Button { + viewModel.requestNextPage() + } label: { + Image(systemName: "chevron.right") + }.disabled(viewModel.isLoading) + } + Label("Icon One", systemImage: "line.horizontal.3.decrease.circle") + .foregroundColor(viewModel.filters == defaultFilters ? .accentColor : Color(UIColor.systemOrange)) + .onTapGesture { + libraryRouter + .route(to: \.filter, (filters: $viewModel.filters, enabledFilterType: viewModel.enabledFilterType, + parentId: viewModel.parentID ?? "")) + } + Button { + libraryRouter.route(to: \.search, .init(parentID: viewModel.parentID)) + } label: { + Image(systemName: "magnifyingglass") + } + } + } + } } // stream BM^S by nicki! diff --git a/Swiftfin/Views/LiveTVHomeView.swift b/Swiftfin/Views/LiveTVHomeView.swift index bc7ded6f..f78f8f5d 100644 --- a/Swiftfin/Views/LiveTVHomeView.swift +++ b/Swiftfin/Views/LiveTVHomeView.swift @@ -1,15 +1,16 @@ -/* JellyfinPlayer/Swiftfin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Stinsen import SwiftUI struct LiveTVHomeView: View { - var body: some View { - Text("Coming Soon") - } + var body: some View { + Text("Coming Soon") + } } diff --git a/Swiftfin/Views/LiveTVProgramsView.swift b/Swiftfin/Views/LiveTVProgramsView.swift index 9cb25845..fba31aba 100644 --- a/Swiftfin/Views/LiveTVProgramsView.swift +++ b/Swiftfin/Views/LiveTVProgramsView.swift @@ -1,15 +1,16 @@ -/* JellyfinPlayer/Swiftfin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Stinsen import SwiftUI struct LiveTVProgramsView: View { - var body: some View { - Text("Coming Soon") - } + var body: some View { + Text("Coming Soon") + } } diff --git a/Swiftfin/Views/NextUpView.swift b/Swiftfin/Views/NextUpView.swift index 0a556eb0..01496746 100644 --- a/Swiftfin/Views/NextUpView.swift +++ b/Swiftfin/Views/NextUpView.swift @@ -1,9 +1,10 @@ -/* JellyfinPlayer/Swiftfin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Combine import JellyfinAPI @@ -11,29 +12,30 @@ import Stinsen import SwiftUI struct NextUpView: View { - @EnvironmentObject var homeRouter: HomeCoordinator.Router + @EnvironmentObject + var homeRouter: HomeCoordinator.Router - var items: [BaseItemDto] + var items: [BaseItemDto] - var body: some View { - VStack(alignment: .leading) { - L10n.nextUp.text - .font(.title2) - .fontWeight(.bold) - .padding(.leading, 16) - ScrollView(.horizontal, showsIndicators: false) { - LazyHStack { - ForEach(items, id: \.id) { item in - Button { - homeRouter.route(to: \.item, item) - } label: { - PortraitItemView(item: item) - } - }.padding(.trailing, 16) - } - .padding(.leading, 20) - } - .frame(height: 200) - } - } + var body: some View { + VStack(alignment: .leading) { + L10n.nextUp.text + .font(.title2) + .fontWeight(.bold) + .padding(.leading, 16) + ScrollView(.horizontal, showsIndicators: false) { + LazyHStack { + ForEach(items, id: \.id) { item in + Button { + homeRouter.route(to: \.item, item) + } label: { + PortraitItemView(item: item) + } + }.padding(.trailing, 16) + } + .padding(.leading, 20) + } + .frame(height: 200) + } + } } diff --git a/Swiftfin/Views/ServerDetailView.swift b/Swiftfin/Views/ServerDetailView.swift index ac13d54b..5bd8645a 100644 --- a/Swiftfin/Views/ServerDetailView.swift +++ b/Swiftfin/Views/ServerDetailView.swift @@ -1,57 +1,58 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import SwiftUI struct ServerDetailView: View { - @ObservedObject var viewModel: ServerDetailViewModel - @State var currentServerURI: String + @ObservedObject + var viewModel: ServerDetailViewModel + @State + var currentServerURI: String - init(viewModel: ServerDetailViewModel) { - self.viewModel = viewModel - self.currentServerURI = viewModel.server.currentURI - } + init(viewModel: ServerDetailViewModel) { + self.viewModel = viewModel + self.currentServerURI = viewModel.server.currentURI + } - var body: some View { - Form { - Section(header: Text("Server Details")) { - HStack { - Text("Name") - Spacer() - Text(viewModel.server.name) - .foregroundColor(.secondary) - } + var body: some View { + Form { + Section(header: Text("Server Details")) { + HStack { + Text("Name") + Spacer() + Text(viewModel.server.name) + .foregroundColor(.secondary) + } - Picker("URI", selection: $currentServerURI) { - ForEach(viewModel.server.uris.sorted(), id: \.self) { uri in - Text(uri).tag(uri) - .foregroundColor(.secondary) - }.onChange(of: currentServerURI) { newValue in - viewModel.setServerCurrentURI(uri: newValue) - } - } + Picker("URI", selection: $currentServerURI) { + ForEach(viewModel.server.uris.sorted(), id: \.self) { uri in + Text(uri).tag(uri) + .foregroundColor(.secondary) + }.onChange(of: currentServerURI) { newValue in + viewModel.setServerCurrentURI(uri: newValue) + } + } - HStack { - Text("Version") - Spacer() - Text(viewModel.server.version) - .foregroundColor(.secondary) - } + HStack { + Text("Version") + Spacer() + Text(viewModel.server.version) + .foregroundColor(.secondary) + } - HStack { - Text("Operating System") - Spacer() - Text(viewModel.server.os) - .foregroundColor(.secondary) - } - } - } - } + HStack { + Text("Operating System") + Spacer() + Text(viewModel.server.os) + .foregroundColor(.secondary) + } + } + } + } } diff --git a/Swiftfin/Views/ServerListView.swift b/Swiftfin/Views/ServerListView.swift index 85833f3b..9502189a 100644 --- a/Swiftfin/Views/ServerListView.swift +++ b/Swiftfin/Views/ServerListView.swift @@ -1,126 +1,127 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import CoreStore import SwiftUI struct ServerListView: View { - @EnvironmentObject var serverListRouter: ServerListCoordinator.Router - @ObservedObject var viewModel: ServerListViewModel + @EnvironmentObject + var serverListRouter: ServerListCoordinator.Router + @ObservedObject + var viewModel: ServerListViewModel - private var listView: some View { - ScrollView { - LazyVStack { - ForEach(viewModel.servers, id: \.id) { server in - Button { - serverListRouter.route(to: \.userList, server) - } label: { - ZStack(alignment: Alignment.leading) { - Rectangle() - .foregroundColor(Color(UIColor.secondarySystemFill)) - .frame(height: 100) - .cornerRadius(10) + private var listView: some View { + ScrollView { + LazyVStack { + ForEach(viewModel.servers, id: \.id) { server in + Button { + serverListRouter.route(to: \.userList, server) + } label: { + ZStack(alignment: Alignment.leading) { + Rectangle() + .foregroundColor(Color(UIColor.secondarySystemFill)) + .frame(height: 100) + .cornerRadius(10) - HStack(spacing: 10) { - Image(systemName: "server.rack") - .font(.system(size: 36)) - .foregroundColor(.primary) + HStack(spacing: 10) { + Image(systemName: "server.rack") + .font(.system(size: 36)) + .foregroundColor(.primary) - VStack(alignment: .leading, spacing: 5) { - Text(server.name) - .font(.title2) - .foregroundColor(.primary) + VStack(alignment: .leading, spacing: 5) { + Text(server.name) + .font(.title2) + .foregroundColor(.primary) - Text(server.currentURI) - .font(.footnote) - .disabled(true) - .foregroundColor(.secondary) + Text(server.currentURI) + .font(.footnote) + .disabled(true) + .foregroundColor(.secondary) - Text(viewModel.userTextFor(server: server)) - .font(.footnote) - .foregroundColor(.primary) - } - }.padding([.leading]) - } - .padding() - } - .contextMenu { - Button(role: .destructive) { - viewModel.remove(server: server) - } label: { - Label("Remove", systemImage: "trash") - } - } - } - } - } - } + Text(viewModel.userTextFor(server: server)) + .font(.footnote) + .foregroundColor(.primary) + } + }.padding([.leading]) + } + .padding() + } + .contextMenu { + Button(role: .destructive) { + viewModel.remove(server: server) + } label: { + Label("Remove", systemImage: "trash") + } + } + } + } + } + } - private var noServerView: some View { - VStack { - Text("Connect to a Jellyfin server to get started") - .frame(minWidth: 50, maxWidth: 240) - .multilineTextAlignment(.center) + private var noServerView: some View { + VStack { + Text("Connect to a Jellyfin server to get started") + .frame(minWidth: 50, maxWidth: 240) + .multilineTextAlignment(.center) - PrimaryButtonView(title: L10n.connect.stringValue) { - serverListRouter.route(to: \.connectToServer) - } - } - } + PrimaryButtonView(title: L10n.connect.stringValue) { + serverListRouter.route(to: \.connectToServer) + } + } + } - @ViewBuilder - private var innerBody: some View { - if viewModel.servers.isEmpty { - noServerView - .offset(y: -50) - } else { - listView - } - } + @ViewBuilder + private var innerBody: some View { + if viewModel.servers.isEmpty { + noServerView + .offset(y: -50) + } else { + listView + } + } - @ViewBuilder - private var trailingToolbarContent: some View { - if viewModel.servers.isEmpty { - EmptyView() - } else { - Button { - serverListRouter.route(to: \.connectToServer) - } label: { - Image(systemName: "plus.circle.fill") - } - } - } + @ViewBuilder + private var trailingToolbarContent: some View { + if viewModel.servers.isEmpty { + EmptyView() + } else { + Button { + serverListRouter.route(to: \.connectToServer) + } label: { + Image(systemName: "plus.circle.fill") + } + } + } - private var leadingToolbarContent: some View { - Button { - serverListRouter.route(to: \.basicAppSettings) - } label: { - Image(systemName: "gearshape.fill") - } - } + private var leadingToolbarContent: some View { + Button { + serverListRouter.route(to: \.basicAppSettings) + } label: { + Image(systemName: "gearshape.fill") + } + } - var body: some View { - innerBody - .navigationTitle("Servers") - .toolbar { - ToolbarItemGroup(placement: .navigationBarTrailing) { - trailingToolbarContent - } - } - .toolbar(content: { - ToolbarItemGroup(placement: .navigationBarLeading) { - leadingToolbarContent - } - }) - .onAppear { - viewModel.fetchServers() - } - } + var body: some View { + innerBody + .navigationTitle("Servers") + .toolbar { + ToolbarItemGroup(placement: .navigationBarTrailing) { + trailingToolbarContent + } + } + .toolbar(content: { + ToolbarItemGroup(placement: .navigationBarLeading) { + leadingToolbarContent + } + }) + .onAppear { + viewModel.fetchServers() + } + } } diff --git a/Swiftfin/Views/SettingsView/ExperimentalSettingsView.swift b/Swiftfin/Views/SettingsView/ExperimentalSettingsView.swift index 179b1889..49fbb6fc 100644 --- a/Swiftfin/Views/SettingsView/ExperimentalSettingsView.swift +++ b/Swiftfin/Views/SettingsView/ExperimentalSettingsView.swift @@ -1,28 +1,28 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Defaults import SwiftUI struct ExperimentalSettingsView: View { - - @Default(.Experimental.syncSubtitleStateWithAdjacent) var syncSubtitleStateWithAdjacent - - var body: some View { - Form { - Section { - - Toggle("Sync Subtitles with Adjacent Episodes", isOn: $syncSubtitleStateWithAdjacent) - - } header: { - Text("Experimental") - } - } - } + + @Default(.Experimental.syncSubtitleStateWithAdjacent) + var syncSubtitleStateWithAdjacent + + var body: some View { + Form { + Section { + + Toggle("Sync Subtitles with Adjacent Episodes", isOn: $syncSubtitleStateWithAdjacent) + + } header: { + Text("Experimental") + } + } + } } diff --git a/Swiftfin/Views/SettingsView/OverlaySettingsView.swift b/Swiftfin/Views/SettingsView/OverlaySettingsView.swift index 0154107b..e7d58ee8 100644 --- a/Swiftfin/Views/SettingsView/OverlaySettingsView.swift +++ b/Swiftfin/Views/SettingsView/OverlaySettingsView.swift @@ -1,37 +1,41 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Defaults import SwiftUI struct OverlaySettingsView: View { - - @Default(.overlayType) var overlayType - @Default(.shouldShowPlayPreviousItem) var shouldShowPlayPreviousItem - @Default(.shouldShowPlayNextItem) var shouldShowPlayNextItem - @Default(.shouldShowAutoPlay) var shouldShowAutoPlay - @Default(.shouldShowJumpButtonsInOverlayMenu) var shouldShowJumpButtonsInOverlayMenu - - var body: some View { - Form { - Section(header: Text("Overlay")) { - Picker("Overlay Type", selection: $overlayType) { - ForEach(OverlayType.allCases, id: \.self) { overlay in - Text(overlay.label).tag(overlay) - } - } - - Toggle("\(Image(systemName: "chevron.left.circle")) Play Previous Item", isOn: $shouldShowPlayPreviousItem) - Toggle("\(Image(systemName: "chevron.right.circle")) Play Next Item", isOn: $shouldShowPlayNextItem) - Toggle("\(Image(systemName: "play.circle.fill")) Auto Play", isOn: $shouldShowAutoPlay) - Toggle("Edit Jump Lengths", isOn: $shouldShowJumpButtonsInOverlayMenu) - } - } - } + + @Default(.overlayType) + var overlayType + @Default(.shouldShowPlayPreviousItem) + var shouldShowPlayPreviousItem + @Default(.shouldShowPlayNextItem) + var shouldShowPlayNextItem + @Default(.shouldShowAutoPlay) + var shouldShowAutoPlay + @Default(.shouldShowJumpButtonsInOverlayMenu) + var shouldShowJumpButtonsInOverlayMenu + + var body: some View { + Form { + Section(header: Text("Overlay")) { + Picker("Overlay Type", selection: $overlayType) { + ForEach(OverlayType.allCases, id: \.self) { overlay in + Text(overlay.label).tag(overlay) + } + } + + Toggle("\(Image(systemName: "chevron.left.circle")) Play Previous Item", isOn: $shouldShowPlayPreviousItem) + Toggle("\(Image(systemName: "chevron.right.circle")) Play Next Item", isOn: $shouldShowPlayNextItem) + Toggle("\(Image(systemName: "play.circle.fill")) Auto Play", isOn: $shouldShowAutoPlay) + Toggle("Edit Jump Lengths", isOn: $shouldShowJumpButtonsInOverlayMenu) + } + } + } } diff --git a/Swiftfin/Views/SettingsView/SettingsView.swift b/Swiftfin/Views/SettingsView/SettingsView.swift index be11081d..aacf6bbb 100644 --- a/Swiftfin/Views/SettingsView/SettingsView.swift +++ b/Swiftfin/Views/SettingsView/SettingsView.swift @@ -1,9 +1,10 @@ -/* JellyfinPlayer/Swiftfin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import CoreData import Defaults @@ -12,133 +13,148 @@ import SwiftUI struct SettingsView: View { - @EnvironmentObject var settingsRouter: SettingsCoordinator.Router - @ObservedObject var viewModel: SettingsViewModel + @EnvironmentObject + var settingsRouter: SettingsCoordinator.Router + @ObservedObject + var viewModel: SettingsViewModel - @Default(.inNetworkBandwidth) var inNetworkStreamBitrate - @Default(.outOfNetworkBandwidth) var outOfNetworkStreamBitrate - @Default(.isAutoSelectSubtitles) var isAutoSelectSubtitles - @Default(.autoSelectSubtitlesLangCode) var autoSelectSubtitlesLangcode - @Default(.autoSelectAudioLangCode) var autoSelectAudioLangcode - @Default(.appAppearance) var appAppearance - @Default(.overlayType) var overlayType - @Default(.videoPlayerJumpForward) var jumpForwardLength - @Default(.videoPlayerJumpBackward) var jumpBackwardLength - @Default(.jumpGesturesEnabled) var jumpGesturesEnabled - @Default(.showPosterLabels) var showPosterLabels - @Default(.showCastAndCrew) var showCastAndCrew - @Default(.resumeOffset) var resumeOffset + @Default(.inNetworkBandwidth) + var inNetworkStreamBitrate + @Default(.outOfNetworkBandwidth) + var outOfNetworkStreamBitrate + @Default(.isAutoSelectSubtitles) + var isAutoSelectSubtitles + @Default(.autoSelectSubtitlesLangCode) + var autoSelectSubtitlesLangcode + @Default(.autoSelectAudioLangCode) + var autoSelectAudioLangcode + @Default(.appAppearance) + var appAppearance + @Default(.overlayType) + var overlayType + @Default(.videoPlayerJumpForward) + var jumpForwardLength + @Default(.videoPlayerJumpBackward) + var jumpBackwardLength + @Default(.jumpGesturesEnabled) + var jumpGesturesEnabled + @Default(.showPosterLabels) + var showPosterLabels + @Default(.showCastAndCrew) + var showCastAndCrew + @Default(.resumeOffset) + var resumeOffset - var body: some View { - Form { - Section(header: EmptyView()) { - HStack { - Text("User") - Spacer() - Text(viewModel.user.username) - .foregroundColor(.jellyfinPurple) - } + var body: some View { + Form { + Section(header: EmptyView()) { + HStack { + Text("User") + Spacer() + Text(viewModel.user.username) + .foregroundColor(.jellyfinPurple) + } - Button { - settingsRouter.route(to: \.serverDetail) - } label: { - HStack { - Text("Server") - .foregroundColor(.primary) - Spacer() - Text(viewModel.server.name) - .foregroundColor(.jellyfinPurple) + Button { + settingsRouter.route(to: \.serverDetail) + } label: { + HStack { + Text("Server") + .foregroundColor(.primary) + Spacer() + Text(viewModel.server.name) + .foregroundColor(.jellyfinPurple) - Image(systemName: "chevron.right") - } - } + Image(systemName: "chevron.right") + } + } - Button { - settingsRouter.dismissCoordinator { - SessionManager.main.logout() - } - } label: { - Text("Switch User") - .font(.callout) - } - } + Button { + settingsRouter.dismissCoordinator { + SessionManager.main.logout() + } + } label: { + Text("Switch User") + .font(.callout) + } + } - // TODO: Implement these for playback -// Section(header: Text("Networking")) { -// Picker("Default local quality", selection: $inNetworkStreamBitrate) { -// ForEach(self.viewModel.bitrates, id: \.self) { bitrate in -// Text(bitrate.name).tag(bitrate.value) -// } -// } + // TODO: Implement these for playback + // Section(header: Text("Networking")) { + // Picker("Default local quality", selection: $inNetworkStreamBitrate) { + // ForEach(self.viewModel.bitrates, id: \.self) { bitrate in + // Text(bitrate.name).tag(bitrate.value) + // } + // } // -// Picker("Default remote quality", selection: $outOfNetworkStreamBitrate) { -// ForEach(self.viewModel.bitrates, id: \.self) { bitrate in -// Text(bitrate.name).tag(bitrate.value) -// } -// } -// } - - Section(header: Text("Video Player")) { - Picker("Jump Forward Length", selection: $jumpForwardLength) { - ForEach(VideoPlayerJumpLength.allCases, id: \.self) { length in - Text(length.label).tag(length.rawValue) - } - } + // Picker("Default remote quality", selection: $outOfNetworkStreamBitrate) { + // ForEach(self.viewModel.bitrates, id: \.self) { bitrate in + // Text(bitrate.name).tag(bitrate.value) + // } + // } + // } - Picker("Jump Backward Length", selection: $jumpBackwardLength) { - ForEach(VideoPlayerJumpLength.allCases, id: \.self) { length in - Text(length.label).tag(length.rawValue) - } - } - - Toggle("Jump Gestures Enabled", isOn: $jumpGesturesEnabled) - - Toggle("Resume 5 Second Offset", isOn: $resumeOffset) - - Button { - settingsRouter.route(to: \.overlaySettings) - } label: { - HStack { - Text("Overlay") - .foregroundColor(.primary) - Spacer() - Text(overlayType.label) - Image(systemName: "chevron.right") - } - } - - Button { - settingsRouter.route(to: \.experimentalSettings) - } label: { - HStack { - Text("Experimental") - .foregroundColor(.primary) - Spacer() - Image(systemName: "chevron.right") - } - } - } + Section(header: Text("Video Player")) { + Picker("Jump Forward Length", selection: $jumpForwardLength) { + ForEach(VideoPlayerJumpLength.allCases, id: \.self) { length in + Text(length.label).tag(length.rawValue) + } + } - Section(header: L10n.accessibility.text) { - Toggle("Show Poster Labels", isOn: $showPosterLabels) - Toggle("Show Cast and Crew", isOn: $showCastAndCrew) - - Picker(L10n.appearance, selection: $appAppearance) { - ForEach(AppAppearance.allCases, id: \.self) { appearance in - Text(appearance.localizedName).tag(appearance.rawValue) - } - } - } - } - .navigationBarTitle("Settings", displayMode: .inline) - .toolbar { - ToolbarItemGroup(placement: .navigationBarLeading) { - Button { - settingsRouter.dismissCoordinator() - } label: { - Image(systemName: "xmark.circle.fill") - } - } - } - } + Picker("Jump Backward Length", selection: $jumpBackwardLength) { + ForEach(VideoPlayerJumpLength.allCases, id: \.self) { length in + Text(length.label).tag(length.rawValue) + } + } + + Toggle("Jump Gestures Enabled", isOn: $jumpGesturesEnabled) + + Toggle("Resume 5 Second Offset", isOn: $resumeOffset) + + Button { + settingsRouter.route(to: \.overlaySettings) + } label: { + HStack { + Text("Overlay") + .foregroundColor(.primary) + Spacer() + Text(overlayType.label) + Image(systemName: "chevron.right") + } + } + + Button { + settingsRouter.route(to: \.experimentalSettings) + } label: { + HStack { + Text("Experimental") + .foregroundColor(.primary) + Spacer() + Image(systemName: "chevron.right") + } + } + } + + Section(header: L10n.accessibility.text) { + Toggle("Show Poster Labels", isOn: $showPosterLabels) + Toggle("Show Cast and Crew", isOn: $showCastAndCrew) + + Picker(L10n.appearance, selection: $appAppearance) { + ForEach(AppAppearance.allCases, id: \.self) { appearance in + Text(appearance.localizedName).tag(appearance.rawValue) + } + } + } + } + .navigationBarTitle("Settings", displayMode: .inline) + .toolbar { + ToolbarItemGroup(placement: .navigationBarLeading) { + Button { + settingsRouter.dismissCoordinator() + } label: { + Image(systemName: "xmark.circle.fill") + } + } + } + } } diff --git a/Swiftfin/Views/UserListView.swift b/Swiftfin/Views/UserListView.swift index cf32deb9..dd43a15f 100644 --- a/Swiftfin/Views/UserListView.swift +++ b/Swiftfin/Views/UserListView.swift @@ -1,122 +1,123 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import SwiftUI struct UserListView: View { - @EnvironmentObject var userListRouter: UserListCoordinator.Router - @ObservedObject var viewModel: UserListViewModel + @EnvironmentObject + var userListRouter: UserListCoordinator.Router + @ObservedObject + var viewModel: UserListViewModel - private var listView: some View { - ScrollView { - LazyVStack { - ForEach(viewModel.users, id: \.id) { user in - Button { - viewModel.login(user: user) - } label: { - ZStack(alignment: Alignment.leading) { - Rectangle() - .foregroundColor(Color(UIColor.secondarySystemFill)) - .frame(height: 50) - .cornerRadius(10) + private var listView: some View { + ScrollView { + LazyVStack { + ForEach(viewModel.users, id: \.id) { user in + Button { + viewModel.login(user: user) + } label: { + ZStack(alignment: Alignment.leading) { + Rectangle() + .foregroundColor(Color(UIColor.secondarySystemFill)) + .frame(height: 50) + .cornerRadius(10) - HStack { - Text(user.username) - .font(.title2) + HStack { + Text(user.username) + .font(.title2) - Spacer() + Spacer() - if viewModel.isLoading { - ProgressView() - } - }.padding(.leading) - } - .padding() - } - .contextMenu { - Button(role: .destructive) { - viewModel.remove(user: user) - } label: { - Label("Remove", systemImage: "trash") - } - } - } - } - } - } + if viewModel.isLoading { + ProgressView() + } + }.padding(.leading) + } + .padding() + } + .contextMenu { + Button(role: .destructive) { + viewModel.remove(user: user) + } label: { + Label("Remove", systemImage: "trash") + } + } + } + } + } + } - private var noUserView: some View { - VStack { - Text("Sign in to get started") - .frame(minWidth: 50, maxWidth: 240) - .multilineTextAlignment(.center) + private var noUserView: some View { + VStack { + Text("Sign in to get started") + .frame(minWidth: 50, maxWidth: 240) + .multilineTextAlignment(.center) - Button { - userListRouter.route(to: \.userSignIn, viewModel.server) - } label: { - ZStack { - Rectangle() - .foregroundColor(Color.jellyfinPurple) - .frame(maxWidth: 400, maxHeight: 50) - .frame(height: 50) - .cornerRadius(10) - .padding(.horizontal, 30) - .padding([.top, .bottom], 20) + Button { + userListRouter.route(to: \.userSignIn, viewModel.server) + } label: { + ZStack { + Rectangle() + .foregroundColor(Color.jellyfinPurple) + .frame(maxWidth: 400, maxHeight: 50) + .frame(height: 50) + .cornerRadius(10) + .padding(.horizontal, 30) + .padding([.top, .bottom], 20) - Text("Sign in") - .foregroundColor(Color.white) - .bold() - } - } - } - } + Text("Sign in") + .foregroundColor(Color.white) + .bold() + } + } + } + } - @ViewBuilder - private var innerBody: some View { - if viewModel.users.isEmpty { - noUserView - .offset(y: -50) - } else { - listView - } - } + @ViewBuilder + private var innerBody: some View { + if viewModel.users.isEmpty { + noUserView + .offset(y: -50) + } else { + listView + } + } - @ViewBuilder - private var toolbarContent: some View { - HStack { - Button { - userListRouter.route(to: \.serverDetail, viewModel.server) - } label: { - Image(systemName: "info.circle.fill") - } + @ViewBuilder + private var toolbarContent: some View { + HStack { + Button { + userListRouter.route(to: \.serverDetail, viewModel.server) + } label: { + Image(systemName: "info.circle.fill") + } - if !viewModel.users.isEmpty { - Button { - userListRouter.route(to: \.userSignIn, viewModel.server) - } label: { - Image(systemName: "person.crop.circle.fill.badge.plus") - } - } - } - } + if !viewModel.users.isEmpty { + Button { + userListRouter.route(to: \.userSignIn, viewModel.server) + } label: { + Image(systemName: "person.crop.circle.fill.badge.plus") + } + } + } + } - var body: some View { - innerBody - .navigationTitle(viewModel.server.name) - .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - toolbarContent - } - } - .onAppear { - viewModel.fetchUsers() - } - } + var body: some View { + innerBody + .navigationTitle(viewModel.server.name) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + toolbarContent + } + } + .onAppear { + viewModel.fetchUsers() + } + } } diff --git a/Swiftfin/Views/UserSignInView.swift b/Swiftfin/Views/UserSignInView.swift index 72a1390f..cbc460e1 100644 --- a/Swiftfin/Views/UserSignInView.swift +++ b/Swiftfin/Views/UserSignInView.swift @@ -1,57 +1,59 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// -import SwiftUI import Stinsen +import SwiftUI struct UserSignInView: View { - @ObservedObject var viewModel: UserSignInViewModel - @State private var username: String = "" - @State private var password: String = "" + @ObservedObject + var viewModel: UserSignInViewModel + @State + private var username: String = "" + @State + private var password: String = "" - var body: some View { - Form { + var body: some View { + Form { - Section { - TextField(L10n.username, text: $username) - .disableAutocorrection(true) - .autocapitalization(.none) + Section { + TextField(L10n.username, text: $username) + .disableAutocorrection(true) + .autocapitalization(.none) - SecureField(L10n.password, text: $password) - .disableAutocorrection(true) - .autocapitalization(.none) + SecureField(L10n.password, text: $password) + .disableAutocorrection(true) + .autocapitalization(.none) - if viewModel.isLoading { - Button(role: .destructive) { - viewModel.cancelSignIn() - } label: { - Text("Cancel") - } - } else { - Button { - viewModel.login(username: username, password: password) - } label: { - Text("Sign In") - } - .disabled(username.isEmpty) - } - } header: { - Text("Sign In to \(viewModel.server.name)") - } - } - .alert(item: $viewModel.errorMessage) { _ in - Alert(title: Text(viewModel.alertTitle), - message: Text(viewModel.errorMessage?.displayMessage ?? "Unknown Error"), - dismissButton: .cancel()) - } - .navigationTitle("Sign In") - .navigationBarBackButtonHidden(viewModel.isLoading) - } + if viewModel.isLoading { + Button(role: .destructive) { + viewModel.cancelSignIn() + } label: { + Text("Cancel") + } + } else { + Button { + viewModel.login(username: username, password: password) + } label: { + Text("Sign In") + } + .disabled(username.isEmpty) + } + } header: { + Text("Sign In to \(viewModel.server.name)") + } + } + .alert(item: $viewModel.errorMessage) { _ in + Alert(title: Text(viewModel.alertTitle), + message: Text(viewModel.errorMessage?.displayMessage ?? "Unknown Error"), + dismissButton: .cancel()) + } + .navigationTitle("Sign In") + .navigationBarBackButtonHidden(viewModel.isLoading) + } } diff --git a/Swiftfin/Views/VideoPlayer/PlaybackSpeed.swift b/Swiftfin/Views/VideoPlayer/PlaybackSpeed.swift deleted file mode 100644 index 90806983..00000000 --- a/Swiftfin/Views/VideoPlayer/PlaybackSpeed.swift +++ /dev/null @@ -1,42 +0,0 @@ -// - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ - -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 - - 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" - } - } -} diff --git a/Swiftfin/Views/VideoPlayer/PlayerOverlayDelegate.swift b/Swiftfin/Views/VideoPlayer/PlayerOverlayDelegate.swift index f80db501..6ea4bc5b 100644 --- a/Swiftfin/Views/VideoPlayer/PlayerOverlayDelegate.swift +++ b/Swiftfin/Views/VideoPlayer/PlayerOverlayDelegate.swift @@ -1,32 +1,31 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Foundation protocol PlayerOverlayDelegate { - - func didSelectClose() - func didSelectMenu() - func didDeselectMenu() - - func didSelectBackward() - func didSelectForward() - func didSelectMain() - - func didGenerallyTap() - - func didBeginScrubbing() - func didEndScrubbing() - - func didSelectAudioStream(index: Int) - func didSelectSubtitleStream(index: Int) - - func didSelectPlayPreviousItem() - func didSelectPlayNextItem() + + func didSelectClose() + func didSelectMenu() + func didDeselectMenu() + + func didSelectBackward() + func didSelectForward() + func didSelectMain() + + func didGenerallyTap() + + func didBeginScrubbing() + func didEndScrubbing() + + func didSelectAudioStream(index: Int) + func didSelectSubtitleStream(index: Int) + + func didSelectPlayPreviousItem() + func didSelectPlayNextItem() } diff --git a/Swiftfin/Views/VideoPlayer/VLCPlayerOverlayView.swift b/Swiftfin/Views/VideoPlayer/VLCPlayerOverlayView.swift index 87f5eefd..0928adc7 100644 --- a/Swiftfin/Views/VideoPlayer/VLCPlayerOverlayView.swift +++ b/Swiftfin/Views/VideoPlayer/VLCPlayerOverlayView.swift @@ -1,10 +1,10 @@ -/* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Combine import Defaults @@ -14,409 +14,412 @@ import Sliders import SwiftUI struct VLCPlayerOverlayView: View { - - @ObservedObject var viewModel: VideoPlayerViewModel - - @ViewBuilder - private var mainButtonView: some View { - if viewModel.overlayType == .normal { - switch viewModel.playerState { - case .stopped, .paused: - Image(systemName: "play.fill") - .font(.system(size: 56, weight: .semibold, design: .default)) - case .playing: - Image(systemName: "pause") - .font(.system(size: 56, weight: .semibold, design: .default)) - default: - ProgressView() - .scaleEffect(2) - } - } else if viewModel.overlayType == .compact { - switch viewModel.playerState { - case .stopped, .paused: - Image(systemName: "play.fill") - .font(.system(size: 28, weight: .heavy, design: .default)) - case .playing: - Image(systemName: "pause") - .font(.system(size: 28, weight: .heavy, design: .default)) - default: - ProgressView() - } - } - } - - @ViewBuilder - private var mainBody: some View { - VStack { - - // MARK: Top Bar - ZStack { - - if viewModel.overlayType == .compact { - LinearGradient(gradient: Gradient(colors: [.black.opacity(0.7), .clear]), - startPoint: .top, - endPoint: .bottom) - .ignoresSafeArea() - .frame(height: 80) - } - - VStack(alignment: .EpisodeSeriesAlignmentGuide) { - - HStack(alignment: .center) { - - HStack { - Button { - viewModel.playerOverlayDelegate?.didSelectClose() - } label: { - Image(systemName: "chevron.backward") - .padding() - .padding(.trailing, -10) - } - - Text(viewModel.title) - .font(.system(size: 28, weight: .regular, design: .default)) - .alignmentGuide(.EpisodeSeriesAlignmentGuide) { context in - context[.leading] - } - } - - Spacer() - - HStack(spacing: 20) { - - if viewModel.shouldShowPlayPreviousItem { - Button { - viewModel.playerOverlayDelegate?.didSelectPlayPreviousItem() - } label: { - Image(systemName: "chevron.left.circle") - } - .disabled(viewModel.previousItemVideoPlayerViewModel == nil) - .foregroundColor(viewModel.nextItemVideoPlayerViewModel == nil ? .gray : .white) - } - - if viewModel.shouldShowPlayNextItem { - Button { - viewModel.playerOverlayDelegate?.didSelectPlayNextItem() - } label: { - Image(systemName: "chevron.right.circle") - } - .disabled(viewModel.nextItemVideoPlayerViewModel == nil) - .foregroundColor(viewModel.nextItemVideoPlayerViewModel == nil ? .gray : .white) - } - - if viewModel.shouldShowAutoPlay { - Button { - viewModel.autoplayEnabled.toggle() - } label: { - if viewModel.autoplayEnabled { - Image(systemName: "play.circle.fill") - } else { - Image(systemName: "stop.circle") - } - } - } - - if !viewModel.subtitleStreams.isEmpty { - Button { - viewModel.subtitlesEnabled.toggle() - } label: { - if viewModel.subtitlesEnabled { - Image(systemName: "captions.bubble.fill") - } else { - Image(systemName: "captions.bubble") - } - } - .disabled(viewModel.selectedSubtitleStreamIndex == -1) - .foregroundColor(viewModel.selectedSubtitleStreamIndex == -1 ? .gray : .white) - } - - // MARK: Settings Menu - Menu { - Menu { - ForEach(viewModel.audioStreams, id: \.self) { audioStream in - Button { - viewModel.selectedAudioStreamIndex = audioStream.index ?? -1 - } label: { - if audioStream.index == viewModel.selectedAudioStreamIndex { - Label.init(audioStream.displayTitle ?? "No Title", systemImage: "checkmark") - } else { - Text(audioStream.displayTitle ?? "No Title") - } - } - } - } label: { - HStack { - Image(systemName: "speaker.wave.3") - Text("Audio") - } - } + @ObservedObject + var viewModel: VideoPlayerViewModel - Menu { - ForEach(viewModel.subtitleStreams, id: \.self) { subtitleStream in - Button { - viewModel.selectedSubtitleStreamIndex = subtitleStream.index ?? -1 - } label: { - if subtitleStream.index == viewModel.selectedSubtitleStreamIndex { - Label.init(subtitleStream.displayTitle ?? "No Title", systemImage: "checkmark") - } else { - Text(subtitleStream.displayTitle ?? "No Title") - } - } - } - } label: { - HStack { - Image(systemName: "captions.bubble") - Text("Subtitles") - } - } + @ViewBuilder + private var mainButtonView: some View { + if viewModel.overlayType == .normal { + switch viewModel.playerState { + case .stopped, .paused: + Image(systemName: "play.fill") + .font(.system(size: 56, weight: .semibold, design: .default)) + case .playing: + Image(systemName: "pause") + .font(.system(size: 56, weight: .semibold, design: .default)) + default: + ProgressView() + .scaleEffect(2) + } + } else if viewModel.overlayType == .compact { + switch viewModel.playerState { + case .stopped, .paused: + Image(systemName: "play.fill") + .font(.system(size: 28, weight: .heavy, design: .default)) + case .playing: + Image(systemName: "pause") + .font(.system(size: 28, weight: .heavy, design: .default)) + default: + ProgressView() + } + } + } - Menu { - ForEach(PlaybackSpeed.allCases, id: \.self) { speed in - Button { - viewModel.playbackSpeed = speed - } label: { - if speed == viewModel.playbackSpeed { - Label(speed.displayTitle, systemImage: "checkmark") - } else { - Text(speed.displayTitle) - } - } - } - } label: { - HStack { - Image(systemName: "speedometer") - Text("Playback Speed") - } - } - - if viewModel.shouldShowJumpButtonsInOverlayMenu { - Menu { - ForEach(VideoPlayerJumpLength.allCases, id: \.self) { forwardLength in - Button { - viewModel.jumpForwardLength = forwardLength - } label: { - if forwardLength == viewModel.jumpForwardLength { - Label(forwardLength.shortLabel, systemImage: "checkmark") - } else { - Text(forwardLength.shortLabel) - } - } - } - } label: { - HStack { - Image(systemName: "goforward") - Text("Jump Forward Length") - } - } - - Menu { - ForEach(VideoPlayerJumpLength.allCases, id: \.self) { backwardLength in - Button { - viewModel.jumpBackwardLength = backwardLength - } label: { - if backwardLength == viewModel.jumpBackwardLength { - Label(backwardLength.shortLabel, systemImage: "checkmark") - } else { - Text(backwardLength.shortLabel) - } - } - } - } label: { - HStack { - Image(systemName: "gobackward") - Text("Jump Backward Length") - } - } - } - } label: { - Image(systemName: "ellipsis.circle") - } - } - } - .font(.system(size: 24)) - .frame(height: 50) - - if let seriesTitle = viewModel.subtitle { - Text(seriesTitle) - .font(.subheadline) - .foregroundColor(Color.gray) - .alignmentGuide(.EpisodeSeriesAlignmentGuide) { context in - context[.leading] - } - .offset(y: -10) - } - } - } - .padding(.horizontal, UIDevice.current.userInterfaceIdiom == .pad ? 50 : 0) - .padding(.top, UIDevice.current.userInterfaceIdiom == .pad ? 10 : 0) - - // MARK: Center - - Spacer() - - if viewModel.overlayType == .normal { - HStack(spacing: 80) { - Button { - viewModel.playerOverlayDelegate?.didSelectBackward() - } label: { - Image(systemName: viewModel.jumpBackwardLength.backwardImageLabel) - } - - Button { - viewModel.playerOverlayDelegate?.didSelectMain() - } label: { - mainButtonView - } - .frame(width: 200) - - Button { - viewModel.playerOverlayDelegate?.didSelectForward() - } label: { - Image(systemName: viewModel.jumpForwardLength.forwardImageLabel) - } - } - .font(.system(size: 48)) - } - - Spacer() - - // MARK: Bottom Bar - ZStack { - - if viewModel.overlayType == .compact { - LinearGradient(gradient: Gradient(colors: [.clear, .black.opacity(0.7)]), - startPoint: .top, - endPoint: .bottom) - .ignoresSafeArea() - .frame(height: 70) - } - - HStack { - - if viewModel.overlayType == .compact { - HStack { - Button { - viewModel.playerOverlayDelegate?.didSelectBackward() - } label: { - Image(systemName: viewModel.jumpBackwardLength.backwardImageLabel) - .padding(.horizontal, 5) - } - - Button { - viewModel.playerOverlayDelegate?.didSelectMain() - } label: { - mainButtonView - .frame(minWidth: 30, maxWidth: 30) - .padding(.horizontal, 10) - } - - Button { - viewModel.playerOverlayDelegate?.didSelectForward() - } label: { - Image(systemName: viewModel.jumpForwardLength.forwardImageLabel) - .padding(.horizontal, 5) - } - } - .font(.system(size: 24, weight: .semibold, design: .default)) - } - - Text(viewModel.leftLabelText) - .font(.system(size: 18, weight: .semibold, design: .default)) - .frame(minWidth: 70, maxWidth: 70) - - ValueSlider(value: $viewModel.sliderPercentage, onEditingChanged: { editing in - viewModel.sliderIsScrubbing = editing - }) - .valueSliderStyle( - HorizontalValueSliderStyle(track: - HorizontalValueTrack(view: - Capsule().foregroundColor(.purple)) - .background(Capsule().foregroundColor(Color.gray.opacity(0.25))) - .frame(height: 4), - thumb: Circle().foregroundColor(.purple) - .onLongPressGesture(perform: { - print("got it here") - }), - thumbSize: CGSize.Circle(radius: viewModel.sliderIsScrubbing ? 20 : 15), - thumbInteractiveSize: CGSize.Circle(radius: 40), - options: .defaultOptions) - ) - .frame(maxHeight: 50) - - Text(viewModel.rightLabelText) - .font(.system(size: 18, weight: .semibold, design: .default)) - .frame(minWidth: 70, maxWidth: 70) - } - .padding(.horizontal) - .frame(maxWidth: UIDevice.current.userInterfaceIdiom == .pad ? 800 : nil) - } - .frame(maxHeight: 50) - } - .ignoresSafeArea(edges: .top) - .tint(Color.white) - .foregroundColor(Color.white) - } - - var body: some View { - if viewModel.overlayType == .normal { - mainBody - .contentShape(Rectangle()) - .onTapGesture { - viewModel.playerOverlayDelegate?.didGenerallyTap() - } - .background { - Color(uiColor: .black.withAlphaComponent(0.5)) - .ignoresSafeArea() - } - } else { - mainBody - .contentShape(Rectangle()) - .onTapGesture { - viewModel.playerOverlayDelegate?.didGenerallyTap() - } - } - } + @ViewBuilder + private var mainBody: some View { + VStack { + + // MARK: Top Bar + + ZStack { + + if viewModel.overlayType == .compact { + LinearGradient(gradient: Gradient(colors: [.black.opacity(0.7), .clear]), + startPoint: .top, + endPoint: .bottom) + .ignoresSafeArea() + .frame(height: 80) + } + + VStack(alignment: .EpisodeSeriesAlignmentGuide) { + + HStack(alignment: .center) { + + HStack { + Button { + viewModel.playerOverlayDelegate?.didSelectClose() + } label: { + Image(systemName: "chevron.backward") + .padding() + .padding(.trailing, -10) + } + + Text(viewModel.title) + .font(.system(size: 28, weight: .regular, design: .default)) + .alignmentGuide(.EpisodeSeriesAlignmentGuide) { context in + context[.leading] + } + } + + Spacer() + + HStack(spacing: 20) { + + if viewModel.shouldShowPlayPreviousItem { + Button { + viewModel.playerOverlayDelegate?.didSelectPlayPreviousItem() + } label: { + Image(systemName: "chevron.left.circle") + } + .disabled(viewModel.previousItemVideoPlayerViewModel == nil) + .foregroundColor(viewModel.nextItemVideoPlayerViewModel == nil ? .gray : .white) + } + + if viewModel.shouldShowPlayNextItem { + Button { + viewModel.playerOverlayDelegate?.didSelectPlayNextItem() + } label: { + Image(systemName: "chevron.right.circle") + } + .disabled(viewModel.nextItemVideoPlayerViewModel == nil) + .foregroundColor(viewModel.nextItemVideoPlayerViewModel == nil ? .gray : .white) + } + + if viewModel.shouldShowAutoPlay { + Button { + viewModel.autoplayEnabled.toggle() + } label: { + if viewModel.autoplayEnabled { + Image(systemName: "play.circle.fill") + } else { + Image(systemName: "stop.circle") + } + } + } + + if !viewModel.subtitleStreams.isEmpty { + Button { + viewModel.subtitlesEnabled.toggle() + } label: { + if viewModel.subtitlesEnabled { + Image(systemName: "captions.bubble.fill") + } else { + Image(systemName: "captions.bubble") + } + } + .disabled(viewModel.selectedSubtitleStreamIndex == -1) + .foregroundColor(viewModel.selectedSubtitleStreamIndex == -1 ? .gray : .white) + } + + // MARK: Settings Menu + + Menu { + + Menu { + ForEach(viewModel.audioStreams, id: \.self) { audioStream in + Button { + viewModel.selectedAudioStreamIndex = audioStream.index ?? -1 + } label: { + if audioStream.index == viewModel.selectedAudioStreamIndex { + Label(audioStream.displayTitle ?? "No Title", systemImage: "checkmark") + } else { + Text(audioStream.displayTitle ?? "No Title") + } + } + } + } label: { + HStack { + Image(systemName: "speaker.wave.3") + Text("Audio") + } + } + + Menu { + ForEach(viewModel.subtitleStreams, id: \.self) { subtitleStream in + Button { + viewModel.selectedSubtitleStreamIndex = subtitleStream.index ?? -1 + } label: { + if subtitleStream.index == viewModel.selectedSubtitleStreamIndex { + Label(subtitleStream.displayTitle ?? "No Title", systemImage: "checkmark") + } else { + Text(subtitleStream.displayTitle ?? "No Title") + } + } + } + } label: { + HStack { + Image(systemName: "captions.bubble") + Text("Subtitles") + } + } + + Menu { + ForEach(PlaybackSpeed.allCases, id: \.self) { speed in + Button { + viewModel.playbackSpeed = speed + } label: { + if speed == viewModel.playbackSpeed { + Label(speed.displayTitle, systemImage: "checkmark") + } else { + Text(speed.displayTitle) + } + } + } + } label: { + HStack { + Image(systemName: "speedometer") + Text("Playback Speed") + } + } + + if viewModel.shouldShowJumpButtonsInOverlayMenu { + Menu { + ForEach(VideoPlayerJumpLength.allCases, id: \.self) { forwardLength in + Button { + viewModel.jumpForwardLength = forwardLength + } label: { + if forwardLength == viewModel.jumpForwardLength { + Label(forwardLength.shortLabel, systemImage: "checkmark") + } else { + Text(forwardLength.shortLabel) + } + } + } + } label: { + HStack { + Image(systemName: "goforward") + Text("Jump Forward Length") + } + } + + Menu { + ForEach(VideoPlayerJumpLength.allCases, id: \.self) { backwardLength in + Button { + viewModel.jumpBackwardLength = backwardLength + } label: { + if backwardLength == viewModel.jumpBackwardLength { + Label(backwardLength.shortLabel, systemImage: "checkmark") + } else { + Text(backwardLength.shortLabel) + } + } + } + } label: { + HStack { + Image(systemName: "gobackward") + Text("Jump Backward Length") + } + } + } + } label: { + Image(systemName: "ellipsis.circle") + } + } + } + .font(.system(size: 24)) + .frame(height: 50) + + if let seriesTitle = viewModel.subtitle { + Text(seriesTitle) + .font(.subheadline) + .foregroundColor(Color.gray) + .alignmentGuide(.EpisodeSeriesAlignmentGuide) { context in + context[.leading] + } + .offset(y: -10) + } + } + } + .padding(.horizontal, UIDevice.current.userInterfaceIdiom == .pad ? 50 : 0) + .padding(.top, UIDevice.current.userInterfaceIdiom == .pad ? 10 : 0) + + // MARK: Center + + Spacer() + + if viewModel.overlayType == .normal { + HStack(spacing: 80) { + Button { + viewModel.playerOverlayDelegate?.didSelectBackward() + } label: { + Image(systemName: viewModel.jumpBackwardLength.backwardImageLabel) + } + + Button { + viewModel.playerOverlayDelegate?.didSelectMain() + } label: { + mainButtonView + } + .frame(width: 200) + + Button { + viewModel.playerOverlayDelegate?.didSelectForward() + } label: { + Image(systemName: viewModel.jumpForwardLength.forwardImageLabel) + } + } + .font(.system(size: 48)) + } + + Spacer() + + // MARK: Bottom Bar + + ZStack { + + if viewModel.overlayType == .compact { + LinearGradient(gradient: Gradient(colors: [.clear, .black.opacity(0.7)]), + startPoint: .top, + endPoint: .bottom) + .ignoresSafeArea() + .frame(height: 70) + } + + HStack { + + if viewModel.overlayType == .compact { + HStack { + Button { + viewModel.playerOverlayDelegate?.didSelectBackward() + } label: { + Image(systemName: viewModel.jumpBackwardLength.backwardImageLabel) + .padding(.horizontal, 5) + } + + Button { + viewModel.playerOverlayDelegate?.didSelectMain() + } label: { + mainButtonView + .frame(minWidth: 30, maxWidth: 30) + .padding(.horizontal, 10) + } + + Button { + viewModel.playerOverlayDelegate?.didSelectForward() + } label: { + Image(systemName: viewModel.jumpForwardLength.forwardImageLabel) + .padding(.horizontal, 5) + } + } + .font(.system(size: 24, weight: .semibold, design: .default)) + } + + Text(viewModel.leftLabelText) + .font(.system(size: 18, weight: .semibold, design: .default)) + .frame(minWidth: 70, maxWidth: 70) + + ValueSlider(value: $viewModel.sliderPercentage, onEditingChanged: { editing in + viewModel.sliderIsScrubbing = editing + }) + .valueSliderStyle(HorizontalValueSliderStyle(track: + HorizontalValueTrack(view: + Capsule().foregroundColor(.purple)) + .background(Capsule().foregroundColor(Color.gray.opacity(0.25))) + .frame(height: 4), + thumb: Circle().foregroundColor(.purple) + .onLongPressGesture(perform: { + print("got it here") + }), + thumbSize: CGSize.Circle(radius: viewModel.sliderIsScrubbing ? 20 : 15), + thumbInteractiveSize: CGSize.Circle(radius: 40), + options: .defaultOptions)) + .frame(maxHeight: 50) + + Text(viewModel.rightLabelText) + .font(.system(size: 18, weight: .semibold, design: .default)) + .frame(minWidth: 70, maxWidth: 70) + } + .padding(.horizontal) + .frame(maxWidth: UIDevice.current.userInterfaceIdiom == .pad ? 800 : nil) + } + .frame(maxHeight: 50) + } + .ignoresSafeArea(edges: .top) + .tint(Color.white) + .foregroundColor(Color.white) + } + + var body: some View { + if viewModel.overlayType == .normal { + mainBody + .contentShape(Rectangle()) + .onTapGesture { + viewModel.playerOverlayDelegate?.didGenerallyTap() + } + .background { + Color(uiColor: .black.withAlphaComponent(0.5)) + .ignoresSafeArea() + } + } else { + mainBody + .contentShape(Rectangle()) + .onTapGesture { + viewModel.playerOverlayDelegate?.didGenerallyTap() + } + } + } } struct VLCPlayerCompactOverlayView_Previews: PreviewProvider { - - static let videoPlayerViewModel = VideoPlayerViewModel(item: BaseItemDto(), - title: "Glorious Purpose", - subtitle: "Loki - S1E1", - streamURL: URL(string: "www.apple.com")!, - streamType: .direct, - response: PlaybackInfoResponse(), - audioStreams: [MediaStream(displayTitle: "English", index: -1)], - subtitleStreams: [MediaStream(displayTitle: "None", index: -1)], - selectedAudioStreamIndex: -1, - selectedSubtitleStreamIndex: -1, - subtitlesEnabled: true, - autoplayEnabled: false, - overlayType: .compact, - shouldShowPlayPreviousItem: true, - shouldShowPlayNextItem: true, - shouldShowAutoPlay: true) - - static var previews: some View { - ZStack { - Color.red - .ignoresSafeArea() - - VLCPlayerOverlayView(viewModel: videoPlayerViewModel) - } - .previewInterfaceOrientation(.landscapeLeft) - } + + static let videoPlayerViewModel = VideoPlayerViewModel(item: BaseItemDto(), + title: "Glorious Purpose", + subtitle: "Loki - S1E1", + streamURL: URL(string: "www.apple.com")!, + streamType: .direct, + response: PlaybackInfoResponse(), + audioStreams: [MediaStream(displayTitle: "English", index: -1)], + subtitleStreams: [MediaStream(displayTitle: "None", index: -1)], + selectedAudioStreamIndex: -1, + selectedSubtitleStreamIndex: -1, + subtitlesEnabled: true, + autoplayEnabled: false, + overlayType: .compact, + shouldShowPlayPreviousItem: true, + shouldShowPlayNextItem: true, + shouldShowAutoPlay: true) + + static var previews: some View { + ZStack { + Color.red + .ignoresSafeArea() + + VLCPlayerOverlayView(viewModel: videoPlayerViewModel) + } + .previewInterfaceOrientation(.landscapeLeft) + } } // MARK: TitleSubtitleAlignment -extension HorizontalAlignment { - - private struct TitleSubtitleAlignment: AlignmentID { - static func defaultValue(in context: ViewDimensions) -> CGFloat { - context[HorizontalAlignment.leading] - } - } - static let EpisodeSeriesAlignmentGuide = HorizontalAlignment(TitleSubtitleAlignment.self) +extension HorizontalAlignment { + + private struct TitleSubtitleAlignment: AlignmentID { + static func defaultValue(in context: ViewDimensions) -> CGFloat { + context[HorizontalAlignment.leading] + } + } + + static let EpisodeSeriesAlignmentGuide = HorizontalAlignment(TitleSubtitleAlignment.self) } diff --git a/Swiftfin/Views/VideoPlayer/VLCPlayerView.swift b/Swiftfin/Views/VideoPlayer/VLCPlayerView.swift index 099d0838..a9684d8f 100644 --- a/Swiftfin/Views/VideoPlayer/VLCPlayerView.swift +++ b/Swiftfin/Views/VideoPlayer/VLCPlayerView.swift @@ -1,27 +1,24 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// -import UIKit import SwiftUI +import UIKit struct VLCPlayerView: UIViewControllerRepresentable { - - let viewModel: VideoPlayerViewModel - - typealias UIViewControllerType = VLCPlayerViewController - - func makeUIViewController(context: Context) -> VLCPlayerViewController { - - return VLCPlayerViewController(viewModel: viewModel) - } - - func updateUIViewController(_ uiViewController: VLCPlayerViewController, context: Context) { - - } + + let viewModel: VideoPlayerViewModel + + typealias UIViewControllerType = VLCPlayerViewController + + func makeUIViewController(context: Context) -> VLCPlayerViewController { + + VLCPlayerViewController(viewModel: viewModel) + } + + func updateUIViewController(_ uiViewController: VLCPlayerViewController, context: Context) {} } diff --git a/Swiftfin/Views/VideoPlayer/VLCPlayerViewController.swift b/Swiftfin/Views/VideoPlayer/VLCPlayerViewController.swift index ce1ba728..50a6d022 100644 --- a/Swiftfin/Views/VideoPlayer/VLCPlayerViewController.swift +++ b/Swiftfin/Views/VideoPlayer/VLCPlayerViewController.swift @@ -1,14 +1,13 @@ // - /* - * SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// -import AVKit import AVFoundation +import AVKit import Combine import Defaults import JellyfinAPI @@ -20,672 +19,693 @@ import UIKit // TODO: Look at making the VLC player layer a view class VLCPlayerViewController: UIViewController { - - // MARK: variables - - private var viewModel: VideoPlayerViewModel - private var vlcMediaPlayer: VLCMediaPlayer - private var lastPlayerTicks: Int64 = 0 - private var lastProgressReportTicks: Int64 = 0 - private var viewModelListeners = Set() - private var overlayDismissTimer: Timer? - - private var currentPlayerTicks: Int64 { - return Int64(vlcMediaPlayer.time.intValue) * 100_000 - } - - private var displayingOverlay: Bool { - return currentOverlayHostingController?.view.alpha ?? 0 > 0 - } - - private lazy var videoContentView = makeVideoContentView() - private lazy var mainGestureView = makeTapGestureView() - private var currentOverlayHostingController: UIHostingController? - private var currentJumpBackwardOverlayView: UIImageView? - private var currentJumpForwardOverlayView: UIImageView? - - // MARK: init - - init(viewModel: VideoPlayerViewModel) { - - self.viewModel = viewModel - self.vlcMediaPlayer = VLCMediaPlayer() - - super.init(nibName: nil, bundle: nil) - - viewModel.playerOverlayDelegate = self - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - private func setupSubviews() { - view.addSubview(videoContentView) - view.addSubview(mainGestureView) - } - - private func setupConstraints() { - NSLayoutConstraint.activate([ - videoContentView.topAnchor.constraint(equalTo: view.topAnchor), - videoContentView.bottomAnchor.constraint(equalTo: view.bottomAnchor), - videoContentView.leftAnchor.constraint(equalTo: view.leftAnchor), - videoContentView.rightAnchor.constraint(equalTo: view.rightAnchor) - ]) - NSLayoutConstraint.activate([ - mainGestureView.topAnchor.constraint(equalTo: videoContentView.topAnchor), - mainGestureView.bottomAnchor.constraint(equalTo: videoContentView.bottomAnchor), - mainGestureView.leftAnchor.constraint(equalTo: videoContentView.leftAnchor), - mainGestureView.rightAnchor.constraint(equalTo: videoContentView.rightAnchor) - ]) - } - - // MARK: viewWillDisappear - - override func viewWillDisappear(_ animated: Bool) { - super.viewWillDisappear(animated) - - NotificationCenter.default.removeObserver(self) - } - - // MARK: viewDidLoad - - override func viewDidLoad() { - super.viewDidLoad() - - setupSubviews() - setupConstraints() - - view.backgroundColor = .black - - setupMediaPlayer(newViewModel: viewModel) - - refreshJumpBackwardOverlayView(with: viewModel.jumpBackwardLength) - refreshJumpForwardOverlayView(with: viewModel.jumpForwardLength) - - let defaultNotificationCenter = NotificationCenter.default - defaultNotificationCenter.addObserver(self, selector: #selector(appWillTerminate), name: UIApplication.willTerminateNotification, object: nil) - defaultNotificationCenter.addObserver(self, selector: #selector(appWillResignActive), name: UIApplication.willResignActiveNotification, object: nil) - defaultNotificationCenter.addObserver(self, selector: #selector(appWillResignActive), name: UIApplication.didEnterBackgroundNotification, object: nil) - } - - @objc private func appWillTerminate() { - viewModel.sendStopReport() - } - - @objc private func appWillResignActive() { - showOverlay() - - stopOverlayDismissTimer() - - vlcMediaPlayer.pause() - - viewModel.sendPauseReport(paused: true) - } - - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - - startPlayback() - } - - // MARK: subviews - - private func makeVideoContentView() -> UIView { - let view = UIView() - view.translatesAutoresizingMaskIntoConstraints = false - view.backgroundColor = .black - - return view - } - - private func makeTapGestureView() -> UIView { - let view = UIView() - view.translatesAutoresizingMaskIntoConstraints = false - - let singleTapGesture = UITapGestureRecognizer(target: self, action: #selector(didTap)) - - let rightSwipeGesture = UISwipeGestureRecognizer(target: self, action: #selector(didRightSwipe)) - rightSwipeGesture.direction = .right - - let leftSwipeGesture = UISwipeGestureRecognizer(target: self, action: #selector(didLeftSwipe)) - leftSwipeGesture.direction = .left - - view.addGestureRecognizer(singleTapGesture) - - if viewModel.jumpGesturesEnabled { - view.addGestureRecognizer(rightSwipeGesture) - view.addGestureRecognizer(leftSwipeGesture) - } - return view - } - - @objc private func didTap() { - self.didGenerallyTap() - } - - @objc private func didRightSwipe() { - self.didSelectForward() - } - - @objc private func didLeftSwipe() { - self.didSelectBackward() - } - - // MARK: setupOverlayHostingController - private func setupOverlayHostingController(viewModel: VideoPlayerViewModel) { + // MARK: variables - // TODO: Look at injecting viewModel into the environment so it updates the current overlay - if let currentOverlayHostingController = currentOverlayHostingController { - // UX fade-out - UIView.animate(withDuration: 0.5) { - currentOverlayHostingController.view.alpha = 0 - } completion: { _ in - currentOverlayHostingController.view.isHidden = true - - currentOverlayHostingController.view.removeFromSuperview() - currentOverlayHostingController.removeFromParent() - } - } - - let newOverlayView = VLCPlayerOverlayView(viewModel: viewModel) - let newOverlayHostingController = UIHostingController(rootView: newOverlayView) - - newOverlayHostingController.view.translatesAutoresizingMaskIntoConstraints = false - newOverlayHostingController.view.backgroundColor = UIColor.clear - - // UX fade-in - newOverlayHostingController.view.alpha = 0 - - addChild(newOverlayHostingController) - view.addSubview(newOverlayHostingController.view) - newOverlayHostingController.didMove(toParent: self) - - NSLayoutConstraint.activate([ - newOverlayHostingController.view.topAnchor.constraint(equalTo: videoContentView.topAnchor), - newOverlayHostingController.view.bottomAnchor.constraint(equalTo: videoContentView.bottomAnchor), - newOverlayHostingController.view.leftAnchor.constraint(equalTo: videoContentView.leftAnchor), - newOverlayHostingController.view.rightAnchor.constraint(equalTo: videoContentView.rightAnchor) - ]) - - // UX fade-in - UIView.animate(withDuration: 0.5) { - newOverlayHostingController.view.alpha = 1 - } - - self.currentOverlayHostingController = newOverlayHostingController - - // There is a weird behavior when after setting the new overlays that the navigation bar pops up, re-hide it - self.navigationController?.isNavigationBarHidden = true - } - - private func refreshJumpBackwardOverlayView(with jumpBackwardLength: VideoPlayerJumpLength) { - - if let currentJumpBackwardOverlayView = currentJumpBackwardOverlayView { - currentJumpBackwardOverlayView.removeFromSuperview() - } - - let symbolConfig = UIImage.SymbolConfiguration(pointSize: 48) - let backwardSymbolImage = UIImage(systemName: jumpBackwardLength.backwardImageLabel, withConfiguration: symbolConfig) - let newJumpBackwardImageView = UIImageView(image: backwardSymbolImage) - - newJumpBackwardImageView.translatesAutoresizingMaskIntoConstraints = false - newJumpBackwardImageView.tintColor = .white - - newJumpBackwardImageView.alpha = 0 - - view.addSubview(newJumpBackwardImageView) - - NSLayoutConstraint.activate([ - newJumpBackwardImageView.leftAnchor.constraint(equalTo: view.leftAnchor, constant: 150), - newJumpBackwardImageView.centerYAnchor.constraint(equalTo: view.centerYAnchor) - ]) - - currentJumpBackwardOverlayView = newJumpBackwardImageView - } - - private func refreshJumpForwardOverlayView(with jumpForwardLength: VideoPlayerJumpLength) { - - if let currentJumpForwardOverlayView = currentJumpForwardOverlayView { - currentJumpForwardOverlayView.removeFromSuperview() - } - - let symbolConfig = UIImage.SymbolConfiguration(pointSize: 48) - let forwardSymbolImage = UIImage(systemName: jumpForwardLength.forwardImageLabel, withConfiguration: symbolConfig) - let newJumpForwardImageView = UIImageView(image: forwardSymbolImage) - - newJumpForwardImageView.translatesAutoresizingMaskIntoConstraints = false - newJumpForwardImageView.tintColor = .white - - newJumpForwardImageView.alpha = 0 - - view.addSubview(newJumpForwardImageView) - - NSLayoutConstraint.activate([ - newJumpForwardImageView.rightAnchor.constraint(equalTo: view.rightAnchor, constant: -150), - newJumpForwardImageView.centerYAnchor.constraint(equalTo: view.centerYAnchor) - ]) - - currentJumpForwardOverlayView = newJumpForwardImageView - } + private var viewModel: VideoPlayerViewModel + private var vlcMediaPlayer: VLCMediaPlayer + private var lastPlayerTicks: Int64 = 0 + private var lastProgressReportTicks: Int64 = 0 + private var viewModelListeners = Set() + private var overlayDismissTimer: Timer? + + private var currentPlayerTicks: Int64 { + Int64(vlcMediaPlayer.time.intValue) * 100_000 + } + + private var displayingOverlay: Bool { + currentOverlayHostingController?.view.alpha ?? 0 > 0 + } + + private lazy var videoContentView = makeVideoContentView() + private lazy var mainGestureView = makeTapGestureView() + private var currentOverlayHostingController: UIHostingController? + private var currentJumpBackwardOverlayView: UIImageView? + private var currentJumpForwardOverlayView: UIImageView? + + // MARK: init + + init(viewModel: VideoPlayerViewModel) { + + self.viewModel = viewModel + self.vlcMediaPlayer = VLCMediaPlayer() + + super.init(nibName: nil, bundle: nil) + + viewModel.playerOverlayDelegate = self + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupSubviews() { + view.addSubview(videoContentView) + view.addSubview(mainGestureView) + } + + private func setupConstraints() { + NSLayoutConstraint.activate([ + videoContentView.topAnchor.constraint(equalTo: view.topAnchor), + videoContentView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + videoContentView.leftAnchor.constraint(equalTo: view.leftAnchor), + videoContentView.rightAnchor.constraint(equalTo: view.rightAnchor), + ]) + NSLayoutConstraint.activate([ + mainGestureView.topAnchor.constraint(equalTo: videoContentView.topAnchor), + mainGestureView.bottomAnchor.constraint(equalTo: videoContentView.bottomAnchor), + mainGestureView.leftAnchor.constraint(equalTo: videoContentView.leftAnchor), + mainGestureView.rightAnchor.constraint(equalTo: videoContentView.rightAnchor), + ]) + } + + // MARK: viewWillDisappear + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + + NotificationCenter.default.removeObserver(self) + } + + // MARK: viewDidLoad + + override func viewDidLoad() { + super.viewDidLoad() + + setupSubviews() + setupConstraints() + + view.backgroundColor = .black + + setupMediaPlayer(newViewModel: viewModel) + + refreshJumpBackwardOverlayView(with: viewModel.jumpBackwardLength) + refreshJumpForwardOverlayView(with: viewModel.jumpForwardLength) + + let defaultNotificationCenter = NotificationCenter.default + defaultNotificationCenter.addObserver(self, selector: #selector(appWillTerminate), name: UIApplication.willTerminateNotification, + object: nil) + defaultNotificationCenter.addObserver(self, selector: #selector(appWillResignActive), + name: UIApplication.willResignActiveNotification, object: nil) + defaultNotificationCenter.addObserver(self, selector: #selector(appWillResignActive), + name: UIApplication.didEnterBackgroundNotification, object: nil) + } + + @objc + private func appWillTerminate() { + viewModel.sendStopReport() + } + + @objc + private func appWillResignActive() { + showOverlay() + + stopOverlayDismissTimer() + + vlcMediaPlayer.pause() + + viewModel.sendPauseReport(paused: true) + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + startPlayback() + } + + // MARK: subviews + + private func makeVideoContentView() -> UIView { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + view.backgroundColor = .black + + return view + } + + private func makeTapGestureView() -> UIView { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + + let singleTapGesture = UITapGestureRecognizer(target: self, action: #selector(didTap)) + + let rightSwipeGesture = UISwipeGestureRecognizer(target: self, action: #selector(didRightSwipe)) + rightSwipeGesture.direction = .right + + let leftSwipeGesture = UISwipeGestureRecognizer(target: self, action: #selector(didLeftSwipe)) + leftSwipeGesture.direction = .left + + view.addGestureRecognizer(singleTapGesture) + + if viewModel.jumpGesturesEnabled { + view.addGestureRecognizer(rightSwipeGesture) + view.addGestureRecognizer(leftSwipeGesture) + } + + return view + } + + @objc + private func didTap() { + self.didGenerallyTap() + } + + @objc + private func didRightSwipe() { + self.didSelectForward() + } + + @objc + private func didLeftSwipe() { + self.didSelectBackward() + } + + // MARK: setupOverlayHostingController + + private func setupOverlayHostingController(viewModel: VideoPlayerViewModel) { + + // TODO: Look at injecting viewModel into the environment so it updates the current overlay + if let currentOverlayHostingController = currentOverlayHostingController { + // UX fade-out + UIView.animate(withDuration: 0.5) { + currentOverlayHostingController.view.alpha = 0 + } completion: { _ in + currentOverlayHostingController.view.isHidden = true + + currentOverlayHostingController.view.removeFromSuperview() + currentOverlayHostingController.removeFromParent() + } + } + + let newOverlayView = VLCPlayerOverlayView(viewModel: viewModel) + let newOverlayHostingController = UIHostingController(rootView: newOverlayView) + + newOverlayHostingController.view.translatesAutoresizingMaskIntoConstraints = false + newOverlayHostingController.view.backgroundColor = UIColor.clear + + // UX fade-in + newOverlayHostingController.view.alpha = 0 + + addChild(newOverlayHostingController) + view.addSubview(newOverlayHostingController.view) + newOverlayHostingController.didMove(toParent: self) + + NSLayoutConstraint.activate([ + newOverlayHostingController.view.topAnchor.constraint(equalTo: videoContentView.topAnchor), + newOverlayHostingController.view.bottomAnchor.constraint(equalTo: videoContentView.bottomAnchor), + newOverlayHostingController.view.leftAnchor.constraint(equalTo: videoContentView.leftAnchor), + newOverlayHostingController.view.rightAnchor.constraint(equalTo: videoContentView.rightAnchor), + ]) + + // UX fade-in + UIView.animate(withDuration: 0.5) { + newOverlayHostingController.view.alpha = 1 + } + + self.currentOverlayHostingController = newOverlayHostingController + + // There is a weird behavior when after setting the new overlays that the navigation bar pops up, re-hide it + self.navigationController?.isNavigationBarHidden = true + } + + private func refreshJumpBackwardOverlayView(with jumpBackwardLength: VideoPlayerJumpLength) { + + if let currentJumpBackwardOverlayView = currentJumpBackwardOverlayView { + currentJumpBackwardOverlayView.removeFromSuperview() + } + + let symbolConfig = UIImage.SymbolConfiguration(pointSize: 48) + let backwardSymbolImage = UIImage(systemName: jumpBackwardLength.backwardImageLabel, withConfiguration: symbolConfig) + let newJumpBackwardImageView = UIImageView(image: backwardSymbolImage) + + newJumpBackwardImageView.translatesAutoresizingMaskIntoConstraints = false + newJumpBackwardImageView.tintColor = .white + + newJumpBackwardImageView.alpha = 0 + + view.addSubview(newJumpBackwardImageView) + + NSLayoutConstraint.activate([ + newJumpBackwardImageView.leftAnchor.constraint(equalTo: view.leftAnchor, constant: 150), + newJumpBackwardImageView.centerYAnchor.constraint(equalTo: view.centerYAnchor), + ]) + + currentJumpBackwardOverlayView = newJumpBackwardImageView + } + + private func refreshJumpForwardOverlayView(with jumpForwardLength: VideoPlayerJumpLength) { + + if let currentJumpForwardOverlayView = currentJumpForwardOverlayView { + currentJumpForwardOverlayView.removeFromSuperview() + } + + let symbolConfig = UIImage.SymbolConfiguration(pointSize: 48) + let forwardSymbolImage = UIImage(systemName: jumpForwardLength.forwardImageLabel, withConfiguration: symbolConfig) + let newJumpForwardImageView = UIImageView(image: forwardSymbolImage) + + newJumpForwardImageView.translatesAutoresizingMaskIntoConstraints = false + newJumpForwardImageView.tintColor = .white + + newJumpForwardImageView.alpha = 0 + + view.addSubview(newJumpForwardImageView) + + NSLayoutConstraint.activate([ + newJumpForwardImageView.rightAnchor.constraint(equalTo: view.rightAnchor, constant: -150), + newJumpForwardImageView.centerYAnchor.constraint(equalTo: view.centerYAnchor), + ]) + + currentJumpForwardOverlayView = newJumpForwardImageView + } } // MARK: setupMediaPlayer -extension VLCPlayerViewController { - - /// Main function that handles setting up the media player with the current VideoPlayerViewModel - /// and also takes the role of setting the 'viewModel' property with the given viewModel - /// - /// Use case for this is setting new media within the same VLCPlayerViewController - func setupMediaPlayer(newViewModel: VideoPlayerViewModel) { - - // remove old player - - if vlcMediaPlayer.media != nil { - viewModelListeners.forEach({ $0.cancel() }) - - vlcMediaPlayer.stop() - viewModel.sendStopReport() - viewModel.playerOverlayDelegate = nil - } - - vlcMediaPlayer = VLCMediaPlayer() - - // setup with new player and view model - - vlcMediaPlayer = VLCMediaPlayer() - - vlcMediaPlayer.delegate = self - vlcMediaPlayer.drawable = videoContentView - - // TODO: Custom subtitle sizes - vlcMediaPlayer.perform(Selector(("setTextRendererFontSize:")), with: 14) - - stopOverlayDismissTimer() - - lastPlayerTicks = newViewModel.item.userData?.playbackPositionTicks ?? 0 - lastProgressReportTicks = newViewModel.item.userData?.playbackPositionTicks ?? 0 - let media = VLCMedia(url: newViewModel.streamURL) - media.addOption("--prefetch-buffer-size=1048576") - media.addOption("--network-caching=5000") - - vlcMediaPlayer.media = media - - setupOverlayHostingController(viewModel: newViewModel) - setupViewModelListeners(viewModel: newViewModel) - - newViewModel.getAdjacentEpisodes() - newViewModel.playerOverlayDelegate = self - - let startPercentage = newViewModel.item.userData?.playedPercentage ?? 0 - - if startPercentage > 0 { - if viewModel.resumeOffset { - let videoDurationSeconds = Double(viewModel.item.runTimeTicks! / 10_000_000) - var startSeconds = round((startPercentage / 100) * videoDurationSeconds) - startSeconds = startSeconds.subtract(5, floor: 0) - let newStartPercentage = startSeconds / videoDurationSeconds - newViewModel.sliderPercentage = newStartPercentage - } else { - newViewModel.sliderPercentage = startPercentage / 100 - } - } - - viewModel = newViewModel - } - - // MARK: startPlayback - func startPlayback() { - vlcMediaPlayer.play() - - // Setup external subtitles - for externalSubtitle in viewModel.subtitleStreams.filter({ $0.deliveryMethod == .external }) { - if let deliveryURL = externalSubtitle.externalURL(base: SessionManager.main.currentLogin.server.currentURI) { - vlcMediaPlayer.addPlaybackSlave(deliveryURL, type: .subtitle, enforce: false) - } - } - - setMediaPlayerTimeAtCurrentSlider() - - viewModel.sendPlayReport() - - restartOverlayDismissTimer() - } - - // MARK: setupViewModelListeners - - private func setupViewModelListeners(viewModel: VideoPlayerViewModel) { - - viewModel.$playbackSpeed.sink { newSpeed in - self.vlcMediaPlayer.rate = Float(newSpeed.rawValue) - }.store(in: &viewModelListeners) - - viewModel.$sliderIsScrubbing.sink { sliderIsScrubbing in - if sliderIsScrubbing { - self.didBeginScrubbing() - } else { - self.didEndScrubbing() - } - }.store(in: &viewModelListeners) - - viewModel.$selectedAudioStreamIndex.sink { newAudioStreamIndex in - self.didSelectAudioStream(index: newAudioStreamIndex) - }.store(in: &viewModelListeners) - - viewModel.$selectedSubtitleStreamIndex.sink { newSubtitleStreamIndex in - self.didSelectSubtitleStream(index: newSubtitleStreamIndex) - }.store(in: &viewModelListeners) - - viewModel.$subtitlesEnabled.sink { newSubtitlesEnabled in - self.didToggleSubtitles(newValue: newSubtitlesEnabled) - }.store(in: &viewModelListeners) - - viewModel.$jumpBackwardLength.sink { newJumpBackwardLength in - self.refreshJumpBackwardOverlayView(with: newJumpBackwardLength) - }.store(in: &viewModelListeners) - - viewModel.$jumpForwardLength.sink { newJumpForwardLength in - self.refreshJumpForwardOverlayView(with: newJumpForwardLength) - }.store(in: &viewModelListeners) - } - - func setMediaPlayerTimeAtCurrentSlider() { - // Necessary math as VLCMediaPlayer doesn't work well - // by just setting the position - let videoPosition = Double(vlcMediaPlayer.time.intValue / 1000) - let videoDuration = Double(viewModel.item.runTimeTicks! / 10_000_000) - let secondsScrubbedTo = round(viewModel.sliderPercentage * videoDuration) - let newPositionOffset = secondsScrubbedTo - videoPosition - - if newPositionOffset > 0 { - vlcMediaPlayer.jumpForward(Int32(newPositionOffset)) - } else { - vlcMediaPlayer.jumpBackward(Int32(abs(newPositionOffset))) - } - } +extension VLCPlayerViewController { + + /// Main function that handles setting up the media player with the current VideoPlayerViewModel + /// and also takes the role of setting the 'viewModel' property with the given viewModel + /// + /// Use case for this is setting new media within the same VLCPlayerViewController + func setupMediaPlayer(newViewModel: VideoPlayerViewModel) { + + // remove old player + + if vlcMediaPlayer.media != nil { + viewModelListeners.forEach { $0.cancel() } + + vlcMediaPlayer.stop() + viewModel.sendStopReport() + viewModel.playerOverlayDelegate = nil + } + + vlcMediaPlayer = VLCMediaPlayer() + + // setup with new player and view model + + vlcMediaPlayer = VLCMediaPlayer() + + vlcMediaPlayer.delegate = self + vlcMediaPlayer.drawable = videoContentView + + // TODO: Custom subtitle sizes + vlcMediaPlayer.perform(Selector(("setTextRendererFontSize:")), with: 14) + + stopOverlayDismissTimer() + + lastPlayerTicks = newViewModel.item.userData?.playbackPositionTicks ?? 0 + lastProgressReportTicks = newViewModel.item.userData?.playbackPositionTicks ?? 0 + + let media = VLCMedia(url: newViewModel.streamURL) + media.addOption("--prefetch-buffer-size=1048576") + media.addOption("--network-caching=5000") + + vlcMediaPlayer.media = media + + setupOverlayHostingController(viewModel: newViewModel) + setupViewModelListeners(viewModel: newViewModel) + + newViewModel.getAdjacentEpisodes() + newViewModel.playerOverlayDelegate = self + + let startPercentage = newViewModel.item.userData?.playedPercentage ?? 0 + + if startPercentage > 0 { + if viewModel.resumeOffset { + let videoDurationSeconds = Double(viewModel.item.runTimeTicks! / 10_000_000) + var startSeconds = round((startPercentage / 100) * videoDurationSeconds) + startSeconds = startSeconds.subtract(5, floor: 0) + let newStartPercentage = startSeconds / videoDurationSeconds + newViewModel.sliderPercentage = newStartPercentage + } else { + newViewModel.sliderPercentage = startPercentage / 100 + } + } + + viewModel = newViewModel + } + + // MARK: startPlayback + + func startPlayback() { + vlcMediaPlayer.play() + + // Setup external subtitles + for externalSubtitle in viewModel.subtitleStreams.filter({ $0.deliveryMethod == .external }) { + if let deliveryURL = externalSubtitle.externalURL(base: SessionManager.main.currentLogin.server.currentURI) { + vlcMediaPlayer.addPlaybackSlave(deliveryURL, type: .subtitle, enforce: false) + } + } + + setMediaPlayerTimeAtCurrentSlider() + + viewModel.sendPlayReport() + + restartOverlayDismissTimer() + } + + // MARK: setupViewModelListeners + + private func setupViewModelListeners(viewModel: VideoPlayerViewModel) { + + viewModel.$playbackSpeed.sink { newSpeed in + self.vlcMediaPlayer.rate = Float(newSpeed.rawValue) + }.store(in: &viewModelListeners) + + viewModel.$sliderIsScrubbing.sink { sliderIsScrubbing in + if sliderIsScrubbing { + self.didBeginScrubbing() + } else { + self.didEndScrubbing() + } + }.store(in: &viewModelListeners) + + viewModel.$selectedAudioStreamIndex.sink { newAudioStreamIndex in + self.didSelectAudioStream(index: newAudioStreamIndex) + }.store(in: &viewModelListeners) + + viewModel.$selectedSubtitleStreamIndex.sink { newSubtitleStreamIndex in + self.didSelectSubtitleStream(index: newSubtitleStreamIndex) + }.store(in: &viewModelListeners) + + viewModel.$subtitlesEnabled.sink { newSubtitlesEnabled in + self.didToggleSubtitles(newValue: newSubtitlesEnabled) + }.store(in: &viewModelListeners) + + viewModel.$jumpBackwardLength.sink { newJumpBackwardLength in + self.refreshJumpBackwardOverlayView(with: newJumpBackwardLength) + }.store(in: &viewModelListeners) + + viewModel.$jumpForwardLength.sink { newJumpForwardLength in + self.refreshJumpForwardOverlayView(with: newJumpForwardLength) + }.store(in: &viewModelListeners) + } + + func setMediaPlayerTimeAtCurrentSlider() { + // Necessary math as VLCMediaPlayer doesn't work well + // by just setting the position + let videoPosition = Double(vlcMediaPlayer.time.intValue / 1000) + let videoDuration = Double(viewModel.item.runTimeTicks! / 10_000_000) + let secondsScrubbedTo = round(viewModel.sliderPercentage * videoDuration) + let newPositionOffset = secondsScrubbedTo - videoPosition + + if newPositionOffset > 0 { + vlcMediaPlayer.jumpForward(Int32(newPositionOffset)) + } else { + vlcMediaPlayer.jumpBackward(Int32(abs(newPositionOffset))) + } + } } // MARK: Show/Hide Overlay + extension VLCPlayerViewController { - - private func showOverlay() { - guard let overlayHostingController = currentOverlayHostingController else { return } - - guard overlayHostingController.view.alpha != 1 else { return } - - UIView.animate(withDuration: 0.2) { - overlayHostingController.view.alpha = 1 - } - } - - private func hideOverlay() { - guard let overlayHostingController = currentOverlayHostingController else { return } - - guard overlayHostingController.view.alpha != 0 else { return } - - UIView.animate(withDuration: 0.2) { - overlayHostingController.view.alpha = 0 - } - } - - private func toggleOverlay() { - guard let overlayHostingController = currentOverlayHostingController else { return } - - if overlayHostingController.view.alpha < 1 { - showOverlay() - } else { - hideOverlay() - } - } + + private func showOverlay() { + guard let overlayHostingController = currentOverlayHostingController else { return } + + guard overlayHostingController.view.alpha != 1 else { return } + + UIView.animate(withDuration: 0.2) { + overlayHostingController.view.alpha = 1 + } + } + + private func hideOverlay() { + guard let overlayHostingController = currentOverlayHostingController else { return } + + guard overlayHostingController.view.alpha != 0 else { return } + + UIView.animate(withDuration: 0.2) { + overlayHostingController.view.alpha = 0 + } + } + + private func toggleOverlay() { + guard let overlayHostingController = currentOverlayHostingController else { return } + + if overlayHostingController.view.alpha < 1 { + showOverlay() + } else { + hideOverlay() + } + } } // MARK: Show/Hide Jump + extension VLCPlayerViewController { - - private func flashJumpBackwardOverlay() { - guard !displayingOverlay, let currentJumpBackwardOverlayView = currentJumpBackwardOverlayView else { return } - - currentJumpBackwardOverlayView.layer.removeAllAnimations() - - UIView.animate(withDuration: 0.1) { - currentJumpBackwardOverlayView.alpha = 1 - } completion: { _ in - self.hideJumpBackwardOverlay() - } - } - - private func hideJumpBackwardOverlay() { - guard let currentJumpBackwardOverlayView = currentJumpBackwardOverlayView else { return } - - UIView.animate(withDuration: 0.3) { - currentJumpBackwardOverlayView.alpha = 0 - } - } - - private func flashJumpFowardOverlay() { - guard !displayingOverlay, let currentJumpForwardOverlayView = currentJumpForwardOverlayView else { return } - - currentJumpForwardOverlayView.layer.removeAllAnimations() - - UIView.animate(withDuration: 0.1) { - currentJumpForwardOverlayView.alpha = 1 - } completion: { _ in - self.hideJumpForwardOverlay() - } - } - - private func hideJumpForwardOverlay() { - guard let currentJumpForwardOverlayView = currentJumpForwardOverlayView else { return } - - UIView.animate(withDuration: 0.3) { - currentJumpForwardOverlayView.alpha = 0 - } - } + + private func flashJumpBackwardOverlay() { + guard !displayingOverlay, let currentJumpBackwardOverlayView = currentJumpBackwardOverlayView else { return } + + currentJumpBackwardOverlayView.layer.removeAllAnimations() + + UIView.animate(withDuration: 0.1) { + currentJumpBackwardOverlayView.alpha = 1 + } completion: { _ in + self.hideJumpBackwardOverlay() + } + } + + private func hideJumpBackwardOverlay() { + guard let currentJumpBackwardOverlayView = currentJumpBackwardOverlayView else { return } + + UIView.animate(withDuration: 0.3) { + currentJumpBackwardOverlayView.alpha = 0 + } + } + + private func flashJumpFowardOverlay() { + guard !displayingOverlay, let currentJumpForwardOverlayView = currentJumpForwardOverlayView else { return } + + currentJumpForwardOverlayView.layer.removeAllAnimations() + + UIView.animate(withDuration: 0.1) { + currentJumpForwardOverlayView.alpha = 1 + } completion: { _ in + self.hideJumpForwardOverlay() + } + } + + private func hideJumpForwardOverlay() { + guard let currentJumpForwardOverlayView = currentJumpForwardOverlayView else { return } + + UIView.animate(withDuration: 0.3) { + currentJumpForwardOverlayView.alpha = 0 + } + } } // MARK: OverlayTimer + extension VLCPlayerViewController { - - private func restartOverlayDismissTimer(interval: Double = 3) { - self.overlayDismissTimer?.invalidate() - self.overlayDismissTimer = Timer.scheduledTimer(timeInterval: interval, target: self, selector: #selector(dismissTimerFired), userInfo: nil, repeats: false) - } - - @objc private func dismissTimerFired() { - self.hideOverlay() - } - - private func stopOverlayDismissTimer() { - self.overlayDismissTimer?.invalidate() - } + + private func restartOverlayDismissTimer(interval: Double = 3) { + self.overlayDismissTimer?.invalidate() + self.overlayDismissTimer = Timer.scheduledTimer(timeInterval: interval, target: self, selector: #selector(dismissTimerFired), + userInfo: nil, repeats: false) + } + + @objc + private func dismissTimerFired() { + self.hideOverlay() + } + + private func stopOverlayDismissTimer() { + self.overlayDismissTimer?.invalidate() + } } // MARK: VLCMediaPlayerDelegate + extension VLCPlayerViewController: VLCMediaPlayerDelegate { - - - // MARK: mediaPlayerStateChanged - func mediaPlayerStateChanged(_ aNotification: Notification!) { - - // Don't show buffering if paused, usually here while scrubbing - if vlcMediaPlayer.state == .buffering && viewModel.playerState == .paused { - return - } - - viewModel.playerState = vlcMediaPlayer.state - - if vlcMediaPlayer.state == VLCMediaPlayerState.ended { - if viewModel.autoplayEnabled && viewModel.nextItemVideoPlayerViewModel != nil { - didSelectPlayNextItem() - } else { - didSelectClose() - } - } - } - - // MARK: mediaPlayerTimeChanged - func mediaPlayerTimeChanged(_ aNotification: Notification!) { - - if !viewModel.sliderIsScrubbing { - viewModel.sliderPercentage = Double(vlcMediaPlayer.position) - } - - // Have to manually set playing because VLCMediaPlayer doesn't - // properly set it itself - if abs(currentPlayerTicks - lastPlayerTicks) >= 10_000 { - viewModel.playerState = VLCMediaPlayerState.playing - } - - // If needing to fix subtitle streams during playback - if vlcMediaPlayer.currentVideoSubTitleIndex != viewModel.selectedSubtitleStreamIndex && - viewModel.subtitlesEnabled { - didSelectSubtitleStream(index: viewModel.selectedSubtitleStreamIndex) - } - - // If needing to fix audio stream during playback - if vlcMediaPlayer.currentAudioTrackIndex != viewModel.selectedAudioStreamIndex { - didSelectAudioStream(index: viewModel.selectedAudioStreamIndex) - } - - lastPlayerTicks = currentPlayerTicks - - // Send progress report every 5 seconds - if abs(lastProgressReportTicks - currentPlayerTicks) >= 500_000_000 { - viewModel.sendProgressReport() - - lastProgressReportTicks = currentPlayerTicks - } - } + + // MARK: mediaPlayerStateChanged + + func mediaPlayerStateChanged(_ aNotification: Notification!) { + + // Don't show buffering if paused, usually here while scrubbing + if vlcMediaPlayer.state == .buffering && viewModel.playerState == .paused { + return + } + + viewModel.playerState = vlcMediaPlayer.state + + if vlcMediaPlayer.state == VLCMediaPlayerState.ended { + if viewModel.autoplayEnabled && viewModel.nextItemVideoPlayerViewModel != nil { + didSelectPlayNextItem() + } else { + didSelectClose() + } + } + } + + // MARK: mediaPlayerTimeChanged + + func mediaPlayerTimeChanged(_ aNotification: Notification!) { + + if !viewModel.sliderIsScrubbing { + viewModel.sliderPercentage = Double(vlcMediaPlayer.position) + } + + // Have to manually set playing because VLCMediaPlayer doesn't + // properly set it itself + if abs(currentPlayerTicks - lastPlayerTicks) >= 10000 { + viewModel.playerState = VLCMediaPlayerState.playing + } + + // If needing to fix subtitle streams during playback + if vlcMediaPlayer.currentVideoSubTitleIndex != viewModel.selectedSubtitleStreamIndex && + viewModel.subtitlesEnabled + { + didSelectSubtitleStream(index: viewModel.selectedSubtitleStreamIndex) + } + + // If needing to fix audio stream during playback + if vlcMediaPlayer.currentAudioTrackIndex != viewModel.selectedAudioStreamIndex { + didSelectAudioStream(index: viewModel.selectedAudioStreamIndex) + } + + lastPlayerTicks = currentPlayerTicks + + // Send progress report every 5 seconds + if abs(lastProgressReportTicks - currentPlayerTicks) >= 500_000_000 { + viewModel.sendProgressReport() + + lastProgressReportTicks = currentPlayerTicks + } + } } // MARK: PlayerOverlayDelegate and more + extension VLCPlayerViewController: PlayerOverlayDelegate { - - func didSelectAudioStream(index: Int) { - vlcMediaPlayer.currentAudioTrackIndex = Int32(index) - - viewModel.sendProgressReport() - - lastProgressReportTicks = currentPlayerTicks - } - - /// Do not call when setting to index -1 - func didSelectSubtitleStream(index: Int) { - viewModel.subtitlesEnabled = true - vlcMediaPlayer.currentVideoSubTitleIndex = Int32(index) - - viewModel.sendProgressReport() - - lastProgressReportTicks = currentPlayerTicks - } - - func didSelectClose() { - vlcMediaPlayer.stop() - - viewModel.sendStopReport() - - dismiss(animated: true, completion: nil) - } - - func didToggleSubtitles(newValue: Bool) { - if newValue { - vlcMediaPlayer.currentVideoSubTitleIndex = Int32(viewModel.selectedSubtitleStreamIndex) - } else { - vlcMediaPlayer.currentVideoSubTitleIndex = -1 - } - } - - // TODO: Implement properly in overlays - func didSelectMenu() { - stopOverlayDismissTimer() - } - - // TODO: Implement properly in overlays - func didDeselectMenu() { - restartOverlayDismissTimer() - } - - func didSelectBackward() { - - flashJumpBackwardOverlay() - - vlcMediaPlayer.jumpBackward(viewModel.jumpBackwardLength.rawValue) - - if displayingOverlay { - restartOverlayDismissTimer() - } - - viewModel.sendProgressReport() - - lastProgressReportTicks = currentPlayerTicks - } - - func didSelectForward() { - - flashJumpFowardOverlay() - - vlcMediaPlayer.jumpForward(viewModel.jumpForwardLength.rawValue) - - if displayingOverlay { - restartOverlayDismissTimer() - } - - viewModel.sendProgressReport() - - lastProgressReportTicks = currentPlayerTicks - } - - func didSelectMain() { - - switch viewModel.playerState { - case .buffering: - vlcMediaPlayer.play() - restartOverlayDismissTimer() - case .playing: - viewModel.sendPauseReport(paused: true) - vlcMediaPlayer.pause() - restartOverlayDismissTimer(interval: 5) - case .paused: - viewModel.sendPauseReport(paused: false) - vlcMediaPlayer.play() - restartOverlayDismissTimer() - default: () - } - } - - func didGenerallyTap() { - toggleOverlay() - - restartOverlayDismissTimer(interval: 5) - } - - func didBeginScrubbing() { - stopOverlayDismissTimer() - } - - func didEndScrubbing() { - setMediaPlayerTimeAtCurrentSlider() - - restartOverlayDismissTimer() - - viewModel.sendProgressReport() - - lastProgressReportTicks = currentPlayerTicks - } - - func didSelectPlayPreviousItem() { - if let previousItemVideoPlayerViewModel = viewModel.previousItemVideoPlayerViewModel { - setupMediaPlayer(newViewModel: previousItemVideoPlayerViewModel) - startPlayback() - } - } - - func didSelectPlayNextItem() { - if let nextItemVideoPlayerViewModel = viewModel.nextItemVideoPlayerViewModel { - setupMediaPlayer(newViewModel: nextItemVideoPlayerViewModel) - startPlayback() - } - } + + func didSelectAudioStream(index: Int) { + vlcMediaPlayer.currentAudioTrackIndex = Int32(index) + + viewModel.sendProgressReport() + + lastProgressReportTicks = currentPlayerTicks + } + + /// Do not call when setting to index -1 + func didSelectSubtitleStream(index: Int) { + viewModel.subtitlesEnabled = true + vlcMediaPlayer.currentVideoSubTitleIndex = Int32(index) + + viewModel.sendProgressReport() + + lastProgressReportTicks = currentPlayerTicks + } + + func didSelectClose() { + vlcMediaPlayer.stop() + + viewModel.sendStopReport() + + dismiss(animated: true, completion: nil) + } + + func didToggleSubtitles(newValue: Bool) { + if newValue { + vlcMediaPlayer.currentVideoSubTitleIndex = Int32(viewModel.selectedSubtitleStreamIndex) + } else { + vlcMediaPlayer.currentVideoSubTitleIndex = -1 + } + } + + // TODO: Implement properly in overlays + func didSelectMenu() { + stopOverlayDismissTimer() + } + + // TODO: Implement properly in overlays + func didDeselectMenu() { + restartOverlayDismissTimer() + } + + func didSelectBackward() { + + flashJumpBackwardOverlay() + + vlcMediaPlayer.jumpBackward(viewModel.jumpBackwardLength.rawValue) + + if displayingOverlay { + restartOverlayDismissTimer() + } + + viewModel.sendProgressReport() + + lastProgressReportTicks = currentPlayerTicks + } + + func didSelectForward() { + + flashJumpFowardOverlay() + + vlcMediaPlayer.jumpForward(viewModel.jumpForwardLength.rawValue) + + if displayingOverlay { + restartOverlayDismissTimer() + } + + viewModel.sendProgressReport() + + lastProgressReportTicks = currentPlayerTicks + } + + func didSelectMain() { + + switch viewModel.playerState { + case .buffering: + vlcMediaPlayer.play() + restartOverlayDismissTimer() + case .playing: + viewModel.sendPauseReport(paused: true) + vlcMediaPlayer.pause() + restartOverlayDismissTimer(interval: 5) + case .paused: + viewModel.sendPauseReport(paused: false) + vlcMediaPlayer.play() + restartOverlayDismissTimer() + default: () + } + } + + func didGenerallyTap() { + toggleOverlay() + + restartOverlayDismissTimer(interval: 5) + } + + func didBeginScrubbing() { + stopOverlayDismissTimer() + } + + func didEndScrubbing() { + setMediaPlayerTimeAtCurrentSlider() + + restartOverlayDismissTimer() + + viewModel.sendProgressReport() + + lastProgressReportTicks = currentPlayerTicks + } + + func didSelectPlayPreviousItem() { + if let previousItemVideoPlayerViewModel = viewModel.previousItemVideoPlayerViewModel { + setupMediaPlayer(newViewModel: previousItemVideoPlayerViewModel) + startPlayback() + } + } + + func didSelectPlayNextItem() { + if let nextItemVideoPlayerViewModel = viewModel.nextItemVideoPlayerViewModel { + setupMediaPlayer(newViewModel: nextItemVideoPlayerViewModel) + startPlayback() + } + } } diff --git a/WidgetExtension/JellyfinWidget.swift b/WidgetExtension/JellyfinWidget.swift index e9eaca38..e59c03c9 100644 --- a/WidgetExtension/JellyfinWidget.swift +++ b/WidgetExtension/JellyfinWidget.swift @@ -1,17 +1,18 @@ -/* SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import SwiftUI import WidgetKit @main struct JellyfinWidgetBundle: WidgetBundle { - @WidgetBundleBuilder - var body: some Widget { - NextUpWidget() - } + @WidgetBundleBuilder + var body: some Widget { + NextUpWidget() + } } diff --git a/WidgetExtension/NextUpWidget.swift b/WidgetExtension/NextUpWidget.swift index 93e0a88e..22d34316 100644 --- a/WidgetExtension/NextUpWidget.swift +++ b/WidgetExtension/NextUpWidget.swift @@ -1,9 +1,10 @@ -/* SwiftFin is subject to the terms of the Mozilla Public - * License, v2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright 2021 Aiden Vigue & Jellyfin Contributors - */ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// import Combine import JellyfinAPI @@ -12,458 +13,458 @@ import SwiftUI import WidgetKit enum WidgetError: String, Error { - case unknown - case emptyServer - case emptyUser - case emptyHeader + case unknown + case emptyServer + case emptyUser + case emptyHeader } struct NextUpWidgetProvider: TimelineProvider { - func placeholder(in context: Context) -> NextUpEntry { - NextUpEntry(date: Date(), items: [], error: nil) - } + func placeholder(in context: Context) -> NextUpEntry { + NextUpEntry(date: Date(), items: [], error: nil) + } - func getSnapshot(in context: Context, completion: @escaping (NextUpEntry) -> Void) { - guard let currentLogin = SessionManager.main.currentLogin else { return } + func getSnapshot(in context: Context, completion: @escaping (NextUpEntry) -> Void) { + guard let currentLogin = SessionManager.main.currentLogin else { return } - let currentDate = Date() - let server = currentLogin.server - let savedUser = currentLogin.user - var tempCancellables = Set() + let currentDate = Date() + let server = currentLogin.server + let savedUser = currentLogin.user + var tempCancellables = Set() - JellyfinAPI.basePath = server.currentURI - TvShowsAPI.getNextUp(userId: savedUser.id, limit: 3, - fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people], - imageTypeLimit: 1, enableImageTypes: [.primary, .backdrop, .thumb]) - .subscribe(on: DispatchQueue.global(qos: .background)) - .sink(receiveCompletion: { result in - switch result { - case .finished: - break - case let .failure(error): - completion(NextUpEntry(date: currentDate, items: [], error: error)) - } - }, receiveValue: { response in - let dispatchGroup = DispatchGroup() - let items = response.items ?? [] - var downloadedItems = [(BaseItemDto, UIImage?)]() - items.enumerated().forEach { _, item in - dispatchGroup.enter() - ImagePipeline.shared.loadImage(with: item.getBackdropImage(maxWidth: 320)) { result in - guard case let .success(image) = result else { - dispatchGroup.leave() - return - } - downloadedItems.append((item, image.image)) - dispatchGroup.leave() - } - } + JellyfinAPI.basePath = server.currentURI + TvShowsAPI.getNextUp(userId: savedUser.id, limit: 3, + fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people], + imageTypeLimit: 1, enableImageTypes: [.primary, .backdrop, .thumb]) + .subscribe(on: DispatchQueue.global(qos: .background)) + .sink(receiveCompletion: { result in + switch result { + case .finished: + break + case let .failure(error): + completion(NextUpEntry(date: currentDate, items: [], error: error)) + } + }, receiveValue: { response in + let dispatchGroup = DispatchGroup() + let items = response.items ?? [] + var downloadedItems = [(BaseItemDto, UIImage?)]() + items.enumerated().forEach { _, item in + dispatchGroup.enter() + ImagePipeline.shared.loadImage(with: item.getBackdropImage(maxWidth: 320)) { result in + guard case let .success(image) = result else { + dispatchGroup.leave() + return + } + downloadedItems.append((item, image.image)) + dispatchGroup.leave() + } + } - dispatchGroup.notify(queue: .main) { - completion(NextUpEntry(date: currentDate, items: downloadedItems, error: nil)) - } - }) - .store(in: &tempCancellables) - } + dispatchGroup.notify(queue: .main) { + completion(NextUpEntry(date: currentDate, items: downloadedItems, error: nil)) + } + }) + .store(in: &tempCancellables) + } - func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { - guard let currentLogin = SessionManager.main.currentLogin else { return } + func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { + guard let currentLogin = SessionManager.main.currentLogin else { return } - let currentDate = Date() - let entryDate = Calendar.current.date(byAdding: .hour, value: 1, to: currentDate)! - let server = currentLogin.server - let savedUser = currentLogin.user + let currentDate = Date() + let entryDate = Calendar.current.date(byAdding: .hour, value: 1, to: currentDate)! + let server = currentLogin.server + let savedUser = currentLogin.user - var tempCancellables = Set() + var tempCancellables = Set() - JellyfinAPI.basePath = server.currentURI - TvShowsAPI.getNextUp(userId: savedUser.id, limit: 3, - fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people], - imageTypeLimit: 1, enableImageTypes: [.primary, .backdrop, .thumb]) - .subscribe(on: DispatchQueue.global(qos: .background)) - .sink(receiveCompletion: { result in - switch result { - case .finished: - break - case let .failure(error): - completion(Timeline(entries: [NextUpEntry(date: currentDate, items: [], error: error)], policy: .after(entryDate))) - } - }, receiveValue: { response in - let dispatchGroup = DispatchGroup() - let items = response.items ?? [] - var downloadedItems = [(BaseItemDto, UIImage?)]() - items.enumerated().forEach { _, item in - dispatchGroup.enter() - ImagePipeline.shared.loadImage(with: item.getBackdropImage(maxWidth: 320)) { result in - guard case let .success(image) = result else { - dispatchGroup.leave() - return - } - downloadedItems.append((item, image.image)) - dispatchGroup.leave() - } - } + JellyfinAPI.basePath = server.currentURI + TvShowsAPI.getNextUp(userId: savedUser.id, limit: 3, + fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people], + imageTypeLimit: 1, enableImageTypes: [.primary, .backdrop, .thumb]) + .subscribe(on: DispatchQueue.global(qos: .background)) + .sink(receiveCompletion: { result in + switch result { + case .finished: + break + case let .failure(error): + completion(Timeline(entries: [NextUpEntry(date: currentDate, items: [], error: error)], policy: .after(entryDate))) + } + }, receiveValue: { response in + let dispatchGroup = DispatchGroup() + let items = response.items ?? [] + var downloadedItems = [(BaseItemDto, UIImage?)]() + items.enumerated().forEach { _, item in + dispatchGroup.enter() + ImagePipeline.shared.loadImage(with: item.getBackdropImage(maxWidth: 320)) { result in + guard case let .success(image) = result else { + dispatchGroup.leave() + return + } + downloadedItems.append((item, image.image)) + dispatchGroup.leave() + } + } - dispatchGroup.notify(queue: .main) { - completion(Timeline(entries: [NextUpEntry(date: currentDate, items: downloadedItems, error: nil)], - policy: .after(entryDate))) - } - }) - .store(in: &tempCancellables) - } + dispatchGroup.notify(queue: .main) { + completion(Timeline(entries: [NextUpEntry(date: currentDate, items: downloadedItems, error: nil)], + policy: .after(entryDate))) + } + }) + .store(in: &tempCancellables) + } } struct NextUpEntry: TimelineEntry { - let date: Date - let items: [(BaseItemDto, UIImage?)] - let error: Error? + let date: Date + let items: [(BaseItemDto, UIImage?)] + let error: Error? } struct NextUpEntryView: View { - var entry: NextUpWidgetProvider.Entry + var entry: NextUpWidgetProvider.Entry - @Environment(\.widgetFamily) - var family + @Environment(\.widgetFamily) + var family - @ViewBuilder - var body: some View { - Group { - if let error = entry.error { - HStack { - Image(systemName: "exclamationmark.octagon") - Text((error as? WidgetError)?.rawValue ?? "") - } - .background(Color.blue) - } else if entry.items.isEmpty { - L10n.emptyNextUp.text - .font(.body) - .bold() - .foregroundColor(.primary) - .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity) - } else { - switch family { - case .systemSmall: - small(item: entry.items.first) - case .systemMedium: - medium(items: entry.items) - case .systemLarge: - large(items: entry.items) - default: - EmptyView() - } - } - } - .background(Color(.secondarySystemBackground)) - } + @ViewBuilder + var body: some View { + Group { + if let error = entry.error { + HStack { + Image(systemName: "exclamationmark.octagon") + Text((error as? WidgetError)?.rawValue ?? "") + } + .background(Color.blue) + } else if entry.items.isEmpty { + L10n.emptyNextUp.text + .font(.body) + .bold() + .foregroundColor(.primary) + .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity) + } else { + switch family { + case .systemSmall: + small(item: entry.items.first) + case .systemMedium: + medium(items: entry.items) + case .systemLarge: + large(items: entry.items) + default: + EmptyView() + } + } + } + .background(Color(.secondarySystemBackground)) + } } extension NextUpEntryView { - var smallVideoPlaceholderView: some View { - VStack(alignment: .leading) { - Color(.systemGray) - .aspectRatio(.init(width: 1, height: 0.5625), contentMode: .fill) - .cornerRadius(8) - .shadow(radius: 8) - Color(.systemGray2) - .frame(width: 100, height: 10) - Color(.systemGray3) - .frame(width: 80, height: 10) - } - } + var smallVideoPlaceholderView: some View { + VStack(alignment: .leading) { + Color(.systemGray) + .aspectRatio(.init(width: 1, height: 0.5625), contentMode: .fill) + .cornerRadius(8) + .shadow(radius: 8) + Color(.systemGray2) + .frame(width: 100, height: 10) + Color(.systemGray3) + .frame(width: 80, height: 10) + } + } - var largeVideoPlaceholderView: some View { - HStack(spacing: 20) { - Color(.systemGray) - .aspectRatio(.init(width: 1, height: 0.5625), contentMode: .fill) - .cornerRadius(8) - .shadow(radius: 8) - VStack(alignment: .leading, spacing: 8) { - Color(.systemGray2) - .frame(width: 100, height: 10) - Color(.systemGray3) - .frame(width: 80, height: 10) - } - .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) - } - } + var largeVideoPlaceholderView: some View { + HStack(spacing: 20) { + Color(.systemGray) + .aspectRatio(.init(width: 1, height: 0.5625), contentMode: .fill) + .cornerRadius(8) + .shadow(radius: 8) + VStack(alignment: .leading, spacing: 8) { + Color(.systemGray2) + .frame(width: 100, height: 10) + Color(.systemGray3) + .frame(width: 80, height: 10) + } + .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) + } + } } extension NextUpEntryView { - var headerSymbol: some View { - Image("WidgetHeaderSymbol") - .resizable() - .frame(width: 12, height: 12) - .cornerRadius(4) - .shadow(radius: 8) - } + var headerSymbol: some View { + Image("WidgetHeaderSymbol") + .resizable() + .frame(width: 12, height: 12) + .cornerRadius(4) + .shadow(radius: 8) + } - func smallVideoView(item: (BaseItemDto, UIImage?)) -> some View { - let url = URL(string: "widget-extension://Users/\(SessionManager.main.currentLogin.user.id)/Items/\(item.0.id!)")! - return Link(destination: url, label: { - VStack(alignment: .leading) { - if let image = item.1 { - Image(uiImage: image) - .resizable() - .aspectRatio(.init(width: 1, height: 0.5625), contentMode: .fill) - .clipped() - .cornerRadius(8) - .shadow(radius: 8) - } - Text(item.0.seriesName ?? "") - .font(.caption) - .fontWeight(.semibold) - .foregroundColor(.primary) - .lineLimit(1) - Text("\(item.0.name ?? "") · \(L10n.seasonAndEpisode(String(item.0.parentIndexNumber ?? 0), String(item.0.indexNumber ?? 0)))") - .font(.caption) - .fontWeight(.semibold) - .foregroundColor(.secondary) - .lineLimit(1) - } - }) - } + func smallVideoView(item: (BaseItemDto, UIImage?)) -> some View { + let url = URL(string: "widget-extension://Users/\(SessionManager.main.currentLogin.user.id)/Items/\(item.0.id!)")! + return Link(destination: url, label: { + VStack(alignment: .leading) { + if let image = item.1 { + Image(uiImage: image) + .resizable() + .aspectRatio(.init(width: 1, height: 0.5625), contentMode: .fill) + .clipped() + .cornerRadius(8) + .shadow(radius: 8) + } + Text(item.0.seriesName ?? "") + .font(.caption) + .fontWeight(.semibold) + .foregroundColor(.primary) + .lineLimit(1) + Text("\(item.0.name ?? "") · \(L10n.seasonAndEpisode(String(item.0.parentIndexNumber ?? 0), String(item.0.indexNumber ?? 0)))") + .font(.caption) + .fontWeight(.semibold) + .foregroundColor(.secondary) + .lineLimit(1) + } + }) + } - func largeVideoView(item: (BaseItemDto, UIImage?)) -> some View { - let url = URL(string: "widget-extension://Users/\(SessionManager.main.currentLogin.user.id)/Items/\(item.0.id!)")! - return Link(destination: url, label: { - HStack(spacing: 20) { - if let image = item.1 { - Image(uiImage: image) - .resizable() - .aspectRatio(.init(width: 1, height: 0.5625), contentMode: .fill) - .clipped() - .cornerRadius(8) - .shadow(radius: 8) - } - VStack(alignment: .leading, spacing: 8) { - Text(item.0.seriesName ?? "") - .font(.caption) - .fontWeight(.semibold) - .foregroundColor(.primary) - .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) + func largeVideoView(item: (BaseItemDto, UIImage?)) -> some View { + let url = URL(string: "widget-extension://Users/\(SessionManager.main.currentLogin.user.id)/Items/\(item.0.id!)")! + return Link(destination: url, label: { + HStack(spacing: 20) { + if let image = item.1 { + Image(uiImage: image) + .resizable() + .aspectRatio(.init(width: 1, height: 0.5625), contentMode: .fill) + .clipped() + .cornerRadius(8) + .shadow(radius: 8) + } + VStack(alignment: .leading, spacing: 8) { + Text(item.0.seriesName ?? "") + .font(.caption) + .fontWeight(.semibold) + .foregroundColor(.primary) + .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) - Text("\(item.0.name ?? "") · \(L10n.seasonAndEpisode(String(item.0.parentIndexNumber ?? 0), String(item.0.indexNumber ?? 0)))") - .font(.caption) - .fontWeight(.semibold) - .foregroundColor(.secondary) - .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) - } - } - }) - } + Text("\(item.0.name ?? "") · \(L10n.seasonAndEpisode(String(item.0.parentIndexNumber ?? 0), String(item.0.indexNumber ?? 0)))") + .font(.caption) + .fontWeight(.semibold) + .foregroundColor(.secondary) + .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) + } + } + }) + } } extension NextUpEntryView { - func small(item: (BaseItemDto, UIImage?)?) -> some View { - VStack(alignment: .trailing) { - headerSymbol - if let item = item { - smallVideoView(item: item) - } else { - smallVideoPlaceholderView - } - } - .padding(12) - } + func small(item: (BaseItemDto, UIImage?)?) -> some View { + VStack(alignment: .trailing) { + headerSymbol + if let item = item { + smallVideoView(item: item) + } else { + smallVideoPlaceholderView + } + } + .padding(12) + } - func medium(items: [(BaseItemDto, UIImage?)]) -> some View { - VStack(alignment: .trailing) { - headerSymbol - HStack(spacing: 16) { - if let firstItem = items[safe: 0] { - smallVideoView(item: firstItem) - } else { - smallVideoPlaceholderView - } - if let secondItem = items[safe: 1] { - smallVideoView(item: secondItem) - } else { - smallVideoPlaceholderView - } - } - } - .padding(12) - } + func medium(items: [(BaseItemDto, UIImage?)]) -> some View { + VStack(alignment: .trailing) { + headerSymbol + HStack(spacing: 16) { + if let firstItem = items[safe: 0] { + smallVideoView(item: firstItem) + } else { + smallVideoPlaceholderView + } + if let secondItem = items[safe: 1] { + smallVideoView(item: secondItem) + } else { + smallVideoPlaceholderView + } + } + } + .padding(12) + } - func large(items: [(BaseItemDto, UIImage?)]) -> some View { - VStack(spacing: 0) { - if let firstItem = items[safe: 0] { - let url = URL(string: "widget-extension://Users/\(SessionManager.main.currentLogin.user.id)/Items/\(firstItem.0.id!)")! - Link(destination: url, - label: { - ZStack(alignment: .topTrailing) { - ZStack(alignment: .bottomLeading) { - if let image = firstItem.1 { - Image(uiImage: image) - .centerCropped() - .innerShadow(color: Color.black.opacity(0.5), radius: 0.5) - } - VStack(alignment: .leading, spacing: 8) { - Text(firstItem.0.seriesName ?? "") - .font(.caption) - .fontWeight(.semibold) - .foregroundColor(.white) - .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) - Text("\(firstItem.0.name ?? "") · \(L10n.seasonAndEpisode(String(firstItem.0.parentIndexNumber ?? 0), String(firstItem.0.indexNumber ?? 0)))") - .font(.caption) - .fontWeight(.semibold) - .foregroundColor(.gray) - .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) - } - .shadow(radius: 8) - .padding(12) - } - headerSymbol - .padding(12) - } - .clipped() - .shadow(radius: 8) - }) - } - VStack(spacing: 8) { - if let secondItem = items[safe: 1] { - largeVideoView(item: secondItem) - } else { - largeVideoPlaceholderView - } - Divider() - if let thirdItem = items[safe: 2] { - largeVideoView(item: thirdItem) - } else { - largeVideoPlaceholderView - } - } - .padding(12) - } - } + func large(items: [(BaseItemDto, UIImage?)]) -> some View { + VStack(spacing: 0) { + if let firstItem = items[safe: 0] { + let url = URL(string: "widget-extension://Users/\(SessionManager.main.currentLogin.user.id)/Items/\(firstItem.0.id!)")! + Link(destination: url, + label: { + ZStack(alignment: .topTrailing) { + ZStack(alignment: .bottomLeading) { + if let image = firstItem.1 { + Image(uiImage: image) + .centerCropped() + .innerShadow(color: Color.black.opacity(0.5), radius: 0.5) + } + VStack(alignment: .leading, spacing: 8) { + Text(firstItem.0.seriesName ?? "") + .font(.caption) + .fontWeight(.semibold) + .foregroundColor(.white) + .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) + Text("\(firstItem.0.name ?? "") · \(L10n.seasonAndEpisode(String(firstItem.0.parentIndexNumber ?? 0), String(firstItem.0.indexNumber ?? 0)))") + .font(.caption) + .fontWeight(.semibold) + .foregroundColor(.gray) + .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) + } + .shadow(radius: 8) + .padding(12) + } + headerSymbol + .padding(12) + } + .clipped() + .shadow(radius: 8) + }) + } + VStack(spacing: 8) { + if let secondItem = items[safe: 1] { + largeVideoView(item: secondItem) + } else { + largeVideoPlaceholderView + } + Divider() + if let thirdItem = items[safe: 2] { + largeVideoView(item: thirdItem) + } else { + largeVideoPlaceholderView + } + } + .padding(12) + } + } } struct NextUpWidget: Widget { - let kind: String = "NextUpWidget" + let kind: String = "NextUpWidget" - var body: some WidgetConfiguration { - StaticConfiguration(kind: kind, - provider: NextUpWidgetProvider()) { entry in - NextUpEntryView(entry: entry) - } - .configurationDisplayName(L10n.nextUp) - .description("Keep watching where you left off or see what's up next.") - .supportedFamilies([.systemSmall, .systemMedium, .systemLarge]) - } + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, + provider: NextUpWidgetProvider()) { entry in + NextUpEntryView(entry: entry) + } + .configurationDisplayName(L10n.nextUp) + .description("Keep watching where you left off or see what's up next.") + .supportedFamilies([.systemSmall, .systemMedium, .systemLarge]) + } } struct NextUpWidget_Previews: PreviewProvider { - static var previews: some View { - Group { - NextUpEntryView(entry: .init(date: Date(), - items: [(.init(name: "Name0", indexNumber: 10, parentIndexNumber: 0, seriesName: "Series0"), - UIImage(named: "WidgetHeaderSymbol"))], - error: nil)) - .previewContext(WidgetPreviewContext(family: .systemSmall)) - NextUpEntryView(entry: .init(date: Date(), - items: [ - (.init(name: "Name0", indexNumber: 10, parentIndexNumber: 0, seriesName: "Series0"), - UIImage(named: "WidgetHeaderSymbol")), - (.init(name: "Name1", indexNumber: 10, parentIndexNumber: 0, seriesName: "Series1"), - UIImage(named: "WidgetHeaderSymbol")) - ], - error: nil)) - .previewContext(WidgetPreviewContext(family: .systemMedium)) - NextUpEntryView(entry: .init(date: Date(), - items: [ - (.init(name: "Name0", indexNumber: 10, parentIndexNumber: 0, seriesName: "Series0"), - UIImage(named: "WidgetHeaderSymbol")), - (.init(name: "Name1", indexNumber: 10, parentIndexNumber: 0, seriesName: "Series1"), - UIImage(named: "WidgetHeaderSymbol")), - (.init(name: "Name2", indexNumber: 10, parentIndexNumber: 0, seriesName: "Series2"), - UIImage(named: "WidgetHeaderSymbol")) - ], - error: nil)) - .previewContext(WidgetPreviewContext(family: .systemLarge)) - NextUpEntryView(entry: .init(date: Date(), - items: [(.init(name: "Name0", indexNumber: 10, parentIndexNumber: 0, seriesName: "Series0"), - UIImage(named: "WidgetHeaderSymbol"))], - error: nil)) - .previewContext(WidgetPreviewContext(family: .systemSmall)) - .preferredColorScheme(.dark) - NextUpEntryView(entry: .init(date: Date(), - items: [ - (.init(name: "Name0", indexNumber: 10, parentIndexNumber: 0, seriesName: "Series0"), - UIImage(named: "WidgetHeaderSymbol")), - (.init(name: "Name1", indexNumber: 10, parentIndexNumber: 0, seriesName: "Series1"), - UIImage(named: "WidgetHeaderSymbol")) - ], - error: nil)) - .previewContext(WidgetPreviewContext(family: .systemMedium)) - .preferredColorScheme(.dark) - NextUpEntryView(entry: .init(date: Date(), - items: [ - (.init(name: "Name0", indexNumber: 10, parentIndexNumber: 0, seriesName: "Series0"), - UIImage(named: "WidgetHeaderSymbol")), - (.init(name: "Name1", indexNumber: 10, parentIndexNumber: 0, seriesName: "Series1"), - UIImage(named: "WidgetHeaderSymbol")), - (.init(name: "Name2", indexNumber: 10, parentIndexNumber: 0, seriesName: "Series2"), - UIImage(named: "WidgetHeaderSymbol")) - ], - error: nil)) - .previewContext(WidgetPreviewContext(family: .systemLarge)) - .preferredColorScheme(.dark) - NextUpEntryView(entry: .init(date: Date(), - items: [], - error: nil)) - .previewContext(WidgetPreviewContext(family: .systemSmall)) - .preferredColorScheme(.dark) - NextUpEntryView(entry: .init(date: Date(), - items: [ - (.init(name: "Name0", indexNumber: 10, parentIndexNumber: 0, seriesName: "Series0"), - UIImage(named: "WidgetHeaderSymbol")) - ], - error: nil)) - .previewContext(WidgetPreviewContext(family: .systemMedium)) - .preferredColorScheme(.dark) - NextUpEntryView(entry: .init(date: Date(), - items: [ - (.init(name: "Name0", indexNumber: 10, parentIndexNumber: 0, seriesName: "Series0"), - UIImage(named: "WidgetHeaderSymbol")), - (.init(name: "Name1", indexNumber: 10, parentIndexNumber: 0, seriesName: "Series1"), - UIImage(named: "WidgetHeaderSymbol")) - ], - error: nil)) - .previewContext(WidgetPreviewContext(family: .systemLarge)) - .preferredColorScheme(.dark) - } - } + static var previews: some View { + Group { + NextUpEntryView(entry: .init(date: Date(), + items: [(.init(name: "Name0", indexNumber: 10, parentIndexNumber: 0, seriesName: "Series0"), + UIImage(named: "WidgetHeaderSymbol"))], + error: nil)) + .previewContext(WidgetPreviewContext(family: .systemSmall)) + NextUpEntryView(entry: .init(date: Date(), + items: [ + (.init(name: "Name0", indexNumber: 10, parentIndexNumber: 0, seriesName: "Series0"), + UIImage(named: "WidgetHeaderSymbol")), + (.init(name: "Name1", indexNumber: 10, parentIndexNumber: 0, seriesName: "Series1"), + UIImage(named: "WidgetHeaderSymbol")), + ], + error: nil)) + .previewContext(WidgetPreviewContext(family: .systemMedium)) + NextUpEntryView(entry: .init(date: Date(), + items: [ + (.init(name: "Name0", indexNumber: 10, parentIndexNumber: 0, seriesName: "Series0"), + UIImage(named: "WidgetHeaderSymbol")), + (.init(name: "Name1", indexNumber: 10, parentIndexNumber: 0, seriesName: "Series1"), + UIImage(named: "WidgetHeaderSymbol")), + (.init(name: "Name2", indexNumber: 10, parentIndexNumber: 0, seriesName: "Series2"), + UIImage(named: "WidgetHeaderSymbol")), + ], + error: nil)) + .previewContext(WidgetPreviewContext(family: .systemLarge)) + NextUpEntryView(entry: .init(date: Date(), + items: [(.init(name: "Name0", indexNumber: 10, parentIndexNumber: 0, seriesName: "Series0"), + UIImage(named: "WidgetHeaderSymbol"))], + error: nil)) + .previewContext(WidgetPreviewContext(family: .systemSmall)) + .preferredColorScheme(.dark) + NextUpEntryView(entry: .init(date: Date(), + items: [ + (.init(name: "Name0", indexNumber: 10, parentIndexNumber: 0, seriesName: "Series0"), + UIImage(named: "WidgetHeaderSymbol")), + (.init(name: "Name1", indexNumber: 10, parentIndexNumber: 0, seriesName: "Series1"), + UIImage(named: "WidgetHeaderSymbol")), + ], + error: nil)) + .previewContext(WidgetPreviewContext(family: .systemMedium)) + .preferredColorScheme(.dark) + NextUpEntryView(entry: .init(date: Date(), + items: [ + (.init(name: "Name0", indexNumber: 10, parentIndexNumber: 0, seriesName: "Series0"), + UIImage(named: "WidgetHeaderSymbol")), + (.init(name: "Name1", indexNumber: 10, parentIndexNumber: 0, seriesName: "Series1"), + UIImage(named: "WidgetHeaderSymbol")), + (.init(name: "Name2", indexNumber: 10, parentIndexNumber: 0, seriesName: "Series2"), + UIImage(named: "WidgetHeaderSymbol")), + ], + error: nil)) + .previewContext(WidgetPreviewContext(family: .systemLarge)) + .preferredColorScheme(.dark) + NextUpEntryView(entry: .init(date: Date(), + items: [], + error: nil)) + .previewContext(WidgetPreviewContext(family: .systemSmall)) + .preferredColorScheme(.dark) + NextUpEntryView(entry: .init(date: Date(), + items: [ + (.init(name: "Name0", indexNumber: 10, parentIndexNumber: 0, seriesName: "Series0"), + UIImage(named: "WidgetHeaderSymbol")), + ], + error: nil)) + .previewContext(WidgetPreviewContext(family: .systemMedium)) + .preferredColorScheme(.dark) + NextUpEntryView(entry: .init(date: Date(), + items: [ + (.init(name: "Name0", indexNumber: 10, parentIndexNumber: 0, seriesName: "Series0"), + UIImage(named: "WidgetHeaderSymbol")), + (.init(name: "Name1", indexNumber: 10, parentIndexNumber: 0, seriesName: "Series1"), + UIImage(named: "WidgetHeaderSymbol")), + ], + error: nil)) + .previewContext(WidgetPreviewContext(family: .systemLarge)) + .preferredColorScheme(.dark) + } + } } import SwiftUI private extension View { - func innerShadow(color: Color, radius: CGFloat = 0.1) -> some View { - modifier(InnerShadow(color: color, radius: min(max(0, radius), 1))) - } + func innerShadow(color: Color, radius: CGFloat = 0.1) -> some View { + modifier(InnerShadow(color: color, radius: min(max(0, radius), 1))) + } } private struct InnerShadow: ViewModifier { - var color: Color = .gray - var radius: CGFloat = 0.1 + var color: Color = .gray + var radius: CGFloat = 0.1 - private var colors: [Color] { - [color.opacity(0.75), color.opacity(0.0), .clear] - } + private var colors: [Color] { + [color.opacity(0.75), color.opacity(0.0), .clear] + } - func body(content: Content) -> some View { - GeometryReader { geo in - content - .overlay(LinearGradient(gradient: Gradient(colors: self.colors), startPoint: .top, endPoint: .bottom) - .frame(height: self.radius * self.minSide(geo)), - alignment: .top) - .overlay(LinearGradient(gradient: Gradient(colors: self.colors), startPoint: .bottom, endPoint: .top) - .frame(height: self.radius * self.minSide(geo)), - alignment: .bottom) - } - } + func body(content: Content) -> some View { + GeometryReader { geo in + content + .overlay(LinearGradient(gradient: Gradient(colors: self.colors), startPoint: .top, endPoint: .bottom) + .frame(height: self.radius * self.minSide(geo)), + alignment: .top) + .overlay(LinearGradient(gradient: Gradient(colors: self.colors), startPoint: .bottom, endPoint: .top) + .frame(height: self.radius * self.minSide(geo)), + alignment: .bottom) + } + } - func minSide(_ geo: GeometryProxy) -> CGFloat { - CGFloat(3) * min(geo.size.width, geo.size.height) / 2 - } + func minSide(_ geo: GeometryProxy) -> CGFloat { + CGFloat(3) * min(geo.size.width, geo.size.height) / 2 + } }