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