Add movie detail, TV show, and episode list views
This commit is contained in:
parent
a2ee8d946b
commit
3fe969a750
5 changed files with 369 additions and 0 deletions
19
Sources/LuminateLibrary/Components/PersonCell.swift
Normal file
19
Sources/LuminateLibrary/Components/PersonCell.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
12
Sources/LuminateLibrary/Components/RatingBadge.swift
Normal file
12
Sources/LuminateLibrary/Components/RatingBadge.swift
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
86
Sources/LuminateLibrary/EpisodeList.swift
Normal file
86
Sources/LuminateLibrary/EpisodeList.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
153
Sources/LuminateLibrary/MovieDetailView.swift
Normal file
153
Sources/LuminateLibrary/MovieDetailView.swift
Normal 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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
99
Sources/LuminateLibrary/TVShowView.swift
Normal file
99
Sources/LuminateLibrary/TVShowView.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue