From 0694fc877886f95b5492883bcf3fe20f5aafd0cf Mon Sep 17 00:00:00 2001 From: Chris Brummel Date: Tue, 2 Jun 2026 18:57:03 -0700 Subject: [PATCH] Present media details in card sheets --- Ruddarr/Dependencies/Router.swift | 104 ++++++++ Ruddarr/Models/Calendar.swift | 74 ++++++ Ruddarr/Models/Movies/Movies.swift | 8 + Ruddarr/Models/Series/Episode.swift | 2 +- Ruddarr/Models/Series/SeriesEpisodes.swift | 10 + Ruddarr/Models/Series/SeriesModel.swift | 8 + Ruddarr/Utilities/Events.swift | 59 +++++ Ruddarr/Views/Calendar/CalendarMedia.swift | 21 +- Ruddarr/Views/CalendarView.swift | 7 + Ruddarr/Views/ContentView.swift | 284 ++++++++++++++++++++- Ruddarr/Views/MoviesView.swift | 21 +- Ruddarr/Views/SeriesView.swift | 21 +- 12 files changed, 589 insertions(+), 30 deletions(-) diff --git a/Ruddarr/Dependencies/Router.swift b/Ruddarr/Dependencies/Router.swift index f833dba3..c9a5ff49 100644 --- a/Ruddarr/Dependencies/Router.swift +++ b/Ruddarr/Dependencies/Router.swift @@ -13,11 +13,115 @@ class Router { var seriesPath: NavigationPath = .init() var calendarPath: NavigationPath = .init() var settingsPath: NavigationPath = .init() + var mediaSheetRoute: MediaSheetRoute? + var mediaSheetPath: NavigationPath = .init() func reset() { moviesPath = .init() seriesPath = .init() calendarPath = .init() + mediaSheetRoute = nil + mediaSheetPath = .init() + } + + func presentMovie(_ movie: Movie) { + mediaSheetPath = .init() + mediaSheetRoute = .movie(movie) + } + + func presentSeries(_ series: Series) { + mediaSheetPath = .init() + mediaSheetRoute = .series(series) + } + + func presentEpisode(_ episode: Episode, grouped: Bool) { + let route = MediaSheetRoute.episode(episode, grouped: grouped) + mediaSheetPath = route.initialPath + mediaSheetRoute = route + } + + func dismissMediaSheet() { + mediaSheetRoute = nil + mediaSheetPath = .init() + } +} + +struct MediaSheetRoute: Identifiable { + enum Kind { + case movie + case series + } + + let id: String + let kind: Kind + let movieId: Movie.ID? + let seriesId: Series.ID? + let seasonId: Season.ID? + let episodeId: Episode.ID? + let instanceId: Instance.ID? + let movie: Movie? + let series: Series? + let episode: Episode? + + static func movie(_ movie: Movie) -> Self { + .init( + id: "movie-\(movie.id)-\(movie.instanceId?.uuidString ?? "unknown")", + kind: .movie, + movieId: movie.id, + seriesId: nil, + seasonId: nil, + episodeId: nil, + instanceId: movie.instanceId, + movie: movie, + series: nil, + episode: nil + ) + } + + static func series(_ series: Series) -> Self { + .init( + id: "series-\(series.id)-\(series.instanceId?.uuidString ?? "unknown")", + kind: .series, + movieId: nil, + seriesId: series.id, + seasonId: nil, + episodeId: nil, + instanceId: series.instanceId, + movie: nil, + series: series, + episode: nil + ) + } + + static func episode(_ episode: Episode, grouped: Bool) -> Self { + .init( + id: "series-\(episode.seriesId)-\(episode.id)-\(episode.instanceId?.uuidString ?? "unknown")", + kind: .series, + movieId: nil, + seriesId: episode.seriesId, + seasonId: episode.seasonNumber, + episodeId: grouped ? nil : episode.id, + instanceId: episode.instanceId, + movie: nil, + series: nil, + episode: episode + ) + } + + var initialPath: NavigationPath { + var path = NavigationPath() + + guard kind == .series, let seriesId, let seasonId else { + return path + } + + path.append(SeriesPath.season(seriesId, seasonId, nil)) + + if let episodeId { + path.append(SeriesPath.episode(seriesId, episodeId)) + } + + return path } } diff --git a/Ruddarr/Models/Calendar.swift b/Ruddarr/Models/Calendar.swift index 88c729f4..6860a59f 100644 --- a/Ruddarr/Models/Calendar.swift +++ b/Ruddarr/Models/Calendar.swift @@ -180,6 +180,80 @@ class MediaCalendar { calendar.startOfDay(for: Date.now).timeIntervalSince1970 } + func applyMonitoringChange(_ change: CalendarMonitoringChange) { + switch change.kind { + case .movie: + updateMovie(change) + case .series: + updateSeries(change) + case .episode: + updateEpisode(change) + } + } + + private func updateMovie(_ change: CalendarMonitoringChange) { + for day in Array(movies.keys) { + guard var items = movies[day] else { continue } + var changed = false + + for index in items.indices { + guard items[index].id == change.mediaId else { continue } + guard matches(items[index].instanceId, change.instanceId) else { continue } + + items[index].monitored = change.monitored + changed = true + } + + if changed { + movies[day] = items + } + } + } + + private func updateSeries(_ change: CalendarMonitoringChange) { + for day in Array(episodes.keys) { + guard var items = episodes[day] else { continue } + var changed = false + + for index in items.indices { + guard items[index].seriesId == change.mediaId else { continue } + guard matches(items[index].instanceId, change.instanceId) else { continue } + guard var series = items[index].series else { continue } + + series.monitored = change.monitored + items[index].series = series + changed = true + } + + if changed { + episodes[day] = items + } + } + } + + private func updateEpisode(_ change: CalendarMonitoringChange) { + for day in Array(episodes.keys) { + guard var items = episodes[day] else { continue } + var changed = false + + for index in items.indices { + guard items[index].id == change.mediaId else { continue } + guard matches(items[index].instanceId, change.instanceId) else { continue } + + items[index].monitored = change.monitored + changed = true + } + + if changed { + episodes[day] = items + } + } + } + + private func matches(_ mediaInstanceId: Instance.ID?, _ changeInstanceId: Instance.ID?) -> Bool { + changeInstanceId == nil || mediaInstanceId == nil || mediaInstanceId == changeInstanceId + } + func reset() { instances = [] dates = [] diff --git a/Ruddarr/Models/Movies/Movies.swift b/Ruddarr/Models/Movies/Movies.swift index a74e4a8c..6a6c7403 100644 --- a/Ruddarr/Models/Movies/Movies.swift +++ b/Ruddarr/Models/Movies/Movies.swift @@ -148,6 +148,14 @@ class Movies { case .update(let movie, let moveFiles): _ = try await dependencies.api.updateMovie(movie, moveFiles, instance) + NotificationCenter.default.postCalendarMonitoringChange( + .init( + kind: .movie, + mediaId: movie.id, + instanceId: movie.instanceId ?? instance.id, + monitored: movie.monitored + ) + ) case .delete(let movie, let addExclusion, let deleteFiles): _ = try await dependencies.api.deleteMovie(movie, addExclusion, deleteFiles, instance) diff --git a/Ruddarr/Models/Series/Episode.swift b/Ruddarr/Models/Series/Episode.swift index 758a57b5..ebb755f8 100644 --- a/Ruddarr/Models/Series/Episode.swift +++ b/Ruddarr/Models/Series/Episode.swift @@ -34,7 +34,7 @@ struct Episode: Identifiable, Codable, Equatable { let sceneSeasonNumber: Int? let unverifiedSceneNumbering: Bool - let series: Series? + var series: Series? var calendarGroupCount: Int? diff --git a/Ruddarr/Models/Series/SeriesEpisodes.swift b/Ruddarr/Models/Series/SeriesEpisodes.swift index c8133d42..9a6f8184 100644 --- a/Ruddarr/Models/Series/SeriesEpisodes.swift +++ b/Ruddarr/Models/Series/SeriesEpisodes.swift @@ -75,6 +75,16 @@ class SeriesEpisodes { do { _ = try await dependencies.api.monitorEpisode(episodes, monitored, instance) + for episode in episodes { + NotificationCenter.default.postCalendarMonitoringChange( + .init( + kind: .episode, + mediaId: episode, + instanceId: instance.id, + monitored: monitored + ) + ) + } } catch is CancellationError { // do nothing } catch let apiError as API.Error { diff --git a/Ruddarr/Models/Series/SeriesModel.swift b/Ruddarr/Models/Series/SeriesModel.swift index 0ecd5cc3..2fd73407 100644 --- a/Ruddarr/Models/Series/SeriesModel.swift +++ b/Ruddarr/Models/Series/SeriesModel.swift @@ -168,6 +168,14 @@ class SeriesModel { case .update(let series, let moveFiles): _ = try await dependencies.api.updateSeries(series, moveFiles, instance) + NotificationCenter.default.postCalendarMonitoringChange( + .init( + kind: .series, + mediaId: series.id, + instanceId: series.instanceId ?? instance.id, + monitored: series.monitored + ) + ) case .delete(let series, let addExclusion, let deleteFiles): _ = try await dependencies.api.deleteSeries(series, addExclusion, deleteFiles, instance) diff --git a/Ruddarr/Utilities/Events.swift b/Ruddarr/Utilities/Events.swift index 140636d6..2af7b82f 100644 --- a/Ruddarr/Utilities/Events.swift +++ b/Ruddarr/Utilities/Events.swift @@ -2,10 +2,69 @@ import Foundation extension Notification.Name { static let scrollToToday = Notification.Name("scrollToTodayInCalendar") + static let calendarMonitoringChanged = Notification.Name("calendarMonitoringChanged") +} + +struct CalendarMonitoringChange { + enum Kind: String { + case movie + case series + case episode + } + + let kind: Kind + let mediaId: Int + let instanceId: Instance.ID? + let monitored: Bool + + init(kind: Kind, mediaId: Int, instanceId: Instance.ID?, monitored: Bool) { + self.kind = kind + self.mediaId = mediaId + self.instanceId = instanceId + self.monitored = monitored + } + + init?(_ notification: Notification) { + guard let userInfo = notification.userInfo else { return nil } + guard let rawKind = userInfo[Key.kind] as? String else { return nil } + guard let kind = Kind(rawValue: rawKind) else { return nil } + guard let mediaId = userInfo[Key.mediaId] as? Int else { return nil } + guard let monitored = userInfo[Key.monitored] as? Bool else { return nil } + + self.kind = kind + self.mediaId = mediaId + self.instanceId = userInfo[Key.instanceId] as? Instance.ID + self.monitored = monitored + } + + var userInfo: [String: Any] { + var userInfo: [String: Any] = [ + Key.kind: kind.rawValue, + Key.mediaId: mediaId, + Key.monitored: monitored, + ] + + if let instanceId { + userInfo[Key.instanceId] = instanceId + } + + return userInfo + } + + private enum Key { + static let kind = "kind" + static let mediaId = "mediaId" + static let instanceId = "instanceId" + static let monitored = "monitored" + } } extension NotificationCenter { func post(name: Notification.Name) { post(name: name, object: nil) } + + func postCalendarMonitoringChange(_ change: CalendarMonitoringChange) { + post(name: .calendarMonitoringChanged, object: nil, userInfo: change.userInfo) + } } diff --git a/Ruddarr/Views/Calendar/CalendarMedia.swift b/Ruddarr/Views/Calendar/CalendarMedia.swift index 986059e2..727381be 100644 --- a/Ruddarr/Views/Calendar/CalendarMedia.swift +++ b/Ruddarr/Views/Calendar/CalendarMedia.swift @@ -37,13 +37,7 @@ struct CalendarMovie: View { .background(.card.opacity(shouldFade ? 0.6 : 1)) .clipShape(RoundedRectangle(cornerRadius: 14)) .onTapGesture { - let deeplink = String( - format: "ruddarr://movies/open/%d?instance=%@", - movie.id, - movie.instanceId!.uuidString - ) - - try? QuickActions.Deeplink(url: URL(string: deeplink)!)() + dependencies.router.presentMovie(movie) } } @@ -113,18 +107,7 @@ struct CalendarEpisode: View { .background(.card.opacity(shouldFade ? 0.6 : 1)) .clipShape(RoundedRectangle(cornerRadius: 14)) .onTapGesture { - var deeplink = String( - format: "ruddarr://series/open/%d?season=%d&instance=%@", - episode.seriesId, - episode.seasonNumber, - episode.instanceId!.uuidString - ) - - if !isGrouped { - deeplink.append("&episode=\(episode.episodeNumber)") - } - - try? QuickActions.Deeplink(url: URL(string: deeplink)!)() + dependencies.router.presentEpisode(episode, grouped: isGrouped) } } diff --git a/Ruddarr/Views/CalendarView.swift b/Ruddarr/Views/CalendarView.swift index 6b14e23a..e6c1ffce 100644 --- a/Ruddarr/Views/CalendarView.swift +++ b/Ruddarr/Views/CalendarView.swift @@ -91,6 +91,13 @@ struct CalendarView: View { scrollTo(calendar.today()) } } + .onReceive(NotificationCenter.default.publisher(for: .calendarMonitoringChanged)) { notification in + guard let change = CalendarMonitoringChange(notification) else { return } + + withAnimation(.easeOut(duration: 0.2)) { + calendar.applyMonitoringChange(change) + } + } .task { await load() } diff --git a/Ruddarr/Views/ContentView.swift b/Ruddarr/Views/ContentView.swift index f55bf042..9b5bd966 100644 --- a/Ruddarr/Views/ContentView.swift +++ b/Ruddarr/Views/ContentView.swift @@ -3,7 +3,6 @@ import SwiftUI #if os(iOS) struct ContentView: View { @EnvironmentObject var settings: AppSettings - var body: some View { TabView(selection: selectedTab) { Tab(movies.label, image: movies.icon, value: movies) { @@ -39,10 +38,18 @@ struct ContentView: View { if !isRunningIn(.preview) { dependencies.router.selectedTab = settings.tab } - UITabBarItem.appearance().badgeColor = UIColor(settings.theme.tint) } .onBecomeActive(perform: handleScenePhaseChange) + .sheet(item: dependencies.$router.mediaSheetRoute, onDismiss: { + dependencies.router.mediaSheetPath = .init() + }, content: { route in + MediaSheet(route: route) + .environmentObject(settings) + .presentationDetents([.large]) + .presentationDragIndicator(.visible) + .presentationBackground(.sheetBackground) + }) .displayToasts() .whatsNewSheet() .reportBugSheet() @@ -80,6 +87,279 @@ struct ContentView: View { } } } + +private struct MediaSheet: View { + let route: MediaSheetRoute + + @EnvironmentObject private var settings: AppSettings + + var body: some View { + switch route.kind { + case .movie: + MovieMediaSheet(route: route, instanceModel: radarrInstance) + .environmentObject(settings) + case .series: + SeriesMediaSheet(route: route, instanceModel: sonarrInstance) + .environmentObject(settings) + } + } + + var radarrInstance: Instance { + if let id = route.instanceId, let instance = settings.instanceById(id) { + return instance + } + return settings.radarrInstance ?? .radarrVoid + } + + var sonarrInstance: Instance { + if let id = route.instanceId, let instance = settings.instanceById(id) { + return instance + } + return settings.sonarrInstance ?? .sonarrVoid + } +} + +private struct MovieMediaSheet: View { + let route: MediaSheetRoute + @State private var instance: RadarrInstance + @EnvironmentObject private var settings: AppSettings + + init(route: MediaSheetRoute, instanceModel: Instance) { + self.route = route + + let instance = RadarrInstance(instanceModel) + + if let movie = route.movie { + instance.movies.items = [movie] + instance.movies.cachedItems = [movie] + instance.movies.itemsCount = 1 + } + + self._instance = State(wrappedValue: instance) + } + + var body: some View { + NavigationStack(path: dependencies.$router.mediaSheetPath) { + if let movieId = route.movieId { + destination(for: .movie(movieId)) + .navigationDestination(for: MoviesPath.self, destination: destination) + } else { + unavailable + } + } + .environment(instance) + .task { + await prepareMovie() + } + } + + @ViewBuilder + func destination(for path: MoviesPath) -> some View { + switch path { + case .search(let query): + MovieSearchView(searchQuery: query) + .environment(instance) + case .preview(let data): + moviePreview(data) + case .movie(let id): + movieContent(id) { movie in + MovieView(movie: movie) + .environment(instance) + .environmentObject(settings) + } + case .edit(let id): + movieContent(id) { movie in + MovieEditView(movie: movie) + .environment(instance) + } + case .releases(let id): + movieContent(id) { movie in + MovieReleasesView(movie: movie) + .environment(instance) + .environmentObject(settings) + } + case .metadata(let id): + movieContent(id) { movie in + MovieMetadataView(movie: movie) + .environment(instance) + } + } + } + + @ViewBuilder + func moviePreview(_ data: Data?) -> some View { + if let data, let movie = try? JSONDecoder().decode(Movie.self, from: data) { + MoviePreviewView(movie: movie) + .environment(instance) + .environmentObject(settings) + } else { + unavailable + } + } + + @ViewBuilder + func movieContent( + _ id: Movie.ID, + @ViewBuilder content: (Binding) -> Content + ) -> some View { + if instance.movies.byId(id) != nil { + content(instance.movies.byId(id)) + } else { + loading + } + } + + var loading: some View { + Loading() + .task { + await prepareMovie() + } + } + + var unavailable: some View { + ContentUnavailableView("Unable to Load Item", systemImage: "exclamationmark.triangle") + } + + func prepareMovie() async { + guard let movieId = route.movieId else { return } + guard instance.movies.byId(movieId) == nil else { return } + + _ = await instance.movies.fetch() + } +} + +private struct SeriesMediaSheet: View { + let route: MediaSheetRoute + @State private var instance: SonarrInstance + @EnvironmentObject private var settings: AppSettings + + init(route: MediaSheetRoute, instanceModel: Instance) { + self.route = route + + let instance = SonarrInstance(instanceModel) + + if var series = route.series ?? route.episode?.series { + series.instanceId = route.instanceId + instance.series.items = [series] + instance.series.cachedItems = [series] + instance.series.itemsCount = 1 + } + + if var episode = route.episode { + episode.instanceId = route.instanceId + instance.episodes.items = [episode] + } + + self._instance = State(wrappedValue: instance) + } + + var body: some View { + NavigationStack(path: dependencies.$router.mediaSheetPath) { + if let seriesId = route.seriesId { + destination(for: .series(seriesId)) + .navigationDestination(for: SeriesPath.self, destination: destination) + } else { + unavailable + } + } + .environment(instance) + .task { + await prepareSeries() + } + } + + @ViewBuilder + func destination(for path: SeriesPath) -> some View { + switch path { + case .search(let query): + SeriesSearchView(searchQuery: query) + .environment(instance) + case .preview(let data): + seriesPreview(data) + case .series(let id): + seriesContent(id) { series in + SeriesDetailView(series: series) + .environment(instance) + .environmentObject(settings) + } + case .edit(let id): + seriesContent(id) { series in + SeriesEditView(series: series) + .environment(instance) + } + case .releases(let id, let season, let episode): + seriesContent(id) { series in + SeriesReleasesView( + series: series, + seasonId: season, + episodeId: episode + ) + .environment(instance) + .environmentObject(settings) + } + case .season(let id, let season, let episode): + seriesContent(id) { series in + SeasonView(series: series, seasonId: season, jumpToEpisode: episode) + .environment(instance) + .environmentObject(settings) + } + case .episode(let id, let episode): + seriesContent(id) { series in + EpisodeView(series: series, episodeId: episode) + .environment(instance) + .environmentObject(settings) + } + } + } + + @ViewBuilder + func seriesPreview(_ data: Data?) -> some View { + if let data, let series = try? JSONDecoder().decode(Series.self, from: data) { + SeriesPreviewView(series: series) + .environment(instance) + .environmentObject(settings) + } else { + unavailable + } + } + + @ViewBuilder + func seriesContent( + _ id: Series.ID, + @ViewBuilder content: (Binding) -> Content + ) -> some View { + if instance.series.byId(id) != nil { + content(instance.series.byId(id)) + } else { + loading + } + } + + var loading: some View { + Loading() + .task { + await prepareSeries() + } + } + + var unavailable: some View { + ContentUnavailableView("Unable to Load Item", systemImage: "exclamationmark.triangle") + } + + func prepareSeries() async { + guard let seriesId = route.seriesId else { return } + + if instance.series.byId(seriesId) == nil { + _ = await instance.series.fetch() + } + + guard let series = instance.series.byId(seriesId) else { return } + + async let maybeFetchEpisodes: () = instance.episodes.maybeFetch(series) + async let maybeFetchFiles: () = instance.files.maybeFetch(series) + (_, _) = await (maybeFetchEpisodes, maybeFetchFiles) + } +} #endif #Preview { diff --git a/Ruddarr/Views/MoviesView.swift b/Ruddarr/Views/MoviesView.swift index 4c8e58b5..a9a3d9ee 100644 --- a/Ruddarr/Views/MoviesView.swift +++ b/Ruddarr/Views/MoviesView.swift @@ -149,11 +149,24 @@ struct MoviesView: View { items: instance.movies.cachedItems, style: settings.grid ) { movie in - NavigationLink(value: MoviesPath.movie(movie.id)) { - switch settings.grid { - case .posters: MovieGridPoster(movie: movie) - case .cards: MovieGridCard(movie: movie) + Group { + #if os(iOS) + Button { + dependencies.router.presentMovie(movie) + } label: { + switch settings.grid { + case .posters: MovieGridPoster(movie: movie) + case .cards: MovieGridCard(movie: movie) + } + } + #elseif os(macOS) + NavigationLink(value: MoviesPath.movie(movie.id)) { + switch settings.grid { + case .posters: MovieGridPoster(movie: movie) + case .cards: MovieGridCard(movie: movie) + } } + #endif } .buttonStyle(.plain) .id(movie.id) diff --git a/Ruddarr/Views/SeriesView.swift b/Ruddarr/Views/SeriesView.swift index 42064a45..90e07eb5 100644 --- a/Ruddarr/Views/SeriesView.swift +++ b/Ruddarr/Views/SeriesView.swift @@ -159,11 +159,24 @@ struct SeriesView: View { items: instance.series.cachedItems, style: settings.grid ) { series in - NavigationLink(value: SeriesPath.series(series.id)) { - switch settings.grid { - case .posters: SeriesGridPoster(series: series) - case .cards: SeriesGridCard(series: series) + Group { + #if os(iOS) + Button { + dependencies.router.presentSeries(series) + } label: { + switch settings.grid { + case .posters: SeriesGridPoster(series: series) + case .cards: SeriesGridCard(series: series) + } + } + #elseif os(macOS) + NavigationLink(value: SeriesPath.series(series.id)) { + switch settings.grid { + case .posters: SeriesGridPoster(series: series) + case .cards: SeriesGridCard(series: series) + } } + #endif } .buttonStyle(.plain) .id(series.id)