diff --git a/Shared/Coordinators/SettingsCoordinator.swift b/Shared/Coordinators/SettingsCoordinator.swift index 49ec4775..2467a708 100644 --- a/Shared/Coordinators/SettingsCoordinator.swift +++ b/Shared/Coordinators/SettingsCoordinator.swift @@ -22,6 +22,8 @@ final class SettingsCoordinator: NavigationCoordinatable { var overlaySettings = makeOverlaySettings @Route(.push) var experimentalSettings = makeExperimentalSettings + @Route(.push) + var missingSettings = makeMissingSettings @ViewBuilder func makeServerDetail() -> some View { @@ -39,6 +41,11 @@ final class SettingsCoordinator: NavigationCoordinatable { ExperimentalSettingsView() } + @ViewBuilder + func makeMissingSettings() -> some View { + MissingItemsSettingsView() + } + @ViewBuilder func makeStart() -> some View { let viewModel = SettingsViewModel(server: SessionManager.main.currentLogin.server, user: SessionManager.main.currentLogin.user) diff --git a/Shared/Extensions/JellyfinAPIExtensions/BaseItemDtoExtensions.swift b/Shared/Extensions/JellyfinAPIExtensions/BaseItemDtoExtensions.swift index 6bb47a0c..7b787d15 100644 --- a/Shared/Extensions/JellyfinAPIExtensions/BaseItemDtoExtensions.swift +++ b/Shared/Extensions/JellyfinAPIExtensions/BaseItemDtoExtensions.swift @@ -289,4 +289,31 @@ public extension BaseItemDto { return mediaItems } + + // MARK: Missing and Unaired + + var missing: Bool { + locationType == .virtual + } + + var unaired: Bool { + if let premierDate = premiereDate { + return premierDate > Date() + } else { + return true + } + } + + var airDateLabel: String? { + guard let premiereDateFormatted = premiereDateFormatted else { return nil } + return L10n.airWithDate(premiereDateFormatted) + } + + var premiereDateFormatted: String? { + guard let premiereDate = premiereDate else { return nil } + + let dateFormatter = DateFormatter() + dateFormatter.dateStyle = .medium + return dateFormatter.string(from: premiereDate) + } } diff --git a/Shared/Generated/Strings.swift b/Shared/Generated/Strings.swift index 2d52cfe5..7ab73115 100644 --- a/Shared/Generated/Strings.swift +++ b/Shared/Generated/Strings.swift @@ -16,6 +16,10 @@ internal enum L10n { internal static let accessibility = L10n.tr("Localizable", "accessibility") /// Add URL internal static let addURL = L10n.tr("Localizable", "addURL") + /// Airs %s + internal static func airWithDate(_ p1: UnsafePointer) -> String { + return L10n.tr("Localizable", "airWithDate", p1) + } /// All Genres internal static let allGenres = L10n.tr("Localizable", "allGenres") /// All Media @@ -144,6 +148,10 @@ internal enum L10n { } /// Media internal static let media = L10n.tr("Localizable", "media") + /// Missing + internal static let missing = L10n.tr("Localizable", "missing") + /// Missing Items + internal static let missingItems = L10n.tr("Localizable", "missingItems") /// More Like This internal static let moreLikeThis = L10n.tr("Localizable", "moreLikeThis") /// Movies @@ -298,6 +306,10 @@ internal enum L10n { internal static let settings = L10n.tr("Localizable", "settings") /// Show Cast & Crew internal static let showCastAndCrew = L10n.tr("Localizable", "showCastAndCrew") + /// Show Missing Episodes + internal static let showMissingEpisodes = L10n.tr("Localizable", "showMissingEpisodes") + /// Show Missing Seasons + internal static let showMissingSeasons = L10n.tr("Localizable", "showMissingSeasons") /// Show Poster Labels internal static let showPosterLabels = L10n.tr("Localizable", "showPosterLabels") /// Signed in as %@ @@ -340,6 +352,8 @@ internal enum L10n { internal static let tvShows = L10n.tr("Localizable", "tvShows") /// Unable to connect to server internal static let unableToConnectServer = L10n.tr("Localizable", "unableToConnectServer") + /// Unaired + internal static let unaired = L10n.tr("Localizable", "unaired") /// Unauthorized internal static let unauthorized = L10n.tr("Localizable", "unauthorized") /// Unauthorized user diff --git a/Shared/SwiftfinStore/SwiftfinStoreDefaults.swift b/Shared/SwiftfinStore/SwiftfinStoreDefaults.swift index 01502ce3..5a50a736 100644 --- a/Shared/SwiftfinStore/SwiftfinStoreDefaults.swift +++ b/Shared/SwiftfinStore/SwiftfinStoreDefaults.swift @@ -58,6 +58,10 @@ extension Defaults.Keys { static let shouldShowPlayNextItem = Key("shouldShowNextItem", default: true, suite: SwiftfinStore.Defaults.generalSuite) static let shouldShowAutoPlay = Key("shouldShowAutoPlayNextItem", default: true, suite: SwiftfinStore.Defaults.generalSuite) + // Should show missing seasons and episodes + static let shouldShowMissingSeasons = Key("shouldShowMissingSeasons", default: true, suite: SwiftfinStore.Defaults.generalSuite) + static let shouldShowMissingEpisodes = Key("shouldShowMissingEpisodes", 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) diff --git a/Shared/ViewModels/EpisodesRowViewModel.swift b/Shared/ViewModels/EpisodesRowManager.swift similarity index 56% rename from Shared/ViewModels/EpisodesRowViewModel.swift rename to Shared/ViewModels/EpisodesRowManager.swift index 7ec32d00..a31d989b 100644 --- a/Shared/ViewModels/EpisodesRowViewModel.swift +++ b/Shared/ViewModels/EpisodesRowManager.swift @@ -6,36 +6,26 @@ // Copyright (c) 2022 Jellyfin & Jellyfin Contributors // +import Defaults import JellyfinAPI import SwiftUI -final class EpisodesRowViewModel: ViewModel { +protocol EpisodesRowManager: ViewModel { + var item: BaseItemDto { get } + var seasonsEpisodes: [BaseItemDto: [BaseItemDto]] { get set } + var selectedSeason: BaseItemDto? { get set } + func retrieveSeasons() + func retrieveEpisodesForSeason(_ season: BaseItemDto) + func select(season: BaseItemDto) +} - // TODO: Protocol these viewmodels for generalization instead of Episode +extension EpisodesRowManager { - @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) + // Also retrieves the current season episodes if available + func retrieveSeasons() { + TvShowsAPI.getSeasons(seriesId: item.seriesId ?? "", + userId: SessionManager.main.currentLogin.user.id, + isMissing: Defaults[.shouldShowMissingSeasons] ? nil : false) .sink { completion in self.handleAPIRequestError(completion: completion) } receiveValue: { response in @@ -43,21 +33,26 @@ final class EpisodesRowViewModel: ViewModel { seasons.forEach { season in self.seasonsEpisodes[season] = [] - if season.id == self.episodeItemViewModel.item.seasonId ?? "" { + if season.id == self.item.seasonId ?? "" { self.selectedSeason = season + self.retrieveEpisodesForSeason(season) + } else if season.id == self.item.id ?? "" { + self.selectedSeason = season + self.retrieveEpisodesForSeason(season) } } } .store(in: &cancellables) } - private func retrieveEpisodesForSeason(_ season: BaseItemDto) { + func retrieveEpisodesForSeason(_ season: BaseItemDto) { guard let seasonID = season.id else { return } - TvShowsAPI.getEpisodes(seriesId: episodeItemViewModel.item.seriesId ?? "", + TvShowsAPI.getEpisodes(seriesId: item.seriesId ?? "", userId: SessionManager.main.currentLogin.user.id, fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people], - seasonId: seasonID) + seasonId: seasonID, + isMissing: Defaults[.shouldShowMissingEpisodes] ? nil : false) .trackActivity(loading) .sink { completion in self.handleAPIRequestError(completion: completion) @@ -66,6 +61,14 @@ final class EpisodesRowViewModel: ViewModel { } .store(in: &cancellables) } + + func select(season: BaseItemDto) { + self.selectedSeason = season + + if seasonsEpisodes[season]!.isEmpty { + retrieveEpisodesForSeason(season) + } + } } final class SingleSeasonEpisodesRowViewModel: ViewModel { diff --git a/Shared/ViewModels/ItemViewModel/EpisodeItemViewModel.swift b/Shared/ViewModels/ItemViewModel/EpisodeItemViewModel.swift index fdd6f902..364f12db 100644 --- a/Shared/ViewModels/ItemViewModel/EpisodeItemViewModel.swift +++ b/Shared/ViewModels/ItemViewModel/EpisodeItemViewModel.swift @@ -11,17 +11,22 @@ import Foundation import JellyfinAPI import Stinsen -final class EpisodeItemViewModel: ItemViewModel { +final class EpisodeItemViewModel: ItemViewModel, EpisodesRowManager { @RouterObject var itemRouter: ItemCoordinator.Router? @Published var series: BaseItemDto? + @Published + var seasonsEpisodes: [BaseItemDto: [BaseItemDto]] = [:] + @Published + var selectedSeason: BaseItemDto? override init(item: BaseItemDto) { super.init(item: item) getEpisodeSeries() + retrieveSeasons() } override func getItemDisplayName() -> String { diff --git a/Shared/ViewModels/ItemViewModel/ItemViewModel.swift b/Shared/ViewModels/ItemViewModel/ItemViewModel.swift index 86fa3764..70143ce2 100644 --- a/Shared/ViewModels/ItemViewModel/ItemViewModel.swift +++ b/Shared/ViewModels/ItemViewModel/ItemViewModel.swift @@ -41,7 +41,9 @@ class ItemViewModel: ViewModel { switch item.itemType { case .episode, .movie: - self.playButtonItem = item + if !item.missing && !item.unaired { + self.playButtonItem = item + } default: () } @@ -76,6 +78,9 @@ class ItemViewModel: ViewModel { } func refreshItemVideoPlayerViewModel(for item: BaseItemDto) { + guard item.itemType == .episode || item.itemType == .movie else { return } + guard !item.missing, !item.unaired else { return } + item.createVideoPlayerViewModel() .sink { completion in self.handleAPIRequestError(completion: completion) @@ -87,6 +92,15 @@ class ItemViewModel: ViewModel { } func playButtonText() -> String { + + if item.unaired { + return L10n.unaired + } + + if item.missing { + return L10n.missing + } + if let itemProgressString = item.getItemProgressString() { return itemProgressString } @@ -103,7 +117,9 @@ class ItemViewModel: ViewModel { } func getSimilarItems() { - LibraryAPI.getSimilarItems(itemId: item.id!, userId: SessionManager.main.currentLogin.user.id, limit: 20, + LibraryAPI.getSimilarItems(itemId: item.id!, + userId: SessionManager.main.currentLogin.user.id, + limit: 10, fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people]) .trackActivity(loading) .sink(receiveCompletion: { [weak self] completion in @@ -116,7 +132,8 @@ class ItemViewModel: ViewModel { func updateWatchState() { if isWatched { - PlaystateAPI.markUnplayedItem(userId: SessionManager.main.currentLogin.user.id, itemId: item.id!) + PlaystateAPI.markUnplayedItem(userId: SessionManager.main.currentLogin.user.id, + itemId: item.id!) .trackActivity(loading) .sink(receiveCompletion: { [weak self] completion in self?.handleAPIRequestError(completion: completion) @@ -125,7 +142,8 @@ class ItemViewModel: ViewModel { }) .store(in: &cancellables) } else { - PlaystateAPI.markPlayedItem(userId: SessionManager.main.currentLogin.user.id, itemId: item.id!) + PlaystateAPI.markPlayedItem(userId: SessionManager.main.currentLogin.user.id, + itemId: item.id!) .trackActivity(loading) .sink(receiveCompletion: { [weak self] completion in self?.handleAPIRequestError(completion: completion) diff --git a/Shared/ViewModels/ItemViewModel/SeasonItemViewModel.swift b/Shared/ViewModels/ItemViewModel/SeasonItemViewModel.swift index 1c6555fc..ac2f09bc 100644 --- a/Shared/ViewModels/ItemViewModel/SeasonItemViewModel.swift +++ b/Shared/ViewModels/ItemViewModel/SeasonItemViewModel.swift @@ -11,7 +11,7 @@ import Foundation import JellyfinAPI import Stinsen -final class SeasonItemViewModel: ItemViewModel { +final class SeasonItemViewModel: ItemViewModel, EpisodesRowManager { @RouterObject var itemRouter: ItemCoordinator.Router? @@ -19,61 +19,75 @@ final class SeasonItemViewModel: ItemViewModel { var episodes: [BaseItemDto] = [] @Published var seriesItem: BaseItemDto? + @Published + var seasonsEpisodes: [BaseItemDto: [BaseItemDto]] = [:] + @Published + var selectedSeason: BaseItemDto? override init(item: BaseItemDto) { super.init(item: item) getSeriesItem() - requestEpisodes() + selectedSeason = item + retrieveSeasons() } override func playButtonText() -> String { - guard let playButtonItem = playButtonItem else { return L10n.play } - guard let episodeLocator = playButtonItem.getEpisodeLocator() else { return L10n.play } + + if item.unaired { + return L10n.unaired + } + + if item.missing { + return L10n.missing + } + + guard let playButtonItem = playButtonItem, let episodeLocator = playButtonItem.getEpisodeLocator() else { return L10n.play } return episodeLocator } - 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") - - 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") +// +// self?.setNextUpInSeason() +// }) +// .store(in: &cancellables) +// } // 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 } - - var firstUnwatchedSearch: BaseItemDto? - - 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 setNextUpInSeason() { +// guard !item.missing else { return } +// guard !episodes.isEmpty else { return } +// +// var firstUnwatchedSearch: BaseItemDto? +// +// 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 } diff --git a/Shared/ViewModels/ItemViewModel/SeriesItemViewModel.swift b/Shared/ViewModels/ItemViewModel/SeriesItemViewModel.swift index fb623eef..05dfb4e8 100644 --- a/Shared/ViewModels/ItemViewModel/SeriesItemViewModel.swift +++ b/Shared/ViewModels/ItemViewModel/SeriesItemViewModel.swift @@ -7,6 +7,7 @@ // import Combine +import Defaults import Foundation import JellyfinAPI @@ -23,8 +24,16 @@ final class SeriesItemViewModel: ItemViewModel { } override func playButtonText() -> String { - guard let playButtonItem = playButtonItem else { return L10n.play } - guard let episodeLocator = playButtonItem.getEpisodeLocator() else { return L10n.play } + + if item.unaired { + return L10n.unaired + } + + if item.missing { + return L10n.missing + } + + guard let playButtonItem = playButtonItem, let episodeLocator = playButtonItem.getEpisodeLocator() else { return L10n.play } return episodeLocator } @@ -37,12 +46,13 @@ final class SeriesItemViewModel: ItemViewModel { 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) + 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 { + if let nextUpItem = response.items?.first, !nextUpItem.unaired, !nextUpItem.missing { self?.playButtonItem = nextUpItem } }) @@ -71,6 +81,7 @@ final class SeriesItemViewModel: ItemViewModel { 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], + isMissing: Defaults[.shouldShowMissingSeasons] ? nil : false, enableUserData: true) .trackActivity(loading) .sink(receiveCompletion: { [weak self] completion in diff --git a/Swiftfin tvOS/Components/EpisodesRowView.swift b/Swiftfin tvOS/Components/EpisodesRowView.swift deleted file mode 100644 index 472edf69..00000000 --- a/Swiftfin tvOS/Components/EpisodesRowView.swift +++ /dev/null @@ -1,135 +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) 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 ?? L10n.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) - - 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) - - Spacer() - } - .frame(width: 500) - .focusable() - } else if let selectedSeason = viewModel.selectedSeason { - if viewModel.seasonsEpisodes[selectedSeason]!.isEmpty { - VStack(alignment: .leading) { - - Color.secondary - .mask(Rectangle().frame(width: 500, height: 280)) - .frame(width: 500, height: 280) - - VStack(alignment: .leading) { - Text("--") - .font(.caption) - .foregroundColor(.secondary) - L10n.noEpisodesAvailable.text - .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/EpisodesRowView/EpisodesRowCard.swift b/Swiftfin tvOS/Components/EpisodesRowView/EpisodesRowCard.swift new file mode 100644 index 00000000..4e6f3772 --- /dev/null +++ b/Swiftfin tvOS/Components/EpisodesRowView/EpisodesRowCard.swift @@ -0,0 +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 (c) 2022 Jellyfin & Jellyfin Contributors +// + +import JellyfinAPI +import SwiftUI + +struct EpisodeRowCard: View { + + @EnvironmentObject + var itemRouter: ItemCoordinator.Router + let viewModel: EpisodesRowManager + let episode: BaseItemDto + + var body: some View { + 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) + + if episode.unaired { + Text(episode.airDateLabel ?? L10n.noOverviewAvailable) + .font(.caption) + .foregroundColor(.secondary) + .fontWeight(.light) + .lineLimit(3) + } else { + Text(episode.overview ?? "") + .font(.caption) + .fontWeight(.light) + .lineLimit(4) + } + } + .padding(.horizontal) + + Spacer() + } + .frame(width: 500) + } + } + .buttonStyle(PlainButtonStyle()) + } +} diff --git a/Swiftfin tvOS/Components/EpisodesRowView/EpisodesRowView.swift b/Swiftfin tvOS/Components/EpisodesRowView/EpisodesRowView.swift new file mode 100644 index 00000000..d9e5c42d --- /dev/null +++ b/Swiftfin tvOS/Components/EpisodesRowView/EpisodesRowView.swift @@ -0,0 +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 (c) 2022 Jellyfin & Jellyfin Contributors +// + +import JellyfinAPI +import SwiftUI + +struct EpisodesRowView: View where RowManager: EpisodesRowManager { + + @EnvironmentObject + var itemRouter: ItemCoordinator.Router + @ObservedObject + var viewModel: RowManager + let onlyCurrentSeason: Bool + + var body: some View { + VStack(alignment: .leading) { + + Text(viewModel.selectedSeason?.name ?? L10n.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) + + 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) + + Spacer() + } + .frame(width: 500) + .focusable() + } else if let selectedSeason = viewModel.selectedSeason { + if let seasonEpisodes = viewModel.seasonsEpisodes[selectedSeason] { + if seasonEpisodes.isEmpty { + VStack(alignment: .leading) { + + Color.secondary + .mask(Rectangle().frame(width: 500, height: 280)) + .frame(width: 500, height: 280) + + VStack(alignment: .leading) { + Text("--") + .font(.caption) + .foregroundColor(.secondary) + L10n.noEpisodesAvailable.text + .font(.footnote) + .padding(.bottom, 1) + } + .padding(.horizontal) + + Spacer() + } + .frame(width: 500) + .focusable() + } else { + ForEach(viewModel.seasonsEpisodes[selectedSeason]!, id: \.self) { episode in + EpisodeRowCard(viewModel: viewModel, episode: episode) + .id(episode.id) + } + } + } + } + } + .padding(.horizontal, 50) + .padding(.vertical) + .onChange(of: viewModel.selectedSeason) { _ in + if viewModel.selectedSeason?.id == viewModel.item.seasonId { + reader.scrollTo(viewModel.item.id) + } + } + .onChange(of: viewModel.seasonsEpisodes) { _ in + if viewModel.selectedSeason?.id == viewModel.item.seasonId { + reader.scrollTo(viewModel.item.id) + } + } + } + .edgesIgnoringSafeArea(.horizontal) + } + } + } +} diff --git a/Swiftfin tvOS/Components/SingleSeasonEpisodesRowView.swift b/Swiftfin tvOS/Components/SingleSeasonEpisodesRowView.swift deleted file mode 100644 index 3f56739c..00000000 --- a/Swiftfin tvOS/Components/SingleSeasonEpisodesRowView.swift +++ /dev/null @@ -1,123 +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) 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) { - - L10n.episodes.text - .font(.title3) - .padding(.horizontal, 50) - - ScrollView(.horizontal) { - ScrollViewReader { _ 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) - - 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) - - Spacer() - } - .frame(width: 500) - .focusable() - } else if viewModel.episodes.isEmpty { - VStack(alignment: .leading) { - - Color.secondary - .mask(Rectangle().frame(width: 500, height: 280)) - .frame(width: 500, height: 280) - - VStack(alignment: .leading) { - Text("--") - .font(.caption) - .foregroundColor(.secondary) - L10n.noEpisodesAvailable.text - .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/Views/ItemView/CinematicItemView/CinematicEpisodeItemView.swift b/Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicEpisodeItemView.swift index b30c1778..8ce92dd3 100644 --- a/Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicEpisodeItemView.swift +++ b/Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicEpisodeItemView.swift @@ -56,7 +56,7 @@ struct CinematicEpisodeItemView: View { CinematicItemAboutView(viewModel: viewModel) - EpisodesRowView(viewModel: EpisodesRowViewModel(episodeItemViewModel: viewModel)) + EpisodesRowView(viewModel: viewModel, onlyCurrentSeason: true) .focusSection() if let seriesItem = viewModel.series { diff --git a/Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicItemViewTopRow.swift b/Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicItemViewTopRow.swift index 31e0ae20..0c95276d 100644 --- a/Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicItemViewTopRow.swift +++ b/Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicItemViewTopRow.swift @@ -123,6 +123,18 @@ struct CinematicItemViewTopRow: View { .overlay(RoundedRectangle(cornerRadius: 2) .stroke(Color.secondary, lineWidth: 1)) } + + if viewModel.item.unaired { + if let premiereDate = viewModel.item.airDateLabel { + Text(premiereDate) + .font(.subheadline) + .fontWeight(.medium) + .lineLimit(1) + } + } + + // Dud text in case nothing was shown, something is necessary for proper alignment + Text("") } else { Text("") } diff --git a/Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicSeasonItemView.swift b/Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicSeasonItemView.swift index 8fda6e65..e6e18eeb 100644 --- a/Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicSeasonItemView.swift +++ b/Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicSeasonItemView.swift @@ -53,7 +53,8 @@ struct CinematicSeasonItemView: View { CinematicItemAboutView(viewModel: viewModel) - SingleSeasonEpisodesRowView(viewModel: SingleSeasonEpisodesRowViewModel(seasonItemViewModel: viewModel)) + EpisodesRowView(viewModel: viewModel, onlyCurrentSeason: true) + .focusSection() if let seriesItem = viewModel.seriesItem { PortraitItemsRowView(rowTitle: L10n.series, diff --git a/Swiftfin tvOS/Views/SettingsView/MissingItemsSettingsView.swift b/Swiftfin tvOS/Views/SettingsView/MissingItemsSettingsView.swift new file mode 100644 index 00000000..983a9604 --- /dev/null +++ b/Swiftfin tvOS/Views/SettingsView/MissingItemsSettingsView.swift @@ -0,0 +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 (c) 2022 Jellyfin & Jellyfin Contributors +// + +import Defaults +import SwiftUI + +struct MissingItemsSettingsView: View { + + @Default(.shouldShowMissingSeasons) + var shouldShowMissingSeasons + + @Default(.shouldShowMissingEpisodes) + var shouldShowMissingEpisodes + + var body: some View { + Form { + Section { + Toggle(L10n.showMissingSeasons, isOn: $shouldShowMissingSeasons) + Toggle(L10n.showMissingEpisodes, isOn: $shouldShowMissingEpisodes) + } header: { + L10n.missingItems.text + } + } + } +} diff --git a/Swiftfin tvOS/Views/SettingsView/SettingsView.swift b/Swiftfin tvOS/Views/SettingsView/SettingsView.swift index 452cd334..212c076b 100644 --- a/Swiftfin tvOS/Views/SettingsView/SettingsView.swift +++ b/Swiftfin tvOS/Views/SettingsView/SettingsView.swift @@ -133,6 +133,17 @@ struct SettingsView: View { Section(header: L10n.accessibility.text) { Toggle(L10n.showPosterLabels, isOn: $showPosterLabels) + Button { + settingsRouter.route(to: \.missingSettings) + } label: { + HStack { + L10n.missingItems.text + .foregroundColor(.primary) + Spacer() + Image(systemName: "chevron.right") + } + } + Picker(L10n.subtitleSize, selection: $subtitleSize) { ForEach(SubtitleSize.allCases, id: \.self) { size in Text(size.label).tag(size.rawValue) diff --git a/Swiftfin.xcodeproj/project.pbxproj b/Swiftfin.xcodeproj/project.pbxproj index afd265f7..8ec7e1f9 100644 --- a/Swiftfin.xcodeproj/project.pbxproj +++ b/Swiftfin.xcodeproj/project.pbxproj @@ -266,8 +266,8 @@ E10D87DE278510E400BD264C /* PosterSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10D87DD278510E300BD264C /* PosterSize.swift */; }; E10D87DF278510E400BD264C /* PosterSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10D87DD278510E300BD264C /* PosterSize.swift */; }; E10D87E0278510E400BD264C /* PosterSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10D87DD278510E300BD264C /* PosterSize.swift */; }; - E10D87E227852FD000BD264C /* EpisodesRowViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10D87E127852FD000BD264C /* EpisodesRowViewModel.swift */; }; - E10D87E327852FD000BD264C /* EpisodesRowViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10D87E127852FD000BD264C /* EpisodesRowViewModel.swift */; }; + E10D87E227852FD000BD264C /* EpisodesRowManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10D87E127852FD000BD264C /* EpisodesRowManager.swift */; }; + E10D87E327852FD000BD264C /* EpisodesRowManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10D87E127852FD000BD264C /* EpisodesRowManager.swift */; }; E10EAA45277BB646000269ED /* JellyfinAPI in Frameworks */ = {isa = PBXBuildFile; productRef = E10EAA44277BB646000269ED /* JellyfinAPI */; }; E10EAA47277BB670000269ED /* JellyfinAPI in Frameworks */ = {isa = PBXBuildFile; productRef = E10EAA46277BB670000269ED /* JellyfinAPI */; }; E10EAA4D277BB716000269ED /* Sliders in Frameworks */ = {isa = PBXBuildFile; productRef = E10EAA4C277BB716000269ED /* Sliders */; }; @@ -325,12 +325,13 @@ E13DD4022717EE79009D4DAF /* UserListCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13DD4012717EE79009D4DAF /* UserListCoordinator.swift */; }; E13F26AF278754E300DF4761 /* CinematicSeriesItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13F26AE278754E300DF4761 /* CinematicSeriesItemView.swift */; }; E13F26B12787589300DF4761 /* CinematicSeasonItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13F26B02787589300DF4761 /* CinematicSeasonItemView.swift */; }; - E13F26B32787597300DF4761 /* SingleSeasonEpisodesRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13F26B22787597300DF4761 /* SingleSeasonEpisodesRowView.swift */; }; E14F7D0726DB36EF007C3AE6 /* ItemPortraitMainView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E14F7D0626DB36EF007C3AE6 /* ItemPortraitMainView.swift */; }; E14F7D0926DB36F7007C3AE6 /* ItemLandscapeMainView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E14F7D0826DB36F7007C3AE6 /* ItemLandscapeMainView.swift */; }; E173DA5026D048D600CC4EB7 /* ServerDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E173DA4F26D048D600CC4EB7 /* ServerDetailView.swift */; }; E173DA5226D04AAF00CC4EB7 /* ColorExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = E173DA5126D04AAF00CC4EB7 /* ColorExtension.swift */; }; E173DA5426D050F500CC4EB7 /* ServerDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E173DA5326D050F500CC4EB7 /* ServerDetailViewModel.swift */; }; + E176DE6D278E30D2001EFD8D /* EpisodeRowCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = E176DE6C278E30D2001EFD8D /* EpisodeRowCard.swift */; }; + E176DE70278E369F001EFD8D /* MissingItemsSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E176DE6F278E369F001EFD8D /* MissingItemsSettingsView.swift */; }; E178857D278037FD0094FBCF /* JellyfinAPI in Frameworks */ = {isa = PBXBuildFile; productRef = E178857C278037FD0094FBCF /* JellyfinAPI */; }; E178859B2780F1F40094FBCF /* tvOSSlider.swift in Sources */ = {isa = PBXBuildFile; fileRef = E178859A2780F1F40094FBCF /* tvOSSlider.swift */; }; E178859E2780F53B0094FBCF /* SliderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E178859D2780F53B0094FBCF /* SliderView.swift */; }; @@ -340,7 +341,6 @@ E18845F626DD631E00B0C5B7 /* BaseItemDto+Stackable.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18845F426DD631E00B0C5B7 /* BaseItemDto+Stackable.swift */; }; E18845F826DEA9C900B0C5B7 /* ItemViewBody.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18845F726DEA9C900B0C5B7 /* ItemViewBody.swift */; }; E188460026DECB9E00B0C5B7 /* ItemLandscapeTopBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18845FF26DECB9E00B0C5B7 /* ItemLandscapeTopBarView.swift */; }; - E188460426DEF04800B0C5B7 /* EpisodeCardVStackView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E188460326DEF04800B0C5B7 /* EpisodeCardVStackView.swift */; }; E19169CE272514760085832A /* HTTPScheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = E19169CD272514760085832A /* HTTPScheme.swift */; }; E19169CF272514760085832A /* HTTPScheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = E19169CD272514760085832A /* HTTPScheme.swift */; }; E19169D0272514760085832A /* HTTPScheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = E19169CD272514760085832A /* HTTPScheme.swift */; }; @@ -389,6 +389,8 @@ E1B59FD92786AE4600A5287E /* NextUpCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1B59FD82786AE4600A5287E /* NextUpCard.swift */; }; E1B6DCE8271A23780015B715 /* CombineExt in Frameworks */ = {isa = PBXBuildFile; productRef = E1B6DCE7271A23780015B715 /* CombineExt */; }; E1B6DCEA271A23880015B715 /* SwiftyJSON in Frameworks */ = {isa = PBXBuildFile; productRef = E1B6DCE9271A23880015B715 /* SwiftyJSON */; }; + E1BDE359278E9ED2004E4022 /* MissingItemsSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1BDE358278E9ED2004E4022 /* MissingItemsSettingsView.swift */; }; + E1BDE35B278EA3A3004E4022 /* EpisodesRowCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1BDE35A278EA3A3004E4022 /* EpisodesRowCard.swift */; }; E1C812BC277A8E5D00918266 /* PlaybackSpeed.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812B4277A8E5D00918266 /* PlaybackSpeed.swift */; }; E1C812BD277A8E5D00918266 /* PlayerOverlayDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812B5277A8E5D00918266 /* PlayerOverlayDelegate.swift */; }; E1C812BE277A8E5D00918266 /* VLCPlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812B6277A8E5D00918266 /* VLCPlayerViewController.swift */; }; @@ -665,7 +667,7 @@ E10D87D92784E4F100BD264C /* ItemViewDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemViewDetailsView.swift; sourceTree = ""; }; E10D87DB2784EC5200BD264C /* EpisodesRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EpisodesRowView.swift; sourceTree = ""; }; E10D87DD278510E300BD264C /* PosterSize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PosterSize.swift; sourceTree = ""; }; - E10D87E127852FD000BD264C /* EpisodesRowViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EpisodesRowViewModel.swift; sourceTree = ""; }; + E10D87E127852FD000BD264C /* EpisodesRowManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EpisodesRowManager.swift; sourceTree = ""; }; E10EAA4E277BBCC4000269ED /* CGSizeExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGSizeExtensions.swift; sourceTree = ""; }; E10EAA52277BBD17000269ED /* BaseItemDto+VideoPlayerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BaseItemDto+VideoPlayerViewModel.swift"; sourceTree = ""; }; E11B1B6B2718CD68006DA3E8 /* JellyfinAPIError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JellyfinAPIError.swift; sourceTree = ""; }; @@ -692,12 +694,13 @@ E13DD4012717EE79009D4DAF /* UserListCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserListCoordinator.swift; sourceTree = ""; }; E13F26AE278754E300DF4761 /* CinematicSeriesItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CinematicSeriesItemView.swift; sourceTree = ""; }; E13F26B02787589300DF4761 /* CinematicSeasonItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CinematicSeasonItemView.swift; sourceTree = ""; }; - E13F26B22787597300DF4761 /* SingleSeasonEpisodesRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SingleSeasonEpisodesRowView.swift; sourceTree = ""; }; E14F7D0626DB36EF007C3AE6 /* ItemPortraitMainView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemPortraitMainView.swift; sourceTree = ""; }; E14F7D0826DB36F7007C3AE6 /* ItemLandscapeMainView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemLandscapeMainView.swift; sourceTree = ""; }; E173DA4F26D048D600CC4EB7 /* ServerDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerDetailView.swift; sourceTree = ""; }; E173DA5126D04AAF00CC4EB7 /* ColorExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorExtension.swift; sourceTree = ""; }; E173DA5326D050F500CC4EB7 /* ServerDetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerDetailViewModel.swift; sourceTree = ""; }; + E176DE6C278E30D2001EFD8D /* EpisodeRowCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EpisodeRowCard.swift; sourceTree = ""; }; + E176DE6F278E369F001EFD8D /* MissingItemsSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MissingItemsSettingsView.swift; sourceTree = ""; }; E178859A2780F1F40094FBCF /* tvOSSlider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = tvOSSlider.swift; sourceTree = ""; }; E178859D2780F53B0094FBCF /* SliderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SliderView.swift; sourceTree = ""; }; E178859F2780F55C0094FBCF /* tvOSVLCOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = tvOSVLCOverlay.swift; sourceTree = ""; }; @@ -705,7 +708,6 @@ E18845F426DD631E00B0C5B7 /* BaseItemDto+Stackable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BaseItemDto+Stackable.swift"; sourceTree = ""; }; E18845F726DEA9C900B0C5B7 /* ItemViewBody.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemViewBody.swift; sourceTree = ""; }; E18845FF26DECB9E00B0C5B7 /* ItemLandscapeTopBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemLandscapeTopBarView.swift; sourceTree = ""; }; - E188460326DEF04800B0C5B7 /* EpisodeCardVStackView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EpisodeCardVStackView.swift; sourceTree = ""; }; E19169CD272514760085832A /* HTTPScheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPScheme.swift; sourceTree = ""; }; E193D4D727193CAC00900D82 /* PortraitImageStackable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PortraitImageStackable.swift; sourceTree = ""; }; E193D4DA27193CCA00900D82 /* PillStackable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PillStackable.swift; sourceTree = ""; }; @@ -726,6 +728,8 @@ E1AD106126D9B7CD003E4A08 /* ItemPortraitHeaderOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemPortraitHeaderOverlayView.swift; sourceTree = ""; }; E1B59FD42786ADE500A5287E /* ContinueWatchingCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContinueWatchingCard.swift; sourceTree = ""; }; E1B59FD82786AE4600A5287E /* NextUpCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NextUpCard.swift; sourceTree = ""; }; + E1BDE358278E9ED2004E4022 /* MissingItemsSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MissingItemsSettingsView.swift; sourceTree = ""; }; + E1BDE35A278EA3A3004E4022 /* EpisodesRowCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EpisodesRowCard.swift; sourceTree = ""; }; E1C812B4277A8E5D00918266 /* PlaybackSpeed.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlaybackSpeed.swift; sourceTree = ""; }; E1C812B5277A8E5D00918266 /* PlayerOverlayDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlayerOverlayDelegate.swift; sourceTree = ""; }; E1C812B6277A8E5D00918266 /* VLCPlayerViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VLCPlayerViewController.swift; sourceTree = ""; }; @@ -855,7 +859,7 @@ children = ( E1D4BF7D2719D1DC00A11E64 /* BasicAppSettingsViewModel.swift */, 625CB5762678C34300530A6E /* ConnectToServerViewModel.swift */, - E10D87E127852FD000BD264C /* EpisodesRowViewModel.swift */, + E10D87E127852FD000BD264C /* EpisodesRowManager.swift */, 625CB5722678C32A00530A6E /* HomeViewModel.swift */, E107BB9127880A4000354E07 /* ItemViewModel */, 62E632D9267D2BC40063E547 /* LatestMediaViewModel.swift */, @@ -991,7 +995,7 @@ 536D3D77267BB9650004248C /* Components */ = { isa = PBXGroup; children = ( - E1E5D5382783A56B00692DFE /* EpisodesRowView.swift */, + E1BDE35C278EA3A7004E4022 /* EpisodesRowView */, E103A6A1278A7EB500820EC7 /* HomeCinematicView */, E1E5D5432783BB5100692DFE /* ItemDetailsView.swift */, 531690F6267ACC00005D8AB9 /* LandscapeItemElement.swift */, @@ -1002,7 +1006,6 @@ E1E5D5412783B33900692DFE /* PortraitItemsRowView.swift */, 536D3D87267C17350004248C /* PublicUserButton.swift */, E17885A3278105170094FBCF /* SFSymbolButton.swift */, - E13F26B22787597300DF4761 /* SingleSeasonEpisodesRowView.swift */, ); path = Components; sourceTree = ""; @@ -1207,8 +1210,7 @@ 53F866422687A45400DCD1D7 /* Components */ = { isa = PBXGroup; children = ( - E188460326DEF04800B0C5B7 /* EpisodeCardVStackView.swift */, - E10D87DB2784EC5200BD264C /* EpisodesRowView.swift */, + E176DE6E278E3522001EFD8D /* EpisodesRowView */, E1AD105B26D9ABDD003E4A08 /* PillHStackView.swift */, E1AD105526D981CE003E4A08 /* PortraitHStackView.swift */, C4BE076D2720FEA8003F4AD1 /* PortraitItemElement.swift */, @@ -1482,6 +1484,15 @@ path = ItemView; sourceTree = ""; }; + E176DE6E278E3522001EFD8D /* EpisodesRowView */ = { + isa = PBXGroup; + children = ( + E10D87DB2784EC5200BD264C /* EpisodesRowView.swift */, + E176DE6C278E30D2001EFD8D /* EpisodeRowCard.swift */, + ); + path = EpisodesRowView; + sourceTree = ""; + }; E178859C2780F5300094FBCF /* tvOSSLider */ = { isa = PBXGroup; children = ( @@ -1599,6 +1610,15 @@ path = NextUpView; sourceTree = ""; }; + E1BDE35C278EA3A7004E4022 /* EpisodesRowView */ = { + isa = PBXGroup; + children = ( + E1E5D5382783A56B00692DFE /* EpisodesRowView.swift */, + E1BDE35A278EA3A3004E4022 /* EpisodesRowCard.swift */, + ); + path = EpisodesRowView; + sourceTree = ""; + }; E1C812CF277AE4C700918266 /* VideoPlayerCoordinator */ = { isa = PBXGroup; children = ( @@ -1637,6 +1657,7 @@ E1E5D54B2783E27200692DFE /* ExperimentalSettingsView.swift */, E1E5D5472783CCF900692DFE /* OverlaySettingsView.swift */, 539B2DA4263BA5B8007FF1A4 /* SettingsView.swift */, + E176DE6F278E369F001EFD8D /* MissingItemsSettingsView.swift */, ); path = SettingsView; sourceTree = ""; @@ -1645,6 +1666,7 @@ isa = PBXGroup; children = ( E1E5D5502783E67700692DFE /* ExperimentalSettingsView.swift */, + E1BDE358278E9ED2004E4022 /* MissingItemsSettingsView.swift */, E1E5D54E2783E67100692DFE /* OverlaySettingsView.swift */, 5398514426B64DA100101B49 /* SettingsView.swift */, ); @@ -2147,6 +2169,7 @@ E103A6A0278A7E4500820EC7 /* UICinematicBackgroundView.swift in Sources */, E100720726BDABC100CE3E31 /* MediaPlayButtonRowView.swift in Sources */, E17885A02780F55C0094FBCF /* tvOSVLCOverlay.swift in Sources */, + E1BDE359278E9ED2004E4022 /* MissingItemsSettingsView.swift in Sources */, E193D54D2719426600900D82 /* LibraryFilterView.swift in Sources */, C4BE07892728448B003F4AD1 /* LiveTVChannelsCoordinator.swift in Sources */, E193D53927193F8E00900D82 /* SearchCoordinator.swift in Sources */, @@ -2156,7 +2179,6 @@ E1D4BF852719D25A00A11E64 /* TrackLanguage.swift in Sources */, 53272532268BF09D0035FBF1 /* MediaViewActionButton.swift in Sources */, 531690F0267ABF72005D8AB9 /* NextUpView.swift in Sources */, - E13F26B32787597300DF4761 /* SingleSeasonEpisodesRowView.swift in Sources */, E193D53427193F7F00900D82 /* HomeCoordinator.swift in Sources */, E193D5502719430400900D82 /* ServerDetailView.swift in Sources */, E11B1B6D2718CD68006DA3E8 /* JellyfinAPIError.swift in Sources */, @@ -2223,11 +2245,12 @@ E13F26B12787589300DF4761 /* CinematicSeasonItemView.swift in Sources */, E1E5D5442783BB5100692DFE /* ItemDetailsView.swift in Sources */, 536D3D74267BA8170004248C /* BackgroundManager.swift in Sources */, - E10D87E327852FD000BD264C /* EpisodesRowViewModel.swift in Sources */, + E10D87E327852FD000BD264C /* EpisodesRowManager.swift in Sources */, 535870632669D21600D05A09 /* JellyfinPlayer_tvOSApp.swift in Sources */, E1D4BF8F271A079A00A11E64 /* BasicAppSettingsView.swift in Sources */, E193D547271941C500900D82 /* UserListView.swift in Sources */, E1D4BF7F2719D1DD00A11E64 /* BasicAppSettingsViewModel.swift in Sources */, + E1BDE35B278EA3A3004E4022 /* EpisodesRowCard.swift in Sources */, E193D53227193F7B00900D82 /* ConnectToServerCoodinator.swift in Sources */, 53ABFDE4267974EF00886593 /* LibraryListViewModel.swift in Sources */, 5364F456266CA0DC0026ECBA /* BaseItemPersonExtensions.swift in Sources */, @@ -2269,9 +2292,10 @@ 62C29EA126D102A500C1D2E7 /* iOSMainTabCoordinator.swift in Sources */, C4BE076E2720FEA8003F4AD1 /* PortraitItemElement.swift in Sources */, E1C812C0277A8E5D00918266 /* VLCPlayerView.swift in Sources */, + E176DE6D278E30D2001EFD8D /* EpisodeRowCard.swift in Sources */, 535BAE9F2649E569005FA86D /* ItemView.swift in Sources */, E10EAA4F277BBCC4000269ED /* CGSizeExtensions.swift in Sources */, - E10D87E227852FD000BD264C /* EpisodesRowViewModel.swift in Sources */, + E10D87E227852FD000BD264C /* EpisodesRowManager.swift in Sources */, C40CD925271F8D1E000FB198 /* MovieLibrariesViewModel.swift in Sources */, 6225FCCB2663841E00E067F6 /* ParallaxHeader.swift in Sources */, 6220D0AD26D5EABB00B8E046 /* ViewExtensions.swift in Sources */, @@ -2285,7 +2309,6 @@ E13DD3E527177D15009D4DAF /* ServerListView.swift in Sources */, E18845F826DEA9C900B0C5B7 /* ItemViewBody.swift in Sources */, E173DA5426D050F500CC4EB7 /* ServerDetailViewModel.swift in Sources */, - E188460426DEF04800B0C5B7 /* EpisodeCardVStackView.swift in Sources */, E19169CE272514760085832A /* HTTPScheme.swift in Sources */, 53192D5D265AA78A008A4215 /* DeviceProfileBuilder.swift in Sources */, C4AE2C3027498D2300AE13CF /* LiveTVHomeView.swift in Sources */, @@ -2361,6 +2384,7 @@ E173DA5026D048D600CC4EB7 /* ServerDetailView.swift in Sources */, 62EC352F267666A5000E9F2D /* SessionManager.swift in Sources */, 62E632E3267D3BA60063E547 /* MovieItemViewModel.swift in Sources */, + E176DE70278E369F001EFD8D /* MissingItemsSettingsView.swift in Sources */, C4BE076F2720FEFF003F4AD1 /* PlainNavigationLinkButton.swift in Sources */, E1EBCB46278BD595009FE6E9 /* ItemOverviewView.swift in Sources */, 091B5A8A2683142E00D78B61 /* ServerDiscovery.swift in Sources */, diff --git a/Swiftfin/Components/EpisodeCardVStackView.swift b/Swiftfin/Components/EpisodeCardVStackView.swift deleted file mode 100644 index 8e959ab1..00000000 --- a/Swiftfin/Components/EpisodeCardVStackView.swift +++ /dev/null @@ -1,108 +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) 2022 Jellyfin & Jellyfin Contributors -// - -import JellyfinAPI -import SwiftUI - -struct EpisodeCardVStackView: View { - - 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) - - 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 { - - // 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) - - VStack(alignment: .leading) { - - // MARK: Title - - Text(item.title) - .font(.subheadline) - .fontWeight(.medium) - .foregroundColor(.primary) - .lineLimit(2) - - HStack { - Text(item.getEpisodeLocator() ?? "") - .font(.subheadline) - .fontWeight(.medium) - .foregroundColor(.secondary) - - if let runtime = item.getItemRuntime() { - Text(runtime) - .font(.subheadline) - .fontWeight(.medium) - .foregroundColor(.secondary) - } - - 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 deleted file mode 100644 index 34ca9329..00000000 --- a/Swiftfin/Components/EpisodesRowView.swift +++ /dev/null @@ -1,162 +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) 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, 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 ?? L10n.season, systemImage: "checkmark") - } else { - Text(season.name ?? L10n.season) - } - } - } - } label: { - HStack(spacing: 5) { - Text(viewModel.selectedSeason?.name ?? L10n.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) - - 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) - - L10n.noEpisodesAvailable.text - .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/EpisodesRowView/EpisodeRowCard.swift b/Swiftfin/Components/EpisodesRowView/EpisodeRowCard.swift new file mode 100644 index 00000000..412eee34 --- /dev/null +++ b/Swiftfin/Components/EpisodesRowView/EpisodeRowCard.swift @@ -0,0 +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 (c) 2022 Jellyfin & Jellyfin Contributors +// + +import JellyfinAPI +import SwiftUI + +struct EpisodeRowCard: View { + + @EnvironmentObject + var itemRouter: ItemCoordinator.Router + let viewModel: EpisodesRowManager + let episode: BaseItemDto + + var body: some View { + 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.item.id { + RoundedRectangle(cornerRadius: 6) + .stroke(Color.jellyfinPurple, lineWidth: 4) + } + } + .padding(.top) + + VStack(alignment: .leading) { + Text(episode.getEpisodeLocator() ?? "S-:E-") + .font(.footnote) + .foregroundColor(.secondary) + Text(episode.name ?? L10n.noTitle) + .font(.body) + .padding(.bottom, 1) + .lineLimit(2) + + if episode.unaired { + Text(episode.airDateLabel ?? L10n.noOverviewAvailable) + .font(.caption) + .foregroundColor(.secondary) + .fontWeight(.light) + .lineLimit(3) + } else { + Text(episode.overview ?? "") + .font(.caption) + .foregroundColor(.secondary) + .fontWeight(.light) + .lineLimit(3) + } + } + + Spacer() + } + .frame(width: 200) + .shadow(radius: 4, y: 2) + } + } + .buttonStyle(PlainButtonStyle()) + } +} diff --git a/Swiftfin/Components/EpisodesRowView/EpisodesRowView.swift b/Swiftfin/Components/EpisodesRowView/EpisodesRowView.swift new file mode 100644 index 00000000..10c5f2ff --- /dev/null +++ b/Swiftfin/Components/EpisodesRowView/EpisodesRowView.swift @@ -0,0 +1,133 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. 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 where RowManager: EpisodesRowManager { + + @EnvironmentObject + var itemRouter: ItemCoordinator.Router + @ObservedObject + var viewModel: RowManager + let onlyCurrentSeason: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + + HStack { + + if onlyCurrentSeason { + if let currentSeason = Array(viewModel.seasonsEpisodes.keys).first(where: { $0.id == viewModel.item.id }) { + Text(currentSeason.name ?? L10n.noTitle) + } + } else { + Menu { + ForEach(Array(viewModel.seasonsEpisodes.keys).sorted(by: { $0.name ?? "" < $1.name ?? "" }), id: \.self) { season in + Button { + viewModel.select(season: season) + } label: { + if season.id == viewModel.selectedSeason?.id { + Label(season.name ?? L10n.season, systemImage: "checkmark") + } else { + Text(season.name ?? L10n.season) + } + } + } + } label: { + HStack(spacing: 5) { + Text(viewModel.selectedSeason?.name ?? L10n.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) + + 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 let seasonEpisodes = viewModel.seasonsEpisodes[selectedSeason] { + if seasonEpisodes.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) + + L10n.noEpisodesAvailable.text + .font(.body) + .padding(.bottom, 1) + .lineLimit(2) + } + + Spacer() + } + .frame(width: 200) + .shadow(radius: 4, y: 2) + } else { + ForEach(seasonEpisodes, id: \.self) { episode in + EpisodeRowCard(viewModel: viewModel, episode: episode) + .id(episode.id) + } + } + } + } + } + .padding(.horizontal) + .onChange(of: viewModel.selectedSeason) { _ in + if viewModel.selectedSeason?.id == viewModel.item.seasonId { + reader.scrollTo(viewModel.item.id) + } + } + .onChange(of: viewModel.seasonsEpisodes) { _ in + if viewModel.selectedSeason?.id == viewModel.item.seasonId { + reader.scrollTo(viewModel.item.id) + } + } + } + .edgesIgnoringSafeArea(.horizontal) + } + } + } +} diff --git a/Swiftfin/Views/ItemView/ItemViewBody.swift b/Swiftfin/Views/ItemView/ItemViewBody.swift index efcc03f9..c7926c3b 100644 --- a/Swiftfin/Views/ItemView/ItemViewBody.swift +++ b/Swiftfin/Views/ItemView/ItemViewBody.swift @@ -62,7 +62,7 @@ struct ItemViewBody: View { // MARK: Genres - if let genres = viewModel.item.genreItems { + if let genres = viewModel.item.genreItems, !genres.isEmpty { PillHStackView(title: L10n.genres, items: genres, selectedAction: { genre in @@ -84,7 +84,9 @@ struct ItemViewBody: View { // MARK: Episodes if let episodeViewModel = viewModel as? EpisodeItemViewModel { - EpisodesRowView(viewModel: EpisodesRowViewModel(episodeItemViewModel: episodeViewModel)) + EpisodesRowView(viewModel: episodeViewModel, onlyCurrentSeason: false) + } else if let seasonViewModel = viewModel as? SeasonItemViewModel { + EpisodesRowView(viewModel: seasonViewModel, onlyCurrentSeason: true) } // MARK: Series @@ -133,12 +135,12 @@ struct ItemViewBody: View { } } - // MARK: More Like This + // MARK: Recommended if !viewModel.similarItems.isEmpty { PortraitImageHStackView(items: viewModel.similarItems, topBarView: { - L10n.moreLikeThis.text + L10n.recommended.text .fontWeight(.semibold) .padding(.bottom) .padding(.horizontal) diff --git a/Swiftfin/Views/ItemView/Landscape/ItemLandscapeMainView.swift b/Swiftfin/Views/ItemView/Landscape/ItemLandscapeMainView.swift index f3a7b73c..62c2c103 100644 --- a/Swiftfin/Views/ItemView/Landscape/ItemLandscapeMainView.swift +++ b/Swiftfin/Views/ItemView/Landscape/ItemLandscapeMainView.swift @@ -63,14 +63,8 @@ struct ItemLandscapeMainView: View { // MARK: ItemViewBody - if let episodeViewModel = viewModel as? SeasonItemViewModel { - EpisodeCardVStackView(items: episodeViewModel.episodes) { episode in - itemRouter.route(to: \.item, episode) - } - } else { - ItemViewBody() - .environmentObject(viewModel) - } + ItemViewBody() + .environmentObject(viewModel) } } } diff --git a/Swiftfin/Views/ItemView/Landscape/ItemLandscapeTopBarView.swift b/Swiftfin/Views/ItemView/Landscape/ItemLandscapeTopBarView.swift index 50c18e47..88120eaa 100644 --- a/Swiftfin/Views/ItemView/Landscape/ItemLandscapeTopBarView.swift +++ b/Swiftfin/Views/ItemView/Landscape/ItemLandscapeTopBarView.swift @@ -26,8 +26,19 @@ struct ItemLandscapeTopBarView: View { .padding(.leading, 16) .padding(.bottom, 10) - if viewModel.item.itemType.showDetails { - // MARK: Runtime + // MARK: Details + + HStack { + + if viewModel.item.unaired { + if let premiereDateLabel = viewModel.item.airDateLabel { + Text(premiereDateLabel) + .font(.subheadline) + .fontWeight(.medium) + .foregroundColor(.secondary) + .lineLimit(1) + } + } if let runtime = viewModel.item.getItemRuntime() { Text(runtime) @@ -36,11 +47,7 @@ struct ItemLandscapeTopBarView: View { .foregroundColor(.secondary) .padding(.leading, 16) } - } - // MARK: Details - - HStack { if viewModel.item.productionYear != nil { Text(String(viewModel.item.productionYear ?? 0)) .font(.subheadline) diff --git a/Swiftfin/Views/ItemView/Portrait/ItemPortraitHeaderOverlayView.swift b/Swiftfin/Views/ItemView/Portrait/ItemPortraitHeaderOverlayView.swift index 6d610b25..fec028d8 100644 --- a/Swiftfin/Views/ItemView/Portrait/ItemPortraitHeaderOverlayView.swift +++ b/Swiftfin/Views/ItemView/Portrait/ItemPortraitHeaderOverlayView.swift @@ -42,6 +42,16 @@ struct PortraitHeaderOverlayView: View { // MARK: Details HStack { + if viewModel.item.unaired { + if let premiereDateLabel = viewModel.item.airDateLabel { + Text(premiereDateLabel) + .font(.subheadline) + .fontWeight(.medium) + .foregroundColor(.secondary) + .lineLimit(1) + } + } + if viewModel.shouldDisplayRuntime() { if let runtime = viewModel.item.getItemRuntime() { Text(runtime) diff --git a/Swiftfin/Views/ItemView/Portrait/ItemPortraitMainView.swift b/Swiftfin/Views/ItemView/Portrait/ItemPortraitMainView.swift index 2b3ecc71..0c6403b1 100644 --- a/Swiftfin/Views/ItemView/Portrait/ItemPortraitMainView.swift +++ b/Swiftfin/Views/ItemView/Portrait/ItemPortraitMainView.swift @@ -46,16 +46,8 @@ struct ItemPortraitMainView: View { 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) - } + ItemViewBody() + .environmentObject(viewModel) } } } diff --git a/Swiftfin/Views/SettingsView/MissingItemsSettingsView.swift b/Swiftfin/Views/SettingsView/MissingItemsSettingsView.swift new file mode 100644 index 00000000..983a9604 --- /dev/null +++ b/Swiftfin/Views/SettingsView/MissingItemsSettingsView.swift @@ -0,0 +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 (c) 2022 Jellyfin & Jellyfin Contributors +// + +import Defaults +import SwiftUI + +struct MissingItemsSettingsView: View { + + @Default(.shouldShowMissingSeasons) + var shouldShowMissingSeasons + + @Default(.shouldShowMissingEpisodes) + var shouldShowMissingEpisodes + + var body: some View { + Form { + Section { + Toggle(L10n.showMissingSeasons, isOn: $shouldShowMissingSeasons) + Toggle(L10n.showMissingEpisodes, isOn: $shouldShowMissingEpisodes) + } header: { + L10n.missingItems.text + } + } + } +} diff --git a/Swiftfin/Views/SettingsView/SettingsView.swift b/Swiftfin/Views/SettingsView/SettingsView.swift index 52f7833d..de5d8f39 100644 --- a/Swiftfin/Views/SettingsView/SettingsView.swift +++ b/Swiftfin/Views/SettingsView/SettingsView.swift @@ -141,6 +141,17 @@ struct SettingsView: View { Toggle(L10n.showPosterLabels, isOn: $showPosterLabels) Toggle(L10n.showCastAndCrew, isOn: $showCastAndCrew) + Button { + settingsRouter.route(to: \.missingSettings) + } label: { + HStack { + L10n.missingItems.text + .foregroundColor(.primary) + Spacer() + Image(systemName: "chevron.right") + } + } + Picker(L10n.appearance, selection: $appAppearance) { ForEach(AppAppearance.allCases, id: \.self) { appearance in Text(appearance.localizedName).tag(appearance.rawValue) diff --git a/Translations/en.lproj/Localizable.strings b/Translations/en.lproj/Localizable.strings index c13da6ec..4d839fe9 100644 Binary files a/Translations/en.lproj/Localizable.strings and b/Translations/en.lproj/Localizable.strings differ