From 6771d3d2c97cb89bc27bfa77c011fe01ce7aac03 Mon Sep 17 00:00:00 2001 From: Ethan Pippin Date: Tue, 11 Jan 2022 16:33:37 -0700 Subject: [PATCH] initial iOS missing items --- Shared/Coordinators/SettingsCoordinator.swift | 7 + .../BaseItemDtoExtensions.swift | 27 +++ Shared/Generated/Strings.swift | 8 + .../SwiftfinStore/SwiftfinStoreDefaults.swift | 4 + ...ewModel.swift => EpisodesRowManager.swift} | 58 +++---- .../ItemViewModel/EpisodeItemViewModel.swift | 7 +- .../ItemViewModel/ItemViewModel.swift | 26 ++- .../ItemViewModel/SeasonItemViewModel.swift | 96 ++++++----- .../ItemViewModel/SeriesItemViewModel.swift | 19 +- Swiftfin.xcodeproj/project.pbxproj | 42 +++-- .../Components/EpisodeCardVStackView.swift | 108 ------------ Swiftfin/Components/EpisodesRowView.swift | 162 ------------------ .../EpisodesRowView/EpisodeRowCard.swift | 70 ++++++++ .../EpisodesRowView/EpisodesRowView.swift | 126 ++++++++++++++ Swiftfin/Views/ItemView/ItemViewBody.swift | 10 +- .../Landscape/ItemLandscapeMainView.swift | 10 +- .../Landscape/ItemLandscapeTopBarView.swift | 19 +- .../ItemPortraitHeaderOverlayView.swift | 10 ++ .../Portrait/ItemPortraitMainView.swift | 12 +- .../MissingItemsSettingsView.swift | 30 ++++ .../Views/SettingsView/SettingsView.swift | 11 ++ Translations/en.lproj/Localizable.strings | Bin 10770 -> 10916 bytes 22 files changed, 470 insertions(+), 392 deletions(-) rename Shared/ViewModels/{EpisodesRowViewModel.swift => EpisodesRowManager.swift} (59%) delete mode 100644 Swiftfin/Components/EpisodeCardVStackView.swift delete mode 100644 Swiftfin/Components/EpisodesRowView.swift create mode 100644 Swiftfin/Components/EpisodesRowView/EpisodeRowCard.swift create mode 100644 Swiftfin/Components/EpisodesRowView/EpisodesRowView.swift create mode 100644 Swiftfin/Views/SettingsView/MissingItemsSettingsView.swift 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..14ddcc8f 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,8 @@ internal enum L10n { } /// Media internal static let media = L10n.tr("Localizable", "media") + /// Missing + internal static let missing = L10n.tr("Localizable", "missing") /// More Like This internal static let moreLikeThis = L10n.tr("Localizable", "moreLikeThis") /// Movies @@ -340,6 +346,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 59% rename from Shared/ViewModels/EpisodesRowViewModel.swift rename to Shared/ViewModels/EpisodesRowManager.swift index 7ec32d00..9d0d7011 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[.shouldShowMissingEpisodes] ? nil : false) .sink { completion in self.handleAPIRequestError(completion: completion) } receiveValue: { response in @@ -43,21 +33,23 @@ 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) } } } .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 +58,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..c96fdc10 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..d10665f8 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..6346df27 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[.shouldShowMissingEpisodes] ? nil : false, enableUserData: true) .trackActivity(loading) .sink(receiveCompletion: { [weak self] completion in diff --git a/Swiftfin.xcodeproj/project.pbxproj b/Swiftfin.xcodeproj/project.pbxproj index afd265f7..994b766b 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 */; }; @@ -331,6 +331,8 @@ 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 +342,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 */; }; @@ -665,7 +666,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 = ""; }; @@ -698,6 +699,8 @@ 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 = ""; }; @@ -855,7 +857,7 @@ children = ( E1D4BF7D2719D1DC00A11E64 /* BasicAppSettingsViewModel.swift */, 625CB5762678C34300530A6E /* ConnectToServerViewModel.swift */, - E10D87E127852FD000BD264C /* EpisodesRowViewModel.swift */, + E10D87E127852FD000BD264C /* EpisodesRowManager.swift */, 625CB5722678C32A00530A6E /* HomeViewModel.swift */, E107BB9127880A4000354E07 /* ItemViewModel */, 62E632D9267D2BC40063E547 /* LatestMediaViewModel.swift */, @@ -1207,8 +1209,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 +1483,15 @@ path = ItemView; sourceTree = ""; }; + E176DE6E278E3522001EFD8D /* EpisodesRowView */ = { + isa = PBXGroup; + children = ( + E10D87DB2784EC5200BD264C /* EpisodesRowView.swift */, + E176DE6C278E30D2001EFD8D /* EpisodeRowCard.swift */, + ); + path = EpisodesRowView; + sourceTree = ""; + }; E178859C2780F5300094FBCF /* tvOSSLider */ = { isa = PBXGroup; children = ( @@ -1637,6 +1647,7 @@ E1E5D54B2783E27200692DFE /* ExperimentalSettingsView.swift */, E1E5D5472783CCF900692DFE /* OverlaySettingsView.swift */, 539B2DA4263BA5B8007FF1A4 /* SettingsView.swift */, + E176DE6F278E369F001EFD8D /* MissingItemsSettingsView.swift */, ); path = SettingsView; sourceTree = ""; @@ -2223,7 +2234,7 @@ 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 */, @@ -2269,9 +2280,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 +2297,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 +2372,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 */, @@ -2773,7 +2785,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 66; DEVELOPMENT_ASSET_PATHS = ""; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = TY84JMYEFE; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; EXCLUDED_ARCHS = ""; @@ -2810,7 +2822,7 @@ CURRENT_PROJECT_VERSION = 66; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_ASSET_PATHS = ""; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = TY84JMYEFE; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; EXCLUDED_ARCHS = ""; @@ -2841,7 +2853,7 @@ ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 66; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = TY84JMYEFE; INFOPLIST_FILE = WidgetExtension/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( @@ -2868,7 +2880,7 @@ ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 66; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = TY84JMYEFE; INFOPLIST_FILE = WidgetExtension/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( 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..e6528406 --- /dev/null +++ b/Swiftfin/Components/EpisodesRowView/EpisodesRowView.swift @@ -0,0 +1,126 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. 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 { + 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 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..14b573ac 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..189cd09a 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..92b3c571 --- /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("Show Missing Seasons", isOn: $shouldShowMissingSeasons) + Toggle("Show Missing Episodes", isOn: $shouldShowMissingEpisodes) + } header: { + Text("Missing Items") + } + } + } +} diff --git a/Swiftfin/Views/SettingsView/SettingsView.swift b/Swiftfin/Views/SettingsView/SettingsView.swift index 52f7833d..82d80b5f 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 { + Text("Missing Items") + .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 c13da6ec429a3ef754c845a816fef935d6d74fed..e8333a082536459d636bd9e33b74956867541bdd 100644 GIT binary patch delta 138 zcmbOfvLtkakd~zqLoP!mLopC$GUPF&Gbk}AFxUdI5`!kQM+92?ERj