Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
179 changes: 179 additions & 0 deletions Shared/Components/LibraryElement.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2026 Jellyfin & Jellyfin Contributors
//

import CollectionVGrid
import SwiftUI

private let libraryListLandscapeWidth: CGFloat = 110
private let libraryListPortraitWidth: CGFloat = 60

@MainActor
protocol LibraryElement: Poster {

associatedtype GridBody: View = DefaultLibraryGridElement<Self>
associatedtype ListBody: View = DefaultLibraryListElement<Self>

func libraryDidSelectElement(router: Router.Wrapper, in namespace: Namespace.ID)

@ViewBuilder
func makeGridBody(libraryStyle: LibraryStyle) -> GridBody

@ViewBuilder
func makeListBody(libraryStyle: LibraryStyle) -> ListBody

static func layout(for libraryStyle: LibraryStyle) -> CollectionVGridLayout
}

extension LibraryElement {

func makeGridBody(libraryStyle: LibraryStyle) -> DefaultLibraryGridElement<Self> {
DefaultLibraryGridElement(element: self, libraryStyle: libraryStyle)
}

func makeListBody(libraryStyle: LibraryStyle) -> DefaultLibraryListElement<Self> {
DefaultLibraryListElement(element: self, libraryStyle: libraryStyle)
}

static func layout(for libraryStyle: LibraryStyle) -> CollectionVGridLayout {
#if os(iOS)
let gridLayout: CollectionVGridLayout = {
switch libraryStyle.posterDisplayType {
case .landscape:
.minWidth(220)
case .portrait, .square:
.minWidth(140)
}
}()

let phoneGridLayout: CollectionVGridLayout = {
switch libraryStyle.posterDisplayType {
case .landscape:
.columns(2)
case .portrait, .square:
.columns(3)
}
}()

switch libraryStyle.displayType {
case .grid:
return UIDevice.isPhone ? phoneGridLayout : gridLayout
case .list:
return .columns(libraryStyle.listColumnCount, insets: .zero, itemSpacing: 0, lineSpacing: 0)
}
#else
switch libraryStyle.displayType {
case .grid:
switch libraryStyle.posterDisplayType {
case .landscape:
return .columns(
5,
insets: EdgeInsets.edgeInsets,
itemSpacing: EdgeInsets.edgePadding,
lineSpacing: EdgeInsets.edgePadding
)
case .portrait, .square:
return .columns(
7,
insets: EdgeInsets.edgeInsets,
itemSpacing: EdgeInsets.edgePadding,
lineSpacing: EdgeInsets.edgePadding
)
}
case .list:
return .columns(
libraryStyle.listColumnCount,
insets: EdgeInsets.edgeInsets,
itemSpacing: EdgeInsets.edgePadding,
lineSpacing: EdgeInsets.edgePadding
)
}
#endif
}
}

struct DefaultLibraryGridElement<Element: LibraryElement>: View {

@Namespace
private var namespace

@Router
private var router

let element: Element
let libraryStyle: LibraryStyle

var body: some View {
Button {
element.libraryDidSelectElement(router: router, in: namespace)
} label: {
VStack(alignment: .leading, spacing: 6) {
PosterImage(item: element, type: libraryStyle.posterDisplayType)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.posterStyle(libraryStyle.posterDisplayType)
.backport
.matchedTransitionSource(id: "item", in: namespace)
.posterShadow()

if element.showTitle {
Text(element.displayTitle)
.font(.footnote)
.foregroundStyle(.primary)
.lineLimit(1, reservesSpace: true)
}
}
}
.buttonStyle(.plain)
.foregroundStyle(.primary, .secondary)
}
}

struct DefaultLibraryListElement<Element: LibraryElement>: View {

@Namespace
private var namespace

@Router
private var router

let element: Element
let libraryStyle: LibraryStyle

private var posterWidth: CGFloat {
libraryStyle.posterDisplayType == .landscape ? libraryListLandscapeWidth : libraryListPortraitWidth
}

var body: some View {
ListRow(insets: .init(vertical: 8, horizontal: EdgeInsets.edgePadding)) {
PosterImage(item: element, type: libraryStyle.posterDisplayType)
.posterStyle(libraryStyle.posterDisplayType)
.frame(width: posterWidth)
.backport
.matchedTransitionSource(id: "item", in: namespace)
.posterShadow()
} content: {
VStack(alignment: .leading, spacing: 5) {
Text(element.displayTitle)
.font(.callout)
.fontWeight(.semibold)
.foregroundStyle(.primary)
.lineLimit(2)
.multilineTextAlignment(.leading)

if let subtitle = element.subtitle {
Text(subtitle)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
} action: {
element.libraryDidSelectElement(router: router, in: namespace)
}
}
}
12 changes: 6 additions & 6 deletions Shared/Components/Localization/CountryPicker.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,28 +12,28 @@ import SwiftUI
struct CountryPicker: View {

@StateObject
private var viewModel: CountriesViewModel
private var viewModel: PagingLibraryViewModel<CountryLibrary>

private let selection: Binding<String?>
private let title: String

init(_ title: String, twoLetterISORegion: Binding<String?>) {
self.selection = twoLetterISORegion
self.title = title
self._viewModel = .init(wrappedValue: .init(initialValue: []))
self._viewModel = .init(wrappedValue: .init(library: .init()))
}

private var currentCountry: CountryInfo? {
viewModel.value.first(property: \.twoLetterISORegionName, equalTo: selection.wrappedValue)
viewModel.elements.first(property: \.twoLetterISORegionName, equalTo: selection.wrappedValue)
}

@ViewBuilder
private var picker: some View {
Picker(
title,
sources: viewModel.value,
sources: viewModel.elements,
selection: selection.map(
getter: { iso in viewModel.value.first(property: \.twoLetterISORegionName, equalTo: iso) },
getter: { iso in viewModel.elements.first(property: \.twoLetterISORegionName, equalTo: iso) },
setter: { info in info?.twoLetterISORegionName }
)
)
Expand All @@ -54,7 +54,7 @@ struct CountryPicker: View {
picker
#endif
}
.enabled(viewModel.state == .initial)
.enabled(viewModel.state == .content)
.onFirstAppear {
viewModel.refresh()
}
Expand Down
18 changes: 9 additions & 9 deletions Shared/Components/Localization/CulturePicker.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import SwiftUI
struct CulturePicker: View {

@StateObject
private var viewModel: CulturesViewModel
private var viewModel: PagingLibraryViewModel<CultureLibrary>

private let selection: Binding<String?>
private let title: String
Expand All @@ -25,22 +25,22 @@ struct CulturePicker: View {
init(_ title: String, twoLetterISOLanguageName: Binding<String?>) {
self.selection = twoLetterISOLanguageName
self.title = title
self._viewModel = .init(wrappedValue: .init(initialValue: []))
self._viewModel = .init(wrappedValue: .init(library: .init()))
self.isUsingTwoLetterISO = true
}

init(_ title: String, threeLetterISOLanguageName: Binding<String?>) {
self.selection = threeLetterISOLanguageName
self.title = title
self._viewModel = .init(wrappedValue: .init(initialValue: []))
self._viewModel = .init(wrappedValue: .init(library: .init()))
self.isUsingTwoLetterISO = false
}

private var currentCulture: CultureDto? {
if isUsingTwoLetterISO {
viewModel.value.first(property: \.twoLetterISOLanguageName, equalTo: selection.wrappedValue)
viewModel.elements.first(property: \.twoLetterISOLanguageName, equalTo: selection.wrappedValue)
} else {
viewModel.value.first(property: \.threeLetterISOLanguageName, equalTo: selection.wrappedValue)
viewModel.elements.first(property: \.threeLetterISOLanguageName, equalTo: selection.wrappedValue)
}
}

Expand All @@ -49,20 +49,20 @@ struct CulturePicker: View {
let _selection = {
if isUsingTwoLetterISO {
selection.map(
getter: { iso in viewModel.value.first(property: \.twoLetterISOLanguageName, equalTo: iso) },
getter: { iso in viewModel.elements.first(property: \.twoLetterISOLanguageName, equalTo: iso) },
setter: { $0?.twoLetterISOLanguageName }
)
} else {
selection.map(
getter: { iso in viewModel.value.first(property: \.threeLetterISOLanguageName, equalTo: iso) },
getter: { iso in viewModel.elements.first(property: \.threeLetterISOLanguageName, equalTo: iso) },
setter: { $0?.threeLetterISOLanguageName }
)
}
}()

Picker(
title,
sources: viewModel.value,
sources: viewModel.elements,
selection: _selection
)
}
Expand All @@ -82,7 +82,7 @@ struct CulturePicker: View {
picker
#endif
}
.enabled(viewModel.state == .initial)
.enabled(viewModel.state == .content)
.onFirstAppear {
viewModel.refresh()
}
Expand Down
12 changes: 6 additions & 6 deletions Shared/Components/Localization/ParentalRatingPicker.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,28 +12,28 @@ import SwiftUI
struct ParentalRatingPicker: View {

@StateObject
private var viewModel: ParentalRatingsViewModel
private var viewModel: PagingLibraryViewModel<ParentalRatingLibrary>

private let selection: Binding<String?>
private let title: String

init(_ title: String, name: Binding<String?>) {
self.selection = name
self.title = title
self._viewModel = .init(wrappedValue: .init(initialValue: []))
self._viewModel = .init(wrappedValue: .init(library: .init()))
}

private var currentParentalRating: ParentalRating? {
viewModel.value.first(property: \.name, equalTo: selection.wrappedValue)
viewModel.elements.first(property: \.name, equalTo: selection.wrappedValue)
}

@ViewBuilder
private var picker: some View {
Picker(
title,
sources: viewModel.value,
sources: viewModel.elements,
selection: selection.map(
getter: { name in viewModel.value.first(property: \.name, equalTo: name) },
getter: { name in viewModel.elements.first(property: \.name, equalTo: name) },
setter: { rating in rating?.name }
)
)
Expand All @@ -54,7 +54,7 @@ struct ParentalRatingPicker: View {
picker
#endif
}
.enabled(viewModel.state == .initial)
.enabled(viewModel.state == .content)
.onFirstAppear {
viewModel.refresh()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,12 @@ extension NavigationRoute {
}
}

static func activityFilters(viewModel: ServerActivityViewModel) -> NavigationRoute {
static func activityFilters(environment: Binding<ServerActivityLibrary.Environment>) -> NavigationRoute {
NavigationRoute(
id: "activityFilters",
style: .sheet
) {
ServerActivityFilterView(viewModel: viewModel)
ServerActivityFilterView(environment: environment)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,15 +53,15 @@ extension NavigationRoute {

@MainActor
static func castAndCrew(people: [BaseItemPerson], itemID: String?) -> NavigationRoute {
let id: String? = itemID == nil ? nil : "castAndCrew-\(itemID!)"
let viewModel = PagingLibraryViewModel(
let id = itemID == nil ? "castAndCrew" : "castAndCrew-\(itemID!)"
let library = StaticLibrary(
title: L10n.castAndCrew.localizedCapitalized,
id: id,
people
elements: people
)

return NavigationRoute(id: "castAndCrew") {
PagingLibraryView(viewModel: viewModel)
PagingLibraryView(library: library)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,15 @@ extension NavigationRoute {
}
}

static func library(
viewModel: PagingLibraryViewModel<some Poster>
) -> NavigationRoute {
@MainActor
static func library<Library: PagingLibrary>(
library: Library
) -> NavigationRoute where Library.Element: LibraryElement {
NavigationRoute(
id: "library-(\(viewModel.parent?.id ?? "Unparented"))",
id: "library-\(library.parent.pagingLibraryID)",
withNamespace: { .push(.zoom(sourceID: "item", namespace: $0)) }
) {
PagingLibraryView(viewModel: viewModel)
PagingLibraryView(library: library)
}
}
}
Loading