Add movie detail, TV show, and episode list views

This commit is contained in:
Brendan Szymanski 2026-06-05 01:48:19 -04:00
parent a2ee8d946b
commit 3fe969a750
5 changed files with 369 additions and 0 deletions

View file

@ -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)
}
}

View file

@ -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))
}
}
}

View file

@ -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)
}
}
}

View file

@ -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() }
}
}
}

View file

@ -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
}
}
}
}