From 77725b751214fa3e4dfc6b8a03ea7c5235bc2603 Mon Sep 17 00:00:00 2001 From: Matt Welch Date: Tue, 14 Apr 2026 18:11:27 -0400 Subject: [PATCH] Upcoming screen added to discover section, selectable by Popular/Upcoming toggle --- Ruddarr/Localizable.xcstrings | 10 ++- Ruddarr/Services/Discovery.swift | 59 +++++++++++--- .../Views/Movies/Search/MovieSearchView.swift | 78 +++++++++++++------ .../Series/Search/SeriesSearchView.swift | 70 ++++++++++++----- Ruddarr/Views/Shared/MediaGrid+Content.swift | 6 +- 5 files changed, 165 insertions(+), 58 deletions(-) diff --git a/Ruddarr/Localizable.xcstrings b/Ruddarr/Localizable.xcstrings index 0e13d4d2..e7f59230 100644 --- a/Ruddarr/Localizable.xcstrings +++ b/Ruddarr/Localizable.xcstrings @@ -1031,6 +1031,10 @@ } } }, + "Coming Soon" : { + "comment" : "A title for a section of a movie search view that shows upcoming movies.", + "isCommentAutoGenerated" : true + }, "Connect a %@ instance under %@." : { "localizations" : { "en" : { @@ -1342,6 +1346,10 @@ } } }, + "Discover" : { + "comment" : "A label for the \"Discover\" tab in the movie search view.", + "isCommentAutoGenerated" : true + }, "Discovering new ways of making you wait." : { "comment" : "Release search taunt", "localizations" : { @@ -5592,5 +5600,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..1bce4712 100644 --- a/Ruddarr/Services/Discovery.swift +++ b/Ruddarr/Services/Discovery.swift @@ -4,16 +4,30 @@ 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" private var movieItems: DiscoveryItems? private var seriesItems: DiscoveryItems? + private var movieUpcomingItems: DiscoveryItems? + private var seriesUpcomingItems: DiscoveryItems? enum MediaType: String { case movies case series } + enum BrowseMode: String { + case discover + case upcoming + + var label: String { + switch self { + case .discover: "Popular" + case .upcoming: "Upcoming" + } + } + } + var movies: [DiscoveryItem] { guard let items = movieItems?.popular else { return [] } guard Platform.deviceType == .phone else { return items } @@ -26,14 +40,38 @@ class Discovery { return Array(items.prefix(24)) } - func fetch(_ type: MediaType) async { + var upcomingMovies: [DiscoveryItem] { + guard let items = movieUpcomingItems?.upcoming else { return [] } + guard Platform.deviceType == .phone else { return items } + return Array(items.prefix(24)) + } + + var upcomingSeries: [DiscoveryItem] { + guard let items = seriesUpcomingItems?.upcoming else { return [] } + guard Platform.deviceType == .phone else { return items } + return Array(items.prefix(24)) + } + + func fetch(_ type: MediaType, mode: BrowseMode = .discover) async { switch type { case .movies: - if isCurrentWindow(movieItems?.timestamp) { return } - movieItems = await load(.movies) + switch mode { + case .discover: + if isCurrentWindow(movieItems?.timestamp) { return } + movieItems = await load(.movies, mode: mode) + case .upcoming: + if isCurrentWindow(movieUpcomingItems?.timestamp) { return } + movieUpcomingItems = await load(.movies, mode: mode) + } case .series: - if isCurrentWindow(seriesItems?.timestamp) { return } - seriesItems = await load(.series) + switch mode { + case .discover: + if isCurrentWindow(seriesItems?.timestamp) { return } + seriesItems = await load(.series, mode: mode) + case .upcoming: + if isCurrentWindow(seriesUpcomingItems?.timestamp) { return } + seriesUpcomingItems = await load(.series, mode: mode) + } } } @@ -49,14 +87,14 @@ class Discovery { return calendar.isDateInToday(date) } - private func load(_ type: MediaType) async -> DiscoveryItems? { + private func load(_ type: MediaType, mode: BrowseMode = .discover) 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(path: "/\(mode.rawValue)/\(type.rawValue)") .appending(queryItems: [ URLQueryItem(name: "language", value: Locale.current.identifier(.bcp47)) ]) @@ -85,7 +123,8 @@ class Discovery { struct DiscoveryItems: Codable, Equatable { let timestamp: String - let popular: [DiscoveryItem] + let popular: [DiscoveryItem]? + let upcoming: [DiscoveryItem]? } struct DiscoveryItem: Identifiable, Codable, Equatable { @@ -98,7 +137,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..d8f6280e 100644 --- a/Ruddarr/Views/Movies/Search/MovieSearchView.swift +++ b/Ruddarr/Views/Movies/Search/MovieSearchView.swift @@ -3,9 +3,11 @@ import Combine struct MovieSearchView: View { @State var searchQuery: String - @State private var searchPresented: Bool = true + @State private var searchPresented: Bool = false + @State private var browseMode: Discovery.BrowseMode = .discover @Environment(RadarrInstance.self) private var instance + @Environment(\.isSearching) private var isSearching let searchTextPublisher = PassthroughSubject() @@ -13,33 +15,46 @@ struct MovieSearchView: View { @Bindable var discovery = Discovery.shared @Bindable var movieLookup = instance.lookup - ScrollView { - if movieLookup.sortedItems.isEmpty && searchQuery.isEmpty { - MediaGrid(items: discovery.movies) { item in - DiscoveryGridPoster(item: item) - } header: { - Text("Popular This Week") - .padding(.top, 12) + VStack(spacing: 0) { + if searchQuery.isEmpty && !isSearching { + Picker(selection: $browseMode, label: EmptyView()) { + Text(Discovery.BrowseMode.discover.label).tag(Discovery.BrowseMode.discover) + Text(Discovery.BrowseMode.upcoming.label).tag(Discovery.BrowseMode.upcoming) } - .viewBottomPadding() - .scenePadding(.horizontal) - .opacity(discovery.movies.isEmpty ? 0 : 1) - .animation(.easeIn, value: discovery.movies) - } else { - MediaGrid(items: movieLookup.sortedItems) { movie in - NavigationLink(value: movie.exists - ? MoviesPath.movie(movie.id) - : MoviesPath.preview(try? JSONEncoder().encode(movie)) - ) { - MovieGridPoster(movie: movie) - }.buttonStyle(.plain) + .pickerStyle(.segmented) + .padding(.horizontal) + .padding(.vertical, 8) + } + + ScrollView { + if movieLookup.sortedItems.isEmpty && searchQuery.isEmpty { + MediaGrid(items: browseItems) { item in + DiscoveryGridPoster(item: item) + } header: { + Text(browseHeader) + .padding(.top, 12) + } + .viewBottomPadding() + .scenePadding(.horizontal) + .opacity(browseItems.isEmpty ? 0 : 1) + .animation(.easeIn, value: browseItems) + } else { + MediaGrid(items: movieLookup.sortedItems) { movie in + NavigationLink(value: movie.exists + ? MoviesPath.movie(movie.id) + : MoviesPath.preview(try? JSONEncoder().encode(movie)) + ) { + MovieGridPoster(movie: movie) + }.buttonStyle(.plain) + } + .padding(.top, 12) + .scenePadding(.horizontal) + .viewBottomPadding() } - .padding(.top, 12) - .scenePadding(.horizontal) - .viewBottomPadding() } + .id(browseMode) + .scrollDismissesKeyboard(.immediately) } - .scrollDismissesKeyboard(.immediately) .searchable( text: $searchQuery, isPresented: $searchPresented, @@ -54,6 +69,7 @@ struct MovieSearchView: View { } .task { await discovery.fetch(.movies) + await discovery.fetch(.movies, mode: .upcoming) } .onSubmit(of: .search) { searchTextPublisher.send(searchQuery) @@ -81,6 +97,20 @@ struct MovieSearchView: View { } } + var browseItems: [DiscoveryItem] { + switch browseMode { + case .discover: Discovery.shared.movies + case .upcoming: Discovery.shared.upcomingMovies + } + } + + var browseHeader: String { + switch browseMode { + case .discover: "Popular This Week" + case .upcoming: "Coming Soon" + } + } + func performSearch() { Task { await instance.lookup.search(query: searchQuery) diff --git a/Ruddarr/Views/Series/Search/SeriesSearchView.swift b/Ruddarr/Views/Series/Search/SeriesSearchView.swift index c0c05b18..0b905013 100644 --- a/Ruddarr/Views/Series/Search/SeriesSearchView.swift +++ b/Ruddarr/Views/Series/Search/SeriesSearchView.swift @@ -3,9 +3,11 @@ import Combine struct SeriesSearchView: View { @State var searchQuery: String - @State private var searchPresented: Bool = true + @State private var searchPresented: Bool = false + @State private var browseMode: Discovery.BrowseMode = .discover @Environment(SonarrInstance.self) private var instance + @Environment(\.isSearching) private var isSearching let searchTextPublisher = PassthroughSubject() @@ -13,31 +15,44 @@ struct SeriesSearchView: View { @Bindable var discovery = Discovery.shared @Bindable var seriesLookup = instance.lookup - ScrollView { - if seriesLookup.sortedItems.isEmpty && searchQuery.isEmpty { - MediaGrid(items: discovery.series) { item in - DiscoveryGridPoster(item: item) - } header: { - Text("Popular This Week") - .padding(.top, 12) + VStack(spacing: 0) { + if searchQuery.isEmpty && !isSearching { + Picker(selection: $browseMode, label: EmptyView()) { + Text(Discovery.BrowseMode.discover.label).tag(Discovery.BrowseMode.discover) + Text(Discovery.BrowseMode.upcoming.label).tag(Discovery.BrowseMode.upcoming) } - .viewBottomPadding() - .scenePadding(.horizontal) - .opacity(discovery.series.isEmpty ? 0 : 1) - .animation(.easeIn, value: discovery.series) - } else { - MediaGrid(items: seriesLookup.sortedItems) { series in - SeriesSearchItem(series: series) - .environment(instance) + .pickerStyle(.segmented) + .padding(.horizontal) + .padding(.vertical, 8) + } + + ScrollView { + if seriesLookup.sortedItems.isEmpty && searchQuery.isEmpty { + MediaGrid(items: browseItems) { item in + DiscoveryGridPoster(item: item) + } header: { + Text(browseHeader) + .padding(.top, 12) + } + .viewBottomPadding() + .scenePadding(.horizontal) + .opacity(browseItems.isEmpty ? 0 : 1) + .animation(.easeIn, value: browseItems) + } else { + MediaGrid(items: seriesLookup.sortedItems) { series in + SeriesSearchItem(series: series) + .environment(instance) + } + .padding(.top, 12) + .scenePadding(.horizontal) + .viewBottomPadding() } - .padding(.top, 12) - .scenePadding(.horizontal) - .viewBottomPadding() } + .id(browseMode) + .scrollDismissesKeyboard(.immediately) } .navigationTitle("Search") .safeNavigationBarTitleDisplayMode(.large) - .scrollDismissesKeyboard(.immediately) .searchable( text: $searchQuery, isPresented: $searchPresented, @@ -52,6 +67,7 @@ struct SeriesSearchView: View { } .task { await discovery.fetch(.series) + await discovery.fetch(.series, mode: .upcoming) } .onSubmit(of: .search) { searchTextPublisher.send(searchQuery) @@ -79,6 +95,20 @@ struct SeriesSearchView: View { } } + var browseItems: [DiscoveryItem] { + switch browseMode { + case .discover: Discovery.shared.series + case .upcoming: Discovery.shared.upcomingSeries + } + } + + var browseHeader: String { + switch browseMode { + case .discover: "Popular This Week" + case .upcoming: "Coming Soon" + } + } + func performSearch() { Task { @MainActor in await instance.lookup.search(query: searchQuery) diff --git a/Ruddarr/Views/Shared/MediaGrid+Content.swift b/Ruddarr/Views/Shared/MediaGrid+Content.swift index ffeb9741..3aa4c0d2 100644 --- a/Ruddarr/Views/Shared/MediaGrid+Content.swift +++ b/Ruddarr/Views/Shared/MediaGrid+Content.swift @@ -166,9 +166,9 @@ struct DiscoveryGridPoster: View { let items: DiscoveryItems = PreviewData.loadObject(name: "popular-movies") VStack { - DiscoveryGridPoster(item: items.popular[3]) - DiscoveryGridPoster(item: items.popular[12]) - DiscoveryGridPoster(item: items.popular[13]) + DiscoveryGridPoster(item: items.popular![3]) + DiscoveryGridPoster(item: items.popular![12]) + DiscoveryGridPoster(item: items.popular![13]) } .environment(RadarrInstance()) .environment(SonarrInstance())