From 3fe969a75059b2b1d6d116cee28c6be6ef93799c Mon Sep 17 00:00:00 2001 From: Brendan Szymanski Date: Fri, 5 Jun 2026 01:48:19 -0400 Subject: [PATCH] Add movie detail, TV show, and episode list views --- .../Components/PersonCell.swift | 19 +++ .../Components/RatingBadge.swift | 12 ++ Sources/LuminateLibrary/EpisodeList.swift | 86 ++++++++++ Sources/LuminateLibrary/MovieDetailView.swift | 153 ++++++++++++++++++ Sources/LuminateLibrary/TVShowView.swift | 99 ++++++++++++ 5 files changed, 369 insertions(+) create mode 100644 Sources/LuminateLibrary/Components/PersonCell.swift create mode 100644 Sources/LuminateLibrary/Components/RatingBadge.swift create mode 100644 Sources/LuminateLibrary/EpisodeList.swift create mode 100644 Sources/LuminateLibrary/MovieDetailView.swift create mode 100644 Sources/LuminateLibrary/TVShowView.swift diff --git a/Sources/LuminateLibrary/Components/PersonCell.swift b/Sources/LuminateLibrary/Components/PersonCell.swift new file mode 100644 index 0000000..c3e59e6 --- /dev/null +++ b/Sources/LuminateLibrary/Components/PersonCell.swift @@ -0,0 +1,19 @@ +import Adwaita +import LuminateCore + +struct PersonCell: View { + var person: Components.Schemas.BaseItemPerson + var view: Body { + VStack { + Avatar(size: 60) + Text(person.Name ?? "") + .style("caption") + if let role = person.Role { + Text(role) + .style("caption") + .dim() + } + } + .frame(minWidth: 100) + } +} diff --git a/Sources/LuminateLibrary/Components/RatingBadge.swift b/Sources/LuminateLibrary/Components/RatingBadge.swift new file mode 100644 index 0000000..6e112be --- /dev/null +++ b/Sources/LuminateLibrary/Components/RatingBadge.swift @@ -0,0 +1,12 @@ +import Adwaita + +struct RatingBadge: View { + var rating: Double + var view: Body { + HStack { + Text("\u{2605}") + .style("accent") + Text(String(format: "%.1f", rating)) + } + } +} diff --git a/Sources/LuminateLibrary/EpisodeList.swift b/Sources/LuminateLibrary/EpisodeList.swift new file mode 100644 index 0000000..87c2528 --- /dev/null +++ b/Sources/LuminateLibrary/EpisodeList.swift @@ -0,0 +1,86 @@ +import Adwaita +import LuminateCore + +struct EpisodeList: View { + + var seriesId: String + var seasonId: String + var client: JellyfinClient + var userId: String + @State private var episodes: [Components.Schemas.BaseItemDto] = [] + + var view: Body { + VStack { + ForEach(episodes) { episode in + EpisodeRow( + episode: episode, + client: client + ) + } + } + .onAppear { + loadEpisodes() + } + } + + private func loadEpisodes() { + Task { + let result = try? await client.getEpisodes( + seriesId: seriesId, + seasonId: seasonId, + userId: userId + ) + await MainActor.run { episodes = result ?? [] } + } + } +} + +struct EpisodeRow: View { + + var episode: Components.Schemas.BaseItemDto + var client: JellyfinClient + @State private var imageData: Data? + + var view: Body { + HStack { + if let data = imageData { + Picture() + .data(data) + .frame(minWidth: 100, maxWidth: 100, minHeight: 56, maxHeight: 56) + .style("card") + } else { + Box(spacing: 0) {} + .frame(minWidth: 100, maxWidth: 100, minHeight: 56, maxHeight: 56) + .style("card") + } + VStack { + Text("\(episode.IndexNumber ?? 0). \(episode.Name ?? "")") + .style("body") + .halign(.start) + Text(episode.runtimeString) + .style("caption") + .halign(.start) + } + .hexpand(true) + Button(icon: .default(icon: .mediaPlaybackStart)) { + + } + .style("flat") + } + .padding(10, .horizontal) + .onAppear { + loadImage() + } + } + + private func loadImage() { + guard let tag = episode.primaryImageTag, let itemId = episode.Id else { return } + Task { + guard let url = await client.imageURL( + itemId: itemId, imageType: .Primary, tag: tag, maxWidth: 200 + ) else { return } + let service = ImageService() + imageData = try? await service.loadImage(url: url) + } + } +} diff --git a/Sources/LuminateLibrary/MovieDetailView.swift b/Sources/LuminateLibrary/MovieDetailView.swift new file mode 100644 index 0000000..e552e41 --- /dev/null +++ b/Sources/LuminateLibrary/MovieDetailView.swift @@ -0,0 +1,153 @@ +import Adwaita +import LuminateCore + +struct MovieDetailView: View { + + var item: Components.Schemas.BaseItemDto + var client: JellyfinClient + var userId: String + @State private var isFavorite: Bool + @State private var isPlayed: Bool + @State private var similarItems: [Components.Schemas.BaseItemDto] = [] + @State private var backdropData: Data? + + init(item: Components.Schemas.BaseItemDto, client: JellyfinClient, userId: String) { + self.item = item + self.client = client + self.userId = userId + _isFavorite = .init(initialValue: item.UserData?.IsFavorite ?? false) + _isPlayed = .init(initialValue: item.UserData?.Played ?? false) + } + + var view: Body { + ScrollView { + VStack { + if let data = backdropData { + Picture() + .data(data) + .frame(minHeight: 300, maxHeight: 300) + .hexpand(true) + } + HStack(alignment: .top) { + PosterCell(item: item, client: client) + .frame(minWidth: 200, maxWidth: 200) + VStack { + Text(item.Name ?? "") + .style("title-1") + .halign(.start) + HStack { + if let year = item.ProductionYear { + Text("\(year)") + } + Text(item.runtimeString) + if let rating = item.CommunityRating { + RatingBadge(rating: rating) + } + } + .halign(.start) + HStack { + Button("Play", icon: .default(icon: .mediaPlaybackStart)) { + + } + .style("suggested-action") + Button(icon: .default(icon: .bookmark)) { + toggleFavorite() + } + Button(isPlayed ? "Mark Unplayed" : "Mark Played") { + togglePlayed() + } + .style("flat") + } + .padding(10, .vertical) + if let overview = item.Overview { + Text(overview) + .style("body") + .halign(.start) + } + } + .hexpand(true) + } + .padding() + if let people = item.People, !people.isEmpty { + VStack { + Text("Cast") + .style("title-3") + .halign(.start) + FlowBox { + ForEach(people) { person in + PersonCell(person: person) + } + } + } + .padding() + } + if !similarItems.isEmpty { + VStack { + Text("Similar") + .style("title-3") + .halign(.start) + ScrollView(.horizontal) { + HStack { + ForEach(similarItems) { sim in + PosterCell(item: sim, client: client) + } + } + } + } + .padding() + } + } + } + .onAppear { + loadBackdrop() + loadSimilar() + } + } + + private func loadBackdrop() { + guard let tag = item.backdropImageTag, let itemId = item.Id else { return } + Task { + guard let url = await client.imageURL( + itemId: itemId, imageType: .Backdrop, tag: tag, maxWidth: 1920 + ) else { return } + let service = ImageService() + backdropData = try? await service.loadImage(url: url) + } + } + + private func loadSimilar() { + Task { + let result = try? await client.getItems( + userId: userId, + includeItemTypes: [.Movie], + fields: [.Overview, .Genres, .MediaSources], + sortBy: [.SortName], + limit: 10, + recursive: true + ) + await MainActor.run { similarItems = result?.Items ?? [] } + } + } + + private func toggleFavorite() { + Task { + if isFavorite { + try? await client.unmarkFavoriteItem(itemId: item.Id ?? "", userId: userId) + } else { + try? await client.markFavoriteItem(itemId: item.Id ?? "", userId: userId) + } + await MainActor.run { isFavorite.toggle() } + } + } + + private func togglePlayed() { + Task { + if isPlayed { + try? await client.markUnplayedItem(itemId: item.Id ?? "", userId: userId) + } else { + try? await client.markPlayedItem(itemId: item.Id ?? "", userId: userId) + } + await MainActor.run { isPlayed.toggle() } + } + } +} diff --git a/Sources/LuminateLibrary/TVShowView.swift b/Sources/LuminateLibrary/TVShowView.swift new file mode 100644 index 0000000..2eb77b0 --- /dev/null +++ b/Sources/LuminateLibrary/TVShowView.swift @@ -0,0 +1,99 @@ +import Adwaita +import LuminateCore + +struct TVShowView: View { + + var item: Components.Schemas.BaseItemDto + var client: JellyfinClient + var userId: String + @State private var seasons: [Components.Schemas.BaseItemDto] = [] + @State private var selectedSeasonId: String? + @State private var backdropData: Data? + + var view: Body { + ScrollView { + VStack { + if let data = backdropData { + Picture() + .data(data) + .frame(minHeight: 300, maxHeight: 300) + .hexpand(true) + } + HStack(alignment: .top) { + PosterCell(item: item, client: client) + .frame(minWidth: 200, maxWidth: 200) + VStack { + Text(item.Name ?? "") + .style("title-1") + .halign(.start) + if let status = item.Status { + Text(status) + .style("caption") + .halign(.start) + } + if let overview = item.Overview { + Text(overview) + .style("body") + .halign(.start) + .padding(10, .vertical) + } + } + .hexpand(true) + } + .padding() + if !seasons.isEmpty { + VStack { + Text("Season") + .style("caption") + .halign(.start) + let ids = seasons.compactMap { $0.Id } + let selected = Binding(get: { + selectedSeasonId ?? ids.first ?? "" + }, set: { newVal in + selectedSeasonId = newVal + }) + let items = seasons.compactMap { season -> DropDownItem? in + guard let id = season.Id else { return nil } + return .init(id: id, title: season.Name ?? "Unknown") + } + DropDown(selected: selected, items: items) + } + .padding(10, .horizontal) + } + if let seasonId = selectedSeasonId { + EpisodeList( + seriesId: item.Id ?? "", + seasonId: seasonId, + client: client, + userId: userId + ) + } + } + } + .onAppear { + loadSeasons() + loadBackdrop() + } + } + + private func loadBackdrop() { + guard let tag = item.backdropImageTag, let itemId = item.Id else { return } + Task { + guard let url = await client.imageURL( + itemId: itemId, imageType: .Backdrop, tag: tag, maxWidth: 1920 + ) else { return } + let service = ImageService() + backdropData = try? await service.loadImage(url: url) + } + } + + private func loadSeasons() { + Task { + let result = try? await client.getSeasons(seriesId: item.Id ?? "", userId: userId) + await MainActor.run { + seasons = result ?? [] + selectedSeasonId = seasons.first?.Id + } + } + } +}