diff --git a/Shared/Services/SwiftfinDefaults.swift b/Shared/Services/SwiftfinDefaults.swift index a0e3822b3..e03691fdb 100644 --- a/Shared/Services/SwiftfinDefaults.swift +++ b/Shared/Services/SwiftfinDefaults.swift @@ -115,6 +115,7 @@ extension Defaults.Keys { static let itemViewType: Key = UserKey("itemViewType", default: .compactLogo) static let showPosterLabels: Key = UserKey("showPosterLabels", default: true) + static let resumePosterType: Key = UserKey("resumePosterType", default: .landscape) static let nextUpPosterType: Key = UserKey("nextUpPosterType", default: .portrait) static let recentlyAddedPosterType: Key = UserKey("recentlyAddedPosterType", default: .portrait) static let latestInLibraryPosterType: Key = UserKey("latestInLibraryPosterType", default: .portrait) diff --git a/Shared/Strings/Strings.swift b/Shared/Strings/Strings.swift index 8face6c3e..7eeefa8e4 100644 --- a/Shared/Strings/Strings.swift +++ b/Shared/Strings/Strings.swift @@ -834,6 +834,8 @@ internal enum L10n { } /// No title internal static let noTitle = L10n.tr("Localizable", "noTitle", fallback: "No title") + /// This Jellyfin server does not have any libraries that are active in Swiftfin. Swiftfin only supports Shows & Movies at this time. + internal static let noValidLibrariesError = L10n.tr("Localizable", "noValidLibrariesError", fallback: "This Jellyfin server does not have any libraries that are active in Swiftfin. Swiftfin only supports Shows & Movies at this time.") /// Official Rating internal static let officialRating = L10n.tr("Localizable", "officialRating", fallback: "Official Rating") /// Offset @@ -972,6 +974,8 @@ internal enum L10n { internal static let red = L10n.tr("Localizable", "red", fallback: "Red") /// The number of reference frames is not supported internal static let refFramesNotSupported = L10n.tr("Localizable", "refFramesNotSupported", fallback: "The number of reference frames is not supported") + /// Refresh + internal static let refresh = L10n.tr("Localizable", "refresh", fallback: "Refresh") /// Refresh Metadata internal static let refreshMetadata = L10n.tr("Localizable", "refreshMetadata", fallback: "Refresh Metadata") /// Regional diff --git a/Shared/ViewModels/HomeViewModel.swift b/Shared/ViewModels/HomeViewModel.swift index 008c0b75f..e4a62f1ac 100644 --- a/Shared/ViewModels/HomeViewModel.swift +++ b/Shared/ViewModels/HomeViewModel.swift @@ -24,7 +24,7 @@ final class HomeViewModel: ViewModel, Stateful { case refresh } - // MARK: BackgroundState + // MARK: Background State enum BackgroundState: Hashable { case refresh @@ -39,11 +39,15 @@ final class HomeViewModel: ViewModel, Stateful { case refreshing } + // MARK: - Published Variables + @Published private(set) var libraries: [LatestInLibraryViewModel] = [] @Published var resumeItems: OrderedSet = [] + // MARK: - Stateful Variables + @Published var backgroundStates: OrderedSet = [] @Published @@ -51,17 +55,23 @@ final class HomeViewModel: ViewModel, Stateful { @Published var state: State = .initial + private var backgroundRefreshTask: AnyCancellable? + private var refreshTask: AnyCancellable? + + // MARK: - Notification Handler + // TODO: replace with views checking what notifications were // posted since last disappear @Published var notificationsReceived: NotificationSet = .init() - private var backgroundRefreshTask: AnyCancellable? - private var refreshTask: AnyCancellable? + // MARK: - Child View Models var nextUpViewModel: NextUpLibraryViewModel = .init() var recentlyAddedViewModel: RecentlyAddedLibraryViewModel = .init() + // MARK: - Initializer + override init() { super.init() @@ -78,6 +88,8 @@ final class HomeViewModel: ViewModel, Stateful { .store(in: &cancellables) } + // MARK: - Respond to Action + func respond(to action: Action) -> State { switch action { case .backgroundRefresh: @@ -114,9 +126,11 @@ final class HomeViewModel: ViewModel, Stateful { .asAnyCancellable() return state + case let .error(error): return .error(error) - case let .setIsPlayed(isPlayed, item): () + + case let .setIsPlayed(isPlayed, item): Task { try await setIsPlayed(isPlayed, for: item) @@ -125,6 +139,7 @@ final class HomeViewModel: ViewModel, Stateful { .store(in: &cancellables) return state + case .refresh: backgroundRefreshTask?.cancel() refreshTask?.cancel() @@ -152,10 +167,12 @@ final class HomeViewModel: ViewModel, Stateful { } .asAnyCancellable() - return .refreshing + return state } } + // MARK: - Refresh + private func refresh() async throws { await nextUpViewModel.send(.refresh) @@ -174,6 +191,8 @@ final class HomeViewModel: ViewModel, Stateful { } } + // MARK: - Get Resume Items + private func getResumeItems() async throws -> [BaseItemDto] { var parameters = Paths.GetResumeItemsParameters() parameters.enableUserData = true @@ -187,6 +206,8 @@ final class HomeViewModel: ViewModel, Stateful { return response.value.items ?? [] } + // MARK: - Get Libraries + private func getLibraries() async throws -> [LatestInLibraryViewModel] { let userViewsPath = Paths.getUserViews(userID: userSession.user.id) @@ -200,6 +221,8 @@ final class HomeViewModel: ViewModel, Stateful { .map { LatestInLibraryViewModel(parent: $0) } } + // MARK: - Get Excluded Libraries + // TODO: use the more updated server/user data when implemented private func getExcludedLibraries() async throws -> [String] { let currentUserPath = Paths.getCurrentUser @@ -208,6 +231,8 @@ final class HomeViewModel: ViewModel, Stateful { return response.value.configuration?.latestItemsExcludes ?? [] } + // MARK: - Toggle Played Status + private func setIsPlayed(_ isPlayed: Bool, for item: BaseItemDto) async throws { let request: Request diff --git a/Swiftfin tvOS/Components/CinematicItemSelector.swift b/Swiftfin tvOS/Components/CinematicItemSelector.swift index 31c1bb222..5e1225dd1 100644 --- a/Swiftfin tvOS/Components/CinematicItemSelector.swift +++ b/Swiftfin tvOS/Components/CinematicItemSelector.swift @@ -29,6 +29,7 @@ struct CinematicItemSelector: View { private var onSelect: (Item) -> Void let items: [Item] + let type: PosterDisplayType var body: some View { ZStack(alignment: .bottomLeading) { @@ -46,7 +47,7 @@ struct CinematicItemSelector: View { .transition(.opacity) } - PosterHStack(type: .landscape, items: items) + PosterHStack(type: type, items: items) .content(itemContent) .imageOverlay(itemImageOverlay) .contextMenu(itemContextMenu) @@ -98,7 +99,7 @@ struct CinematicItemSelector: View { extension CinematicItemSelector { - init(items: [Item]) { + init(items: [Item], type: PosterDisplayType) { self.init( topContent: { _ in EmptyView() }, itemContent: { _ in EmptyView() }, @@ -106,7 +107,8 @@ extension CinematicItemSelector { itemContextMenu: { _ in EmptyView() }, trailingContent: { EmptyView() }, onSelect: { _ in }, - items: items + items: items, + type: type ) } } diff --git a/Swiftfin tvOS/Components/ErrorView.swift b/Swiftfin tvOS/Components/ErrorView.swift new file mode 100644 index 000000000..fec8343a2 --- /dev/null +++ b/Swiftfin tvOS/Components/ErrorView.swift @@ -0,0 +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 (c) 2025 Jellyfin & Jellyfin Contributors +// + +import SwiftUI + +// TODO: should use environment refresh instead? +struct ErrorView: View { + + private let error: ErrorType + private var onRetry: (() -> Void)? + + var body: some View { + VStack(spacing: 30) { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 100)) + .foregroundColor(Color.red) + + Text(error.localizedDescription) + .frame(maxWidth: 500) + .multilineTextAlignment(.center) + + if let onRetry { + PrimaryButton(title: L10n.retry) + .onSelect(onRetry) + } + } + } +} + +extension ErrorView { + + init(error: ErrorType) { + self.init( + error: error, + onRetry: nil + ) + } + + func onRetry(_ action: @escaping () -> Void) -> Self { + copy(modifying: \.onRetry, with: action) + } +} diff --git a/Swiftfin tvOS/Components/PosterHStack.swift b/Swiftfin tvOS/Components/PosterHStack.swift index 244b1c48a..2f849f87b 100644 --- a/Swiftfin tvOS/Components/PosterHStack.swift +++ b/Swiftfin tvOS/Components/PosterHStack.swift @@ -59,7 +59,7 @@ struct PosterHStack: View wher .clipsToBounds(false) .dataPrefix(20) .insets(horizontal: EdgeInsets.edgePadding, vertical: 20) - .itemSpacing(EdgeInsets.edgePadding - 20) + .itemSpacing(EdgeInsets.edgePadding - (type == .landscape ? 15 : 20)) .scrollBehavior(.continuousLeadingEdge) } .focusSection() diff --git a/Swiftfin tvOS/Components/PrimaryButton.swift b/Swiftfin tvOS/Components/PrimaryButton.swift new file mode 100644 index 000000000..364b4f9ba --- /dev/null +++ b/Swiftfin tvOS/Components/PrimaryButton.swift @@ -0,0 +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 (c) 2025 Jellyfin & Jellyfin Contributors +// + +import Defaults +import SwiftUI + +struct PrimaryButton: View { + + // MARK: - Accent Color + + @Default(.accentColor) + private var accentColor + + // MARK: - Focus State + + @FocusState + private var isFocused + + // MARK: - Primary Button Variables + + private let title: String + private var onSelect: () -> Void + + // MARK: - body + + var body: some View { + ZStack { + ListRowButton(title) { + onSelect() + } + .focused($isFocused) + .foregroundStyle(accentColor.overlayColor, isFocused ? Color.jellyfinPurple : Color.gray) + } + .frame(maxWidth: 500) + .frame(height: 75) + } +} + +extension PrimaryButton { + + init(title: String) { + self.init( + title: title, + onSelect: {} + ) + } + + func onSelect(_ action: @escaping () -> Void) -> Self { + copy(modifying: \.onSelect, with: action) + } +} diff --git a/Swiftfin tvOS/Views/ChannelLibraryView/ChannelLibraryView.swift b/Swiftfin tvOS/Views/ChannelLibraryView/ChannelLibraryView.swift index 8a2ad4c48..3afabc925 100644 --- a/Swiftfin tvOS/Views/ChannelLibraryView/ChannelLibraryView.swift +++ b/Swiftfin tvOS/Views/ChannelLibraryView/ChannelLibraryView.swift @@ -40,7 +40,7 @@ struct ChannelLibraryView: View { } var body: some View { - WrappedView { + ZStack { switch viewModel.state { case .content: if viewModel.elements.isEmpty { @@ -49,7 +49,10 @@ struct ChannelLibraryView: View { contentView } case let .error(error): - Text(error.localizedDescription) + ErrorView(error: error) + .onRetry { + viewModel.send(.refresh) + } case .initial, .refreshing: ProgressView() } diff --git a/Swiftfin tvOS/Views/HomeView/Components/CinematicRecentlyAddedView.swift b/Swiftfin tvOS/Views/HomeView/Components/CinematicRecentlyAddedView.swift deleted file mode 100644 index 7b5036343..000000000 --- a/Swiftfin tvOS/Views/HomeView/Components/CinematicRecentlyAddedView.swift +++ /dev/null @@ -1,59 +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 (c) 2025 Jellyfin & Jellyfin Contributors -// - -import JellyfinAPI -import SwiftUI - -extension HomeView { - - struct CinematicRecentlyAddedView: View { - - @EnvironmentObject - private var router: HomeCoordinator.Router - - @ObservedObject - var viewModel: RecentlyAddedLibraryViewModel - - private func itemSelectorImageSource(for item: BaseItemDto) -> ImageSource { - if item.type == .episode { - return item.seriesImageSource( - .logo, - maxWidth: UIScreen.main.bounds.width * 0.4, - maxHeight: 200 - ) - } else { - return item.imageSource( - .logo, - maxWidth: UIScreen.main.bounds.width * 0.4, - maxHeight: 200 - ) - } - } - - var body: some View { - CinematicItemSelector(items: viewModel.elements.elements) - .topContent { item in - ImageView(itemSelectorImageSource(for: item)) - .placeholder { _ in - EmptyView() - } - .failure { - Text(item.displayTitle) - .font(.largeTitle) - .fontWeight(.semibold) - } - .edgePadding(.leading) - .aspectRatio(contentMode: .fit) - .frame(height: 200, alignment: .bottomLeading) - } - .onSelect { item in - router.route(to: \.item, item) - } - } - } -} diff --git a/Swiftfin tvOS/Views/HomeView/Components/CinematicResumeItemView.swift b/Swiftfin tvOS/Views/HomeView/Components/CinematicResumeItemView.swift deleted file mode 100644 index a8fc2b2c5..000000000 --- a/Swiftfin tvOS/Views/HomeView/Components/CinematicResumeItemView.swift +++ /dev/null @@ -1,73 +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 (c) 2025 Jellyfin & Jellyfin Contributors -// - -import JellyfinAPI -import SwiftUI - -extension HomeView { - - struct CinematicResumeView: View { - - @EnvironmentObject - private var router: HomeCoordinator.Router - - @ObservedObject - var viewModel: HomeViewModel - - private func itemSelectorImageSource(for item: BaseItemDto) -> ImageSource { - if item.type == .episode { - return item.seriesImageSource( - .logo, - maxWidth: UIScreen.main.bounds.width * 0.4, - maxHeight: 200 - ) - } else { - return item.imageSource( - .logo, - maxWidth: UIScreen.main.bounds.width * 0.4, - maxHeight: 200 - ) - } - } - - var body: some View { - CinematicItemSelector(items: viewModel.resumeItems.elements) - .topContent { item in - ImageView(itemSelectorImageSource(for: item)) - .placeholder { _ in - EmptyView() - } - .failure { - Text(item.displayTitle) - .font(.largeTitle) - .fontWeight(.semibold) - } - .edgePadding(.leading) - .aspectRatio(contentMode: .fit) - .frame(height: 200, alignment: .bottomLeading) - } - .content { item in - // TODO: clean up - if item.type == .episode { - PosterButton.EpisodeContentSubtitleContent.Subtitle(item: item) - } else { - Text(" ") - } - } - .itemImageOverlay { item in - LandscapePosterProgressBar( - title: item.progressLabel ?? L10n.continue, - progress: (item.userData?.playedPercentage ?? 0) / 100 - ) - } - .onSelect { item in - router.route(to: \.item, item) - } - } - } -} diff --git a/Swiftfin tvOS/Views/HomeView/Components/ContinueWatchingView.swift b/Swiftfin tvOS/Views/HomeView/Components/ContinueWatchingView.swift new file mode 100644 index 000000000..a8493adac --- /dev/null +++ b/Swiftfin tvOS/Views/HomeView/Components/ContinueWatchingView.swift @@ -0,0 +1,128 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. 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) 2025 Jellyfin & Jellyfin Contributors +// + +import Defaults +import JellyfinAPI +import SwiftUI + +extension HomeView { + + struct ContinueWatchingView: View { + + // MARK: - Defaults + + @Default(.Customization.resumePosterType) + private var posterType + + // MARK: - Observed & Environment Objects + + @EnvironmentObject + private var router: HomeCoordinator.Router + + @ObservedObject + var viewModel: HomeViewModel + + // MARK: - Cinematic Image Source + + private func cinematicImageSource(for item: BaseItemDto) -> ImageSource { + if item.type == .episode { + return item.seriesImageSource( + .logo, + maxWidth: 800, + maxHeight: 200 + ) + } else { + return item.imageSource( + .logo, + maxWidth: 800, + maxHeight: 200 + ) + } + } + + // MARK: - Body + + var body: some View { + ZStack { + switch viewModel.state { + case .content: + if viewModel.resumeItems.isNotEmpty { + contentView + } + case let .error(error): + ErrorView(error: error) + .onRetry { + viewModel.send(.refresh) + } + case .initial, .refreshing: + LoadingView( + title: L10n.resume, + cinematic: true, + posterType: posterType + ) + } + } + .animation(.linear(duration: 0.1), value: viewModel.state) + .ignoresSafeArea() + } + + // MARK: - Content View + + @ViewBuilder + var contentView: some View { + CinematicItemSelector( + items: viewModel.resumeItems.elements, + type: posterType + ) + .topContent { item in + ImageView(cinematicImageSource(for: item)) + .placeholder { _ in + EmptyView() + } + .failure { + Text(item.displayTitle) + .font(.largeTitle) + .fontWeight(.semibold) + } + .edgePadding(.leading) + .aspectRatio(contentMode: .fit) + .frame(height: 200, alignment: .bottomLeading) + } + .content { item in + // TODO: clean up + if item.type == .episode { + PosterButton.EpisodeContentSubtitleContent.Subtitle(item: item) + } else { + Text(" ") + } + } + .contextMenu { item in + Button { + viewModel.send(.setIsPlayed(true, item)) + } label: { + Label(L10n.played, systemImage: "checkmark.circle") + } + + Button(role: .destructive) { + viewModel.send(.setIsPlayed(false, item)) + } label: { + Label(L10n.unplayed, systemImage: "minus.circle") + } + } + .itemImageOverlay { item in + LandscapePosterProgressBar( + title: item.progressLabel ?? L10n.continue, + progress: (item.userData?.playedPercentage ?? 0) / 100 + ) + } + .onSelect { item in + router.route(to: \.item, item) + } + } + } +} diff --git a/Swiftfin tvOS/Views/HomeView/Components/HomeLoadingView.swift b/Swiftfin tvOS/Views/HomeView/Components/HomeLoadingView.swift new file mode 100644 index 000000000..2b51701fc --- /dev/null +++ b/Swiftfin tvOS/Views/HomeView/Components/HomeLoadingView.swift @@ -0,0 +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 (c) 2025 Jellyfin & Jellyfin Contributors +// + +import JellyfinAPI +import SwiftUI + +extension HomeView { + + struct LoadingView: View { + + private var title: String + private var cinematic: Bool + private var posterType: PosterDisplayType = .portrait + + private let elements: [BaseItemDto] + + // MARK: - Initializer + + init( + title: String, + cinematic: Bool = false, + posterType: PosterDisplayType = .portrait + ) { + self.title = title + self.cinematic = cinematic + self.posterType = posterType + + let tempItem = BaseItemDto(name: "") + + self.elements = [tempItem, tempItem, tempItem] + } + + // MARK: - Cinematic Image Source + + private func cinematicImageSource(for item: BaseItemDto) -> ImageSource { + if item.type == .episode { + return item.seriesImageSource( + .logo, + maxWidth: 800, + maxHeight: 200 + ) + } else { + return item.imageSource( + .logo, + maxWidth: 800, + maxHeight: 200 + ) + } + } + + // MARK: - Body + + @ViewBuilder + var body: some View { + switch cinematic { + case false: + standardView + case true: + cinematicView + } + } + + // MARK: - Standard View + + var standardView: some View { + PosterHStack( + title: title, + type: posterType, + items: elements + ) + .imageOverlay { _ in + EmptyView() + } + } + + // MARK: - Cinematic View + + var cinematicView: some View { + CinematicItemSelector( + items: elements, + type: posterType + ) + .topContent { item in + ImageView(cinematicImageSource(for: item)) + .placeholder { _ in + EmptyView() + } + .edgePadding(.leading) + .aspectRatio(contentMode: .fit) + .frame(height: 200, alignment: .bottomLeading) + } + } + } +} diff --git a/Swiftfin tvOS/Views/HomeView/Components/LatestInLibraryView.swift b/Swiftfin tvOS/Views/HomeView/Components/LatestInLibraryView.swift index a4b36ce3c..eba99586c 100644 --- a/Swiftfin tvOS/Views/HomeView/Components/LatestInLibraryView.swift +++ b/Swiftfin tvOS/Views/HomeView/Components/LatestInLibraryView.swift @@ -6,6 +6,7 @@ // Copyright (c) 2025 Jellyfin & Jellyfin Contributors // +import Defaults import JellyfinAPI import SwiftUI @@ -13,23 +14,56 @@ extension HomeView { struct LatestInLibraryView: View { + // MARK: - Defaults + + @Default(.Customization.latestInLibraryPosterType) + private var posterType + + // MARK: - Observed & Environment Objects + @EnvironmentObject private var router: HomeCoordinator.Router @ObservedObject var viewModel: LatestInLibraryViewModel + // MARK: - Body + var body: some View { - if viewModel.elements.isNotEmpty { - PosterHStack( - title: L10n.latestWithString(viewModel.parent?.displayTitle ?? .emptyDash), - type: .portrait, - items: viewModel.elements - ) - .onSelect { item in - router.route(to: \.item, item) + ZStack { + switch viewModel.state { + case .content: + if viewModel.elements.isNotEmpty { + contentView + } + case let .error(error): + ErrorView(error: error) + .onRetry { + viewModel.send(.refresh) + } + case .initial, .refreshing: + LoadingView( + title: L10n.latestWithString(viewModel.parent?.displayTitle ?? .emptyDash), + posterType: posterType + ) } } + .animation(.linear(duration: 0.1), value: viewModel.state) + .ignoresSafeArea() + } + + // MARK: - Content View + + @ViewBuilder + var contentView: some View { + PosterHStack( + title: L10n.latestWithString(viewModel.parent?.displayTitle ?? .emptyDash), + type: posterType, + items: viewModel.elements + ) + .onSelect { item in + router.route(to: \.item, item) + } } } } diff --git a/Swiftfin tvOS/Views/HomeView/Components/NextUpView.swift b/Swiftfin tvOS/Views/HomeView/Components/NextUpView.swift index b8a8209da..83bb14f76 100644 --- a/Swiftfin tvOS/Views/HomeView/Components/NextUpView.swift +++ b/Swiftfin tvOS/Views/HomeView/Components/NextUpView.swift @@ -7,14 +7,19 @@ // import Defaults +import JellyfinAPI import SwiftUI extension HomeView { struct NextUpView: View { + // MARK: - Defaults + @Default(.Customization.nextUpPosterType) - private var nextUpPosterType + private var posterType + + // MARK: - Observed & Environment Objects @EnvironmentObject private var router: HomeCoordinator.Router @@ -22,17 +27,96 @@ extension HomeView { @ObservedObject var viewModel: NextUpLibraryViewModel - var body: some View { - if viewModel.elements.isNotEmpty { - PosterHStack( - title: L10n.nextUp, - type: nextUpPosterType, - items: viewModel.elements + // MARK: - Libary Cinematic Background + + var cinematic: Bool = false + + // MARK: - Cinematic Image Source + + private func cinematicImageSource(for item: BaseItemDto) -> ImageSource { + if item.type == .episode { + return item.seriesImageSource( + .logo, + maxWidth: 800, + maxHeight: 200 ) - .onSelect { item in - router.route(to: \.item, item) + } else { + return item.imageSource( + .logo, + maxWidth: 800, + maxHeight: 200 + ) + } + } + + // MARK: - Body + + var body: some View { + ZStack { + switch viewModel.state { + case .content: + if viewModel.elements.isNotEmpty { + switch cinematic { + case true: + cinematicView + case false: + standardView + } + } + case let .error(error): + ErrorView(error: error) + .onRetry { + viewModel.send(.refresh) + } + case .initial, .refreshing: + LoadingView( + title: L10n.nextUp, + posterType: posterType + ) } } + .animation(.linear(duration: 0.1), value: viewModel.state) + .ignoresSafeArea() + } + + // MARK: - Cinematic View + + var cinematicView: some View { + CinematicItemSelector( + items: viewModel.elements.elements, + type: posterType + ) + .topContent { item in + ImageView(cinematicImageSource(for: item)) + .placeholder { _ in + EmptyView() + } + .failure { + Text(item.displayTitle) + .font(.largeTitle) + .fontWeight(.semibold) + } + .edgePadding(.leading) + .aspectRatio(contentMode: .fit) + .frame(height: 200, alignment: .bottomLeading) + } + .onSelect { item in + router.route(to: \.item, item) + } + } + + // MARK: - Standard View + + @ViewBuilder + var standardView: some View { + PosterHStack( + title: L10n.nextUp, + type: posterType, + items: viewModel.elements + ) + .onSelect { item in + router.route(to: \.item, item) + } } } } diff --git a/Swiftfin tvOS/Views/HomeView/Components/RecentlyAddedView.swift b/Swiftfin tvOS/Views/HomeView/Components/RecentlyAddedView.swift index cdd2d1502..6b97c5a43 100644 --- a/Swiftfin tvOS/Views/HomeView/Components/RecentlyAddedView.swift +++ b/Swiftfin tvOS/Views/HomeView/Components/RecentlyAddedView.swift @@ -7,14 +7,19 @@ // import Defaults +import JellyfinAPI import SwiftUI extension HomeView { struct RecentlyAddedView: View { + // MARK: - Defaults + @Default(.Customization.recentlyAddedPosterType) - private var recentlyAddedPosterType + private var posterType + + // MARK: - Observed & Environment Objects @EnvironmentObject private var router: HomeCoordinator.Router @@ -22,17 +27,96 @@ extension HomeView { @ObservedObject var viewModel: RecentlyAddedLibraryViewModel - var body: some View { - if viewModel.elements.isNotEmpty { - PosterHStack( - title: L10n.recentlyAdded, - type: recentlyAddedPosterType, - items: viewModel.elements + // MARK: - Libary Cinematic Background + + var cinematic: Bool = false + + // MARK: - Cinematic Image Source + + private func cinematicImageSource(for item: BaseItemDto) -> ImageSource { + if item.type == .episode { + return item.seriesImageSource( + .logo, + maxWidth: 800, + maxHeight: 200 ) - .onSelect { item in - router.route(to: \.item, item) + } else { + return item.imageSource( + .logo, + maxWidth: 800, + maxHeight: 200 + ) + } + } + + // MARK: - Body + + var body: some View { + ZStack { + switch viewModel.state { + case .content: + if viewModel.elements.isNotEmpty { + switch cinematic { + case true: + cinematicView + case false: + standardView + } + } + case let .error(error): + ErrorView(error: error) + .onRetry { + viewModel.send(.refresh) + } + case .initial, .refreshing: + LoadingView( + title: L10n.recentlyAdded, + posterType: posterType + ) } } + .animation(.linear(duration: 0.1), value: viewModel.state) + .ignoresSafeArea() + } + + // MARK: - Cinematic View + + var cinematicView: some View { + CinematicItemSelector( + items: viewModel.elements.elements, + type: posterType + ) + .topContent { item in + ImageView(cinematicImageSource(for: item)) + .placeholder { _ in + EmptyView() + } + .failure { + Text(item.displayTitle) + .font(.largeTitle) + .fontWeight(.semibold) + } + .edgePadding(.leading) + .aspectRatio(contentMode: .fit) + .frame(height: 200, alignment: .bottomLeading) + } + .onSelect { item in + router.route(to: \.item, item) + } + } + + // MARK: - Standard View + + @ViewBuilder + var standardView: some View { + PosterHStack( + title: L10n.recentlyAdded, + type: posterType, + items: viewModel.elements + ) + .onSelect { item in + router.route(to: \.item, item) + } } } } diff --git a/Swiftfin tvOS/Views/HomeView/HomeErrorView.swift b/Swiftfin tvOS/Views/HomeView/HomeErrorView.swift deleted file mode 100644 index b7d62961a..000000000 --- a/Swiftfin tvOS/Views/HomeView/HomeErrorView.swift +++ /dev/null @@ -1,70 +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 (c) 2025 Jellyfin & Jellyfin Contributors -// - -import SwiftUI - -// TODO: make general `ErrorView` like iOS - -#warning("TODO: implement") - -extension HomeView { - - struct ErrorView: View { - - @ObservedObject - var viewModel: HomeViewModel - - var body: some View { - Text("TODO") - } - } -} - -// extension HomeView { -// -// struct ErrorView: View { -// -// @ObservedObject -// var viewModel: HomeViewModel -// -// let errorMessage: ErrorMessage -// -// var body: some View { -// VStack { -// 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.message) -// .frame(minWidth: 50, maxWidth: 240) -// .multilineTextAlignment(.center) -// -// Button { -//// viewModel.refresh() -// } label: { -// L10n.retry.text -// .bold() -// .font(.callout) -// .frame(width: 400, height: 75) -// .background(Color.jellyfinPurple) -// } -// .buttonStyle(.card) -// } -// .offset(y: -50) -// } -// } -// } diff --git a/Swiftfin tvOS/Views/HomeView/HomeView.swift b/Swiftfin tvOS/Views/HomeView/HomeView.swift index 5cfb0b65b..31501a901 100644 --- a/Swiftfin tvOS/Views/HomeView/HomeView.swift +++ b/Swiftfin tvOS/Views/HomeView/HomeView.swift @@ -13,60 +13,57 @@ import SwiftUI struct HomeView: View { - @EnvironmentObject - private var router: HomeCoordinator.Router - - @StateObject - private var viewModel = HomeViewModel() + // MARK: - Defaults @Default(.Customization.Home.showRecentlyAdded) private var showRecentlyAdded - @ViewBuilder - private var contentView: some View { - ScrollView { - VStack(alignment: .leading, spacing: 0) { + // MARK: - State & Environment Objects + + @EnvironmentObject + private var router: HomeCoordinator.Router - if viewModel.resumeItems.isNotEmpty { - CinematicResumeView(viewModel: viewModel) + @StateObject + private var viewModel = HomeViewModel() - NextUpView(viewModel: viewModel.nextUpViewModel) + // MARK: - Cinematic State - if showRecentlyAdded { - RecentlyAddedView(viewModel: viewModel.recentlyAddedViewModel) - } - } else { - if showRecentlyAdded { - CinematicRecentlyAddedView(viewModel: viewModel.recentlyAddedViewModel) - } - NextUpView(viewModel: viewModel.nextUpViewModel) - } + @State + private var isCinematic: Bool = true - ForEach(viewModel.libraries) { viewModel in - LatestInLibraryView(viewModel: viewModel) - } - } - } - } + // MARK: - Body var body: some View { - WrappedView { - Group { - switch viewModel.state { - case .content: + ZStack { + Color.clear + switch viewModel.state { + case .content: + if viewModel.libraries.isEmpty { + ErrorView( + error: JellyfinAPIError(L10n.noValidLibrariesError) + ) + } else { contentView - case let .error(error): - Text(error.localizedDescription) - case .initial, .refreshing: - ProgressView() } + case let .error(error): + ErrorView(error: error) + .onRetry { + viewModel.send(.refresh) + } + case .initial, .refreshing: + ProgressView() } - .transition(.opacity.animation(.linear(duration: 0.2))) } + .animation(.linear(duration: 0.1), value: viewModel.state) + .ignoresSafeArea(isCinematic ? .all : []) .onFirstAppear { viewModel.send(.refresh) } - .ignoresSafeArea() + .topBarTrailing { + if viewModel.backgroundStates.contains(.refresh) { + ProgressView() + } + } .sinceLastDisappear { interval in if interval > 60 || viewModel.notificationsReceived.contains(.itemMetadataDidChange) { viewModel.send(.backgroundRefresh) @@ -74,4 +71,52 @@ struct HomeView: View { } } } + + // MARK: - Content View + + @ViewBuilder + private var contentView: some View { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + + ContinueWatchingView(viewModel: viewModel) + + NextUpView( + viewModel: viewModel.nextUpViewModel, + cinematic: viewModel.resumeItems.isEmpty + ) + + if showRecentlyAdded { + RecentlyAddedView( + viewModel: viewModel.recentlyAddedViewModel, + cinematic: viewModel.resumeItems.isEmpty + && viewModel.nextUpViewModel.elements.isEmpty + ) + } + + ForEach(viewModel.libraries.indices, id: \.self) { index in + LatestInLibraryView(viewModel: viewModel.libraries[index]) + } + + Divider() + + refreshButtonView + } + } + } + + // MARK: - Refresh Button View + + private var refreshButtonView: some View { + HStack { + Spacer() + PrimaryButton(title: L10n.refresh) + .onSelect { + viewModel.send(.refresh) + } + Spacer() + } + .focusSection() + .padding() + } } diff --git a/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/EpisodeHStack.swift b/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/EpisodeHStack.swift index d5302d846..584d0972b 100644 --- a/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/EpisodeHStack.swift +++ b/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/EpisodeHStack.swift @@ -62,7 +62,7 @@ extension SeriesEpisodeSelector { } var body: some View { - WrappedView { + ZStack { switch viewModel.state { case .content: contentView(viewModel: viewModel) diff --git a/Swiftfin tvOS/Views/ItemView/ItemView.swift b/Swiftfin tvOS/Views/ItemView/ItemView.swift index cce801081..ae32609ab 100644 --- a/Swiftfin tvOS/Views/ItemView/ItemView.swift +++ b/Swiftfin tvOS/Views/ItemView/ItemView.swift @@ -52,17 +52,20 @@ struct ItemView: View { } var body: some View { - WrappedView { + ZStack { switch viewModel.state { case .content: contentView case let .error(error): - Text(error.localizedDescription) + ErrorView(error: error) + .onRetry { + viewModel.send(.refresh) + } case .initial, .refreshing: ProgressView() } } - .transition(.opacity.animation(.linear(duration: 0.2))) + .animation(.linear(duration: 0.1), value: viewModel.state) .onFirstAppear { viewModel.send(.refresh) } diff --git a/Swiftfin tvOS/Views/MediaView/MediaView.swift b/Swiftfin tvOS/Views/MediaView/MediaView.swift index 961f95b3a..5a36296b9 100644 --- a/Swiftfin tvOS/Views/MediaView/MediaView.swift +++ b/Swiftfin tvOS/Views/MediaView/MediaView.swift @@ -52,19 +52,21 @@ struct MediaView: View { } var body: some View { - WrappedView { - Group { - switch viewModel.state { - case .content: - contentView - case let .error(error): - Text(error.localizedDescription) - case .initial, .refreshing: - ProgressView() - } + ZStack { + Color.clear + switch viewModel.state { + case .content: + contentView + case let .error(error): + ErrorView(error: error) + .onRetry { + viewModel.send(.refresh) + } + case .initial, .refreshing: + ProgressView() } - .transition(.opacity.animation(.linear(duration: 0.2))) } + .animation(.linear(duration: 0.1), value: viewModel.state) .ignoresSafeArea() .onFirstAppear { viewModel.send(.refresh) diff --git a/Swiftfin tvOS/Views/PagingLibraryView/PagingLibraryView.swift b/Swiftfin tvOS/Views/PagingLibraryView/PagingLibraryView.swift index c91bfeda1..7f9a0289f 100644 --- a/Swiftfin tvOS/Views/PagingLibraryView/PagingLibraryView.swift +++ b/Swiftfin tvOS/Views/PagingLibraryView/PagingLibraryView.swift @@ -184,25 +184,25 @@ struct PagingLibraryView: View { .blurred() } - WrappedView { - Group { - switch viewModel.state { - case let .error(error): - Text(error.localizedDescription) - case .initial, .refreshing: - ProgressView() - case .content: - if viewModel.elements.isEmpty { - L10n.noResults.text - } else { - contentView - } + switch viewModel.state { + case let .error(error): + ErrorView(error: error) + .onRetry { + viewModel.send(.refresh) } + case .initial, .refreshing: + ProgressView() + case .content: + if viewModel.elements.isEmpty { + L10n.noResults.text + } else { + contentView } } } - .ignoresSafeArea() .navigationTitle(viewModel.parent?.displayTitle ?? "") + .animation(.linear(duration: 0.1), value: viewModel.state) + .ignoresSafeArea() .onFirstAppear { if viewModel.state == .initial { viewModel.send(.refresh) diff --git a/Swiftfin tvOS/Views/ProgramsView/Components/ProgramProgressOverlay.swift b/Swiftfin tvOS/Views/ProgramsView/Components/ProgramProgressOverlay.swift index 7c5ad24af..324e1ab56 100644 --- a/Swiftfin tvOS/Views/ProgramsView/Components/ProgramProgressOverlay.swift +++ b/Swiftfin tvOS/Views/ProgramsView/Components/ProgramProgressOverlay.swift @@ -20,7 +20,7 @@ extension ProgramsView { private let timer = Timer.publish(every: 5, on: .main, in: .common).autoconnect() var body: some View { - WrappedView { + ZStack { if let startDate = program.startDate, startDate < Date.now { LandscapePosterProgressBar( progress: program.programProgress ?? 0 diff --git a/Swiftfin tvOS/Views/ProgramsView/ProgramsView.swift b/Swiftfin tvOS/Views/ProgramsView/ProgramsView.swift index 686854cf0..074838a4c 100644 --- a/Swiftfin tvOS/Views/ProgramsView/ProgramsView.swift +++ b/Swiftfin tvOS/Views/ProgramsView/ProgramsView.swift @@ -78,7 +78,7 @@ struct ProgramsView: View { } var body: some View { - WrappedView { + ZStack { switch programsViewModel.state { case .content: if programsViewModel.hasNoResults { @@ -87,7 +87,10 @@ struct ProgramsView: View { contentView } case let .error(error): - Text(error.localizedDescription) + ErrorView(error: error) + .onRetry { + programsViewModel.send(.refresh) + } case .initial, .refreshing: ProgressView() } diff --git a/Swiftfin tvOS/Views/QuickConnectView.swift b/Swiftfin tvOS/Views/QuickConnectView.swift index ad5e68939..14a70adfc 100644 --- a/Swiftfin tvOS/Views/QuickConnectView.swift +++ b/Swiftfin tvOS/Views/QuickConnectView.swift @@ -47,7 +47,7 @@ struct QuickConnectView: View { } var body: some View { - WrappedView { + ZStack { switch viewModel.state { case .idle, .authenticated: Color.clear @@ -56,8 +56,10 @@ struct QuickConnectView: View { case let .polling(code): pollingView(code: code) case let .error(error): - Text(error.localizedDescription) -// ErrorView(error: error) + ErrorView(error: error) + .onRetry { + viewModel.start() + } } } .edgePadding() diff --git a/Swiftfin tvOS/Views/SearchView.swift b/Swiftfin tvOS/Views/SearchView.swift index ce6b6ec97..0e6d33a8e 100644 --- a/Swiftfin tvOS/Views/SearchView.swift +++ b/Swiftfin tvOS/Views/SearchView.swift @@ -110,25 +110,26 @@ struct SearchView: View { } var body: some View { - WrappedView { - Group { - switch viewModel.state { - case let .error(error): - Text(error.localizedDescription) - case .initial: - suggestionsView - case .content: - if viewModel.hasNoResults { - L10n.noResults.text - } else { - resultsView + ZStack { + switch viewModel.state { + case let .error(error): + ErrorView(error: error) + .onRetry { + viewModel.send(.search(query: searchQuery)) } - case .searching: - ProgressView() + case .initial: + suggestionsView + case .content: + if viewModel.hasNoResults { + L10n.noResults.text + } else { + resultsView } + case .searching: + ProgressView() } - .transition(.opacity.animation(.linear(duration: 0.2))) } + .animation(.linear(duration: 0.1), value: viewModel.state) .ignoresSafeArea(edges: [.bottom, .horizontal]) .onFirstAppear { viewModel.send(.getSuggestions) diff --git a/Swiftfin tvOS/Views/SettingsView/CustomizeViewsSettings/CustomizeViewsSettings.swift b/Swiftfin tvOS/Views/SettingsView/CustomizeViewsSettings/CustomizeViewsSettings.swift index 2d0573da9..d435514ab 100644 --- a/Swiftfin tvOS/Views/SettingsView/CustomizeViewsSettings/CustomizeViewsSettings.swift +++ b/Swiftfin tvOS/Views/SettingsView/CustomizeViewsSettings/CustomizeViewsSettings.swift @@ -18,6 +18,8 @@ struct CustomizeViewsSettings: View { @Default(.Customization.showPosterLabels) private var showPosterLabels + @Default(.Customization.resumePosterType) + private var resumePosterType @Default(.Customization.nextUpPosterType) private var nextUpPosterType @Default(.Customization.recentlyAddedPosterType) @@ -73,6 +75,8 @@ struct CustomizeViewsSettings: View { Toggle(L10n.showPosterLabels, isOn: $showPosterLabels) + InlineEnumToggle(title: L10n.resume, selection: $resumePosterType) + InlineEnumToggle(title: L10n.next, selection: $nextUpPosterType) InlineEnumToggle(title: L10n.recentlyAdded, selection: $recentlyAddedPosterType) diff --git a/Swiftfin.xcodeproj/project.pbxproj b/Swiftfin.xcodeproj/project.pbxproj index de6413a78..7cb43cf94 100644 --- a/Swiftfin.xcodeproj/project.pbxproj +++ b/Swiftfin.xcodeproj/project.pbxproj @@ -14,6 +14,8 @@ 4E0195E42CE0467B007844F4 /* ItemSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E0195E32CE04678007844F4 /* ItemSection.swift */; }; 4E0253BD2CBF0C06007EB9CD /* DeviceType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E12F9152CBE9615006C217E /* DeviceType.swift */; }; 4E026A8B2CE804E7005471B5 /* ResetUserPasswordView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E026A8A2CE804E7005471B5 /* ResetUserPasswordView.swift */; }; + 4E02CDF92D2097D80086D480 /* ErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E02CDF82D2097D60086D480 /* ErrorView.swift */; }; + 4E02CDFB2D2098050086D480 /* PrimaryButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E02CDFA2D2098020086D480 /* PrimaryButton.swift */; }; 4E0A8FFB2CAF74D20014B047 /* TaskCompletionStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E0A8FFA2CAF74CD0014B047 /* TaskCompletionStatus.swift */; }; 4E0A8FFC2CAF74D20014B047 /* TaskCompletionStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E0A8FFA2CAF74CD0014B047 /* TaskCompletionStatus.swift */; }; 4E10C8112CC030CD0012CC9F /* DeviceDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E10C8102CC030C90012CC9F /* DeviceDetailsView.swift */; }; @@ -147,6 +149,7 @@ 4E762AAE2C3A1A95004D1579 /* PlaybackBitrate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E762AAD2C3A1A95004D1579 /* PlaybackBitrate.swift */; }; 4E762AAF2C3A1A95004D1579 /* PlaybackBitrate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E762AAD2C3A1A95004D1579 /* PlaybackBitrate.swift */; }; 4E8274F52D2ECF1900F5E610 /* UserProfileSettingsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E8274F32D2ECF0200F5E610 /* UserProfileSettingsCoordinator.swift */; }; + 4E84AE2E2D275301004AA0F1 /* ContinueWatchingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E84AE2D2D2752FE004AA0F1 /* ContinueWatchingView.swift */; }; 4E884C652CEBB301004CF6AD /* LearnMoreModal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E884C642CEBB2FF004CF6AD /* LearnMoreModal.swift */; }; 4E8B34EA2AB91B6E0018F305 /* ItemFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E8B34E92AB91B6E0018F305 /* ItemFilter.swift */; }; 4E8B34EB2AB91B6E0018F305 /* ItemFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E8B34E92AB91B6E0018F305 /* ItemFilter.swift */; }; @@ -172,6 +175,7 @@ 4E9A24E92C82B79D0023DA83 /* EditCustomDeviceProfileCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EC1C8572C80332500E2879E /* EditCustomDeviceProfileCoordinator.swift */; }; 4E9A24EB2C82B9ED0023DA83 /* CustomDeviceProfileCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E9A24EA2C82B9ED0023DA83 /* CustomDeviceProfileCoordinator.swift */; }; 4E9A24ED2C82BAFB0023DA83 /* EditCustomDeviceProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E9A24EC2C82BAFB0023DA83 /* EditCustomDeviceProfileView.swift */; }; + 4E9DCE692D31680C009186EF /* HomeLoadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E9DCE682D316808009186EF /* HomeLoadingView.swift */; }; 4EA09DE12CC4E4F100CB27E4 /* APIKeysView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EA09DE02CC4E4F000CB27E4 /* APIKeysView.swift */; }; 4EA09DE42CC4E85C00CB27E4 /* APIKeysRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EA09DE32CC4E85700CB27E4 /* APIKeysRow.swift */; }; 4EA397472CD31CC000904C25 /* AddServerUserViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EA397452CD31CB900904C25 /* AddServerUserViewModel.swift */; }; @@ -557,9 +561,7 @@ E12CC1BC28D11E1000678D5D /* RecentlyAddedViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E12CC1BA28D11E1000678D5D /* RecentlyAddedViewModel.swift */; }; E12CC1BE28D11F4500678D5D /* RecentlyAddedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E12CC1BD28D11F4500678D5D /* RecentlyAddedView.swift */; }; E12CC1C528D12D9B00678D5D /* SeeAllPosterButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E12CC1C428D12D9B00678D5D /* SeeAllPosterButton.swift */; }; - E12CC1C728D12FD600678D5D /* CinematicRecentlyAddedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E12CC1C628D12FD600678D5D /* CinematicRecentlyAddedView.swift */; }; E12CC1C928D132B800678D5D /* RecentlyAddedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E12CC1C828D132B800678D5D /* RecentlyAddedView.swift */; }; - E12CC1CB28D1333400678D5D /* CinematicResumeItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E12CC1CA28D1333400678D5D /* CinematicResumeItemView.swift */; }; E12CC1CD28D135C700678D5D /* NextUpView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E12CC1CC28D135C700678D5D /* NextUpView.swift */; }; E12E30F1296383810022FAC9 /* SplitFormWindowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E12E30F0296383810022FAC9 /* SplitFormWindowView.swift */; }; E12E30F5296392EC0022FAC9 /* EnumPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E12E30F4296392EC0022FAC9 /* EnumPickerView.swift */; }; @@ -942,7 +944,6 @@ E1A3E4CF2BB7E02B005C59F8 /* DelayedProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A3E4CE2BB7E02B005C59F8 /* DelayedProgressView.swift */; }; E1A3E4D12BB7F5BF005C59F8 /* ErrorCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A3E4D02BB7F5BF005C59F8 /* ErrorCard.swift */; }; E1A42E4A28CA6CCD00A14DCB /* CinematicItemSelector.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A42E4928CA6CCD00A14DCB /* CinematicItemSelector.swift */; }; - E1A42E4F28CBD3E100A14DCB /* HomeErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A42E4E28CBD3E100A14DCB /* HomeErrorView.swift */; }; E1A42E5128CBE44500A14DCB /* LandscapePosterProgressBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A42E5028CBE44500A14DCB /* LandscapePosterProgressBar.swift */; }; E1A5056A2D0B733F007EE305 /* Optional.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A505692D0B733F007EE305 /* Optional.swift */; }; E1A5056B2D0B733F007EE305 /* Optional.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A505692D0B733F007EE305 /* Optional.swift */; }; @@ -1199,6 +1200,8 @@ 4E01446B2D0292E000193038 /* Trie.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Trie.swift; sourceTree = ""; }; 4E0195E32CE04678007844F4 /* ItemSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemSection.swift; sourceTree = ""; }; 4E026A8A2CE804E7005471B5 /* ResetUserPasswordView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResetUserPasswordView.swift; sourceTree = ""; }; + 4E02CDF82D2097D60086D480 /* ErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorView.swift; sourceTree = ""; }; + 4E02CDFA2D2098020086D480 /* PrimaryButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrimaryButton.swift; sourceTree = ""; }; 4E0A8FFA2CAF74CD0014B047 /* TaskCompletionStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskCompletionStatus.swift; sourceTree = ""; }; 4E10C8102CC030C90012CC9F /* DeviceDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceDetailsView.swift; sourceTree = ""; }; 4E10C8162CC045530012CC9F /* CompatibilitiesSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompatibilitiesSection.swift; sourceTree = ""; }; @@ -1297,6 +1300,7 @@ 4E75B34A2D164AC100D16531 /* PurgeUnusedStrings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PurgeUnusedStrings.swift; sourceTree = ""; }; 4E762AAD2C3A1A95004D1579 /* PlaybackBitrate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlaybackBitrate.swift; sourceTree = ""; }; 4E8274F32D2ECF0200F5E610 /* UserProfileSettingsCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileSettingsCoordinator.swift; sourceTree = ""; }; + 4E84AE2D2D2752FE004AA0F1 /* ContinueWatchingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContinueWatchingView.swift; sourceTree = ""; }; 4E884C642CEBB2FF004CF6AD /* LearnMoreModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LearnMoreModal.swift; sourceTree = ""; }; 4E8B34E92AB91B6E0018F305 /* ItemFilter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ItemFilter.swift; sourceTree = ""; }; 4E8F74A02CE03C8B00CC8969 /* ItemEditorCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemEditorCoordinator.swift; sourceTree = ""; }; @@ -1318,6 +1322,7 @@ 4E9A24E72C82B6190023DA83 /* CustomProfileButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomProfileButton.swift; sourceTree = ""; }; 4E9A24EA2C82B9ED0023DA83 /* CustomDeviceProfileCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomDeviceProfileCoordinator.swift; sourceTree = ""; }; 4E9A24EC2C82BAFB0023DA83 /* EditCustomDeviceProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditCustomDeviceProfileView.swift; sourceTree = ""; }; + 4E9DCE682D316808009186EF /* HomeLoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeLoadingView.swift; sourceTree = ""; }; 4EA09DE02CC4E4F000CB27E4 /* APIKeysView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIKeysView.swift; sourceTree = ""; }; 4EA09DE32CC4E85700CB27E4 /* APIKeysRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIKeysRow.swift; sourceTree = ""; }; 4EA397452CD31CB900904C25 /* AddServerUserViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddServerUserViewModel.swift; sourceTree = ""; }; @@ -1620,9 +1625,7 @@ E12CC1BA28D11E1000678D5D /* RecentlyAddedViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentlyAddedViewModel.swift; sourceTree = ""; }; E12CC1BD28D11F4500678D5D /* RecentlyAddedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentlyAddedView.swift; sourceTree = ""; }; E12CC1C428D12D9B00678D5D /* SeeAllPosterButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeeAllPosterButton.swift; sourceTree = ""; }; - E12CC1C628D12FD600678D5D /* CinematicRecentlyAddedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CinematicRecentlyAddedView.swift; sourceTree = ""; }; E12CC1C828D132B800678D5D /* RecentlyAddedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentlyAddedView.swift; sourceTree = ""; }; - E12CC1CA28D1333400678D5D /* CinematicResumeItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CinematicResumeItemView.swift; sourceTree = ""; }; E12CC1CC28D135C700678D5D /* NextUpView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NextUpView.swift; sourceTree = ""; }; E12E30F0296383810022FAC9 /* SplitFormWindowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitFormWindowView.swift; sourceTree = ""; }; E12E30F4296392EC0022FAC9 /* EnumPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnumPickerView.swift; sourceTree = ""; }; @@ -1834,7 +1837,6 @@ E1A3E4CE2BB7E02B005C59F8 /* DelayedProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DelayedProgressView.swift; sourceTree = ""; }; E1A3E4D02BB7F5BF005C59F8 /* ErrorCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorCard.swift; sourceTree = ""; }; E1A42E4928CA6CCD00A14DCB /* CinematicItemSelector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CinematicItemSelector.swift; sourceTree = ""; }; - E1A42E4E28CBD3E100A14DCB /* HomeErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeErrorView.swift; sourceTree = ""; }; E1A42E5028CBE44500A14DCB /* LandscapePosterProgressBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LandscapePosterProgressBar.swift; sourceTree = ""; }; E1A505692D0B733F007EE305 /* Optional.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Optional.swift; sourceTree = ""; }; E1A7F0DE2BD4EC7400620DDD /* Dictionary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Dictionary.swift; sourceTree = ""; }; @@ -3114,6 +3116,7 @@ E1A42E4928CA6CCD00A14DCB /* CinematicItemSelector.swift */, E1C92618288756BD002A7A66 /* DotHStack.swift */, E12E30F4296392EC0022FAC9 /* EnumPickerView.swift */, + 4E02CDF82D2097D60086D480 /* ErrorView.swift */, E1549677296CB22B00C4EF88 /* InlineEnumToggle.swift */, E1A42E5028CBE44500A14DCB /* LandscapePosterProgressBar.swift */, E1763A632BF3C9AA004DF6AB /* ListRowButton.swift */, @@ -3121,6 +3124,7 @@ 4E2AC4D32C6C4C1200DD600D /* OrderedSectionSelectorView.swift */, E1C92617288756BD002A7A66 /* PosterButton.swift */, E1C92619288756BD002A7A66 /* PosterHStack.swift */, + 4E02CDFA2D2098020086D480 /* PrimaryButton.swift */, E12CC1C428D12D9B00678D5D /* SeeAllPosterButton.swift */, E1E9EFE928C6B96400CC1F8B /* ServerButton.swift */, E17885A3278105170094FBCF /* SFSymbolButton.swift */, @@ -3893,8 +3897,8 @@ E12CC1C328D12D6300678D5D /* Components */ = { isa = PBXGroup; children = ( - E12CC1C628D12FD600678D5D /* CinematicRecentlyAddedView.swift */, - E12CC1CA28D1333400678D5D /* CinematicResumeItemView.swift */, + 4E84AE2D2D2752FE004AA0F1 /* ContinueWatchingView.swift */, + 4E9DCE682D316808009186EF /* HomeLoadingView.swift */, E1C925F828875647002A7A66 /* LatestInLibraryView.swift */, E12CC1CC28D135C700678D5D /* NextUpView.swift */, E12CC1C828D132B800678D5D /* RecentlyAddedView.swift */, @@ -4100,8 +4104,8 @@ children = ( E1763A282BF3046A004DF6AB /* AddUserButton.swift */, E1763A262BF303C9004DF6AB /* ServerSelectionMenu.swift */, - E1763A2A2BF3046E004DF6AB /* UserGridButton.swift */, BDA623522D0D0854009A157F /* SelectUserBottomBar.swift */, + E1763A2A2BF3046E004DF6AB /* UserGridButton.swift */, ); path = Components; sourceTree = ""; @@ -4469,7 +4473,6 @@ isa = PBXGroup; children = ( E12CC1C328D12D6300678D5D /* Components */, - E1A42E4E28CBD3E100A14DCB /* HomeErrorView.swift */, 531690E6267ABD79005D8AB9 /* HomeView.swift */, ); path = HomeView; @@ -5343,7 +5346,6 @@ 4E2AC4C32C6C491200DD600D /* AudoCodec.swift in Sources */, E1575EA6293E7D40001665B1 /* VideoPlayer.swift in Sources */, E185920628CDAA6400326F80 /* CastAndCrewHStack.swift in Sources */, - E1A42E4F28CBD3E100A14DCB /* HomeErrorView.swift in Sources */, 4E8F74AB2CE03DD300CC8969 /* DeleteItemViewModel.swift in Sources */, 53CD2A40268A49C2002ABD4E /* ItemView.swift in Sources */, E122A9142788EAAD0060FA63 /* MediaStream.swift in Sources */, @@ -5369,6 +5371,7 @@ E1575E6E293E77B5001665B1 /* SpecialFeatureType.swift in Sources */, E12CC1C528D12D9B00678D5D /* SeeAllPosterButton.swift in Sources */, E18A8E8128D6083700333B9A /* MediaSourceInfo+ItemVideoPlayerViewModel.swift in Sources */, + 4E02CDFB2D2098050086D480 /* PrimaryButton.swift in Sources */, E1002B652793CEE800E47059 /* ChapterInfo.swift in Sources */, 4E9A24E62C82B5A50023DA83 /* CustomDeviceProfileSettingsView.swift in Sources */, E1153D9A2BBA3E9800424D36 /* ErrorCard.swift in Sources */, @@ -5401,6 +5404,7 @@ E1763A272BF303C9004DF6AB /* ServerSelectionMenu.swift in Sources */, 4E9A24E92C82B79D0023DA83 /* EditCustomDeviceProfileCoordinator.swift in Sources */, E1C9261C288756BD002A7A66 /* PosterHStack.swift in Sources */, + 4E02CDF92D2097D80086D480 /* ErrorView.swift in Sources */, E1722DB229491C3900CC0239 /* ImageBlurHashes.swift in Sources */, 4E9A24ED2C82BAFB0023DA83 /* EditCustomDeviceProfileView.swift in Sources */, E12CC1BC28D11E1000678D5D /* RecentlyAddedViewModel.swift in Sources */, @@ -5445,7 +5449,6 @@ E1575E80293E77CF001665B1 /* VideoPlayerViewModel.swift in Sources */, E1C926122887565C002A7A66 /* SeriesItemContentView.swift in Sources */, E193D53727193F8700900D82 /* MediaCoordinator.swift in Sources */, - E12CC1CB28D1333400678D5D /* CinematicResumeItemView.swift in Sources */, 4E2AC4D42C6C4C1200DD600D /* OrderedSectionSelectorView.swift in Sources */, E1575E9C293E7B1E001665B1 /* Collection.swift in Sources */, E1C9260F2887565C002A7A66 /* AttributeHStack.swift in Sources */, @@ -5502,6 +5505,8 @@ E1D37F492B9C648E00343D2B /* MaxHeightText.swift in Sources */, E146A9DC2BE6E9BF0034DA1E /* StoredValues+User.swift in Sources */, E154677A289AF48200087E35 /* CollectionItemContentView.swift in Sources */, + 4E9DCE692D31680C009186EF /* HomeLoadingView.swift in Sources */, + 4E84AE2E2D275301004AA0F1 /* ContinueWatchingView.swift in Sources */, E1DABAFC2A270EE7008AC34A /* MediaSourcesCard.swift in Sources */, E193D53D27193F9700900D82 /* UserSignInCoordinator.swift in Sources */, E14EDEC62B8FB64E000F00A4 /* AnyItemFilter.swift in Sources */, @@ -5594,7 +5599,6 @@ E1E1643A28BAC2EF00323B0A /* SearchView.swift in Sources */, E1763A642BF3C9AA004DF6AB /* ListRowButton.swift in Sources */, E1388A43293F0AAD009721B1 /* PreferenceUIHostingController.swift in Sources */, - E12CC1C728D12FD600678D5D /* CinematicRecentlyAddedView.swift in Sources */, E1153D942BBA3D3000424D36 /* EpisodeContent.swift in Sources */, E11BDF982B865F550045C54A /* ItemTag.swift in Sources */, E1DC9848296DEFF500982F06 /* FavoriteIndicator.swift in Sources */, diff --git a/Translations/en.lproj/Localizable.strings b/Translations/en.lproj/Localizable.strings index 3433af752..444add596 100644 --- a/Translations/en.lproj/Localizable.strings +++ b/Translations/en.lproj/Localizable.strings @@ -1183,6 +1183,9 @@ /// No title "noTitle" = "No title"; +/// This Jellyfin server does not have any libraries that are active in Swiftfin. Swiftfin only supports Shows & Movies at this time. +"noValidLibrariesError" = "This Jellyfin server does not have any libraries that are active in Swiftfin. Swiftfin only supports Shows & Movies at this time."; + /// Official Rating "officialRating" = "Official Rating"; @@ -1387,6 +1390,9 @@ /// The number of reference frames is not supported "refFramesNotSupported" = "The number of reference frames is not supported"; +/// Refresh +"refresh" = "Refresh"; + /// Refresh Metadata "refreshMetadata" = "Refresh Metadata";