Custom display logic for media types
This commit is contained in:
parent
2a50394264
commit
3ee94fb0f9
18 changed files with 365 additions and 148 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
75
Sources/LuminateCore/DisplayType.swift
Normal file
75
Sources/LuminateCore/DisplayType.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue