Custom display logic for media types

This commit is contained in:
Brendan Szymanski 2026-06-21 12:08:42 -04:00
parent 2a50394264
commit 3ee94fb0f9
18 changed files with 365 additions and 148 deletions

View file

@ -124,18 +124,24 @@ struct ContentView: View {
var view: Body {
NavigationView($stack, "Luminate") { page in
switch page {
case .itemPage(let title, let items):
ItemPage(title: title, items: items, navigation: $stack)
case .items(let title, let items, let type):
ItemPage(title: title, items: items, type: type, navigation: $stack)
.topToolbar {
ToolbarView()
}
.navigationTitle(title)
case .collectionPage(let item):
ItemPage(item: item, navigation: $stack)
.navigationTitle(page.description)
case .item(let item, let type):
ItemPage(item: item, type: type, navigation: $stack)
.topToolbar {
ToolbarView()
}
.navigationTitle(item.name ?? "Library")
.navigationTitle(page.description)
case .movieDetail(let item):
MovieDetailView(for: item)
.topToolbar {
ToolbarView()
}
.navigationTitle(page.description)
}
} initialView: {
HomeView(navigation: $stack)

View file

@ -33,10 +33,10 @@ public struct HomeView: View {
@Injected(\.client) var client
@Injected(\.userId) var userId
@Binding var navigation: NavigationStack<Page>
@State private var resumeItems: [Components.Schemas.BaseItemDto] = []
@State private var nextUpItems: [Components.Schemas.BaseItemDto] = []
@State private var latestItems: [Components.Schemas.BaseItemDto] = []
@State private var libraries: [Components.Schemas.BaseItemDto] = []
@State private var resumeItems: [BaseItemDto] = []
@State private var nextUpItems: [BaseItemDto] = []
@State private var latestItems: [BaseItemDto] = []
@State private var libraries: [BaseItemDto] = []
@State private var isLoading = true
@State private var isLoadingData = false
@ -55,6 +55,12 @@ public struct HomeView: View {
.frame(minWidth: 64)
.frame(maxWidth: 64)
} else {
ItemGrid(
items: libraries,
type: .library,
navigation: $navigation
)
.padding(32, .bottom)
if !resumeItems.isEmpty {
let title = "Continue Watching"
MediaRow(
@ -62,7 +68,7 @@ public struct HomeView: View {
items: resumeItems,
navigation: $navigation,
onSeeAll: {
navigation.push(.itemPage(title: title, items: resumeItems))
navigation.push(.items(title: title, items: resumeItems))
}
)
.padding(32, .bottom)
@ -74,7 +80,7 @@ public struct HomeView: View {
items: nextUpItems,
navigation: $navigation,
onSeeAll: {
navigation.push(.itemPage(title: title, items: nextUpItems))
navigation.push(.items(title: title, items: nextUpItems))
}
)
.padding(32, .bottom)
@ -86,16 +92,11 @@ public struct HomeView: View {
items: latestItems,
navigation: $navigation,
onSeeAll: {
navigation.push(.itemPage(title: title, items: latestItems))
navigation.push(.items(title: title, items: latestItems))
}
)
.padding(32, .bottom)
}
ItemGrid(
items: libraries,
navigation: $navigation
)
.padding(32, .bottom)
}
}
.padding(8, .horizontal)
@ -113,14 +114,17 @@ public struct HomeView: View {
Task {
async let resume = client.getItems(
userId: userId,
fields: [.primaryImageAspectRatio],
filters: [.isResumable],
sortBy: [.datePlayed],
sortOrder: [.descending],
limit: 20,
recursive: true
)
async let nextUp = client.getNextUp(userId: userId, limit: 20)
async let latest = client.getLatestMedia(userId: userId, limit: 20)
async let nextUp = client.getNextUp(
userId: userId, limit: 20, fields: [.primaryImageAspectRatio])
async let latest = client.getLatestMedia(
userId: userId, fields: [.primaryImageAspectRatio], limit: 20)
async let views = client.getUserViews(userId: userId)
do {
let (resume, nextUp, latest, views) = try await (resume, nextUp, latest, views)

View file

@ -29,34 +29,42 @@ import LuminateUI
public struct ItemPage: View {
private var title: String?
private var parentId: String?
private var type: DisplayType
@Binding var navigation: NavigationStack<Page>
@State private var items: [BaseItemDto] = []
@State private var isLoading = false
@Injected(\.client) var client
@Injected(\.userId) var userId
nonisolated public init(
title: String, items: [Components.Schemas.BaseItemDto],
title: String, items: [BaseItemDto],
type: DisplayType = .mixed,
navigation: Binding<NavigationStack<Page>>
) {
self.title = title
self._items = .init(wrappedValue: items)
self.parentId = nil
self.type = type
_navigation = navigation
}
nonisolated public init(
item: Components.Schemas.BaseItemDto,
item: BaseItemDto,
type: DisplayType = .mixed,
navigation: Binding<NavigationStack<Page>>
) {
self.title = item.name
self.parentId = item.id
self.type = type
_isLoading = .init(wrappedValue: item.id != nil)
_navigation = navigation
}
private var title: String?
private var parentId: String?
@Binding var navigation: NavigationStack<Page>
@State private var items: [Components.Schemas.BaseItemDto] = []
@State private var isLoading = false
@Injected(\.client) var client
@Injected(\.userId) var userId
public var view: Body {
ScrollView {
Clamp()
@ -68,6 +76,7 @@ public struct ItemPage: View {
} else {
ItemGrid(
items: items,
type: type,
navigation: $navigation,
title: title
)
@ -88,6 +97,7 @@ public struct ItemPage: View {
let result = try? await client.getItems(
userId: userId,
parentId: parentId,
fields: [.primaryImageAspectRatio],
sortBy: [.sortName],
sortOrder: [.ascending]
)

View file

@ -21,11 +21,11 @@
import Foundation
extension Components.Schemas.BaseItemPerson: Identifiable {}
extension BaseItemPerson: Identifiable {}
extension Components.Schemas.BaseItemDto: Identifiable {}
extension BaseItemDto: Identifiable {}
extension Components.Schemas.SearchHint {
extension SearchHint {
public var runtimeString: String {
guard let ticks = runTimeTicks else { return "" }
let totalSeconds = Int(ticks / 10_000_000)
@ -36,7 +36,7 @@ extension Components.Schemas.SearchHint {
}
}
extension Components.Schemas.BaseItemDto {
extension BaseItemDto {
public var runtimeString: String {
guard let ticks = runTimeTicks else { return "" }
let totalSeconds = Int(ticks / 10_000_000)
@ -46,16 +46,61 @@ extension Components.Schemas.BaseItemDto {
return "\(minutes)m"
}
public var yearString: String {
guard let year = productionYear else { return "" }
return "\(year)"
public var yearString: String? {
productionYear.map(String.init)
}
public var episodePlacementString: String? {
guard let seasonNumber = parentIndexNumber else { return nil }
guard let episodeNumber = indexNumber else { return nil }
guard let episodeName = name else { return nil }
return "S\(seasonNumber):E\(episodeNumber) - \(episodeName)"
}
public var primaryImageTag: String? {
imageTags?.additionalProperties["Primary"]
}
public var seriesRunYears: String? {
guard isShow else { return nil }
guard let startYear = yearString else { return nil }
guard status == "Ended" else { return "\(startYear) - Present" }
guard let endDate else { return nil }
let endYear = String(Calendar.current.component(.year, from: endDate))
return startYear == endYear ? startYear : "\(startYear) - \(endYear)"
}
public var backdropImageTag: String? {
backdropImageTags?.first
}
public var type: BaseItemKind? {
_type?.value1
}
public var isShow: Bool {
_type?.value1 == .series
}
public var displayType: DisplayType {
return switch type {
case .season: .season
case .episode: .episode
case .movie: .movie
case .person: .person
case .series: .series
default: .mixed
}
}
public var childDisplayType: DisplayType? {
return switch type {
case .season: .episode
case .episode: .none
case .movie: .none
case .person: .mixed
case .series: .season
default: .mixed
}
}
}

View file

@ -0,0 +1,75 @@
//
// MediaRow.swift
//
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
//
// SPDX-License-Identifier: GPL-3.0-or-later
//
import Foundation
public enum DisplayType {
case mixed
case season
case episode
case movie
case person
case library
case series
public var aspectRatio: Float {
return switch self {
case .mixed: 1.5
case .season: 1.5
case .episode: 0.5625
case .movie: 1.5
case .person: 1
case .library: 0.5625
case .series: 1.5
}
}
public func itemId(for item: BaseItemDto) -> String? {
return switch self {
case .mixed: item.seriesId ?? item.id
default: item.id
}
}
public func title(for item: BaseItemDto) -> String? {
return switch self {
case .mixed: item.seriesName ?? item.name
case .library: .none
default: item.name
}
}
public func subtitle(for item: BaseItemDto) -> String? {
return switch self {
case .library: .none
case .mixed: item.episodePlacementString ?? item.seriesRunYears ?? item.yearString
default: item.yearString
}
}
public func itemWidth(isMobile: Bool = false) -> Int {
switch self {
case .episode: return isMobile ? 150 : 300
case .library: return isMobile ? 125 : 250
default: return isMobile ? 100 : 200
}
}
}

View file

@ -143,7 +143,7 @@ public actor JellyfinClient {
}
public func authenticate(username: String, password: String) async throws
-> Components.Schemas.AuthenticationResult
-> AuthenticationResult
{
let response = try await client.authenticateUserByName(
Operations.AuthenticateUserByName.Input(
@ -187,17 +187,17 @@ public actor JellyfinClient {
public func getItems(
userId: String,
parentId: String? = nil,
includeItemTypes: [Components.Schemas.BaseItemKind]? = nil,
fields: [Components.Schemas.ItemFields]? = nil,
filters: [Components.Schemas.ItemFilter]? = nil,
sortBy: [Components.Schemas.ItemSortBy]? = nil,
sortOrder: [Components.Schemas.SortOrder]? = nil,
includeItemTypes: [BaseItemKind]? = nil,
fields: [ItemFields]? = nil,
filters: [ItemFilter]? = nil,
sortBy: [ItemSortBy]? = nil,
sortOrder: [SortOrder]? = nil,
searchTerm: String? = nil,
startIndex: Int32? = nil,
limit: Int32? = nil,
recursive: Bool? = nil,
isFavorite: Bool? = nil
) async throws -> Components.Schemas.BaseItemDtoQueryResult {
) async throws -> BaseItemDtoQueryResult {
guard token != nil else { throw JellyfinError.notAuthenticated }
var query = Operations.GetItems.Input.Query(userId: userId)
query.parentId = parentId
@ -234,7 +234,7 @@ public actor JellyfinClient {
}
public func getItem(itemId: String, userId: String? = nil) async throws
-> Components.Schemas.BaseItemDto
-> BaseItemDto
{
guard token != nil else { throw JellyfinError.notAuthenticated }
let response = try await client.getItem(
@ -264,7 +264,7 @@ public actor JellyfinClient {
}
public func getUserViews(userId: String) async throws
-> Components.Schemas.BaseItemDtoQueryResult
-> BaseItemDtoQueryResult
{
guard token != nil else { throw JellyfinError.notAuthenticated }
let response = try await client.getUserViews(
@ -296,12 +296,12 @@ public actor JellyfinClient {
userId: String,
startIndex: Int32? = nil,
limit: Int32? = nil,
fields: [Components.Schemas.ItemFields]? = nil,
fields: [ItemFields]? = nil,
seriesId: String? = nil,
parentId: String? = nil,
enableResumable: Bool? = nil,
enableRewatching: Bool? = nil
) async throws -> Components.Schemas.BaseItemDtoQueryResult {
) async throws -> BaseItemDtoQueryResult {
guard token != nil else { throw JellyfinError.notAuthenticated }
var query = Operations.GetNextUp.Input.Query(userId: userId)
query.startIndex = startIndex
@ -336,13 +336,13 @@ public actor JellyfinClient {
public func getSeasons(
seriesId: String,
userId: String,
fields: [Components.Schemas.ItemFields]? = nil,
fields: [ItemFields]? = nil,
isSpecialSeason: Bool? = nil,
enableImages: Bool? = nil,
imageTypeLimit: Int32? = nil,
enableImageTypes: [Components.Schemas.ImageType]? = nil,
enableImageTypes: [ImageType]? = nil,
enableUserData: Bool? = nil
) async throws -> Components.Schemas.BaseItemDtoQueryResult {
) async throws -> BaseItemDtoQueryResult {
guard token != nil else { throw JellyfinError.notAuthenticated }
var query = Operations.GetSeasons.Input.Query(userId: userId)
query.fields = fields
@ -384,14 +384,14 @@ public actor JellyfinClient {
userId: String,
seasonId: String? = nil,
season: Int32? = nil,
fields: [Components.Schemas.ItemFields]? = nil,
fields: [ItemFields]? = nil,
startIndex: Int32? = nil,
limit: Int32? = nil,
enableImages: Bool? = nil,
imageTypeLimit: Int32? = nil,
enableImageTypes: [Components.Schemas.ImageType]? = nil,
enableImageTypes: [ImageType]? = nil,
enableUserData: Bool? = nil
) async throws -> Components.Schemas.BaseItemDtoQueryResult {
) async throws -> BaseItemDtoQueryResult {
guard token != nil else { throw JellyfinError.notAuthenticated }
var query = Operations.GetEpisodes.Input.Query(userId: userId)
query.seasonId = seasonId
@ -436,9 +436,9 @@ public actor JellyfinClient {
userId: String? = nil,
startIndex: Int32? = nil,
limit: Int32? = nil,
includeItemTypes: [Components.Schemas.BaseItemKind]? = nil,
includeItemTypes: [BaseItemKind]? = nil,
parentId: String? = nil
) async throws -> Components.Schemas.SearchHintResult {
) async throws -> SearchHintResult {
guard token != nil else { throw JellyfinError.notAuthenticated }
var query = Operations.GetSearchHints.Input.Query(searchTerm: searchTerm)
query.userId = userId
@ -470,7 +470,7 @@ public actor JellyfinClient {
}
public func markPlayedItem(itemId: String, userId: String, datePlayed: Date? = nil) async throws
-> Components.Schemas.UserItemDataDto
-> UserItemDataDto
{
guard token != nil else { throw JellyfinError.notAuthenticated }
let response = try await client.markPlayedItem(
@ -502,7 +502,7 @@ public actor JellyfinClient {
}
public func markUnplayedItem(itemId: String, userId: String) async throws
-> Components.Schemas.UserItemDataDto
-> UserItemDataDto
{
guard token != nil else { throw JellyfinError.notAuthenticated }
let response = try await client.markUnplayedItem(
@ -534,7 +534,7 @@ public actor JellyfinClient {
}
public func markFavoriteItem(itemId: String, userId: String) async throws
-> Components.Schemas.UserItemDataDto
-> UserItemDataDto
{
guard token != nil else { throw JellyfinError.notAuthenticated }
let response = try await client.markFavoriteItem(
@ -564,7 +564,7 @@ public actor JellyfinClient {
}
public func unmarkFavoriteItem(itemId: String, userId: String) async throws
-> Components.Schemas.UserItemDataDto
-> UserItemDataDto
{
guard token != nil else { throw JellyfinError.notAuthenticated }
let response = try await client.unmarkFavoriteItem(
@ -594,7 +594,7 @@ public actor JellyfinClient {
}
public func getPlaybackInfo(itemId: String, userId: String) async throws
-> Components.Schemas.PlaybackInfoResponse
-> PlaybackInfoResponse
{
guard token != nil else { throw JellyfinError.notAuthenticated }
let response = try await client.getPlaybackInfo(
@ -625,7 +625,7 @@ public actor JellyfinClient {
}
}
public func reportPlaybackStart(info: Components.Schemas.PlaybackStartInfo) async throws {
public func reportPlaybackStart(info: PlaybackStartInfo) async throws {
guard token != nil else { throw JellyfinError.notAuthenticated }
let response = try await client.reportPlaybackStart(
Operations.ReportPlaybackStart.Input(
@ -645,7 +645,7 @@ public actor JellyfinClient {
}
}
public func reportPlaybackProgress(info: Components.Schemas.PlaybackProgressInfo) async throws {
public func reportPlaybackProgress(info: PlaybackProgressInfo) async throws {
guard token != nil else { throw JellyfinError.notAuthenticated }
let response = try await client.reportPlaybackProgress(
Operations.ReportPlaybackProgress.Input(
@ -665,7 +665,7 @@ public actor JellyfinClient {
}
}
public func reportPlaybackStopped(info: Components.Schemas.PlaybackStopInfo) async throws {
public func reportPlaybackStopped(info: PlaybackStopInfo) async throws {
guard token != nil else { throw JellyfinError.notAuthenticated }
let response = try await client.reportPlaybackStopped(
Operations.ReportPlaybackStopped.Input(
@ -688,15 +688,15 @@ public actor JellyfinClient {
public func getLatestMedia(
userId: String,
parentId: String? = nil,
fields: [Components.Schemas.ItemFields]? = nil,
includeItemTypes: [Components.Schemas.BaseItemKind]? = nil,
fields: [ItemFields]? = nil,
includeItemTypes: [BaseItemKind]? = nil,
limit: Int32? = nil,
enableImages: Bool? = nil,
imageTypeLimit: Int32? = nil,
enableImageTypes: [Components.Schemas.ImageType]? = nil,
enableImageTypes: [ImageType]? = nil,
enableUserData: Bool? = nil,
groupItems: Bool? = nil
) async throws -> [Components.Schemas.BaseItemDto] {
) async throws -> [BaseItemDto] {
guard token != nil else { throw JellyfinError.notAuthenticated }
var query = Operations.GetLatestMedia.Input.Query(userId: userId)
query.parentId = parentId
@ -731,8 +731,8 @@ public actor JellyfinClient {
}
}
public func imageURL(
itemId: String, imageType: Components.Schemas.ImageType, tag: String? = nil,
nonisolated public func imageURL(
itemId: String, imageType: ImageType, tag: String? = nil,
maxWidth: Int32? = nil, quality: Int32? = 90
) -> URL? {
guard var components = URLComponents(url: serverURL, resolvingAgainstBaseURL: false) else {
@ -747,7 +747,7 @@ public actor JellyfinClient {
return components.url
}
public func userImageURL(userId: String, tag: String? = nil) -> URL? {
nonisolated public func userImageURL(userId: String, tag: String? = nil) -> URL? {
guard var components = URLComponents(url: serverURL, resolvingAgainstBaseURL: false) else {
return nil
}

View file

@ -23,6 +23,24 @@
@_exported import OpenAPIRuntime
@_exported import OpenAPIURLSession
public typealias AuthenticationResult = Components.Schemas.AuthenticationResult
public typealias BaseItemDto = Components.Schemas.BaseItemDto
public typealias BaseItemDtoQueryResult = Components.Schemas.BaseItemDtoQueryResult
public typealias BaseItemKind = Components.Schemas.BaseItemKind
public typealias BaseItemPerson = Components.Schemas.BaseItemPerson
public typealias ImageType = Components.Schemas.ImageType
public typealias ItemFields = Components.Schemas.ItemFields
public typealias ItemFilter = Components.Schemas.ItemFilter
public typealias ItemSortBy = Components.Schemas.ItemSortBy
public typealias PlaybackInfoResponse = Components.Schemas.PlaybackInfoResponse
public typealias PlaybackProgressInfo = Components.Schemas.PlaybackProgressInfo
public typealias PlaybackStartInfo = Components.Schemas.PlaybackStartInfo
public typealias PlaybackStopInfo = Components.Schemas.PlaybackStopInfo
public typealias SearchHint = Components.Schemas.SearchHint
public typealias SearchHintResult = Components.Schemas.SearchHintResult
public typealias SortOrder = Components.Schemas.SortOrder
public typealias UserItemDataDto = Components.Schemas.UserItemDataDto
public enum LuminateCore {
public static let version = "0.1.0"
}

View file

@ -20,15 +20,15 @@
//
public enum Page: CustomStringConvertible {
case collectionPage(item: Components.Schemas.BaseItemDto)
case itemPage(title: String, items: [Components.Schemas.BaseItemDto])
case item(item: BaseItemDto, type: DisplayType = .mixed)
case items(title: String, items: [BaseItemDto], type: DisplayType = .mixed)
case movieDetail(item: BaseItemDto)
public var description: String {
switch self {
case .collectionPage(let item):
return item.name ?? "Library"
case .itemPage(let title, _):
return title
return switch self {
case .item(let item, _): item.name ?? "Library"
case .items(let title, _, _): title
case .movieDetail(let item): item.name ?? "Luminate"
}
}
}

View file

@ -25,7 +25,7 @@ import LuminateCore
public struct PlayerView: View {
public var item: Components.Schemas.BaseItemDto
public var item: BaseItemDto
public var client: JellyfinClient
public var userId: String
public var mediaSourceId: String
@ -38,7 +38,7 @@ public struct PlayerView: View {
public var onClose: () -> Void
public init(
item: Components.Schemas.BaseItemDto,
item: BaseItemDto,
client: JellyfinClient,
userId: String,
mediaSourceId: String,

View file

@ -30,7 +30,7 @@ struct EpisodeList: View {
var seasonId: String
var client: JellyfinClient
var userId: String
@State private var episodes: [Components.Schemas.BaseItemDto] = []
@State private var episodes: [BaseItemDto] = []
var view: Body {
VStack {
@ -60,7 +60,7 @@ struct EpisodeList: View {
struct EpisodeRow: View {
var episode: Components.Schemas.BaseItemDto
var episode: BaseItemDto
var client: JellyfinClient
@Injected(\.imageService) var imageService
@Injected(\.viewUpdateScheduler) var viewUpdateScheduler

View file

@ -26,36 +26,56 @@ import LuminateDI
struct HomePosterCell: View {
let item: Components.Schemas.BaseItemDto
let minWidth: Int = 200
@Binding var navigation: NavigationStack<Page>
@Injected(\.client) var client
@Injected(\.imageService) var imageService
@Injected(\.viewUpdateScheduler) var viewUpdateScheduler
let itemId: String?
let title: String?
let subtitle: String?
let width: Int
let imageAspectRatio: Float
let onClick: (() -> Void)?
@Injected(\.client) private var client
@Injected(\.imageService) private var imageService
@Injected(\.viewUpdateScheduler) private var viewUpdateScheduler
@State private var imageData: Data?
private struct Constants {
static let padding = 8
}
init(
itemId: String? = nil,
title: String? = nil,
subtitle: String? = nil,
width: Int = 200,
imageAspectRatio: Float = 1.5,
onClick: @escaping () -> Void
) {
self.itemId = itemId
self.title = title
self.subtitle = subtitle
self.width = width
self.imageAspectRatio = imageAspectRatio
self.onClick = onClick
}
var view: Body {
Bin {
VStack(spacing: 6) {
VStack(spacing: showTextSection ? 6 : 0) {
imageSection
textSection
}
.padding(Constants.padding)
}.onClick {
if item.isFolder ?? false {
navigation.push(.collectionPage(item: item))
if showTextSection {
textSection
}
}
.padding(padding)
}
.onClick(handler: onClick ?? {})
.onAppear {
Idle {
loadImage()
}
}
.frame(minWidth: minWidth + (Constants.padding * 2))
.frame(minWidth: width)
.halign(.fill)
.valign(.start)
.hexpand(false)
@ -65,7 +85,7 @@ struct HomePosterCell: View {
@ViewBuilder
private var imageSection: Body {
AspectContainer(aspectRatio: 1.5)
AspectContainer(aspectRatio: imageAspectRatio)
.child {
image
.halign(.fill)
@ -73,7 +93,7 @@ struct HomePosterCell: View {
.overflow(.hidden)
.card()
}
.frame(minWidth: minWidth)
.frame(minWidth: width - (padding * 2))
}
@ViewBuilder
@ -98,14 +118,13 @@ struct HomePosterCell: View {
private var textSection: Body {
Bin {
VStack(spacing: 2) {
if let title = item.name, !title.isEmpty {
Text(item.name ?? "")
if let title, !title.isEmpty {
Text(title)
.maxWidthChars(0)
.ellipsize()
.heading()
}
let subtitle = item.yearString
if !subtitle.isEmpty {
if let subtitle, !subtitle.isEmpty {
Text(subtitle)
.maxWidthChars(0)
.ellipsize()
@ -119,19 +138,25 @@ struct HomePosterCell: View {
.hexpand()
}
private var showTextSection: Bool {
(title?.isEmpty == false) && (subtitle?.isEmpty == false)
}
private var padding: Int {
showTextSection ? Constants.padding : 0
}
private func loadImage() {
guard let tag = item.primaryImageTag,
let itemId = item.seriesId ?? item.id
guard let itemId else { return }
guard
let url = client.imageURL(
itemId: itemId,
imageType: .primary,
maxWidth: width.cInt * 2
)
else { return }
Task {
guard
let url = await client.imageURL(
itemId: itemId,
imageType: .primary,
tag: tag,
maxWidth: 400
)
else { return }
let data = try? await imageService.loadImage(url: url)
_imageData.rawValue = data

View file

@ -25,16 +25,20 @@ import LuminateCore
public struct ItemGrid: View {
public var items: [Components.Schemas.BaseItemDto]
@Binding public var navigation: NavigationStack<Page>
public var items: [BaseItemDto]
public var type: DisplayType
public var title: String?
@Binding public var navigation: NavigationStack<Page>
public init(
items: [Components.Schemas.BaseItemDto],
items: [BaseItemDto],
type: DisplayType = .mixed,
navigation: Binding<NavigationStack<Page>>,
title: String? = nil
) {
self.items = items
self.type = type
_navigation = navigation
self.title = title
}
@ -46,11 +50,24 @@ public struct ItemGrid: View {
.halign(.start)
.padding(10, .horizontal)
FlowGrid(items, id: \.id) { item in
HomePosterCell(item: item, navigation: $navigation)
HomePosterCell(
itemId: type.itemId(for: item),
title: type.title(for: item),
subtitle: type.subtitle(for: item),
width: type.itemWidth(),
imageAspectRatio: type.aspectRatio
) {
if let childDisplayType = item.childDisplayType {
navigation.push(.item(item: item, type: childDisplayType))
}
if item.type == .movie {
navigation.push(.movieDetail(item: item))
}
}
}
.columnSpacing(16)
.rowSpacing(16)
.minimumSize(216)
.minimumSize(type.itemWidth())
.halign(.fill)
}
}

View file

@ -22,22 +22,29 @@
import Adwaita
import Foundation
import LuminateCore
import LuminateDI
public struct MediaRow: View {
public var title: String
public var items: [Components.Schemas.BaseItemDto]
@Binding public var navigation: NavigationStack<Page>
public var items: [BaseItemDto]
public var type: DisplayType
public var onSeeAll: (() -> Void)?
@Binding public var navigation: NavigationStack<Page>
@Injected(\.client) private var client
public init(
title: String,
items: [Components.Schemas.BaseItemDto],
items: [BaseItemDto],
type: DisplayType = .mixed,
navigation: Binding<NavigationStack<Page>>,
onSeeAll: (() -> Void)? = nil
onSeeAll: @escaping () -> Void
) {
self.title = title
self.items = items
self.type = type
_navigation = navigation
self.onSeeAll = onSeeAll
}
@ -47,25 +54,37 @@ public struct MediaRow: View {
HStack {
Text(title)
.title3()
/// Poor man's `Spacer()`
Bin()
.hexpand()
.halign(.start)
if let onSeeAll {
Button("See All") {
onSeeAll()
}
.halign(.end)
.hexpand()
}
}
.halign(.fill)
ScrollView {
ForEach(items, horizontal: true) { item in
HomePosterCell(item: item, navigation: $navigation)
.padding(16, .trailing)
HomePosterCell(
itemId: type.itemId(for: item),
title: type.title(for: item),
subtitle: type.subtitle(for: item),
width: type.itemWidth(),
imageAspectRatio: type.aspectRatio
) {
if let childDisplayType = item.childDisplayType {
navigation.push(.item(item: item, type: childDisplayType))
}
if item.type == .movie {
navigation.push(.movieDetail(item: item))
}
}
.padding(16, .trailing)
}
}
// .propagateNaturalHeight()
.vscrollbarPolicy(.never)
.hscrollbarPolicy(.external)
.style("undershoot-start")

View file

@ -24,26 +24,24 @@ import Foundation
import LuminateCore
import LuminateDI
struct MovieDetailView: View {
public struct MovieDetailView: View {
var item: Components.Schemas.BaseItemDto
var client: JellyfinClient
var userId: String
@Injected(\.imageService) var imageService
var item: BaseItemDto
@Injected(\.client) private var client
@Injected(\.userId) private var userId
@Injected(\.imageService) private var imageService
@State private var isFavorite: Bool
@State private var isPlayed: Bool
@State private var similarItems: [Components.Schemas.BaseItemDto] = []
@State private var similarItems: [BaseItemDto] = []
@State private var backdropData: Data?
init(item: Components.Schemas.BaseItemDto, client: JellyfinClient, userId: String) {
public init(for item: BaseItemDto) {
self.item = item
self.client = client
self.userId = userId
_isFavorite = .init(wrappedValue: item.userData?.value1.isFavorite ?? false)
_isPlayed = .init(wrappedValue: item.userData?.value1.played ?? false)
}
var view: Body {
public var view: Body {
ScrollView {
VStack {
if let data = backdropData {
@ -151,7 +149,7 @@ struct MovieDetailView: View {
limit: 10,
recursive: true
)
await MainActor.run { similarItems = result?.items ?? [] }
similarItems = result?.items ?? []
}
}
@ -162,7 +160,7 @@ struct MovieDetailView: View {
} else {
try? await client.markFavoriteItem(itemId: item.id ?? "", userId: userId)
}
await MainActor.run { isFavorite.toggle() }
isFavorite.toggle()
}
}
@ -173,7 +171,7 @@ struct MovieDetailView: View {
} else {
try? await client.markPlayedItem(itemId: item.id ?? "", userId: userId)
}
await MainActor.run { isPlayed.toggle() }
isPlayed.toggle()
}
}
}

View file

@ -23,7 +23,7 @@ import Adwaita
import LuminateCore
struct PersonCell: View {
var person: Components.Schemas.BaseItemPerson
var person: BaseItemPerson
var view: Body {
VStack {
Avatar(showInitials: false, size: 60)

View file

@ -26,7 +26,7 @@ import LuminateDI
struct PosterCell: View {
var item: Components.Schemas.BaseItemDto
var item: BaseItemDto
@Injected(\.client) private var client: JellyfinClient
@Injected(\.imageService) private var imageService
@Injected(\.viewUpdateScheduler) private var viewUpdateScheduler

View file

@ -29,7 +29,7 @@ struct SearchView: View {
var client: JellyfinClient
var userId: String
@State private var searchText = ""
@State private var results: [Components.Schemas.SearchHint] = []
@State private var results: [SearchHint] = []
@State private var isSearching = false
var view: Body {
@ -73,13 +73,13 @@ struct SearchView: View {
}
}
extension Components.Schemas.SearchHint: Identifiable {
extension SearchHint: Identifiable {
public var id: String { id ?? itemId ?? String(describing: self) }
}
struct SearchResultRow: View {
var hint: Components.Schemas.SearchHint
var hint: SearchHint
var client: JellyfinClient
@Injected(\.viewUpdateScheduler) var viewUpdateScheduler
@Injected(\.imageService) var imageService

View file

@ -26,11 +26,11 @@ import LuminateDI
struct TVShowView: View {
var item: Components.Schemas.BaseItemDto
var item: BaseItemDto
var client: JellyfinClient
var userId: String
@Injected(\.imageService) var imageService
@State private var seasons: [Components.Schemas.BaseItemDto] = []
@State private var seasons: [BaseItemDto] = []
@State private var selectedSeasonId: String?
@State private var backdropData: Data?