diff --git a/Ruddarr/Localizable.xcstrings b/Ruddarr/Localizable.xcstrings index 7414d64c..113fbf82 100644 --- a/Ruddarr/Localizable.xcstrings +++ b/Ruddarr/Localizable.xcstrings @@ -3835,6 +3835,26 @@ } } }, + "Popular Movies" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Popular Movies" + } + } + } + }, + "Popular Series" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Popular Series" + } + } + } + }, "Popular This Week" : { "localizations" : { "en" : { @@ -3907,6 +3927,10 @@ } } }, + "Preview Unavailable" : { + "comment" : "A label describing a movie preview that is unavailable.", + "isCommentAutoGenerated" : true + }, "Previous Airing" : { "localizations" : { "en" : { @@ -4500,6 +4524,46 @@ } } }, + "See all popular movies" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "See all popular movies" + } + } + } + }, + "See all popular series" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "See all popular series" + } + } + } + }, + "See all upcoming movies" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "See all upcoming movies" + } + } + } + }, + "See all upcoming series" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "See all upcoming series" + } + } + } + }, "Seeders" : { "comment" : "Release filter", "localizations" : { @@ -5108,6 +5172,14 @@ } } }, + "Unable to open this movie preview." : { + "comment" : "A message displayed when a movie preview cannot be opened.", + "isCommentAutoGenerated" : true + }, + "Unable to open this series preview." : { + "comment" : "A description of the error that occurs when trying to open a series preview.", + "isCommentAutoGenerated" : true + }, "Unaired" : { "comment" : "(Single word) Episode status label", "localizations" : { @@ -5260,6 +5332,26 @@ } } }, + "Upcoming Movies" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Upcoming Movies" + } + } + } + }, + "Upcoming Series" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Upcoming Series" + } + } + } + }, "Upgrade to Sonarr v4.0.5 or newer." : { "localizations" : { "en" : { @@ -5541,5 +5633,5 @@ } } }, - "version" : "1.0" + "version" : "1.1" } \ No newline at end of file diff --git a/Ruddarr/Services/Discovery.swift b/Ruddarr/Services/Discovery.swift index 14d3f9df..816a27a2 100644 --- a/Ruddarr/Services/Discovery.swift +++ b/Ruddarr/Services/Discovery.swift @@ -4,10 +4,13 @@ import SwiftUI @Observable class Discovery { static let shared = Discovery() - static let url: String = "https://api.ruddarr.com" + static let url: String = "http://192.168.40.73:8787" + static let railItemLimit = 6 - private var movieItems: DiscoveryItems? - private var seriesItems: DiscoveryItems? + private var moviePopularItems: DiscoveryItems? + private var movieUpcomingItems: DiscoveryItems? + private var seriesPopularItems: DiscoveryItems? + private var seriesUpcomingItems: DiscoveryItems? enum MediaType: String { case movies @@ -15,13 +18,23 @@ class Discovery { } var movies: [DiscoveryItem] { - guard let items = movieItems?.popular else { return [] } - guard Platform.deviceType == .phone else { return items } - return Array(items.prefix(24)) + items(from: moviePopularItems) + } + + var upcomingMovies: [DiscoveryItem] { + items(from: movieUpcomingItems) } var series: [DiscoveryItem] { - guard let items = seriesItems?.popular else { return [] } + items(from: seriesPopularItems) + } + + var upcomingSeries: [DiscoveryItem] { + items(from: seriesUpcomingItems) + } + + private func items(from response: DiscoveryItems?) -> [DiscoveryItem] { + guard let items = response?.popular else { return [] } guard Platform.deviceType == .phone else { return items } return Array(items.prefix(24)) } @@ -29,11 +42,21 @@ class Discovery { func fetch(_ type: MediaType) async { switch type { case .movies: - if isCurrentWindow(movieItems?.timestamp) { return } - movieItems = await load(.movies) + if !isCurrentWindow(moviePopularItems?.timestamp) { + moviePopularItems = await load(.movies, .popular) + } + + if !isCurrentWindow(movieUpcomingItems?.timestamp) { + movieUpcomingItems = await load(.movies, .upcoming) + } case .series: - if isCurrentWindow(seriesItems?.timestamp) { return } - seriesItems = await load(.series) + if !isCurrentWindow(seriesPopularItems?.timestamp) { + seriesPopularItems = await load(.series, .popular) + } + + if !isCurrentWindow(seriesUpcomingItems?.timestamp) { + seriesUpcomingItems = await load(.series, .upcoming) + } } } @@ -49,17 +72,15 @@ class Discovery { return calendar.isDateInToday(date) } - private func load(_ type: MediaType) async -> DiscoveryItems? { + private func load(_ type: MediaType, _ section: DiscoverySection) async -> DiscoveryItems? { // return PreviewData.loadObject(name: "popular-\(type.rawValue)") guard let baseURL = URL(string: Discovery.url) else { return nil } do { let url = baseURL - .appending(path: "/discover/\(type.rawValue)") - .appending(queryItems: [ - URLQueryItem(name: "language", value: Locale.current.identifier(.bcp47)) - ]) + .appending(path: "/\(section.endpoint)/\(type.rawValue)") + .appending(queryItems: queryItems(for: section)) var request = URLRequest(url: url) request.addValue("application/json", forHTTPHeaderField: "Accept") @@ -69,23 +90,79 @@ class Discovery { let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 599 guard statusCode < 400 else { - leaveBreadcrumb(.error, category: "discovery", message: "Bad status code", data: ["status": statusCode]) + leaveBreadcrumb(.error, category: "discovery", message: "Bad status code", data: [ + "status": statusCode, + "endpoint": section.endpoint, + "type": type.rawValue, + ]) return nil } return try JSONDecoder().decode(DiscoveryItems.self, from: json) } catch { - leaveBreadcrumb(.error, category: "discovery", message: "Request failed", data: ["error": error]) + leaveBreadcrumb(.error, category: "discovery", message: "Request failed", data: [ + "error": error, + "endpoint": section.endpoint, + "type": type.rawValue, + ]) } return nil } + + private func queryItems(for section: DiscoverySection) -> [URLQueryItem] { + var items: [URLQueryItem] = [] + + let language = Locale.current.identifier(.bcp47) + if !language.isEmpty { + items.append(.init(name: "language", value: language)) + } + + if section == .upcoming { + let region = Locale.current.region?.identifier ?? "US" + items.append(.init(name: "region", value: region)) + } + + return items + } } struct DiscoveryItems: Codable, Equatable { let timestamp: String let popular: [DiscoveryItem] + + enum CodingKeys: String, CodingKey { + case timestamp + case popular + case upcoming + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + timestamp = try container.decode(String.self, forKey: .timestamp) + popular = try container.decodeIfPresent([DiscoveryItem].self, forKey: .popular) + ?? container.decodeIfPresent([DiscoveryItem].self, forKey: .upcoming) + ?? [] + } + + func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(timestamp, forKey: .timestamp) + try container.encode(popular, forKey: .popular) + } +} + +enum DiscoverySection: String, Hashable { + case popular + case upcoming + + var endpoint: String { + switch self { + case .popular: "discover" + case .upcoming: "upcoming" + } + } } struct DiscoveryItem: Identifiable, Codable, Equatable { @@ -98,7 +175,7 @@ struct DiscoveryItem: Identifiable, Codable, Equatable { let vote_average: Double let vote_count: Int let score: Double - let poster_path: String + let poster_path: String? enum ItemType: String, Codable { case movie diff --git a/Ruddarr/Views/Movies/Search/MovieSearchView.swift b/Ruddarr/Views/Movies/Search/MovieSearchView.swift index 88b952b3..92296515 100644 --- a/Ruddarr/Views/Movies/Search/MovieSearchView.swift +++ b/Ruddarr/Views/Movies/Search/MovieSearchView.swift @@ -3,9 +3,10 @@ import Combine struct MovieSearchView: View { @State var searchQuery: String - @State private var searchPresented: Bool = true + @State private var searchPresented: Bool = false @Environment(RadarrInstance.self) private var instance + @Environment(\.deviceType) private var deviceType let searchTextPublisher = PassthroughSubject() @@ -13,18 +14,12 @@ struct MovieSearchView: View { @Bindable var discovery = Discovery.shared @Bindable var movieLookup = instance.lookup + let popularItems = discovery.movies + let upcomingItems = discovery.upcomingMovies + ScrollView { if movieLookup.sortedItems.isEmpty && searchQuery.isEmpty { - MediaGrid(items: discovery.movies) { item in - DiscoveryGridPoster(item: item) - } header: { - Text("Popular This Week") - .padding(.top, 12) - } - .viewBottomPadding() - .scenePadding(.horizontal) - .opacity(discovery.movies.isEmpty ? 0 : 1) - .animation(.easeIn, value: discovery.movies) + discoveryContent(popularItems, upcomingItems) } else { MediaGrid(items: movieLookup.sortedItems) { movie in NavigationLink(value: movie.exists @@ -81,6 +76,78 @@ struct MovieSearchView: View { } } + @ViewBuilder + func discoveryContent(_ popularItems: [DiscoveryItem], _ upcomingItems: [DiscoveryItem]) -> some View { + if deviceType == .phone { + phoneDiscoveryContent(popularItems, upcomingItems) + } else { + stackedDiscoveryContent(popularItems, upcomingItems) + } + } + + func isDiscoveryEmpty(_ popularItems: [DiscoveryItem], _ upcomingItems: [DiscoveryItem]) -> Bool { + popularItems.isEmpty && upcomingItems.isEmpty + } + + func phoneDiscoveryContent(_ popularItems: [DiscoveryItem], _ upcomingItems: [DiscoveryItem]) -> some View { + VStack(spacing: 20) { + if !popularItems.isEmpty { + DiscoveryRail( + title: "Popular This Week", + items: Array(popularItems.prefix(Discovery.railItemLimit)), + seeAllLabel: "See all popular movies", + destination: popularItems.count > Discovery.railItemLimit + ? MoviesPath.discover(.popular) + : nil + ) + } + + if !upcomingItems.isEmpty { + DiscoveryRail( + title: "Upcoming", + items: Array(upcomingItems.prefix(Discovery.railItemLimit)), + seeAllLabel: "See all upcoming movies", + destination: upcomingItems.count > Discovery.railItemLimit + ? MoviesPath.discover(.upcoming) + : nil + ) + } + } + .padding(.top, 12) + .viewBottomPadding() + .scenePadding(.horizontal) + .opacity(isDiscoveryEmpty(popularItems, upcomingItems) ? 0 : 1) + .animation(.easeIn, value: popularItems) + .animation(.easeIn, value: upcomingItems) + } + + func stackedDiscoveryContent(_ popularItems: [DiscoveryItem], _ upcomingItems: [DiscoveryItem]) -> some View { + VStack(spacing: 20) { + if !popularItems.isEmpty { + MediaGrid(items: popularItems) { item in + DiscoveryGridPoster(item: item) + } header: { + Text("Popular This Week") + .padding(.top, 12) + } + } + + if !upcomingItems.isEmpty { + MediaGrid(items: upcomingItems) { item in + DiscoveryGridPoster(item: item) + } header: { + Text("Upcoming") + .padding(.top, 12) + } + } + } + .viewBottomPadding() + .scenePadding(.horizontal) + .opacity(isDiscoveryEmpty(popularItems, upcomingItems) ? 0 : 1) + .animation(.easeIn, value: popularItems) + .animation(.easeIn, value: upcomingItems) + } + func performSearch() { Task { await instance.lookup.search(query: searchQuery) @@ -104,6 +171,51 @@ struct MovieSearchView: View { } } +struct MovieDiscoveryView: View { + var section: DiscoverySection + + var body: some View { + @Bindable var discovery = Discovery.shared + + ScrollView { + MediaGrid(items: items(discovery)) { item in + DiscoveryGridPoster(item: item) + } header: { + Text(header) + .padding(.top, 12) + } + .viewBottomPadding() + .scenePadding(.horizontal) + } + .navigationTitle(navigationTitle) + .safeNavigationBarTitleDisplayMode(.inline) + .task { + await discovery.fetch(.movies) + } + } + + func items(_ discovery: Discovery) -> [DiscoveryItem] { + switch section { + case .popular: discovery.movies + case .upcoming: discovery.upcomingMovies + } + } + + var header: LocalizedStringKey { + switch section { + case .popular: "Popular This Week" + case .upcoming: "Upcoming" + } + } + + var navigationTitle: LocalizedStringKey { + switch section { + case .popular: "Popular Movies" + case .upcoming: "Upcoming Movies" + } + } +} + #Preview { dependencies.router.selectedTab = .movies dependencies.router.moviesPath.append(MoviesPath.search()) diff --git a/Ruddarr/Views/MoviesView.swift b/Ruddarr/Views/MoviesView.swift index 4c8e58b5..2ba84647 100644 --- a/Ruddarr/Views/MoviesView.swift +++ b/Ruddarr/Views/MoviesView.swift @@ -3,6 +3,7 @@ import Combine enum MoviesPath: Hashable { case search(String = "") + case discover(DiscoverySection) case preview(Data?) case movie(Movie.ID) case edit(Movie.ID) @@ -49,7 +50,7 @@ struct MoviesView: View { } .task { guard !instance.isVoid else { return } - await fetchMoviesThrottled() + await fetchMoviesWithAlertThrottled(ignoreOffline: true) } .refreshable { await Task { await fetchMoviesWithAlert() }.value @@ -121,11 +122,20 @@ struct MoviesView: View { case .search(let query): MovieSearchView(searchQuery: query) .environment(instance) + case .discover(let section): + MovieDiscoveryView(section: section) + .environment(instance) case .preview(let data): - if let data, let movie = try? JSONDecoder().decode(Movie.self, from: data) { + if let movie = decodePreviewMovie(data) { MoviePreviewView(movie: movie) .environment(instance) .environmentObject(settings) + } else { + ContentUnavailableView { + Label("Preview Unavailable", systemImage: "exclamationmark.triangle") + } description: { + Text("Unable to open this movie preview.") + } } case .movie(let id): MovieView(movie: instance.movies.byId(id)) @@ -215,6 +225,21 @@ struct MoviesView: View { } } + func decodePreviewMovie(_ data: Data?) -> Movie? { + guard let data else { return nil } + + do { + return try JSONDecoder().decode(Movie.self, from: data) + } catch { + leaveBreadcrumb(.error, category: "view.movies", message: "Failed to decode movie preview", data: [ + "error": error, + "bytes": data.count, + ]) + + return nil + } + } + func updateDisplayedMovies() { instance.movies.updateCachedItems(sort, searchQuery) } @@ -236,10 +261,9 @@ struct MoviesView: View { } } - func fetchMoviesThrottled() async { + func fetchMoviesWithAlertThrottled(ignoreOffline: Bool = false) async { guard Date.now.timeIntervalSince(lastFetch) >= 15 else { return } - _ = await instance.movies.fetch() - updateDisplayedMovies() + await fetchMoviesWithAlert(ignoreOffline: ignoreOffline) lastFetch = .now } diff --git a/Ruddarr/Views/Series/Search/SeriesSearchView.swift b/Ruddarr/Views/Series/Search/SeriesSearchView.swift index c0c05b18..e90c3b47 100644 --- a/Ruddarr/Views/Series/Search/SeriesSearchView.swift +++ b/Ruddarr/Views/Series/Search/SeriesSearchView.swift @@ -3,9 +3,10 @@ import Combine struct SeriesSearchView: View { @State var searchQuery: String - @State private var searchPresented: Bool = true + @State private var searchPresented: Bool = false @Environment(SonarrInstance.self) private var instance + @Environment(\.deviceType) private var deviceType let searchTextPublisher = PassthroughSubject() @@ -13,18 +14,12 @@ struct SeriesSearchView: View { @Bindable var discovery = Discovery.shared @Bindable var seriesLookup = instance.lookup + let popularItems = discovery.series + let upcomingItems = discovery.upcomingSeries + ScrollView { if seriesLookup.sortedItems.isEmpty && searchQuery.isEmpty { - MediaGrid(items: discovery.series) { item in - DiscoveryGridPoster(item: item) - } header: { - Text("Popular This Week") - .padding(.top, 12) - } - .viewBottomPadding() - .scenePadding(.horizontal) - .opacity(discovery.series.isEmpty ? 0 : 1) - .animation(.easeIn, value: discovery.series) + discoveryContent(popularItems, upcomingItems) } else { MediaGrid(items: seriesLookup.sortedItems) { series in SeriesSearchItem(series: series) @@ -79,6 +74,78 @@ struct SeriesSearchView: View { } } + @ViewBuilder + func discoveryContent(_ popularItems: [DiscoveryItem], _ upcomingItems: [DiscoveryItem]) -> some View { + if deviceType == .phone { + phoneDiscoveryContent(popularItems, upcomingItems) + } else { + stackedDiscoveryContent(popularItems, upcomingItems) + } + } + + func isDiscoveryEmpty(_ popularItems: [DiscoveryItem], _ upcomingItems: [DiscoveryItem]) -> Bool { + popularItems.isEmpty && upcomingItems.isEmpty + } + + func phoneDiscoveryContent(_ popularItems: [DiscoveryItem], _ upcomingItems: [DiscoveryItem]) -> some View { + VStack(spacing: 20) { + if !popularItems.isEmpty { + DiscoveryRail( + title: "Popular This Week", + items: Array(popularItems.prefix(Discovery.railItemLimit)), + seeAllLabel: "See all popular series", + destination: popularItems.count > Discovery.railItemLimit + ? SeriesPath.discover(.popular) + : nil + ) + } + + if !upcomingItems.isEmpty { + DiscoveryRail( + title: "Upcoming", + items: Array(upcomingItems.prefix(Discovery.railItemLimit)), + seeAllLabel: "See all upcoming series", + destination: upcomingItems.count > Discovery.railItemLimit + ? SeriesPath.discover(.upcoming) + : nil + ) + } + } + .padding(.top, 12) + .viewBottomPadding() + .scenePadding(.horizontal) + .opacity(isDiscoveryEmpty(popularItems, upcomingItems) ? 0 : 1) + .animation(.easeIn, value: popularItems) + .animation(.easeIn, value: upcomingItems) + } + + func stackedDiscoveryContent(_ popularItems: [DiscoveryItem], _ upcomingItems: [DiscoveryItem]) -> some View { + VStack(spacing: 20) { + if !popularItems.isEmpty { + MediaGrid(items: popularItems) { item in + DiscoveryGridPoster(item: item) + } header: { + Text("Popular This Week") + .padding(.top, 12) + } + } + + if !upcomingItems.isEmpty { + MediaGrid(items: upcomingItems) { item in + DiscoveryGridPoster(item: item) + } header: { + Text("Upcoming") + .padding(.top, 12) + } + } + } + .viewBottomPadding() + .scenePadding(.horizontal) + .opacity(isDiscoveryEmpty(popularItems, upcomingItems) ? 0 : 1) + .animation(.easeIn, value: popularItems) + .animation(.easeIn, value: upcomingItems) + } + func performSearch() { Task { @MainActor in await instance.lookup.search(query: searchQuery) @@ -102,6 +169,51 @@ struct SeriesSearchView: View { } } +struct SeriesDiscoveryView: View { + var section: DiscoverySection + + var body: some View { + @Bindable var discovery = Discovery.shared + + ScrollView { + MediaGrid(items: items(discovery)) { item in + DiscoveryGridPoster(item: item) + } header: { + Text(header) + .padding(.top, 12) + } + .viewBottomPadding() + .scenePadding(.horizontal) + } + .navigationTitle(navigationTitle) + .safeNavigationBarTitleDisplayMode(.inline) + .task { + await discovery.fetch(.series) + } + } + + func items(_ discovery: Discovery) -> [DiscoveryItem] { + switch section { + case .popular: discovery.series + case .upcoming: discovery.upcomingSeries + } + } + + var header: LocalizedStringKey { + switch section { + case .popular: "Popular This Week" + case .upcoming: "Upcoming" + } + } + + var navigationTitle: LocalizedStringKey { + switch section { + case .popular: "Popular Series" + case .upcoming: "Upcoming Series" + } + } +} + struct SeriesSearchItem: View { var series: Series diff --git a/Ruddarr/Views/SeriesView.swift b/Ruddarr/Views/SeriesView.swift index 42064a45..4d2eb4f2 100644 --- a/Ruddarr/Views/SeriesView.swift +++ b/Ruddarr/Views/SeriesView.swift @@ -3,6 +3,7 @@ import Combine enum SeriesPath: Hashable { case search(String = "") + case discover(DiscoverySection) case preview(Data?) case series(Series.ID) case edit(Series.ID) @@ -50,7 +51,7 @@ struct SeriesView: View { } .task { guard !instance.isVoid else { return } - await fetchSeriesThrottled() + await fetchSeriesWithAlertThrottled(ignoreOffline: true) } .refreshable { await Task { await fetchSeriesWithAlert() }.value @@ -122,11 +123,20 @@ struct SeriesView: View { case .search(let query): SeriesSearchView(searchQuery: query) .environment(instance) + case .discover(let section): + SeriesDiscoveryView(section: section) + .environment(instance) case .preview(let data): - if let data, let series = try? JSONDecoder().decode(Series.self, from: data) { + if let series = decodePreviewSeries(data) { SeriesPreviewView(series: series) .environment(instance) .environmentObject(settings) + } else { + ContentUnavailableView { + Label("Preview Unavailable", systemImage: "exclamationmark.triangle") + } description: { + Text("Unable to open this series preview.") + } } case .series(let id): SeriesDetailView(series: instance.series.byId(id)) @@ -225,6 +235,21 @@ struct SeriesView: View { } } + func decodePreviewSeries(_ data: Data?) -> Series? { + guard let data else { return nil } + + do { + return try JSONDecoder().decode(Series.self, from: data) + } catch { + leaveBreadcrumb(.error, category: "view.series", message: "Failed to decode series preview", data: [ + "error": error, + "bytes": data.count, + ]) + + return nil + } + } + func updateDisplayedSeries() { instance.series.updateCachedItems(sort, searchQuery) } @@ -246,10 +271,9 @@ struct SeriesView: View { } } - func fetchSeriesThrottled() async { + func fetchSeriesWithAlertThrottled(ignoreOffline: Bool = false) async { guard Date.now.timeIntervalSince(lastFetch) >= 15 else { return } - _ = await instance.series.fetch() - updateDisplayedSeries() + await fetchSeriesWithAlert(ignoreOffline: ignoreOffline) lastFetch = .now } diff --git a/Ruddarr/Views/Shared/MediaGrid+Content.swift b/Ruddarr/Views/Shared/MediaGrid+Content.swift index ffeb9741..a4593987 100644 --- a/Ruddarr/Views/Shared/MediaGrid+Content.swift +++ b/Ruddarr/Views/Shared/MediaGrid+Content.swift @@ -123,11 +123,14 @@ struct DiscoveryGridPoster: View { defer { isLoading = false } do { - let result = try await radarrInstance.lookup.fetch(tmdb: item.id) + guard let result = try await radarrInstance.lookup.fetch(tmdb: item.id) else { + let message = String(format: String(localized: "No Results for \"%@\""), item.title) + self.error = API.Error(from: AppError(message)) + return + } - dependencies.router.moviesPath.append( - MoviesPath.preview(try? JSONEncoder().encode(result)) - ) + let data = try JSONEncoder().encode(result) + dependencies.router.moviesPath.append(MoviesPath.preview(data)) } catch { self.error = API.Error(from: error) } @@ -151,17 +154,81 @@ struct DiscoveryGridPoster: View { defer { isLoading = false } do { - let result = try await sonarrInstance.lookup.fetch(tmdb: item.id) + guard let result = try await sonarrInstance.lookup.fetch(tmdb: item.id) else { + let message = String(format: String(localized: "No Results for \"%@\""), item.title) + self.error = API.Error(from: AppError(message)) + return + } - dependencies.router.seriesPath.append( - SeriesPath.preview(try? JSONEncoder().encode(result)) - ) + let data = try JSONEncoder().encode(result) + dependencies.router.seriesPath.append(SeriesPath.preview(data)) } catch { self.error = API.Error(from: error) } } } +struct DiscoveryRail: View { + var title: LocalizedStringKey + var items: [DiscoveryItem] + var seeAllLabel: LocalizedStringKey + var destination: Path? + + private let posterWidth: CGFloat = 120 + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text(title) + .font(.title3.bold()) + .frame(maxWidth: .infinity, alignment: .leading) + + ScrollView(.horizontal) { + HStack(spacing: 12) { + ForEach(items) { item in + DiscoveryGridPoster(item: item) + .frame(width: posterWidth) + } + + if let destination { + NavigationLink(value: destination) { + DiscoverySeeAllPoster(label: seeAllLabel) + .frame(width: posterWidth) + } + .buttonStyle(.plain) + } + } + } + .scrollIndicators(.never) + } + } +} + +struct DiscoverySeeAllPoster: View { + var label: LocalizedStringKey + + var body: some View { + RoundedRectangle(cornerRadius: 14) + .fill(.card) + .overlay { + VStack(spacing: 8) { + Image(systemName: "arrow.right") + .imageScale(.large) + + Text(label) + .font(.footnote.weight(.semibold)) + .multilineTextAlignment(.center) + } + .foregroundStyle(.secondary) + .padding(12) + } + .aspectRatio( + CGSize(width: 150, height: 225), + contentMode: .fit + ) + .accessibilityLabel(Text(label)) + } +} + #Preview { let items: DiscoveryItems = PreviewData.loadObject(name: "popular-movies")