diff --git a/.gitignore b/.gitignore index 23e76a6bf8..e07c3ab19a 100644 --- a/.gitignore +++ b/.gitignore @@ -104,3 +104,4 @@ XcodeConfig/DevelopmentTeam.xcconfig # Other .idea +.code diff --git a/Shared/Components/ActionButtonHStack.swift b/Shared/Components/ActionButtonHStack.swift new file mode 100644 index 0000000000..315a3f0f9b --- /dev/null +++ b/Shared/Components/ActionButtonHStack.swift @@ -0,0 +1,86 @@ +// +// 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 Defaults +import Factory +import JellyfinAPI +import SwiftUI + +// TODO: find some other name + +struct ActionButtonHStack: View { + + @Injected(\.itemUserDataHandler) + private var itemUserDataHandler + + @StoredValue(.User.enabledTrailers) + private var enabledTrailers: TrailerSelection + + let item: BaseItemDto + let localTrailers: [BaseItemDto] + + var body: some View { + HStack(alignment: .center, spacing: 10) { + + if item.canBePlayed { + + let isPlayedSelected = item.userData?.isPlayed == true + + Button(L10n.played, systemImage: "checkmark") { + itemUserDataHandler.setPlayedStatus(for: item, isPlayed: !isPlayedSelected) + } + .foregroundStyle(.white, Color.jellyfinPurple) + .isHighlighted(isPlayedSelected) + .frame(maxWidth: .infinity) + } + + let isFavoriteSelected = item.userData?.isFavorite == true + + Button(L10n.favorite, systemImage: isFavoriteSelected ? "heart.fill" : "heart") { + itemUserDataHandler.setFavoriteStatus(for: item, isFavorited: !isFavoriteSelected) + } + .foregroundStyle(.white, .red) + .isHighlighted(isFavoriteSelected) + .frame(maxWidth: .infinity) + + TrailerMenu( + localTrailers: enabledTrailers.contains(.local) ? localTrailers : [], + remoteTrailers: enabledTrailers.contains(.external) ? (item.remoteTrailers ?? []) : [] + ) + .menuStyle(.button) + .foregroundStyle(.white) + .isHighlighted(false) + .frame(maxWidth: .infinity) + + if UIDevice.isTV { + Menu { + Section { + Button(L10n.delete) {} + Button(L10n.edit) {} + } + } label: { + Label { + Text(L10n.advanced) + } icon: { + Image(systemName: "ellipsis") + .rotationEffect(.degrees(90)) + } + } + .menuStyle(.button) + .foregroundStyle(.black, .white) + .frame(width: 60) + } + } + .font(.title3) + .fontWeight(.semibold) + .labelStyle(ActionLabelStyle()) + .buttonStyle(_BasicHoverButtonStyle()) + .frame(height: UIDevice.isTV ? 100 : 44) + .withViewContext(.isOverComplexContent) + } +} diff --git a/Shared/Components/AlternateLayoutView.swift b/Shared/Components/AlternateLayoutView.swift index 6023a5868e..ece049f4c1 100644 --- a/Shared/Components/AlternateLayoutView.swift +++ b/Shared/Components/AlternateLayoutView.swift @@ -12,14 +12,12 @@ import SwiftUI struct AlternateLayoutView: View { @State - private var layoutSize: CGSize = .zero + private var layoutFrame: CGRect = .zero private let alignment: Alignment private let content: (CGSize) -> Content private let layout: Layout - private let passLayoutSize: Bool - init( alignment: Alignment = .center, @ViewBuilder layout: @escaping () -> Layout, @@ -28,8 +26,6 @@ struct AlternateLayoutView: View { self.alignment = alignment self.content = { _ in content() } self.layout = layout() - - self.passLayoutSize = false } init( @@ -40,20 +36,14 @@ struct AlternateLayoutView: View { self.alignment = alignment self.content = content self.layout = layout() - - self.passLayoutSize = true } var body: some View { layout .hidden() - .trackingSize($layoutSize) + .trackingFrame($layoutFrame) .overlay(alignment: alignment) { - if passLayoutSize { - content(layoutSize) - } else { - content(.zero) - } + content(layoutFrame.size) } } } diff --git a/Shared/Components/AttributeBadge.swift b/Shared/Components/AttributeBadge.swift index 0684ee821b..2be20d7c68 100644 --- a/Shared/Components/AttributeBadge.swift +++ b/Shared/Components/AttributeBadge.swift @@ -8,7 +8,7 @@ import SwiftUI -struct AttributeBadge: View { +struct AttributeBadge: View { @Environment(\.font) private var font @@ -19,36 +19,39 @@ struct AttributeBadge: View { } private let style: Style - private let content: () -> any View + private let content: Content - private var usedFont: Font { + init( + style: Style, + @ViewBuilder content: () -> Content + ) { + self.style = style + self.content = content() + } + + private var resolvedFont: Font { font ?? .caption.weight(.semibold) } @ViewBuilder private var innerBody: some View { if style == .fill { - content() - .eraseToAnyView() + content .padding(.init(vertical: 1, horizontal: 4)) .hidden() .background { - Color(UIColor.lightGray) - .cornerRadius(2) + RoundedRectangle(cornerRadius: 2) .inverseMask { - content() - .eraseToAnyView() + content .padding(.init(vertical: 1, horizontal: 4)) } } } else { - content() - .eraseToAnyView() - .foregroundStyle(Color(UIColor.lightGray)) + content .padding(.init(vertical: 1, horizontal: 4)) .overlay( RoundedRectangle(cornerRadius: 2) - .stroke(Color(UIColor.lightGray), lineWidth: 1) + .stroke(lineWidth: 1) ) } } @@ -56,94 +59,7 @@ struct AttributeBadge: View { var body: some View { innerBody .labelStyle(AttributeBadgeLabelStyle()) - .font(usedFont) - } -} - -extension AttributeBadge { - - init( - style: Style, - title: @autoclosure @escaping () -> Text - ) { - self.init(style: style) { - title() - } - } - - init( - style: Style, - title: String - ) { - self.init(style: style) { - Text(title) - } - } - - init( - style: Style, - title: String, - image: Image - ) { - self.style = style - self.content = { - Label { Text(title) } icon: { image } - } - } - - init( - style: Style, - title: String, - image: @escaping () -> Image - ) { - self.style = style - self.content = { - Label { Text(title) } icon: { image() } - } - } - - init( - style: Style, - title: String, - systemName: String - ) { - self.style = style - self.content = { - Label { Text(title) } icon: { Image(systemName: systemName) } - } - } - - init( - style: Style, - title: Text, - image: Image - ) { - self.style = style - self.content = { - Label { title } icon: { image } - } - } - - init( - style: Style, - title: Text, - image: @escaping () -> Image - ) { - self.style = style - self.content = { - Label { title } icon: { image() } - } - } - - init( - style: Style, - title: Text, - systemName: String - ) { - self.style = style - self.content = { - Label { title } icon: { Image(systemName: systemName) } - } + .font(resolvedFont) } } diff --git a/Shared/Components/AttributesHStack.swift b/Shared/Components/AttributesHStack.swift new file mode 100644 index 0000000000..95dfb3b0c5 --- /dev/null +++ b/Shared/Components/AttributesHStack.swift @@ -0,0 +1,139 @@ +// +// 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 JellyfinAPI +import SwiftUI + +struct AttributesHStack: View { + + private let alignment: HorizontalAlignment + private let flowDirection: FlowLayout.Direction + private let item: BaseItemDto + private let mediaSource: MediaSourceInfo? + + init( + item: BaseItemDto, + mediaSource: MediaSourceInfo?, + alignment: HorizontalAlignment = .center, + flowDirection: FlowLayout.Direction = .up + ) { + self.alignment = alignment + self.flowDirection = flowDirection + self.item = item + self.mediaSource = mediaSource + } + + var body: some View { + FlowLayout( + alignment: alignment, + direction: flowDirection, + spacing: UIDevice.isTV ? 20 : 8 + ) { + CriticRating() + CommunityRating() + OfficialRating() + VideoQuality() + AudioChannels() + Subtitles() + } + .foregroundStyle(.secondary) + .lineLimit(1) + } + + @ViewBuilder + private func CriticRating() -> some View { + if let criticRating = item.criticRating { + AttributeBadge(style: .outline) { + Label { + Text("\(criticRating, specifier: "%.0f")") + } icon: { + if criticRating >= 60 { + Image(.tomatoFresh) + .symbolRenderingMode(.hierarchical) + } else { + Image(.tomatoRotten) + } + } + } + } + } + + @ViewBuilder + private func CommunityRating() -> some View { + if let communityRating = item.communityRating { + AttributeBadge(style: .outline) { + Label { + Text("\(communityRating, specifier: "%.01f")") + } icon: { + Image(systemName: "star.fill") + } + } + } + } + + @ViewBuilder + private func OfficialRating() -> some View { + if let officialRating = item.officialRating { + AttributeBadge(style: .outline) { + Text(officialRating) + } + } + } + + @ViewBuilder + private func VideoQuality() -> some View { + if let mediaStreams = mediaSource?.mediaStreams { + if mediaStreams.has4KVideo { + AttributeBadge(style: .fill) { + Text("4K") + } + } else if mediaStreams.hasHDVideo { + AttributeBadge(style: .fill) { + Text("HD") + } + } + if mediaStreams.hasDolbyVision { + AttributeBadge(style: .fill) { + Text("DV") + } + } + if mediaStreams.hasHDRVideo { + AttributeBadge(style: .fill) { + Text("HDR") + } + } + } + } + + @ViewBuilder + private func AudioChannels() -> some View { + if let mediaStreams = mediaSource?.mediaStreams { + if mediaStreams.has51AudioChannelLayout { + AttributeBadge(style: .fill) { + Text("5.1") + } + } + if mediaStreams.has71AudioChannelLayout { + AttributeBadge(style: .fill) { + Text("7.1") + } + } + } + } + + @ViewBuilder + private func Subtitles() -> some View { + if let mediaStreams = mediaSource?.mediaStreams, + mediaStreams.hasSubtitles + { + AttributeBadge(style: .outline) { + Text("CC") + } + } + } +} diff --git a/Shared/Components/BlurView.swift b/Shared/Components/BlurView.swift deleted file mode 100644 index 18f4572b32..0000000000 --- a/Shared/Components/BlurView.swift +++ /dev/null @@ -1,29 +0,0 @@ -// -// 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 SwiftUI -import UIKit - -struct BlurView: UIViewRepresentable { - - let style: UIBlurEffect.Style - - init(style: UIBlurEffect.Style = .regular) { - self.style = style - } - - func makeUIView(context: Context) -> UIVisualEffectView { - let view = UIVisualEffectView(effect: UIBlurEffect(style: style)) - view.translatesAutoresizingMaskIntoConstraints = false - return view - } - - func updateUIView(_ uiView: UIVisualEffectView, context: Context) { - uiView.effect = UIBlurEffect(style: style) - } -} diff --git a/Shared/Components/ChevronButton.swift b/Shared/Components/ChevronButton.swift index a3fce94239..968a777ee2 100644 --- a/Shared/Components/ChevronButton.swift +++ b/Shared/Components/ChevronButton.swift @@ -373,7 +373,13 @@ private struct ChevronButtonLabeledContentStyle: LabeledContentStyle { } } -private struct BoldIconLabelStyle: LabelStyle { +extension LabelStyle where Self == BoldIconLabelStyle { + static var boldIcon: BoldIconLabelStyle { + BoldIconLabelStyle() + } +} + +struct BoldIconLabelStyle: LabelStyle { func makeBody(configuration: Configuration) -> some View { Label { configuration.title diff --git a/Shared/Components/ComplexSecondaryShapeStyle.swift b/Shared/Components/ComplexSecondaryShapeStyle.swift index ec7219691b..8498aa73e1 100644 --- a/Shared/Components/ComplexSecondaryShapeStyle.swift +++ b/Shared/Components/ComplexSecondaryShapeStyle.swift @@ -18,12 +18,15 @@ extension ShapeStyle where Self == ComplexSecondaryShapeStyle { struct ComplexSecondaryShapeStyle: ShapeStyle { func resolve(in environment: EnvironmentValues) -> some ShapeStyle { - if environment.isOverComplexContent { + if environment.viewContext.contains(.isOverComplexContent) { // TODO: different on tvOS AnyShapeStyle(Material.ultraThinMaterial) } else { // TODO: change to a solid color - AnyShapeStyle(Color.secondarySystemFill) +// AnyShapeStyle(Color.secondarySystemFill) + environment.colorScheme == .dark ? + AnyShapeStyle(Color(.sRGB, red: 0.18, green: 0.18, blue: 0.18, opacity: 1.0)) : + AnyShapeStyle(Color(.sRGB, red: 0.90, green: 0.90, blue: 0.90, opacity: 1.0)) } } } diff --git a/Shared/Components/Localization/CountryPicker.swift b/Shared/Components/CountryPicker.swift similarity index 74% rename from Shared/Components/Localization/CountryPicker.swift rename to Shared/Components/CountryPicker.swift index 451c86c864..918e805181 100644 --- a/Shared/Components/Localization/CountryPicker.swift +++ b/Shared/Components/CountryPicker.swift @@ -12,7 +12,7 @@ import SwiftUI struct CountryPicker: View { @StateObject - private var viewModel: CountriesViewModel + private var viewModel: PagingLibraryViewModel private let selection: Binding private let title: String @@ -20,20 +20,20 @@ struct CountryPicker: View { init(_ title: String, twoLetterISORegion: Binding) { 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 } ) ) @@ -54,7 +54,7 @@ struct CountryPicker: View { picker #endif } - .enabled(viewModel.state == .initial) + .enabled(viewModel.state == .content) .onFirstAppear { viewModel.refresh() } diff --git a/Shared/Components/Localization/CulturePicker.swift b/Shared/Components/CulturePicker.swift similarity index 72% rename from Shared/Components/Localization/CulturePicker.swift rename to Shared/Components/CulturePicker.swift index fe29b3ab92..c5b23c5108 100644 --- a/Shared/Components/Localization/CulturePicker.swift +++ b/Shared/Components/CulturePicker.swift @@ -14,7 +14,7 @@ import SwiftUI struct CulturePicker: View { @StateObject - private var viewModel: CulturesViewModel + private var viewModel: PagingLibraryViewModel private let selection: Binding private let title: String @@ -23,22 +23,22 @@ struct CulturePicker: View { init(_ title: String, twoLetterISOLanguageName: Binding) { 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) { 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) } } @@ -47,12 +47,12 @@ 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 } ) } @@ -60,7 +60,7 @@ struct CulturePicker: View { Picker( title, - sources: viewModel.value, + sources: viewModel.elements, selection: _selection ) } @@ -80,7 +80,7 @@ struct CulturePicker: View { picker #endif } - .enabled(viewModel.state == .initial) + .enabled(viewModel.state == .content) .onFirstAppear { viewModel.refresh() } diff --git a/Swiftfin/Components/DotHStack.swift b/Shared/Components/DotHStack.swift similarity index 57% rename from Swiftfin/Components/DotHStack.swift rename to Shared/Components/DotHStack.swift index 56541bf2af..006882585d 100644 --- a/Swiftfin/Components/DotHStack.swift +++ b/Shared/Components/DotHStack.swift @@ -8,13 +8,24 @@ import SwiftUI +#if os(iOS) +private let defaultPadding: CGFloat = 5 +private let defaultCircleSize: CGFloat = 2 +#else +private let defaultPadding: CGFloat = 10 +private let defaultCircleSize: CGFloat = 5 +#endif + func DotHStack( - padding: CGFloat = 5, + padding: CGFloat = defaultPadding, @ViewBuilder content: @escaping () -> some View ) -> some View { SeparatorHStack { Circle() - .frame(width: 2, height: 2) + .frame( + width: defaultCircleSize, + height: defaultCircleSize + ) .padding(.horizontal, padding) } content: { content() diff --git a/Shared/Components/EpisodeHStack/Components/EpisodeHStack+ElementView.swift b/Shared/Components/EpisodeHStack/Components/EpisodeHStack+ElementView.swift new file mode 100644 index 0000000000..6882e36169 --- /dev/null +++ b/Shared/Components/EpisodeHStack/Components/EpisodeHStack+ElementView.swift @@ -0,0 +1,152 @@ +// +// 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 Defaults +import SwiftUI + +extension EpisodeHStack { + + struct ElementView: View { + + @Default(.accentColor) + private var accentColor + + @Environment(\.isEnabled) + private var isEnabled + + @FocusState + private var isElementFocused + + private let action: () -> Void + private let content: Content + private let description: String + private let menuContent: MenuContent + private let subtitle: Subtitle + private let title: String + + init( + title: String, + subtitle: Subtitle, + description: String, + action: @escaping () -> Void, + @ViewBuilder content: () -> Content, + @ViewBuilder menuContent: () -> MenuContent + ) { + self.action = action + self.content = content() + self.description = description + self.menuContent = menuContent() + self.subtitle = subtitle + self.title = title + } + + var body: some View { + VStack(alignment: .leading) { + content + + Button(action: action) { + VStack(alignment: .leading) { + subtitle + .fontWeight(.semibold) + .foregroundStyle(.secondary) + .lineLimit(1) + + Text(title) + .font(.callout) + .lineLimit(1) + + Text(description) + .foregroundStyle(.secondary) + .multilineTextAlignment(.leading) + .lineLimit(3, reservesSpace: true) + + Text(L10n.seeMore) + .fontWeight(.light) + .foregroundStyle(accentColor) + .hidden(!isEnabled) + } + .font(.footnote) + .frame(maxWidth: .infinity, alignment: .leading) + #if os(tvOS) + .padding(10) + #endif + } + .foregroundStyle(.primary, .secondary) + .buttonStyle(.card) + // TODO: complete +// #if os(iOS) +// .overlay(alignment: .topTrailing) { +// if MenuContent.self != EmptyView.self { +// AlternateLayoutView(alignment: .trailing) { +// Text(" ") +// } content: { layoutSize in +// Menu { +// menuContent +// } label: { +// Label(L10n.options, systemImage: "ellipsis") +// .labelStyle(.iconOnly) +// .frame(width: layoutSize.height, height: layoutSize.height) +// } +// .contentShape(Rectangle()) +// } +// .fontWeight(.semibold) +// .foregroundStyle(.secondary) +// } +// } +// #endif + } + .focused($isElementFocused) + } + } +} + +extension EpisodeHStack.ElementView where Subtitle == Text { + + init( + title: String, + subtitle: String, + description: String, + action: @escaping () -> Void, + @ViewBuilder content: () -> Content, + @ViewBuilder menuContent: () -> MenuContent + ) { + self.action = action + self.content = content() + self.description = description + self.menuContent = menuContent() + self.subtitle = Text(subtitle) + self.title = title + } +} + +extension EpisodeHStack.ElementView where Content == AnyView, Subtitle == Text, MenuContent == EmptyView { + + init( + title: String, + subtitle: String, + description: String, + systemImage: String? = nil, + action: @escaping () -> Void + ) { + self.action = action + self.content = Rectangle() + .fill(.complexSecondary) + .posterStyle(.landscape) + .overlay { + if let systemImage { + RelativeSystemImageView(systemName: systemImage) + .foregroundStyle(.secondary) + } + } + .eraseToAnyView() + self.description = description + self.menuContent = EmptyView() + self.subtitle = Text(subtitle) + self.title = title + } +} diff --git a/Shared/Components/EpisodeHStack/EpisodeHStack.swift b/Shared/Components/EpisodeHStack/EpisodeHStack.swift new file mode 100644 index 0000000000..7d044fe5cc --- /dev/null +++ b/Shared/Components/EpisodeHStack/EpisodeHStack.swift @@ -0,0 +1,250 @@ +// +// 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 CollectionHStack +import JellyfinAPI +import SwiftUI + +struct EpisodeHStack: View where Library.Element == BaseItemDto { + + private enum Element: Identifiable { + case empty + case element(BaseItemDto) + case error(Error) + case loading + + var id: Int { + switch self { + case let .element(episode): + episode.id?.hashValue ?? 0 + default: + UUID().hashValue + } + } + } + + @ViewContextContains(.isInParent) + private var isInParent + + @ObservedObject + private var viewModel: PagingLibraryViewModel + + @Router + private var router + + @State + private var didScrollToPlayButtonItem = false + + @StateObject + private var proxy = CollectionHStackProxy() + + private let header: Header + private let playButtonItemID: BaseItemDto.ID? + + init( + viewModel: PagingLibraryViewModel, + playButtonItemID: BaseItemDto.ID? = nil, + @ViewBuilder header: () -> Header = { EmptyView() } + ) { + self.viewModel = viewModel + self.playButtonItemID = playButtonItemID + self.header = header() + } + + private var elements: [Element] { + switch viewModel.state { + case .content: + if viewModel.elements.isEmpty { + [.empty] + } else { + viewModel.elements.map { .element($0) } + } + case .error: + viewModel.error.map { error in + [.error(error)] + } ?? [] + case .initial, .refreshing: + Array(repeating: Element.loading, count: 10) + } + } + + private var layout: CollectionHStackLayout { + #if os(tvOS) + .grid( + columns: 3.5, + rows: 1, + columnTrailingInset: 0 + ) + #else + if UIDevice.isPad { + .minimumWidth( + columnWidth: 300, + rows: 1 + ) + } else { + .grid( + columns: 1.5, + rows: 1, + columnTrailingInset: 0 + ) + } + #endif + } + + private var itemSpacing: CGFloat { + #if os(tvOS) + 40 + #else + EdgeInsets.edgePadding / 2 + #endif + } + + @ViewBuilder + func _subtitle(episode: BaseItemDto) -> some View { + if isInParent { + Text(episode.episodeLocator ?? .emptyDash) + } else { + DotHStack { + if let seriesName = episode.seriesName { + Text(seriesName) + } + + Text(episode.seasonEpisodeLabel ?? .emptyDash) + } + } + } + + @ViewBuilder + private func _episode(_ episode: BaseItemDto) -> some View { + + var description: String { + if episode.isUnaired { + episode.airDateLabel ?? L10n.noOverviewAvailable + } else { + episode.overview ?? L10n.noOverviewAvailable + } + } + + WithNamespace { namespace in + ElementView( + title: episode.displayTitle, + subtitle: _subtitle(episode: episode), + description: description + ) { + router.route(to: .item(item: episode), in: namespace) + } content: { + Button { + router.route( + to: .videoPlayer( + item: episode, + queue: EpisodeMediaPlayerQueue(episode: episode) + ) + ) + } label: { + ImageView(episode.landscapeImageSources(maxWidth: 200, environment: .init(useParent: false))) + .failure { + SystemImageContentView(systemName: episode.systemImage) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .contentShape(.contextMenuPreview, Rectangle()) + .posterStyle(.landscape) + .backport + .matchedTransitionSource(id: "item", in: namespace) + .posterShadow() + } + .foregroundStyle(.primary, .secondary) + .buttonStyle(.card) + } menuContent: { + // TODO: don't have, just use environment context menu? + Button("Go to Episode", systemImage: "info.circle") { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + router.route(to: .item(item: episode)) + } + } + + if !isInParent, let seriesID = episode.seriesID { + Button("Go to Show", systemImage: "info.circle") { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + router.route( + to: .item( + displayTitle: episode.seriesName ?? "", + id: seriesID + ) + ) + } + } + } + } + } + } + + @ViewBuilder + private var stack: some View { + CollectionHStack( + uniqueElements: elements, + layout: layout + ) { element in + switch element { + case .empty: + ElementView( + title: L10n.noResults, + subtitle: .emptyDash, + description: L10n.noEpisodesAvailable, + action: {} + ) + .disabled(true) + case let .error(error): + ElementView( + title: L10n.error, + subtitle: .emptyDash, + description: error.localizedDescription, + systemImage: "arrow.clockwise" + ) { + viewModel.refresh() + } + case .loading: + ElementView( + title: String.random(count: 10 ..< 20), + subtitle: String.random(count: 7 ..< 12), + description: String.random(count: 20 ..< 80), + action: {} + ) + .redacted(reason: .placeholder) + .disabled(true) + case let .element(episode): + _episode(episode) + .onFirstAppear { + guard !didScrollToPlayButtonItem else { return } + didScrollToPlayButtonItem = true + guard let playButtonItemID else { return } + + // good enough? + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + proxy.scrollTo(id: playButtonItemID, animated: false) + } + } + } + } + .clipsToBounds(false) + .insets(horizontal: EdgeInsets.edgePadding) + .itemSpacing(itemSpacing) + .scrollBehavior(.continuousLeadingEdge) + .proxy(proxy) + .scrollDisabled(viewModel.state != .content) + } + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Section { + stack + } header: { + header + } + } + } +} diff --git a/Shared/Components/Layouts/FlowLayout.swift b/Shared/Components/FlowLayout.swift similarity index 100% rename from Shared/Components/Layouts/FlowLayout.swift rename to Shared/Components/FlowLayout.swift diff --git a/Shared/Components/ImageView.swift b/Shared/Components/ImageView.swift index 006f89086c..68c13043e3 100644 --- a/Shared/Components/ImageView.swift +++ b/Shared/Components/ImageView.swift @@ -19,38 +19,26 @@ import SwiftUI // the blur hash view is at full opacity. // - refactor for option // - take a look at `RotateContentView` -// TODO: make Image and Placeholder generic constraints rather than any View -struct ImageView: View { +struct ImageView<_Image: View, Placeholder: View, Failure: View>: View { @State private var sources: [ImageSource] - private var image: (Image) -> any View + private let failure: Failure + private let image: (Image) -> _Image private var pipeline: ImagePipeline - private var placeholder: ((ImageSource) -> any View)? - private var failure: Failure - - @ViewBuilder - private func _placeholder(_ currentSource: ImageSource) -> some View { - if let placeholder { - placeholder(currentSource) - .eraseToAnyView() - } else { - DefaultPlaceholderView(blurHash: currentSource.blurHash) - } - } + private let placeholder: (ImageSource) -> Placeholder var body: some View { if let currentSource = sources.first { LazyImage(url: currentSource.url, transaction: .init(animation: .linear)) { state in if state.isLoading { - _placeholder(currentSource) + placeholder(currentSource) } else if let _image = state.image { if let data = state.imageContainer?.data { FastSVGView(data: data) } else { image(_image.resizable()) - .eraseToAnyView() } } else if state.error != nil { failure @@ -67,7 +55,7 @@ struct ImageView: View { } } -extension ImageView where Failure == EmptyView { +extension ImageView where _Image == Image, Placeholder == DefaultPlaceholderView, Failure == EmptyView { init(_ source: ImageSource) { self.init([source].compacted(using: \.url)) @@ -76,10 +64,10 @@ extension ImageView where Failure == EmptyView { init(_ sources: [ImageSource]) { self.init( sources: sources.compacted(using: \.url), + failure: EmptyView(), image: { $0 }, pipeline: .shared, - placeholder: nil, - failure: EmptyView() + placeholder: { DefaultPlaceholderView(blurHash: $0.blurHash) } ) } @@ -100,39 +88,49 @@ extension ImageView where Failure == EmptyView { extension ImageView { - func image(@ViewBuilder _ content: @escaping (Image) -> any View) -> Self { - copy(modifying: \.image, with: content) + func failure( + @ViewBuilder _ content: @escaping () -> F + ) -> ImageView<_Image, Placeholder, F> { + ImageView<_Image, Placeholder, F>( + sources: sources, + failure: content(), + image: image, + pipeline: pipeline, + placeholder: placeholder + ) } - func pipeline(_ pipeline: ImagePipeline) -> Self { - copy(modifying: \.pipeline, with: pipeline) + func image( + @ViewBuilder _ content: @escaping (Image) -> I + ) -> ImageView { + ImageView( + sources: sources, + failure: failure, + image: content, + pipeline: pipeline, + placeholder: placeholder + ) } - func placeholder(@ViewBuilder _ content: @escaping (ImageSource) -> any View) -> Self { - copy(modifying: \.placeholder, with: content) + func pipeline(_ pipeline: ImagePipeline) -> Self { + copy(modifying: \.pipeline, with: pipeline) } - func failure(@ViewBuilder _ content: @escaping () -> NewFailure) -> ImageView { - ImageView( + func placeholder( + @ViewBuilder _ content: @escaping (ImageSource) -> P + ) -> ImageView<_Image, P, Failure> { + ImageView<_Image, P, Failure>( sources: sources, + failure: failure, image: image, pipeline: pipeline, - placeholder: placeholder, - failure: content() + placeholder: content ) } } // MARK: Defaults -struct DefaultFailureView: View { - - var body: some View { - Color.secondarySystemFill - .opacity(0.75) - } -} - struct DefaultPlaceholderView: View { let blurHash: String? diff --git a/Shared/Components/LetterPickerBar/LetterPickerBar.swift b/Shared/Components/LetterPickerBar/LetterPickerBar.swift index 2416995f22..1d53b81d7d 100644 --- a/Shared/Components/LetterPickerBar/LetterPickerBar.swift +++ b/Shared/Components/LetterPickerBar/LetterPickerBar.swift @@ -62,7 +62,7 @@ struct LetterPickerBar: View { $focusedLetter, viewModel.currentFilters.letter.first ?? ItemLetter.allCases.first - ?? ItemLetter(stringLiteral: "#"), + ?? ItemLetter(value: "#"), priority: focusedLetter == nil ? .userInitiated : .automatic ) #endif diff --git a/Shared/Components/LibraryElement.swift b/Shared/Components/LibraryElement.swift new file mode 100644 index 0000000000..89b5648cd3 --- /dev/null +++ b/Shared/Components/LibraryElement.swift @@ -0,0 +1,93 @@ +// +// 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 JellyfinAPI +import SwiftUI + +private let landscapeWidth: CGFloat = 110 +private let portraitWidth: CGFloat = 60 + +@MainActor +protocol LibraryElement: Displayable, Poster { + + associatedtype GridBody: View = EmptyView + associatedtype ListBody: View = EmptyView + + func libraryDidSelectElement(router: Router.Wrapper, in namespace: Namespace.ID) + + @MainActor + @ViewBuilder + func makeGridBody(libraryStyle: LibraryStyle) -> GridBody + + @MainActor + @ViewBuilder + func makeListBody(libraryStyle: LibraryStyle) -> ListBody + + static func layout(for libraryStyle: LibraryStyle) -> CollectionVGridLayout +} + +extension LibraryElement { + + static func layout(for libraryStyle: LibraryStyle) -> CollectionVGridLayout { + #if os(iOS) + var padLayout: CollectionVGridLayout { + switch (libraryStyle.posterDisplayType, libraryStyle.displayType) { + case (.landscape, .grid): + .minWidth(220) + case (.portrait, .grid), (.square, .grid): + .minWidth(140) + case (_, .list): + .columns(libraryStyle.listColumnCount, insets: .zero, itemSpacing: 0, lineSpacing: 0) + } + } + + var phoneLayout: CollectionVGridLayout { + switch (libraryStyle.posterDisplayType, libraryStyle.displayType) { + case (.landscape, .grid): + .columns(2) + case (.portrait, .grid): + .columns(3) + case (.square, .grid): + .columns(3) + case (_, .list): + .columns(1, insets: .zero, itemSpacing: 0, lineSpacing: 0) + } + } + + return UIDevice.isPhone ? phoneLayout : padLayout + #else + var layout: CollectionVGridLayout { + switch (libraryStyle.posterDisplayType, libraryStyle.displayType) { + case (.landscape, .grid): + .columns( + 5, + insets: EdgeInsets.edgeInsets, + itemSpacing: EdgeInsets.edgePadding, + lineSpacing: EdgeInsets.edgePadding + ) + case (.portrait, .grid), (.square, .grid): + .columns( + 7, + insets: EdgeInsets.edgeInsets, + itemSpacing: EdgeInsets.edgePadding, + lineSpacing: EdgeInsets.edgePadding + ) + case (_, .list): + .columns( + libraryStyle.listColumnCount, + insets: EdgeInsets.edgeInsets, + itemSpacing: EdgeInsets.edgePadding, + lineSpacing: EdgeInsets.edgePadding + ) + } + } + return layout + #endif + } +} diff --git a/Shared/Components/ListRow.swift b/Shared/Components/ListRow.swift new file mode 100644 index 0000000000..57db078367 --- /dev/null +++ b/Shared/Components/ListRow.swift @@ -0,0 +1,62 @@ +// +// 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 SwiftUI + +/// A view for usage in a plain `List` or a `CollectionVGrid` +struct ListRow: View { + + @State + private var contentFrame: CGRect = .zero + + private let action: () -> Void + private let content: Content + private let insets: EdgeInsets + private let leading: Leading + + init( + insets: EdgeInsets = .zero, + action: @escaping () -> Void, + @ViewBuilder leading: @escaping () -> Leading, + @ViewBuilder content: @escaping () -> Content + ) { + self.action = action + self.content = content() + self.insets = insets + self.leading = leading() + } + + var body: some View { + Button(action: action) { + HStack(spacing: EdgeInsets.edgePadding) { + + leading + + content + .frame( + maxWidth: .infinity, + maxHeight: .infinity, + alignment: .leading + ) + .trackingFrame($contentFrame) + } + .padding(insets) + } + .foregroundStyle(.primary, .secondary) + .contentShape(.contextMenuPreview, Rectangle()) + .listRowSeparator(.hidden) + .overlay(alignment: .bottomTrailing) { +// Color.secondarySystemFill + Divider() + .frame( + width: contentFrame.width + insets.trailing +// height: 1 + ) + } + } +} diff --git a/Shared/Components/MarkedList.swift b/Shared/Components/MarkedList.swift index 9f2fedaca2..e182aa16c1 100644 --- a/Shared/Components/MarkedList.swift +++ b/Shared/Components/MarkedList.swift @@ -9,9 +9,6 @@ import SwiftUI /// A `VStack` that displays subviews with a marker on the top leading edge. -/// -/// In a marker view, ensure that views that are only used for layout are -/// tagged with `hidden` to avoid them being read by accessibility features. struct MarkedList: View { private let content: Content diff --git a/Shared/Components/MaxHeightText.swift b/Shared/Components/MaxHeightText.swift deleted file mode 100644 index de49b8ef65..0000000000 --- a/Shared/Components/MaxHeightText.swift +++ /dev/null @@ -1,38 +0,0 @@ -// -// 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 SwiftUI - -// TODO: anchor for scaleEffect? -// TODO: try an implementation that doesn't require passing in the height - -/// A `Text` wrapper that will scale down the underlying `Text` view -/// if the height is greater than the given `maxHeight`. -struct MaxHeightText: View { - - @State - private var scale = 1.0 - - let text: String - let maxHeight: CGFloat - - var body: some View { - Text(text) - .fixedSize(horizontal: false, vertical: true) - .hidden() - .overlay { - Text(text) - .scaleEffect(CGSize(width: scale, height: scale), anchor: .bottom) - } - .onSizeChanged { newSize, _ in - if newSize.height > maxHeight { - scale = maxHeight / newSize.height - } - } - } -} diff --git a/Shared/Components/MirrorExtensionView.swift b/Shared/Components/MirrorExtensionView.swift new file mode 100644 index 0000000000..4d2f9c6a20 --- /dev/null +++ b/Shared/Components/MirrorExtensionView.swift @@ -0,0 +1,53 @@ +// +// 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 SwiftUI + +struct MirrorExtensionView: View { + + @State + private var contentFrame: CGRect = .zero + + private let content: Content + private let edges: Edge.Set + + init(edges: Edge.Set, @ViewBuilder content: () -> Content) { + self.content = content() + self.edges = edges + } + + @ViewBuilder + private var mirroredContent: some View { + ZStack { + content + + content + .blur(radius: 5) + + content + .blur(radius: 20) + } + } + + var body: some View { + content + .trackingFrame($contentFrame) + .overlay(alignment: .top) { + if edges.contains(.top) { + mirroredContent + .scaleEffect(y: -1, anchor: .top) + } + } + .overlay(alignment: .bottom) { + if edges.contains(.bottom) { + mirroredContent + .scaleEffect(y: -1, anchor: .bottom) + } + } + } +} diff --git a/Swiftfin/Extensions/View/Modifiers/NavigationBarOffset/NavigationBarOffsetView.swift b/Shared/Components/OffsetNavigationBar/OffsetNavigationBar-iOS.swift similarity index 61% rename from Swiftfin/Extensions/View/Modifiers/NavigationBarOffset/NavigationBarOffsetView.swift rename to Shared/Components/OffsetNavigationBar/OffsetNavigationBar-iOS.swift index 0f80d2e466..48644c879f 100644 --- a/Swiftfin/Extensions/View/Modifiers/NavigationBarOffset/NavigationBarOffsetView.swift +++ b/Shared/Components/OffsetNavigationBar/OffsetNavigationBar-iOS.swift @@ -8,42 +8,80 @@ import SwiftUI -// TODO: fix lifecycle with zoom transition +struct OffsetNavigationBar: View { -struct NavigationBarOffsetView: UIViewControllerRepresentable { + @Environment(\.frameForParentView) + private var frameForParentView - @Binding - private var scrollViewOffset: CGFloat + private let content: Content + private let headerMaxY: CGFloat? + private let start: CGFloat + + init( + headerMaxY: CGFloat?, + start: CGFloat = 25, + @ViewBuilder content: @escaping () -> Content + ) { + self.content = content() + self.headerMaxY = headerMaxY + self.start = start + } + + var body: some View { + if let headerMaxY { + NavigationBarOffsetView( + headerOffset: headerMaxY, + start: frameForParentView[.scrollView, default: .zero].safeAreaInsets.top + start, + end: frameForParentView[.scrollView, default: .zero].safeAreaInsets.top + ) { + content + } + .ignoresSafeArea() + } else { + content + } + } +} +private struct NavigationBarOffsetView: UIViewControllerRepresentable { + + private let content: Content + private let headerOffset: CGFloat private let start: CGFloat private let end: CGFloat - private let content: () -> Content init( - scrollViewOffset: Binding, + headerOffset: CGFloat, start: CGFloat, end: CGFloat, @ViewBuilder content: @escaping () -> Content ) { - self._scrollViewOffset = scrollViewOffset + self.content = content() + self.headerOffset = headerOffset self.start = start self.end = end - self.content = content } func makeUIViewController(context: Context) -> UINavigationBarOffsetHostingController { - UINavigationBarOffsetHostingController(rootView: content()) + UINavigationBarOffsetHostingController(rootView: content) } func updateUIViewController(_ uiViewController: UINavigationBarOffsetHostingController, context: Context) { - uiViewController.scrollViewDidScroll(scrollViewOffset, start: start, end: end) + uiViewController.scrollViewDidScroll( + headerOffset, + start: start, + end: end + ) } } -class UINavigationBarOffsetHostingController: UIHostingController { +private class UINavigationBarOffsetHostingController: UIHostingController { private var lastAlpha: CGFloat = 0 + // scrollview offset will trigger during transitions + private var hasCalledWillDisappear = false + private lazy var blurView: UIVisualEffectView = { let blurView = UIVisualEffectView(effect: UIBlurEffect(style: .systemThinMaterial)) blurView.translatesAutoresizingMaskIntoConstraints = false @@ -54,7 +92,6 @@ class UINavigationBarOffsetHostingController: UIHostingController super.viewDidLoad() view.backgroundColor = nil - view.addSubview(blurView) blurView.alpha = 0 @@ -68,6 +105,8 @@ class UINavigationBarOffsetHostingController: UIHostingController func scrollViewDidScroll(_ offset: CGFloat, start: CGFloat, end: CGFloat) { + guard !hasCalledWillDisappear else { return } + let diff = end - start let currentProgress = (offset - start) / diff let alpha = clamp(currentProgress, min: 0, max: 1) @@ -81,6 +120,8 @@ class UINavigationBarOffsetHostingController: UIHostingController override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) + hasCalledWillDisappear = false + navigationController?.navigationBar .titleTextAttributes = [NSAttributedString.Key.foregroundColor: UIColor.label.withAlphaComponent(lastAlpha)] navigationController?.navigationBar.setBackgroundImage(UIImage(), for: .default) @@ -90,6 +131,8 @@ class UINavigationBarOffsetHostingController: UIHostingController override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) + hasCalledWillDisappear = true + navigationController?.navigationBar.titleTextAttributes = [NSAttributedString.Key.foregroundColor: UIColor.label] navigationController?.navigationBar.setBackgroundImage(nil, for: .default) navigationController?.navigationBar.shadowImage = nil diff --git a/Swiftfin tvOS/Components/DotHStack.swift b/Shared/Components/OffsetNavigationBar/OffsetNavigationBar-tvOS.swift similarity index 62% rename from Swiftfin tvOS/Components/DotHStack.swift rename to Shared/Components/OffsetNavigationBar/OffsetNavigationBar-tvOS.swift index c09d59eed1..0366b39a37 100644 --- a/Swiftfin tvOS/Components/DotHStack.swift +++ b/Shared/Components/OffsetNavigationBar/OffsetNavigationBar-tvOS.swift @@ -8,15 +8,10 @@ import SwiftUI -func DotHStack( - padding: CGFloat = 10, +func OffsetNavigationBar( + headerMaxY: CGFloat?, + start: CGFloat = 25, @ViewBuilder content: @escaping () -> some View ) -> some View { - SeparatorHStack { - Circle() - .frame(width: 5, height: 5) - .padding(.horizontal, 10) - } content: { - content() - } + content() } diff --git a/Shared/Components/Localization/ParentalRatingPicker.swift b/Shared/Components/ParentalRatingPicker.swift similarity index 75% rename from Shared/Components/Localization/ParentalRatingPicker.swift rename to Shared/Components/ParentalRatingPicker.swift index 328a58bec6..858d7ca0c4 100644 --- a/Shared/Components/Localization/ParentalRatingPicker.swift +++ b/Shared/Components/ParentalRatingPicker.swift @@ -12,7 +12,7 @@ import SwiftUI struct ParentalRatingPicker: View { @StateObject - private var viewModel: ParentalRatingsViewModel + private var viewModel: PagingLibraryViewModel private let selection: Binding private let title: String @@ -20,20 +20,20 @@ struct ParentalRatingPicker: View { init(_ title: String, name: Binding) { 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 } ) ) @@ -54,7 +54,7 @@ struct ParentalRatingPicker: View { picker #endif } - .enabled(viewModel.state == .initial) + .enabled(viewModel.state == .content) .onFirstAppear { viewModel.refresh() } diff --git a/Shared/Components/PlayButton.swift b/Shared/Components/PlayButton.swift new file mode 100644 index 0000000000..8d7979a0d1 --- /dev/null +++ b/Shared/Components/PlayButton.swift @@ -0,0 +1,145 @@ +// +// 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 Defaults +import JellyfinAPI +import Logging +import SwiftUI + +struct PlayButton: View { + + @Default(.accentColor) + private var accentColor + + @ObservedObject + var viewModel: ItemViewModel + + @Router + private var router + + private let logger = Logger.swiftfin() + + private var mediaSource: String? { + guard viewModel.item.mediaSources?.count ?? 0 > 1 else { return nil } + return viewModel.selectedMediaSource?.displayTitle + } + + private func play(fromBeginning: Bool = false) { + guard let playButtonItem = viewModel.playButtonItem, + let selectedMediaSource = viewModel.selectedMediaSource + else { + logger.error("Play selected with no item or media source") + return + } + + let queue: (any MediaPlayerQueue)? = { + if playButtonItem.type == .episode { + return EpisodeMediaPlayerQueue(episode: playButtonItem) + } + return nil + }() + + let provider = MediaPlayerItemProvider(item: playButtonItem) { item in + try await MediaPlayerItem.build( + for: item, + mediaSource: selectedMediaSource + ) { + if fromBeginning { + $0.userData?.playbackPositionTicks = 0 + } + } + } + + router.route( + to: .videoPlayer( + provider: provider, + queue: queue + ) + ) + } + + @ViewBuilder + private var versionMenu: some View { + if let mediaSources = viewModel.playButtonItem?.mediaSources, + mediaSources.count > 1 + { + Menu(L10n.version, systemImage: "list.dash") { + Picker( + L10n.version, + sources: mediaSources, + selection: $viewModel.selectedMediaSource, + noneStyle: nil + ) + } + .menuStyle(.button) + .labelStyle( + .tintedMaterial( + tint: .white, + foregroundColor: .black + ) + ) + .labelStyle(.iconOnly) + .aspectRatio(1, contentMode: .fit) + } + } + + private var playButton: some View { + Button { + play() + } label: { + HStack { + Image(systemName: "play.fill") + + VStack(spacing: 2) { + Text(viewModel.playButtonItem?.playButtonLabel ?? L10n.play) + + if let mediaSource { + Marquee(mediaSource, speed: 40, delay: 3, fade: 5) + .font(.caption) + .fontWeight(.medium) + } + } + } + .font(.callout) + .fontWeight(.semibold) + } + .foregroundStyle(accentColor.overlayColor, accentColor) + .buttonStyle(.primary) + .contextMenu { + if viewModel.playButtonItem?.userData?.playbackPositionTicks != 0 { + Button(L10n.playFromBeginning, systemImage: "gobackward") { + play(fromBeginning: true) + } + } + } + .isSelected(true) + .disabled(viewModel.selectedMediaSource == nil) + } + + var body: some View { + HStack { + playButton + + versionMenu + } + .frame(height: UIDevice.isTV ? 100 : 44) + } +} + +struct InlineLabelStyle: LabelStyle { + + private let content: (Configuration) -> Content + + init(@ViewBuilder content: @escaping (Configuration) -> Content) { + self.content = content + } + + func makeBody(configuration: Configuration) -> some View { + content(configuration) + } +} diff --git a/Shared/Components/PosterButton.swift b/Shared/Components/PosterButton.swift new file mode 100644 index 0000000000..a2c9532020 --- /dev/null +++ b/Shared/Components/PosterButton.swift @@ -0,0 +1,176 @@ +// +// 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 Defaults +import JellyfinAPI +import SwiftUI + +struct PosterButton: View { + + @Environment(\.viewContext) + private var viewContext + + @Namespace + private var namespace + + @State + private var posterFrame: CGRect = .zero + + private let item: Item + private let type: PosterDisplayType + private let size: PosterDisplayType.Size + private let label: Label + private let action: (Namespace.ID) -> Void + + init( + item: Item, + type: PosterDisplayType, + size: PosterDisplayType.Size = .small, + action: @escaping (Namespace.ID) -> Void, + @ViewBuilder label: @escaping () -> Label + ) { + self.item = item + self.type = type + self.size = size + self.action = action + self.label = label() + } + + @ViewBuilder + private func posterImage(overlay: some View = EmptyView()) -> some View { + PosterImage( + item: item, + type: type, + size: size + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .overlay { overlay } + .contentShape(.contextMenuPreview, Rectangle()) + .posterCornerRadius(type) + .backport + .matchedTransitionSource(id: "item", in: namespace) + .posterShadow() + .hoverEffect(.highlight) + } + + @ViewBuilder + private var resolvedLabel: some View { + Group { + if Label.self != EmptyView.self { + label + } else { + item.posterLabel + } + } + .allowsHitTesting(false) + } + + @ViewBuilder + private func buttonLabel(overlay: some View = EmptyView()) -> some View { + VStack(alignment: .leading) { + posterImage(overlay: overlay) + + resolvedLabel + } + } + + var body: some View { + Button { + action(namespace) + } label: { + // Layout required for tvOS focused offset label behavior + #if os(tvOS) + posterImage(overlay: item.posterOverlay(for: type)) + resolvedLabel + .frame(maxWidth: .infinity, alignment: .leading) + #else + buttonLabel(overlay: item.posterOverlay(for: type)) + .trackingFrame($posterFrame) + #endif + } + .foregroundStyle(.primary, .secondary) + .accessibilityLabel(item.displayTitle) + .buttonStyle(.borderless) + .focusedValue(\.focusedPoster, .init(item)) + .matchedContextMenu(for: item) { + let frameScale = 1.3 + + buttonLabel() + .withViewContext(viewContext) + .frame( + width: posterFrame.width * frameScale, + height: posterFrame.height * frameScale + ) + .padding(20) + .background { + RoundedRectangle(cornerRadius: 10) + .fill(.complexSecondary) + } + } + } +} + +extension PosterButton where Label == EmptyView { + + init( + item: Item, + type: PosterDisplayType, + size: PosterDisplayType.Size = .small, + action: @escaping (Namespace.ID) -> Void + ) { + self.init( + item: item, + type: type, + size: size, + action: action + ) { + EmptyView() + } + } +} + +// TODO: turn into a LabelStyle? +struct TitleSubtitleContentView: View { + + private let title: String + private let content: Content + + init( + title: String, + @ViewBuilder content: @escaping () -> Content + ) { + self.title = title + self.content = content() + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + Text(title) + .fontWeight(.regular) + .foregroundStyle(.primary) + .lineLimit(1, reservesSpace: true) + + content + .fontWeight(.medium) + .foregroundStyle(.secondary) + .lineLimit(1, reservesSpace: true) + } + .font(.footnote) + } +} + +extension TitleSubtitleContentView where Content == Text { + + init( + title: String, + subtitle: String + ) { + self.title = title + self.content = Text(subtitle) + } +} diff --git a/Shared/Components/PosterHStack.swift b/Shared/Components/PosterHStack.swift new file mode 100644 index 0000000000..f9c60aabdb --- /dev/null +++ b/Shared/Components/PosterHStack.swift @@ -0,0 +1,165 @@ +// +// 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 CollectionHStack +import SwiftUI + +struct PosterHStack< + Data: Collection, + Header: View +>: View where Data.Element: Poster, Data.Index == Int { + + private let elements: Data + private let header: Header + private let displayType: PosterDisplayType + private let size: PosterDisplayType.Size + + private var action: (Data.Element, Namespace.ID) -> Void + + private var layout: CollectionHStackLayout { + #if os(tvOS) + .grid( + columns: displayType == .landscape ? 5 : 7, + rows: 1, + columnTrailingInset: 0 + ) + #else + if UIDevice.isPad { + let minWidth: CGFloat = { + switch (displayType, size) { + case (.landscape, .small): + 220 + case (.landscape, .medium): + 300 + case (_, .small): + 140 + case (_, .medium): + 200 + } + }() + + return .minimumWidth( + columnWidth: minWidth, + rows: 1 + ) + } else { + let columnCount: CGFloat = { + switch (displayType, size) { + case (.landscape, .small): + 2 + case (.landscape, .medium): + 1.5 + case (_, .small): + 3 + case (_, .medium): + 2 + } + }() + + return .grid( + columns: columnCount, + rows: 1, + columnTrailingInset: 0 + ) + } + #endif + } + + private var itemSpacing: CGFloat { + #if os(tvOS) + 40 + #else + EdgeInsets.edgePadding / 2 + #endif + } + + @ViewBuilder + private var stack: some View { + if elements.isNotEmpty { + CollectionHStack( + uniqueElements: elements, + layout: layout + ) { item in + PosterButton( + item: item, + type: displayType, + size: size + ) { namespace in + action(item, namespace) + } + } + .clipsToBounds(false) + .dataPrefix(20) + .insets(horizontal: EdgeInsets.edgePadding) + .itemSpacing(itemSpacing) + .scrollBehavior(.continuousLeadingEdge) + .withViewContext(.isThumb) + } + } + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Section { + stack + } header: { + header + } + } + } +} + +extension PosterHStack { + + init( + elements: Data, + type: PosterDisplayType, + size: PosterDisplayType.Size = .small, + action: @escaping (Data.Element, Namespace.ID) -> Void, + @ViewBuilder header: () -> Header + ) { + self.elements = elements + self.header = header() + self.displayType = type + self.size = size + self.action = action + } +} + +extension PosterHStack where Header == DefaultHeader { + + init( + title: String, + elements: Data, + type: PosterDisplayType, + size: PosterDisplayType.Size = .small, + action: @escaping (Data.Element, Namespace.ID) -> Void + ) { + self.init( + elements: elements, + type: type, + size: size, + action: action, + header: { DefaultHeader(title: title) } + ) + } +} + +// MARK: Default Header + +struct DefaultHeader: View { + + let title: String + + var body: some View { + Text(title) + .font(.title2) + .fontWeight(.semibold) + .accessibilityAddTraits(.isHeader) + .edgePadding(.horizontal) + } +} diff --git a/Shared/Components/PosterHStackLibrarySection.swift b/Shared/Components/PosterHStackLibrarySection.swift new file mode 100644 index 0000000000..2d40c9f357 --- /dev/null +++ b/Shared/Components/PosterHStackLibrarySection.swift @@ -0,0 +1,118 @@ +// +// 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 JellyfinAPI +import SwiftUI + +// TODO: move to PosterGroup? + +struct SeeAllPoster: Poster { + var preferredPosterDisplayType: PosterDisplayType { + .portrait + } + + var displayTitle: String = L10n.seeAll + var id: String { + "see-all" + } + + var systemImage: String { + "ellipsis" + } +} + +struct PosterHStackLibrarySection: View where Library.Element: LibraryElement { + + enum Element: Hashable { + case element(Library.Element) + case seeAll + + var asAnyPoster: AnyPoster { + switch self { + case let .element(element): + AnyPoster(element) + case .seeAll: + AnyPoster(BaseItemDto()) + } + } + } + + @ObservedObject + private var viewModel: PagingLibraryViewModel + + @Router + private var router + + private var _elements: [Element] { + #if os(tvOS) + viewModel.elements.elements + .prefix(20) + .map { .element($0) } + .appending(.seeAll) + #else + viewModel.elements.elements + .map { .element($0) } + #endif + } + + private let group: PosterGroup + + init(viewModel: PagingLibraryViewModel, group: PosterGroup) { + self.group = group + self.viewModel = viewModel + } + + private func routeToLibrary() { + router.route(to: .library(library: viewModel.library)) + } + + @ViewBuilder + private var header: some View { + #if os(tvOS) + Text(viewModel.library.parent.displayTitle) + .font(.title3) + .lineLimit(1) + .accessibilityAddTraits(.isHeader) + .edgePadding(.horizontal) + #else + Button(action: routeToLibrary) { + HStack(spacing: 3) { + Text(viewModel.library.parent.displayTitle) + .font(.title2) + .lineLimit(1) + + Image(systemName: "chevron.forward") + .font(.title3) + .foregroundStyle(.secondary) + } + .fontWeight(.semibold) + } + .foregroundStyle(.primary, .secondary) + .accessibilityAddTraits(.isHeader) + .accessibilityAction(named: Text("Open library"), routeToLibrary) + .edgePadding(.horizontal) + #endif + } + + var body: some View { + if viewModel.elements.isNotEmpty { + PosterHStack( + elements: viewModel.elements, +// elements: _elements.map(\.asAnyPoster), + type: group.posterDisplayType, + size: group.posterSize + ) { element, namespace in + element.libraryDidSelectElement(router: router, in: namespace) + } header: { + header + } + .animation(.linear(duration: 0.2), value: viewModel.elements) + .withViewContext(.isThumb) + } + } +} diff --git a/Shared/Components/PosterImage.swift b/Shared/Components/PosterImage.swift index 26171c660c..d85469a5d1 100644 --- a/Shared/Components/PosterImage.swift +++ b/Shared/Components/PosterImage.swift @@ -7,43 +7,69 @@ // import BlurHashKit +import Nuke import SwiftUI -/// Retrieving images by exact pixel dimensions is a bit -/// intense for normal usage and eases cache usage and modifications. -private let landscapeMaxWidth: CGFloat = 300 -private let portraitMaxWidth: CGFloat = 200 +#if os(iOS) +private let landscapeMaxWidth: CGFloat = 200 +private let portraitMaxWidth: CGFloat = 120 +#else +private let landscapeMaxWidth: CGFloat = 500 +private let portraitMaxWidth: CGFloat = 500 +#endif -struct PosterImage: View { +struct PosterImage: View { + + @Environment(\.viewContext) + private var viewContext + + @ForTypeInEnvironment any WithDefaultValue>(\.customEnvironmentValueRegistry) + private var customEnvironmentValueRegistry private let contentMode: ContentMode + private let element: Element + // TODO: figure out what to do with this private let imageMaxWidth: CGFloat - private let item: Item + private var pipeline: ImagePipeline + private let size: PosterDisplayType.Size private let type: PosterDisplayType + private var customEnvironmentValue: Element.Environment { + (customEnvironmentValueRegistry?(element) as? Element.Environment) ?? .default + } + + private var imageSources: [ImageSource] { + if var environment = customEnvironmentValue as? WithViewContext { + environment.viewContext = viewContext + return element.imageSources( + for: type, + size: size, + environment: environment as! Element.Environment + ) + } else { + return element.imageSources( + for: type, + size: size, + environment: customEnvironmentValue + ) + } + } + init( - item: Item, + item: Element, type: PosterDisplayType, contentMode: ContentMode = .fill, - maxWidth: CGFloat? = nil + maxWidth: CGFloat? = nil, + size: PosterDisplayType.Size = .small ) { self.contentMode = contentMode + self.element = item self.imageMaxWidth = maxWidth ?? (type == .landscape ? landscapeMaxWidth : portraitMaxWidth) - self.item = item + self.pipeline = .shared + self.size = size self.type = type } - private var imageSources: [ImageSource] { - switch type { - case .landscape: - item.landscapeImageSources(maxWidth: imageMaxWidth, quality: 90) - case .portrait: - item.portraitImageSources(maxWidth: imageMaxWidth, quality: 90) - case .square: - item.squareImageSources(maxWidth: imageMaxWidth, quality: 90) - } - } - var body: some View { ZStack { Rectangle() @@ -53,33 +79,24 @@ struct PosterImage: View { Color.clear } content: { ImageView(imageSources) - .image(item.transform) + .image { image in + element.transform(image: image, displayType: type) + } .placeholder { imageSource in if let blurHash = imageSource.blurHash { BlurHashView(blurHash: blurHash) - } else if item.showTitle { - SystemImageContentView( - systemName: item.systemImage - ) } else { SystemImageContentView( - title: item.displayTitle, - systemName: item.systemImage + systemName: element.systemImage ) } } .failure { - if item.showTitle { - SystemImageContentView( - systemName: item.systemImage - ) - } else { - SystemImageContentView( - title: item.displayTitle, - systemName: item.systemImage - ) - } + SystemImageContentView( + systemName: element.systemImage + ) } + .accessibilityRemoveTraits(.isImage) } } .posterStyle( @@ -88,3 +105,10 @@ struct PosterImage: View { ) } } + +extension PosterImage { + + func pipeline(_ pipeline: ImagePipeline) -> Self { + copy(modifying: \.pipeline, with: pipeline) + } +} diff --git a/Shared/Components/PosterIndicators/FavoriteIndicator.swift b/Shared/Components/PosterIndicators/FavoriteIndicator.swift index 6f41114665..2ded0016e4 100644 --- a/Shared/Components/PosterIndicators/FavoriteIndicator.swift +++ b/Shared/Components/PosterIndicators/FavoriteIndicator.swift @@ -10,18 +10,11 @@ import SwiftUI struct FavoriteIndicator: View { - let size: CGFloat - var body: some View { - ZStack(alignment: .bottomLeading) { - Color.clear - - Image(systemName: "heart.circle.fill") - .resizable() - .frame(width: size, height: size) - .symbolRenderingMode(.palette) - .foregroundStyle(.white, .pink) - .padding(3) - } + Image(systemName: "heart.circle.fill") + .resizable() + .symbolRenderingMode(.palette) + .foregroundStyle(.white, .pink) + .frame(width: 25, height: 25) } } diff --git a/Shared/Components/PosterIndicators/ProgressIndicator.swift b/Shared/Components/PosterIndicators/PlayedIndicator.swift similarity index 54% rename from Shared/Components/PosterIndicators/ProgressIndicator.swift rename to Shared/Components/PosterIndicators/PlayedIndicator.swift index e93e6adf36..418bb65d3f 100644 --- a/Shared/Components/PosterIndicators/ProgressIndicator.swift +++ b/Shared/Components/PosterIndicators/PlayedIndicator.swift @@ -7,25 +7,18 @@ // import Defaults -import JellyfinAPI import SwiftUI -struct ProgressIndicator: View { +struct PlayedIndicator: View { @Default(.accentColor) private var accentColor - let progress: CGFloat - let height: CGFloat - var body: some View { - VStack { - Spacer() - - accentColor - .scaleEffect(x: progress, y: 1, anchor: .leading) - .frame(height: height) - } - .frame(maxWidth: .infinity) + Image(systemName: "checkmark.circle.fill") + .resizable() + .symbolRenderingMode(.palette) + .foregroundStyle(accentColor.overlayColor, accentColor) + .frame(width: 25, height: 25) } } diff --git a/Shared/Components/PosterIndicators/PosterIndicatorsOverlay.swift b/Shared/Components/PosterIndicators/PosterIndicatorsOverlay.swift new file mode 100644 index 0000000000..6d8152a425 --- /dev/null +++ b/Shared/Components/PosterIndicators/PosterIndicatorsOverlay.swift @@ -0,0 +1,57 @@ +// +// 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 JellyfinAPI +import SwiftUI + +struct PosterIndicatorsOverlay: View { + + let item: BaseItemDto + let indicators: PosterIndicator + let posterDisplayType: PosterDisplayType + + var body: some View { + VStack(spacing: 0) { + ZStack { + + if indicators.contains(.unplayed), item.canBePlayed, item.userData?.isPlayed == false { + UnplayedIndicator() + } + + HStack { + if indicators.contains(.favorited), item.userData?.isFavorite == true { + FavoriteIndicator() + } + + if indicators.contains(.played), item.userData?.isPlayed == true { + PlayedIndicator() + } + } + .padding(5) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomTrailing) + .zIndex(10) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + + if indicators.contains(.progress), + let progress = item.progress, + let runtime = item.runtime, + let playbackPosition = item.userData?.playbackPosition, + playbackPosition < runtime + { + // TODO: have "x left" string + ProgressIndicator( + title: (runtime - playbackPosition).formatted(.hourMinuteAbbreviated), + progress: progress, + posterDisplayType: posterDisplayType + ) + .zIndex(5) + } + } + } +} diff --git a/Shared/Components/PosterIndicators/PosterProgressIndicator.swift b/Shared/Components/PosterIndicators/PosterProgressIndicator.swift new file mode 100644 index 0000000000..1a188063ff --- /dev/null +++ b/Shared/Components/PosterIndicators/PosterProgressIndicator.swift @@ -0,0 +1,74 @@ +// +// 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 Defaults +import SwiftUI + +// TODO: report indicator height, rather than + +struct ProgressIndicator: View { + + @Default(.accentColor) + private var accentColor + + let title: String + let progress: Double + let posterDisplayType: PosterDisplayType + + @ViewBuilder + private var compactView: some View { + ContainerRelativeView( + alignment: .bottomLeading, + ratio: .init(width: progress, height: 1) + ) { + Rectangle() + .fill(accentColor) + .frame( + maxWidth: .infinity, + maxHeight: .infinity, + alignment: .bottomLeading + ) + } + .frame(height: 6) + } + + @ViewBuilder + private var regularView: some View { + VStack(alignment: .leading, spacing: 5) { + + Text(title) + .font(.system(.footnote, design: .rounded)) + .fontWeight(.medium) + + ProgressView(value: progress) + .progressViewStyle(.playback) + .foregroundStyle(.white) + .frame(height: 6) + } + .padding(.bottom, 5) + .padding(.horizontal, 5) + .background(extendedBy: .init(top: 5, leading: 0, bottom: 0, trailing: 0)) { + Rectangle() + .fill(Color.black) + .maskLinearGradient { + (location: 0, opacity: 0) + (location: 0.5, opacity: 0.7) + (location: 1, opacity: 1) + } + } + .colorScheme(.dark) + } + + var body: some View { + if posterDisplayType != .landscape { + compactView + } else { + regularView + } + } +} diff --git a/Shared/Components/PosterIndicators/UnplayedIndicator.swift b/Shared/Components/PosterIndicators/UnplayedIndicator.swift new file mode 100644 index 0000000000..74f8538e4d --- /dev/null +++ b/Shared/Components/PosterIndicators/UnplayedIndicator.swift @@ -0,0 +1,37 @@ +// +// 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 Defaults +import SwiftUI + +struct UnplayedIndicator: View { + + struct Q3RightTriangle: Shape { + + func path(in rect: CGRect) -> Path { + var path = Path() + + path.move(to: CGPoint(x: rect.maxX, y: rect.minY)) + path.addLine(to: CGPoint(x: rect.minX, y: rect.minY)) + path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY)) + path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY)) + + return path + } + } + + @Default(.accentColor) + private var accentColor + + var body: some View { + Q3RightTriangle() + .fill(accentColor) + .frame(width: 25, height: 25) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing) + } +} diff --git a/Shared/Components/PosterIndicators/UnwatchedIndicator.swift b/Shared/Components/PosterIndicators/UnwatchedIndicator.swift deleted file mode 100644 index 2aa28658c0..0000000000 --- a/Shared/Components/PosterIndicators/UnwatchedIndicator.swift +++ /dev/null @@ -1,67 +0,0 @@ -// -// 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 Defaults -import SwiftUI - -struct UnwatchedIndicator: View { - - private let size: CGFloat - private let count: Int? - - #if os(iOS) - private let padding: CGFloat = 4 - private let bottomLeadingRadius: CGFloat = 5 - #else - private let padding: CGFloat = 8 - private let bottomLeadingRadius: CGFloat = 10 - #endif - - init(size: CGFloat, count: Int? = nil) { - self.size = size - self.count = count - } - - var body: some View { - ZStack(alignment: .topTrailing) { - Color.clear - - if let count, count > 0 { - Text(count.description) - .fontWeight(.semibold) - .foregroundStyle(.primary) - .padding(.horizontal, padding) - .fixedSize(horizontal: false, vertical: true) - .frame(height: size, alignment: .center) - .frame(minWidth: size) - .background { - UnevenRoundedRectangle(bottomLeadingRadius: bottomLeadingRadius) - .foregroundStyle(.secondary) - } - } else { - Q3RightTriangle() - .frame(width: size, height: size) - .foregroundStyle(.secondary) - } - } - } -} - -struct Q3RightTriangle: Shape { - - func path(in rect: CGRect) -> Path { - var path = Path() - - path.move(to: CGPoint(x: rect.maxX, y: rect.minY)) - path.addLine(to: CGPoint(x: rect.minX, y: rect.minY)) - path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY)) - path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY)) - - return path - } -} diff --git a/Shared/Components/PosterIndicators/WatchedIndicator.swift b/Shared/Components/PosterIndicators/WatchedIndicator.swift deleted file mode 100644 index beebd92db9..0000000000 --- a/Shared/Components/PosterIndicators/WatchedIndicator.swift +++ /dev/null @@ -1,31 +0,0 @@ -// -// 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 Defaults -import SwiftUI - -struct WatchedIndicator: View { - - @Default(.accentColor) - private var accentColor - - let size: CGFloat - - var body: some View { - ZStack(alignment: .bottomTrailing) { - Color.clear - - Image(systemName: "checkmark.circle.fill") - .resizable() - .frame(width: size, height: size) - .symbolRenderingMode(.palette) - .foregroundStyle(.white, accentColor) - .padding(3) - } - } -} diff --git a/Shared/Components/PrimaryButtonStyle.swift b/Shared/Components/PrimaryButtonStyle.swift index 537275b980..86ae1a5b65 100644 --- a/Shared/Components/PrimaryButtonStyle.swift +++ b/Shared/Components/PrimaryButtonStyle.swift @@ -16,8 +16,13 @@ extension PrimitiveButtonStyle where Self == PrimaryButtonStyle { struct PrimaryButtonStyle: PrimitiveButtonStyle { + @ViewContextContains(.isOverComplexContent) + private var isOverComplexContent + @Environment(\.isEnabled) private var isEnabled + @Environment(\.isHighlighted) + private var isHighlighted @FocusState private var isFocused: Bool @@ -41,7 +46,15 @@ struct PrimaryButtonStyle: PrimitiveButtonStyle { ) } else { if isEnabled { - return AnyShapeStyle(HierarchicalShapeStyle.secondary) + if isHighlighted { + return AnyShapeStyle(HierarchicalShapeStyle.secondary) + } else { + if isOverComplexContent { + return AnyShapeStyle(Material.ultraThinMaterial) + } else { + return AnyShapeStyle(HierarchicalShapeStyle.secondary) + } + } } else { return AnyShapeStyle(Color.gray) } diff --git a/Shared/Components/ProgressBar.swift b/Shared/Components/ProgressBar.swift deleted file mode 100644 index 052d4c56bb..0000000000 --- a/Shared/Components/ProgressBar.swift +++ /dev/null @@ -1,33 +0,0 @@ -// -// 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 SwiftUI - -// TODO: remove and replace with `PlaybackProgressViewStyle` -struct ProgressBar: View { - - @State - private var contentSize: CGSize = .zero - - let progress: CGFloat - - var body: some View { - Capsule() - .foregroundStyle(.secondary) - .opacity(0.2) - .overlay(alignment: .leading) { - Capsule() - .mask(alignment: .leading) { - Rectangle() - } - .frame(width: contentSize.width * progress) - .foregroundStyle(.primary) - } - .trackingSize($contentSize) - } -} diff --git a/Shared/Components/ReversibleHStack.swift b/Shared/Components/ReversibleHStack.swift new file mode 100644 index 0000000000..16e6678d73 --- /dev/null +++ b/Shared/Components/ReversibleHStack.swift @@ -0,0 +1,76 @@ +// +// 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 SwiftUI + +@ViewBuilder +func HStack( + reversed: Bool, + alignment: VerticalAlignment = .center, + spacing: CGFloat? = nil, + @ViewBuilder content: @escaping () -> some View +) -> some View { + ReversibleHStack( + isReversed: reversed, + alignment: alignment, + spacing: spacing, + content: content + ) +} + +private struct ReversibleHStack: View { + + private let isReversed: Bool + private let alignment: VerticalAlignment + private let content: Content + private let spacing: CGFloat? + + init( + isReversed: Bool, + alignment: VerticalAlignment = .center, + spacing: CGFloat? = nil, + @ViewBuilder content: @escaping () -> Content + ) { + self.isReversed = isReversed + self.alignment = alignment + self.spacing = spacing + self.content = content() + } + + var body: some View { + _VariadicView.Tree( + ReversibleHStackLayout( + isReversed: isReversed, + alignment: alignment, + spacing: spacing + ) + ) { + content + } + } + + struct ReversibleHStackLayout: _VariadicView_UnaryViewRoot { + + let isReversed: Bool + let alignment: VerticalAlignment + let spacing: CGFloat? + + @ViewBuilder + func body(children: _VariadicView.Children) -> some View { + HStack(alignment: alignment, spacing: spacing) { + if isReversed { + ForEach(children.reversed()) { child in + child + } + } else { + children + } + } + } + } +} diff --git a/Shared/Components/SeeMoreText.swift b/Shared/Components/SeeMoreText.swift new file mode 100644 index 0000000000..7ced9cdaaf --- /dev/null +++ b/Shared/Components/SeeMoreText.swift @@ -0,0 +1,109 @@ +// +// 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 SwiftUI + +// TODO: fix when using `lineLimit(reserveSpace > 1)` +// - see more button gets large frame + +struct SeeMoreText: View { + + @State + private var fullTextFrame: CGRect = .zero + @State + private var layoutTextFrame: CGRect = .zero + + private var isTruncated: Bool { + fullTextFrame.height > layoutTextFrame.height + } + + private let isTruncatedBinding: Binding + private let seeMoreAction: () -> Void + private let text: Text + + init( + _ text: String, + isTruncated: Binding = .constant(true), + seeMoreAction: @escaping () -> Void + ) { + self.text = Text(text) + self.isTruncatedBinding = isTruncated + self.seeMoreAction = seeMoreAction + } + + init( + _ text: Text, + isTruncated: Binding = .constant(true), + seeMoreAction: @escaping () -> Void + ) { + self.text = text + self.isTruncatedBinding = isTruncated + self.seeMoreAction = seeMoreAction + } + + @ViewBuilder + private var seeMoreText: some View { + Text(L10n.seeMore) + .textCase(.uppercase) + .fontWeight(.semibold) + } + + @ViewBuilder + private var textView: some View { + text + .trackingFrame($layoutTextFrame) + .inverseMask(alignment: .bottomTrailing) { + seeMoreText + .padding(.leading, 20) + .background { + HStack(spacing: 0) { + Rectangle() + .fill( + LinearGradient( + stops: [ + .init(color: .clear, location: 0), + .init(color: .black, location: 0.75), + ], + startPoint: .leading, + endPoint: .trailing + ) + ) + .frame(width: 20) + + Rectangle() + .fill(Color.black) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomTrailing) + .isVisible(isTruncated) + } + .overlay(alignment: .bottomTrailing) { + if isTruncated { + seeMoreText + } + } + .background(alignment: .top) { + text + .lineLimit(nil) + .fixedSize(horizontal: false, vertical: true) + .trackingFrame($fullTextFrame) + .hidden() + } + .backport + .onChange(of: isTruncated) { _, newValue in + isTruncatedBinding.wrappedValue = newValue + } + } + + var body: some View { + Button(action: seeMoreAction) { + textView + } + .buttonStyle(.plain) + } +} diff --git a/Shared/Components/SeparatorHStack.swift b/Shared/Components/SeparatorHStack.swift index 99d79f7c60..a059aaee24 100644 --- a/Shared/Components/SeparatorHStack.swift +++ b/Shared/Components/SeparatorHStack.swift @@ -16,57 +16,42 @@ import SwiftUI /// for spacing. struct SeparatorHStack: View { - private var content: Content - private var separator: Separator + private let alignment: VerticalAlignment + private let content: Content + private let separator: Separator + + init( + alignment: VerticalAlignment = .center, + @ViewBuilder separator: @escaping () -> Separator, + @ViewBuilder content: @escaping () -> Content + ) { + self.alignment = alignment + self.content = content() + self.separator = separator() + } var body: some View { _VariadicView.Tree( SeparatorHStackLayout( + alignment: alignment, separator: separator ) ) { content } } -} - -extension SeparatorHStack where Separator == RowDivider { - - init( - @ViewBuilder content: @escaping () -> Content - ) { - self.init( - content: content(), - separator: RowDivider() - ) - } -} - -extension SeparatorHStack { - - init( - @ViewBuilder separator: @escaping () -> Separator, - @ViewBuilder content: @escaping () -> Content - ) { - self.init( - content: content(), - separator: separator() - ) - } -} - -extension SeparatorHStack { struct SeparatorHStackLayout: _VariadicView_UnaryViewRoot { - var separator: Separator + let alignment: VerticalAlignment + let separator: Separator @ViewBuilder func body(children: _VariadicView.Children) -> some View { let last = children.last?.id - localHStack { + HStack(alignment: alignment, spacing: 0) { ForEach(children) { child in child @@ -76,12 +61,5 @@ extension SeparatorHStack { } } } - - @ViewBuilder - private func localHStack(@ViewBuilder content: @escaping () -> some View) -> some View { - HStack(spacing: 0) { - content() - } - } } } diff --git a/Shared/Components/SeparatorVStack.swift b/Shared/Components/SeparatorVStack.swift index 43730020e2..6e6c8d7b59 100644 --- a/Shared/Components/SeparatorVStack.swift +++ b/Shared/Components/SeparatorVStack.swift @@ -20,6 +20,16 @@ struct SeparatorVStack: View { private let content: Content private let separator: Separator + init( + alignment: HorizontalAlignment = .center, + @ViewBuilder separator: @escaping () -> Separator, + @ViewBuilder content: @escaping () -> Content + ) { + self.alignment = alignment + self.content = content() + self.separator = separator() + } + var body: some View { _VariadicView.Tree( SeparatorVStackLayout( @@ -30,24 +40,6 @@ struct SeparatorVStack: View { content } } -} - -extension SeparatorVStack { - - init( - alignment: HorizontalAlignment = .center, - @ViewBuilder separator: @escaping () -> Separator, - @ViewBuilder content: @escaping () -> Content - ) { - self.init( - alignment: alignment, - content: content(), - separator: separator() - ) - } -} - -extension SeparatorVStack { struct SeparatorVStackLayout: _VariadicView_UnaryViewRoot { diff --git a/Shared/Components/SystemImageContentView.swift b/Shared/Components/SystemImageContentView.swift index 26b6167085..4674460578 100644 --- a/Shared/Components/SystemImageContentView.swift +++ b/Shared/Components/SystemImageContentView.swift @@ -14,31 +14,39 @@ import SwiftUI struct ContainerRelativeView: View { + private let alignment: Alignment private let content: Content private let ratio: CGSize init( + alignment: Alignment = .center, ratio: CGFloat, @ViewBuilder content: () -> Content ) { + self.alignment = alignment self.content = content() self.ratio = CGSize(width: ratio, height: ratio) } init( + alignment: Alignment = .center, ratio: CGSize = CGSize(width: 1, height: 1), @ViewBuilder content: () -> Content ) { + self.alignment = alignment self.content = content() self.ratio = ratio } var body: some View { - AlternateLayoutView { + AlternateLayoutView(alignment: alignment) { Color.clear } content: { size in content - .frame(width: size.width * ratio.width, height: size.height * ratio.height) + .frame( + width: size.width * ratio.width, + height: size.height * ratio.height + ) } } } diff --git a/Shared/Components/TrackingPreference.swift b/Shared/Components/TrackingPreference.swift new file mode 100644 index 0000000000..a4dff17833 --- /dev/null +++ b/Shared/Components/TrackingPreference.swift @@ -0,0 +1,33 @@ +// +// 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 SwiftUI + +struct TrackingPreference: View where Key.Value: Equatable { + + @State + private var currentValue = Key.defaultValue + + private let content: (Key.Value) -> Content + private let key: Key.Type + + init( + key: Key.Type, + @ViewBuilder content: @escaping (Key.Value) -> Content + ) { + self.content = content + self.key = key + } + + var body: some View { + content(currentValue) + .onPreferenceChange(key) { newValue in + currentValue = newValue + } + } +} diff --git a/Shared/Components/TrailerMenu.swift b/Shared/Components/TrailerMenu.swift new file mode 100644 index 0000000000..2ef5fd9ea3 --- /dev/null +++ b/Shared/Components/TrailerMenu.swift @@ -0,0 +1,77 @@ +// +// 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 JellyfinAPI +import SwiftUI + +struct TrailerMenu: View { + + @Router + private var router + + @State + private var error: Error? + + let localTrailers: [BaseItemDto] + let remoteTrailers: [MediaURL] + + var body: some View { + if localTrailers.isNotEmpty || remoteTrailers.isNotEmpty { + ConditionalMenu( + isMenu: (localTrailers.count + remoteTrailers.count) > 1 + ) { + if let firstTrailer = localTrailers.first { + playLocalTrailer(firstTrailer) + } + + if let firstTrailer = remoteTrailers.first { + playExternalTrailer(firstTrailer) + } + } menuContent: { + if localTrailers.isNotEmpty { + Section(L10n.local) { + ForEach(localTrailers) { trailer in + Button( + trailer.name ?? L10n.trailer + ) { + playLocalTrailer(trailer) + } + } + } + } + + if remoteTrailers.isNotEmpty { + Section(L10n.external) { + ForEach(remoteTrailers, id: \.hashValue) { mediaURL in + Button( + mediaURL.name ?? L10n.trailer + ) { + playExternalTrailer(mediaURL) + } + } + } + } + } label: { + Label(L10n.trailers, systemImage: "movieclapper") + } + .errorMessage($error) + } + } + + private func playLocalTrailer(_ trailer: BaseItemDto) { + router.route(to: .videoPlayer(item: trailer)) + } + + private func playExternalTrailer(_ trailer: MediaURL) { + do { + try UIApplication.shared.open(trailer) + } catch { + self.error = error + } + } +} diff --git a/Shared/Components/TruncatedText.swift b/Shared/Components/TruncatedText.swift index 2369a6ded6..f397063111 100644 --- a/Shared/Components/TruncatedText.swift +++ b/Shared/Components/TruncatedText.swift @@ -9,11 +9,7 @@ import Defaults import SwiftUI -// TODO: only allow `view` selection when truncated? -// TODO: fix when also using `lineLimit(reserveSpace > 1)` -// TODO: some false positives for showing see more? -// TODO: allow removing empty lines - +@available(*, deprecated, message: "Use `SeeMoreText` instead") struct TruncatedText: View { enum SeeMoreType { diff --git a/Shared/Components/VisualEffectView.swift b/Shared/Components/VisualEffectView.swift new file mode 100644 index 0000000000..69e441676e --- /dev/null +++ b/Shared/Components/VisualEffectView.swift @@ -0,0 +1,51 @@ +// +// 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 SwiftUI +import UIKit + +@available(*, deprecated, message: "Use `Material` or `VisualEffectView` instead") +typealias BlurView = VisualEffectView + +struct VisualEffectView: UIViewRepresentable { + + private let effect: UIVisualEffect + private let tint: Color? + + init( + blur style: UIBlurEffect.Style = .regular, + tint: Color? = nil + ) { + self.effect = UIBlurEffect(style: style) + self.tint = tint + } + + init( + effect: UIVisualEffect, + tint: Color? = nil + ) { + self.effect = effect + self.tint = tint + } + + func makeUIView(context: Context) -> UIVisualEffectView { + UIVisualEffectView(effect: effect) + } + + func updateUIView(_ uiView: UIVisualEffectView, context: Context) { + uiView.effect = effect + + let overlayView = uiView.subviews.first { type(of: $0) == NSClassFromString("_UIVisualEffectSubview") } + + if let tint { + overlayView?.backgroundColor = UIColor(tint) + } else { + overlayView?.backgroundColor = nil + } + } +} diff --git a/Shared/Components/WithDefaults.swift b/Shared/Components/WithDefaults.swift new file mode 100644 index 0000000000..2d1254623a --- /dev/null +++ b/Shared/Components/WithDefaults.swift @@ -0,0 +1,30 @@ +// +// 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 Defaults +import SwiftUI + +struct WithDefaults: View { + + @Default + private var value: Value + + private let content: (Value) -> Content + + init( + _ key: Defaults.Key, + @ViewBuilder content: @escaping (Value) -> Content + ) { + self._value = Default(key) + self.content = content + } + + var body: some View { + content(value) + } +} diff --git a/Shared/Components/WithEnvironment.swift b/Shared/Components/WithEnvironment.swift new file mode 100644 index 0000000000..93ea24ea86 --- /dev/null +++ b/Shared/Components/WithEnvironment.swift @@ -0,0 +1,29 @@ +// +// 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 SwiftUI + +struct WithEnvironment: View { + + @Environment + private var environment: EnvironmentValue + + private let content: (EnvironmentValue) -> Content + + init( + _ keyPath: KeyPath, + @ViewBuilder content: @escaping (EnvironmentValue) -> Content + ) { + self._environment = Environment(keyPath) + self.content = content + } + + var body: some View { + content(environment) + } +} diff --git a/Shared/Components/WithFrame.swift b/Shared/Components/WithFrame.swift new file mode 100644 index 0000000000..a0455eb687 --- /dev/null +++ b/Shared/Components/WithFrame.swift @@ -0,0 +1,31 @@ +// +// 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 SwiftUI + +struct WithFrame: View { + + @State + private var frame: FrameAndSafeAreaInsets = .zero + + private let content: (FrameAndSafeAreaInsets) -> Content + + init(@ViewBuilder content: @escaping (FrameAndSafeAreaInsets) -> Content) { + self.content = content + } + + var body: some View { + content(frame) + .onFrameChanged(perform: { frame, safeArea in + self.frame = .init( + frame: frame, + safeAreaInsets: safeArea + ) + }) + } +} diff --git a/Shared/Components/WithNamespace.swift b/Shared/Components/WithNamespace.swift new file mode 100644 index 0000000000..92674fde09 --- /dev/null +++ b/Shared/Components/WithNamespace.swift @@ -0,0 +1,21 @@ +// +// 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 SwiftUI + +struct WithNamespace: View { + + @Namespace + private var namespace: Namespace.ID + + let content: (Namespace.ID) -> Content + + var body: some View { + content(namespace) + } +} diff --git a/Shared/Components/RowDivider.swift b/Shared/Components/WithRouter.swift similarity index 67% rename from Shared/Components/RowDivider.swift rename to Shared/Components/WithRouter.swift index 9261cc545d..e485a457a1 100644 --- a/Shared/Components/RowDivider.swift +++ b/Shared/Components/WithRouter.swift @@ -8,11 +8,14 @@ import SwiftUI -struct RowDivider: View { +struct WithRouter: View { + + @Router + private var router + + let content: (Router.Wrapper) -> Content var body: some View { - Color.secondarySystemFill - .frame(height: 1) - .edgePadding(.horizontal) + content(router) } } diff --git a/Shared/Components/WrappedView.swift b/Shared/Components/WrappedView.swift deleted file mode 100644 index 69aadaf4ac..0000000000 --- a/Shared/Components/WrappedView.swift +++ /dev/null @@ -1,23 +0,0 @@ -// -// 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 SwiftUI - -// TODO: mainly used as a view to hold views for states -// but doesn't work with animations/transitions. -// Look at alternative with just ZStack and remove - -struct WrappedView: View { - - @ViewBuilder - let content: () -> Content - - var body: some View { - content() - } -} diff --git a/Shared/Coordinators/Navigation/NavigationInjectionView.swift b/Shared/Coordinators/Navigation/NavigationInjectionView.swift index 205b540956..7965136466 100644 --- a/Shared/Coordinators/Navigation/NavigationInjectionView.swift +++ b/Shared/Coordinators/Navigation/NavigationInjectionView.swift @@ -48,6 +48,7 @@ struct NavigationInjectionView: View { route.destination } } + .trackingFrame(for: .navigationStack) .environment( \.router, .init( diff --git a/Shared/Coordinators/Navigation/NavigationRoute/NavigationRoute+Admin.swift b/Shared/Coordinators/Navigation/NavigationRoute/NavigationRoute+Admin.swift index f744c45425..c47b29b259 100644 --- a/Shared/Coordinators/Navigation/NavigationRoute/NavigationRoute+Admin.swift +++ b/Shared/Coordinators/Navigation/NavigationRoute/NavigationRoute+Admin.swift @@ -14,7 +14,7 @@ extension NavigationRoute { // MARK: - Active Sessions - static func activeDeviceDetails(box: BindingBox) -> NavigationRoute { + static func activeDeviceDetails(box: PublishedBox) -> NavigationRoute { NavigationRoute(id: "activeDeviceDetails") { ActiveSessionDetailView(box: box) } @@ -44,12 +44,14 @@ extension NavigationRoute { } } + // TODO: remove, make date selection popover static func activityFilters(viewModel: ServerActivityViewModel) -> NavigationRoute { NavigationRoute( id: "activityFilters", style: .sheet ) { - ServerActivityFilterView(viewModel: viewModel) + EmptyView() +// ServerActivityFilterView(viewModel: viewModel) } } diff --git a/Shared/Coordinators/Navigation/NavigationRoute/NavigationRoute+Download.swift b/Shared/Coordinators/Navigation/NavigationRoute/NavigationRoute+Download.swift deleted file mode 100644 index 1c9d985a39..0000000000 --- a/Shared/Coordinators/Navigation/NavigationRoute/NavigationRoute+Download.swift +++ /dev/null @@ -1,36 +0,0 @@ -// -// 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 JellyfinAPI -import SwiftUI - -extension NavigationRoute { - - static var downloadList: NavigationRoute { - NavigationRoute( - id: "downloadList" - ) { - #if os(iOS) - DownloadListView(viewModel: .init()) - #else - EmptyView() - #endif - } - } - - #if os(iOS) - static func downloadTask(downloadTask: DownloadTask) -> NavigationRoute { - NavigationRoute( - id: "downloadTask", - style: .sheet - ) { - DownloadTaskView(downloadTask: downloadTask) - } - } - #endif -} diff --git a/Shared/Coordinators/Navigation/NavigationRoute/NavigationRoute+Item.swift b/Shared/Coordinators/Navigation/NavigationRoute/NavigationRoute+Item.swift index 90bb27b18c..b703960619 100644 --- a/Shared/Coordinators/Navigation/NavigationRoute/NavigationRoute+Item.swift +++ b/Shared/Coordinators/Navigation/NavigationRoute/NavigationRoute+Item.swift @@ -65,16 +65,13 @@ extension NavigationRoute { @MainActor static func castAndCrew(people: [BaseItemPerson], itemID: String?) -> NavigationRoute { - let id: String? = itemID == nil ? nil : "castAndCrew-\(itemID!)" - let viewModel = PagingLibraryViewModel( - title: L10n.castAndCrew.localizedCapitalized, - id: id, - people + let library = StaticLibrary( + title: L10n.castAndCrew, + id: "castAndCrew", + elements: people ) - return NavigationRoute(id: "castAndCrew") { - PagingLibraryView(viewModel: viewModel) - } + return .library(library: library) } #if os(iOS) @@ -202,16 +199,44 @@ extension NavigationRoute { } } + @MainActor + static func person(_ person: BaseItemPerson) -> NavigationRoute { + .item(item: .init(person: person)) + } + + @MainActor static func item(item: BaseItemDto) -> NavigationRoute { NavigationRoute( id: "item-\(item.id ?? "Unknown")", withNamespace: { .push(.zoom(sourceID: "item", namespace: $0)) } ) { - ItemView(item: item) +// ItemView(item: item) + ItemContentGroupView( + provider: ItemGroupProvider( + displayTitle: item.displayTitle, + id: item.id! + ) + ) + } + } + + @MainActor + static func item(displayTitle: String, id: String) -> NavigationRoute { + NavigationRoute( + id: "item-\(id)", + withNamespace: { .push(.zoom(sourceID: "item", namespace: $0)) } + ) { + ItemContentGroupView( + provider: ItemGroupProvider( + displayTitle: displayTitle, + id: id + ) + ) } } #if os(iOS) + @MainActor static func itemEditor(viewModel: ItemEditorViewModel) -> NavigationRoute { NavigationRoute( id: "itemEditor", @@ -221,6 +246,16 @@ extension NavigationRoute { } } + @MainActor + static func editItem(_ item: BaseItemDto) -> NavigationRoute { + NavigationRoute( + id: "itemEditor", + style: .sheet + ) { + ItemEditorView(viewModel: ItemEditorViewModel(item: item)) + } + } + static func itemImageDetails(viewModel: ItemImagesViewModel, imageInfo: ImageInfo) -> NavigationRoute { NavigationRoute( id: "itemImageDetails", diff --git a/Shared/Coordinators/Navigation/NavigationRoute/NavigationRoute+Library.swift b/Shared/Coordinators/Navigation/NavigationRoute/NavigationRoute+Library.swift index 265068281a..99b252eaca 100644 --- a/Shared/Coordinators/Navigation/NavigationRoute/NavigationRoute+Library.swift +++ b/Shared/Coordinators/Navigation/NavigationRoute/NavigationRoute+Library.swift @@ -9,6 +9,7 @@ import JellyfinAPI import SwiftUI +@MainActor extension NavigationRoute { static func filter(type: ItemFilterType, viewModel: FilterViewModel) -> NavigationRoute { @@ -23,14 +24,25 @@ extension NavigationRoute { } } - static func library( - viewModel: PagingLibraryViewModel + static func contentGroup( + provider: some ContentGroupProvider ) -> NavigationRoute { NavigationRoute( - id: "library-(\(viewModel.parent?.id ?? "Unparented"))", + id: "content-group-\(provider.id)", withNamespace: { .push(.zoom(sourceID: "item", namespace: $0)) } ) { - PagingLibraryView(viewModel: viewModel) + ContentGroupView(provider: provider) + } + } + + static func library( + library: Library + ) -> NavigationRoute where Library.Element: LibraryElement { + NavigationRoute( + id: "library-\(library.parent.libraryID)", + withNamespace: { .push(.zoom(sourceID: "item", namespace: $0)) } + ) { + PagingLibraryView(library: library) } } } diff --git a/Shared/Coordinators/Navigation/NavigationRoute/NavigationRoute+Media.swift b/Shared/Coordinators/Navigation/NavigationRoute/NavigationRoute+Media.swift index d418d44458..8431caec7d 100644 --- a/Shared/Coordinators/Navigation/NavigationRoute/NavigationRoute+Media.swift +++ b/Shared/Coordinators/Navigation/NavigationRoute/NavigationRoute+Media.swift @@ -15,20 +15,12 @@ import Transmission extension NavigationRoute { - static var channels: NavigationRoute { - NavigationRoute( - id: "channels" - ) { - ChannelLibraryView() - } - } - - static var liveTV: NavigationRoute { - NavigationRoute( - id: "liveTV" - ) { - ProgramsView() - } + @MainActor + static let liveTV = NavigationRoute( + id: "liveTV", + withNamespace: { .push(.zoom(sourceID: "item", namespace: $0)) } + ) { + ContentGroupView(provider: LiveTVGroupProvider()) } static func mediaSourceInfo(source: MediaSourceInfo) -> NavigationRoute { @@ -52,10 +44,12 @@ extension NavigationRoute { mediaSource: MediaSourceInfo? = nil, queue: (any MediaPlayerQueue)? = nil ) -> NavigationRoute { - let provider = MediaPlayerItemProvider(item: item) { item in - try await MediaPlayerItem.build(for: item, mediaSource: mediaSource) - } - return Self.videoPlayer(provider: provider, queue: queue) + videoPlayer( + provider: MediaPlayerItemProvider(item: item) { item in + try await MediaPlayerItem.build(for: item, mediaSource: mediaSource) + }, + queue: queue + ) } @MainActor @@ -63,13 +57,13 @@ extension NavigationRoute { provider: MediaPlayerItemProvider, queue: (any MediaPlayerQueue)? = nil ) -> NavigationRoute { - let manager = MediaPlayerManager( - item: provider.item, - queue: queue, - mediaPlayerItemProvider: provider.function + videoPlayer( + manager: MediaPlayerManager( + item: provider.item, + queue: queue, + mediaPlayerItemProvider: provider.function + ) ) - - return Self.videoPlayer(manager: manager) } @MainActor @@ -116,7 +110,7 @@ struct VideoPlayerViewShim: View { .ignoresSafeArea() .persistentSystemOverlays(.hidden) .toolbar(.hidden, for: .navigationBar) - .onSizeChanged { _, safeArea in + .onFrameChanged { _, safeArea in self.safeAreaInsets = safeArea.max(EdgeInsets.edgePadding) } } diff --git a/Shared/Coordinators/Navigation/NavigationRoute/NavigationRoute+Settings.swift b/Shared/Coordinators/Navigation/NavigationRoute/NavigationRoute+Settings.swift index 7b80b516b4..dd733ccf67 100644 --- a/Shared/Coordinators/Navigation/NavigationRoute/NavigationRoute+Settings.swift +++ b/Shared/Coordinators/Navigation/NavigationRoute/NavigationRoute+Settings.swift @@ -145,6 +145,24 @@ extension NavigationRoute { } #endif + static let itemSettings = NavigationRoute( + id: "itemSettings" + ) { + CustomizeViewsSettings.ItemSection() + } + + static let librarySettings = NavigationRoute( + id: "librarySettings" + ) { + CustomizeViewsSettings.LibrarySection() + } + + static let posterSettings = NavigationRoute( + id: "posterSettings" + ) { + CustomizeViewsSettings.PosterSection() + } + static var indicatorSettings: NavigationRoute { NavigationRoute( id: "indicatorSettings" diff --git a/Shared/Coordinators/Navigation/Router.swift b/Shared/Coordinators/Navigation/Router.swift index 1b3ee56acf..85eda93c65 100644 --- a/Shared/Coordinators/Navigation/Router.swift +++ b/Shared/Coordinators/Navigation/Router.swift @@ -16,6 +16,7 @@ extension NavigationCoordinator { let navigationCoordinator: NavigationCoordinator? let rootCoordinator: RootCoordinator? + // TODO: on navigation route dismissed func route( to route: NavigationRoute, transition: NavigationRoute.TransitionType? = nil, @@ -43,6 +44,14 @@ struct Router: DynamicProperty { let router: NavigationCoordinator.Router let dismiss: DismissAction + var isRootOfPath: Bool { + guard let router = router.navigationCoordinator else { + return false + } + + return router.path.isEmpty + } + func route( to route: NavigationRoute, in namespace: Namespace.ID? = nil diff --git a/Shared/Coordinators/Navigation/WithTransitionReaderPublisher.swift b/Shared/Coordinators/Navigation/WithTransitionReaderPublisher.swift index c309740931..03c50e112e 100644 --- a/Shared/Coordinators/Navigation/WithTransitionReaderPublisher.swift +++ b/Shared/Coordinators/Navigation/WithTransitionReaderPublisher.swift @@ -7,15 +7,18 @@ // #if os(iOS) +import Combine import SwiftUI import Transmission +typealias TransitionReaderProxySubject = PassthroughSubject + // TODO: sometimes causes hangs? struct WithTransitionReaderPublisher: View { @StateObject - private var publishedBox: PublishedBox> = .init(initialValue: .init()) + private var publishedBox: PublishedBox = .init(initialValue: .init()) let content: Content @@ -43,7 +46,7 @@ struct TransitionReaderObserver: DynamicProperty { @Environment(\.transitionReader) private var publisher - var wrappedValue: LegacyEventPublisher { + var wrappedValue: TransitionReaderProxySubject { publisher } } @@ -51,6 +54,6 @@ struct TransitionReaderObserver: DynamicProperty { extension EnvironmentValues { @Entry - var transitionReader: LegacyEventPublisher = .init() + var transitionReader: TransitionReaderProxySubject = .init() } #endif diff --git a/Shared/Coordinators/Root/RootView.swift b/Shared/Coordinators/Root/RootView.swift index dad72d2304..f1bd49353b 100644 --- a/Shared/Coordinators/Root/RootView.swift +++ b/Shared/Coordinators/Root/RootView.swift @@ -15,25 +15,9 @@ struct RootView: View { var body: some View { ZStack { - if rootCoordinator.root.id == RootItem.appLoading.id { - RootItem.appLoading.content - } - - if rootCoordinator.root.id == RootItem.mainTab.id { - RootItem.mainTab.content - } - - if rootCoordinator.root.id == RootItem.selectUser.id { - RootItem.selectUser.content - } - - #if os(iOS) - if rootCoordinator.root.id == RootItem.serverCheck.id { - RootItem.serverCheck.content - } - #endif + rootCoordinator.root.content } - .animation(.linear(duration: 0.1), value: rootCoordinator.root.id) + .animation(.linear(duration: 0.2), value: rootCoordinator.root.id) .environmentObject(rootCoordinator) } } diff --git a/Shared/Coordinators/Tabs/MainTabView.swift b/Shared/Coordinators/Tabs/MainTabView.swift index f0d9e8477f..7918ec951b 100644 --- a/Shared/Coordinators/Tabs/MainTabView.swift +++ b/Shared/Coordinators/Tabs/MainTabView.swift @@ -6,26 +6,30 @@ // Copyright (c) 2026 Jellyfin & Jellyfin Contributors // +import Defaults import Factory +import JellyfinAPI import SwiftUI -// TODO: move popup to router -// - or, make tab view environment object - -// TODO: fix weird tvOS icon rendering struct MainTabView: View { + @Default(.Customization.Episodes.useSeriesLandscapeBackdrop) + private var useSeriesLandscapeBackdrop + + @Default(.Customization.Library._libraryStyle) + private var defaultLibraryStyle + #if os(iOS) @StateObject private var tabCoordinator = TabCoordinator { - TabItem.home - TabItem.search - TabItem.media + TabItemSetting.contentGroup(.default) + TabItemSetting.search + TabItemSetting.media } #else @StateObject private var tabCoordinator = TabCoordinator { - TabItem.home + TabItem.contentGroup(provider: DefaultContentGroupProvider()) TabItem.library( title: L10n.tvShowsCapitalized, systemName: "tv", @@ -42,7 +46,6 @@ struct MainTabView: View { } #endif - @ViewBuilder var body: some View { TabView(selection: $tabCoordinator.selectedTabID) { ForEach(tabCoordinator.tabs, id: \.item.id) { tab in @@ -50,20 +53,69 @@ struct MainTabView: View { coordinator: tab.coordinator ) { tab.item.content + #if os(iOS) + .topBarTrailing { + if tab.item.id != "settings" { + SettingsBarButton() + } + } + #endif } .environmentObject(tabCoordinator) .environment(\.tabItemSelected, tab.publisher) .tabItem { Label( - tab.item.title, + tab.item.displayTitle, systemImage: tab.item.systemImage ) - .labelStyle(tab.item.labelStyle) .symbolRenderingMode(.monochrome) - .eraseToAnyView() } .tag(tab.item.id) } } + .contextMenu(for: BaseItemDto.self) { item in + if item.type == .episode { + WithRouter { router in + Button("Go to Episode", systemImage: "info.circle") { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + router.route(to: .item(item: item)) + } + } + } + + if let seriesID = item.seriesID { + WithRouter { router in + Button("Go to Show", systemImage: "info.circle") { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + router.route( + to: .item( + displayTitle: item.displayTitle, + id: seriesID + ) + ) + } + } + } + } + } else { + WithRouter { router in + Button("Go to Item", systemImage: "info.circle") { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + router.route(to: .item(item: item)) + } + } + } + } + } + .libraryStyle(for: BaseItemDto.self) { _, _ in + (defaultLibraryStyle, $defaultLibraryStyle) + } + .libraryStyle(for: ChannelProgram.self) { _, _ in + (defaultLibraryStyle, $defaultLibraryStyle) + } + .customEnvironment( + for: BaseItemDto.self, + value: .init(useParent: useSeriesLandscapeBackdrop) + ) } } diff --git a/Shared/Coordinators/Tabs/TabCoordinator.swift b/Shared/Coordinators/Tabs/TabCoordinator.swift index 217c7b938e..fab636f697 100644 --- a/Shared/Coordinators/Tabs/TabCoordinator.swift +++ b/Shared/Coordinators/Tabs/TabCoordinator.swift @@ -46,5 +46,18 @@ final class TabCoordinator: ObservableObject { let event = TabItemSelectedPublisher() return (tab, coordinator, event) } + + self.selectedTabID = tabs.first?.id + } + + init(@ArrayBuilder tabs: () -> [TabItemSetting]) { + let tabs = tabs() + self.tabs = tabs.map { tab in + let coordinator = NavigationCoordinator() + let event = TabItemSelectedPublisher() + return (tab.item, coordinator, event) + } + + self.selectedTabID = tabs.first?.item.id } } diff --git a/Shared/Coordinators/Tabs/TabItem.swift b/Shared/Coordinators/Tabs/TabItem.swift index dcb286cd19..226d135857 100644 --- a/Shared/Coordinators/Tabs/TabItem.swift +++ b/Shared/Coordinators/Tabs/TabItem.swift @@ -8,13 +8,66 @@ import SwiftUI -// TODO: selected icon @MainActor -struct TabItem: Identifiable, Hashable { +enum TabItemSetting: @preconcurrency Identifiable { + + case adminDashboard + case contentGroup(ContentGroupProviderSetting) + case item(id: String) + case liveTV + case media + case search + case settings + + var id: String { + switch self { + case .adminDashboard: + "admin-dashboard" + case let .contentGroup(provider): + provider.provider.id + case let .item(id): + id + case .liveTV: + "live-tv" + case .media: + "media" + case .search: + "search" + case .settings: + "settings" + } + } + + var item: TabItem { + switch self { + case .adminDashboard: + #if os(iOS) + .adminDashboard + #else + .settings + #endif + case let .contentGroup(provider): + .contentGroup(provider: provider.provider) + case let .item(id): + .contentGroup(provider: ItemGroupProvider(displayTitle: "", id: id)) + case .liveTV: + .liveTV + case .media: + .media + case .search: + .search + case .settings: + .settings + } + } +} + +@MainActor +struct TabItem: Displayable, Identifiable, SystemImageable { let content: AnyView + let displayTitle: String let id: String - let title: String let systemImage: String let labelStyle: any LabelStyle @@ -27,29 +80,35 @@ struct TabItem: Identifiable, Hashable { ) { self.content = AnyView(content()) self.id = id - self.title = title + self.displayTitle = title self.systemImage = systemImage self.labelStyle = labelStyle } - - func hash(into hasher: inout Hasher) { - hasher.combine(id) - } - - static func == (lhs: Self, rhs: Self) -> Bool { - lhs.id == rhs.id - } } extension TabItem { - static var home: TabItem { + static let adminDashboard = TabItem( + id: "admin-dashboard", + title: L10n.dashboard, + systemImage: "server.rack" + ) { + #if os(iOS) + AdminDashboardView() + #else + EmptyView() + #endif + } + + static func contentGroup( + provider: some ContentGroupProvider + ) -> TabItem { TabItem( - id: "home", - title: L10n.home, - systemImage: "house" + id: provider.id, + title: provider.displayTitle, + systemImage: "house.fill" ) { - HomeView() + ContentGroupView(provider: provider) } } @@ -58,46 +117,53 @@ extension TabItem { systemName: String, filters: ItemFilterCollection ) -> TabItem { - TabItem( - id: "library-\(UUID().uuidString)", + + let id = "library-\(UUID().uuidString)" + + return TabItem( + id: id, title: title, systemImage: systemName ) { - let viewModel = ItemLibraryViewModel( + let library = ItemLibrary( + parent: .init(name: title), filters: filters ) - PagingLibraryView(viewModel: viewModel) + PagingLibraryView(library: library) } } - static var media: TabItem { - TabItem( - id: "media", - title: L10n.media, - systemImage: "rectangle.stack.fill" - ) { - MediaView() - } + static let liveTV = TabItem( + id: "live-tv", + title: L10n.liveTV, + systemImage: "play.tv" + ) { + NavigationRoute.liveTV.destination } - static var search: TabItem { - TabItem( - id: "search", - title: L10n.search, - systemImage: "magnifyingglass" - ) { - SearchView() - } + static let media = TabItem( + id: "media", + title: L10n.media, + systemImage: "rectangle.stack.fill" + ) { + MediaView() } - static var settings: TabItem { - TabItem( - id: "settings", - title: L10n.settings, - systemImage: "gearshape" - ) { - SettingsView() - } + static let search = TabItem( + id: "search", + title: L10n.search, + systemImage: "magnifyingglass" + ) { + SearchView() + } + + static let settings = TabItem( + id: "settings", + title: L10n.settings, + systemImage: "gearshape", + labelStyle: .iconOnly + ) { + SettingsView() } } diff --git a/Shared/Coordinators/Tabs/TabItemSelectedPublisher.swift b/Shared/Coordinators/Tabs/TabItemSelectedPublisher.swift index 20da77f5fd..5a997db2d3 100644 --- a/Shared/Coordinators/Tabs/TabItemSelectedPublisher.swift +++ b/Shared/Coordinators/Tabs/TabItemSelectedPublisher.swift @@ -6,11 +6,12 @@ // Copyright (c) 2026 Jellyfin & Jellyfin Contributors // +import Combine import SwiftUI extension TabCoordinator { - typealias TabItemSelectedPublisher = LegacyEventPublisher + typealias TabItemSelectedPublisher = PassthroughSubject } @propertyWrapper diff --git a/Shared/Extensions/Array.swift b/Shared/Extensions/Array.swift index 4c6acc6984..e117a73b6a 100644 --- a/Shared/Extensions/Array.swift +++ b/Shared/Extensions/Array.swift @@ -14,6 +14,10 @@ extension Array { self + [element] } + func appending(elementsOf contents: [Element]) -> [Element] { + self + contents + } + func appending(_ element: @autoclosure () -> Element, if condition: Bool) -> [Element] { if condition { self + [element()] diff --git a/Shared/Extensions/Binding.swift b/Shared/Extensions/Binding.swift index 778bb13660..71149003f5 100644 --- a/Shared/Extensions/Binding.swift +++ b/Shared/Extensions/Binding.swift @@ -37,6 +37,19 @@ extension Binding { ) } + func contains(_ value: V) -> Binding where Value == Set { + Binding( + get: { wrappedValue.contains(value) }, + set: { shouldBeContained in + if shouldBeContained { + wrappedValue.insert(value) + } else { + wrappedValue.remove(value) + } + } + ) + } + func map(getter: @escaping (Value) -> V, setter: @escaping (V) -> Value) -> Binding { Binding( get: { getter(wrappedValue) }, @@ -56,6 +69,22 @@ extension Binding { } } +extension Binding where Value: OptionSet { + + func contains(_ element: Value.Element) -> Binding { + Binding( + get: { wrappedValue.contains(element) }, + set: { newValue in + if newValue { + wrappedValue.insert(element) + } else { + wrappedValue.remove(element) + } + } + ) + } +} + extension Binding where Value: RangeReplaceableCollection, Value.Element: Equatable { func contains(_ element: Value.Element) -> Binding { diff --git a/Shared/Extensions/BoxedPublished.swift b/Shared/Extensions/BoxedPublished.swift deleted file mode 100644 index 3ad8f1db58..0000000000 --- a/Shared/Extensions/BoxedPublished.swift +++ /dev/null @@ -1,33 +0,0 @@ -// -// 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 SwiftUI - -@propertyWrapper -struct BoxedPublished: DynamicProperty { - - @StateObject - var storage: PublishedBox - - init(wrappedValue: Value) { - self._storage = StateObject(wrappedValue: PublishedBox(initialValue: wrappedValue)) - } - - var wrappedValue: Value { - get { storage.value } - nonmutating set { storage.value = newValue } - } - - var projectedValue: Published.Publisher { - storage.$value - } - - var box: PublishedBox { - storage - } -} diff --git a/Shared/Extensions/Collection.swift b/Shared/Extensions/Collection.swift index 32c9f3016c..071d38fccc 100644 --- a/Shared/Extensions/Collection.swift +++ b/Shared/Extensions/Collection.swift @@ -10,8 +10,8 @@ import Foundation extension Collection { - var asArray: [Element] { - Array(self) + var nilIfEmpty: Self? { + isEmpty ? nil : self } var isNotEmpty: Bool { diff --git a/Shared/Extensions/ViewExtensions/Modifiers/OnReceiveNotificationModifier.swift b/Shared/Extensions/CoordinateSpace.swift similarity index 51% rename from Shared/Extensions/ViewExtensions/Modifiers/OnReceiveNotificationModifier.swift rename to Shared/Extensions/CoordinateSpace.swift index a190821d7f..78e182dda5 100644 --- a/Shared/Extensions/ViewExtensions/Modifiers/OnReceiveNotificationModifier.swift +++ b/Shared/Extensions/CoordinateSpace.swift @@ -8,13 +8,9 @@ import SwiftUI -struct OnReceiveNotificationModifier>: ViewModifier { +extension CoordinateSpace { - let key: K - let onReceive: (P) -> Void - - func body(content: Content) -> some View { - content - .onReceive(key.publisher, perform: onReceive) - } + static let navigationStack = CoordinateSpace.named("navigationStack") + static let scrollView = CoordinateSpace.named("scrollView") + static let scrollViewHeader = CoordinateSpace.named("scrollView.header") } diff --git a/Shared/Extensions/Dictionary.swift b/Shared/Extensions/Dictionary.swift index db01106018..e6c0d1db15 100644 --- a/Shared/Extensions/Dictionary.swift +++ b/Shared/Extensions/Dictionary.swift @@ -10,8 +10,15 @@ import Foundation extension Dictionary { - subscript(key: Key?) -> Value? { - guard let key else { return nil } - return self[key] + func inserting(value: Value, for key: Key) -> Self { + var copy = self + copy[key] = value + return copy + } + + func removingValue(for key: Key) -> Self { + var copy = self + copy.removeValue(forKey: key) + return copy } } diff --git a/Shared/Extensions/EdgeInsets.swift b/Shared/Extensions/EdgeInsets.swift index 9acd80b7f7..00e639b12f 100644 --- a/Shared/Extensions/EdgeInsets.swift +++ b/Shared/Extensions/EdgeInsets.swift @@ -15,7 +15,7 @@ extension EdgeInsets { /// typically the edges of the View's scene static let edgePadding: CGFloat = { #if os(tvOS) - 44 + 60 #else if UIDevice.isPad { 24 @@ -51,17 +51,6 @@ extension EdgeInsets { } } -extension NSDirectionalEdgeInsets { - - init(constant: CGFloat) { - self.init(top: constant, leading: constant, bottom: constant, trailing: constant) - } - - init(vertical: CGFloat = 0, horizontal: CGFloat = 0) { - self.init(top: vertical, leading: horizontal, bottom: vertical, trailing: horizontal) - } -} - extension UIEdgeInsets { var asEdgeInsets: EdgeInsets { diff --git a/Shared/Extensions/EnvironmentValues.swift b/Shared/Extensions/EnvironmentValues.swift index 79db73de20..611f4a9602 100644 --- a/Shared/Extensions/EnvironmentValues.swift +++ b/Shared/Extensions/EnvironmentValues.swift @@ -13,6 +13,9 @@ extension EnvironmentValues { @Entry var audioOffset: Binding = .constant(.zero) + @Entry + var frameForParentView: [CoordinateSpace: FrameAndSafeAreaInsets] = [:] + @Entry var isEditing: Bool = false @@ -28,9 +31,13 @@ extension EnvironmentValues { @Entry var isSelected: Bool = false + @Entry + var _navigationTitle: String? = nil + @Entry var playbackSpeed: Binding = .constant(1) + @available(*, deprecated, message: "Use `frameForParentView` instead") @Entry var safeAreaInsets: EdgeInsets = UIApplication.shared.keyWindow?.safeAreaInsets.asEdgeInsets ?? .zero diff --git a/Shared/Extensions/FocusedValues.swift b/Shared/Extensions/FocusedValues.swift index a7118c7f4f..970fb90e07 100644 --- a/Shared/Extensions/FocusedValues.swift +++ b/Shared/Extensions/FocusedValues.swift @@ -10,6 +10,10 @@ import SwiftUI extension FocusedValues { + // TODO: fix @Entry var focusedPoster: AnyPoster? + + @Entry + var focusedPosterID: Int? } diff --git a/Shared/Extensions/Font.swift b/Shared/Extensions/Font.swift deleted file mode 100644 index 1134b7485b..0000000000 --- a/Shared/Extensions/Font.swift +++ /dev/null @@ -1,43 +0,0 @@ -// -// 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 SwiftUI - -extension Font { - - var uiFont: UIFont? { - switch self { - #if os(iOS) - case .largeTitle: - return UIFont.preferredFont(forTextStyle: .largeTitle) - #endif - case .title: - return UIFont.preferredFont(forTextStyle: .title1) - case .title2: - return UIFont.preferredFont(forTextStyle: .title2) - case .title3: - return UIFont.preferredFont(forTextStyle: .title3) - case .headline: - return UIFont.preferredFont(forTextStyle: .headline) - case .subheadline: - return UIFont.preferredFont(forTextStyle: .subheadline) - case .callout: - return UIFont.preferredFont(forTextStyle: .callout) - case .caption: - return UIFont.preferredFont(forTextStyle: .caption1) - case .caption2: - return UIFont.preferredFont(forTextStyle: .caption2) - case .footnote: - return UIFont.preferredFont(forTextStyle: .footnote) - case .body: - return UIFont.preferredFont(forTextStyle: .body) - default: - return nil - } - } -} diff --git a/Shared/Extensions/Form.swift b/Shared/Extensions/Form.swift index 9f2c03925d..2eb8b3e974 100644 --- a/Shared/Extensions/Form.swift +++ b/Shared/Extensions/Form.swift @@ -69,7 +69,8 @@ private struct PlatformForm: PlatformView { Form { content } - .navigationBarTitleDisplayMode(.inline) + .backport + .toolbarTitleDisplayMode(.inline) } var tvOSView: some View { diff --git a/Shared/Extensions/FormatStyle.swift b/Shared/Extensions/FormatStyle.swift index d795ba6fb3..a431be1297 100644 --- a/Shared/Extensions/FormatStyle.swift +++ b/Shared/Extensions/FormatStyle.swift @@ -52,6 +52,21 @@ extension FormatStyle where Self == Duration.UnitsFormatStyle { } } +struct RuntimeUnitsFormatStyle: FormatStyle { + + let width: Duration.UnitsFormatStyle.UnitWidth + + func format(_ value: Duration) -> String { + let formatStyle = Duration.UnitsFormatStyle( + allowedUnits: [.hours, .minutes], + width: width + ) + return formatStyle.format(value) + } +} + +// TODO: rename `RuntimeTimeFormatStyle` +// TODO: make one for units struct RuntimeFormatStyle: FormatStyle { func format(_ value: Duration) -> String { diff --git a/Shared/Extensions/Int.swift b/Shared/Extensions/Int.swift index ec09fdd3c4..27e199b387 100644 --- a/Shared/Extensions/Int.swift +++ b/Shared/Extensions/Int.swift @@ -8,43 +8,9 @@ import Foundation -// TODO: replace all with formatters or use Duration - -extension FixedWidthInteger { - - var timeLabel: String { - let hours = self / 3600 - let minutes = (self % 3600) / 60 - let seconds = self % 3600 % 60 - - let hourText = hours > 0 ? String(hours).appending(":") : "" - let minutesText = hours > 0 ? String(minutes).leftPad(maxWidth: 2, with: "0").appending(":") : String(minutes) - .appending(":") - let secondsText = String(seconds).leftPad(maxWidth: 2, with: "0") - - return hourText - .appending(minutesText) - .appending(secondsText) - } -} - extension Int { - /// Label if the current value represents milliseconds - var millisecondLabel: String { - let isNegative = self < 0 - let value = abs(self) - let seconds = "\(value / 1000)" - let milliseconds = "\(value % 1000)".first ?? "0" - - return seconds - .appending(".") - .appending(milliseconds) - .appending("s") - .prepending("-", if: isNegative) - } - - /// Label if the current value represents seconds + @available(*, deprecated, message: "Use a `Duration` formatter instead") var secondLabel: String { let isNegative = self < 0 let value = abs(self) @@ -64,22 +30,6 @@ extension Int { } } -struct MilliseondFormatter: FormatStyle { - - func format(_ value: Int) -> String { - let isNegative = value < 0 - let value = abs(value) - let seconds = "\(value / 1000)" - let milliseconds = "\(value % 1000)".first ?? "0" - - return seconds - .appending(".") - .appending(milliseconds) - .appending("s") - .prepending("-", if: isNegative) - } -} - struct SecondFormatter: FormatStyle { func format(_ value: Int) -> String { diff --git a/Shared/Extensions/JellyfinAPI/ActivityLogEntry.swift b/Shared/Extensions/JellyfinAPI/ActivityLogEntry.swift index efee1a56fe..04458c1fd3 100644 --- a/Shared/Extensions/JellyfinAPI/ActivityLogEntry.swift +++ b/Shared/Extensions/JellyfinAPI/ActivityLogEntry.swift @@ -19,15 +19,7 @@ extension ActivityLogEntry: Poster { name ?? L10n.unknown } - var unwrappedIDHashOrZero: Int { - id?.hashValue ?? 0 - } - var systemImage: String { "text.document" } - - func transform(image: Image) -> some View { - image - } } diff --git a/Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto+Images.swift b/Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto+Images.swift index d8b4733f71..e4f19fb9cb 100644 --- a/Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto+Images.swift +++ b/Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto+Images.swift @@ -62,13 +62,15 @@ extension BaseItemDto { _ type: ImageType, maxWidth: CGFloat? = nil, maxHeight: CGFloat? = nil, - quality: Int? = nil + quality: Int? = nil, + requireTag: Bool = true ) -> ImageSource { _imageSource( type, maxWidth: maxWidth, maxHeight: maxHeight, - quality: quality + quality: quality, + requireTag: requireTag ) } @@ -115,6 +117,36 @@ extension BaseItemDto { ) } + func imageSource( + id: String?, + blurHash: String? = nil, + _ type: ImageType, + maxWidth: CGFloat? = nil, + maxHeight: CGFloat? = nil, + quality: Int? = nil + ) -> ImageSource { + guard let id else { + return ImageSource( + url: nil, + blurHash: nil + ) + } + + let url = _imageURL( + type, + maxWidth: maxWidth, + maxHeight: maxHeight, + quality: quality, + itemID: id, + requireTag: false + ) + + return ImageSource( + url: url, + blurHash: blurHash + ) + } + // MARK: private func _imageURL( @@ -167,14 +199,16 @@ extension BaseItemDto { _ type: ImageType, maxWidth: CGFloat?, maxHeight: CGFloat?, - quality: Int? + quality: Int?, + requireTag: Bool = true ) -> ImageSource { let url = _imageURL( type, maxWidth: maxWidth, maxHeight: maxHeight, quality: quality, - itemID: id ?? "" + itemID: id ?? "", + requireTag: requireTag ) let blurHash = blurHashString(for: type) diff --git a/Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto+LibraryParent.swift b/Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto+LibraryParent.swift index de4dbd127e..5adc49c823 100644 --- a/Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto+LibraryParent.swift +++ b/Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto+LibraryParent.swift @@ -8,32 +8,58 @@ import JellyfinAPI -extension BaseItemDto: LibraryParent { +protocol LibraryGrouping: Displayable, Equatable, Hashable, Identifiable, Storable { + var id: String { get } +} + +extension BaseItemDto: _LibraryParent { + + struct Grouping: LibraryGrouping { + + let displayTitle: String + let id: String - var libraryType: BaseItemKind? { - type + static let episodes = Grouping(displayTitle: L10n.episodes, id: "episodes") + static let series = Grouping(displayTitle: L10n.series, id: "series") } - var supportedItemTypes: [BaseItemKind] { - guard let collectionType else { return [] } + var libraryID: String { + id ?? "unknown" + } - switch (collectionType, libraryType) { - case (_, .folder): - return BaseItemKind.supportedCases - .appending([.folder, .collectionFolder]) - case (.movies, _): - return [.movie] - case (.tvshows, _): - return [.series] - case (.boxsets, _): - return BaseItemKind.supportedCases + var groupings: (defaultSelection: Grouping, elements: [Grouping])? { + switch collectionType { + case .tvshows: + let episodes = Grouping(displayTitle: L10n.episodes, id: "episodes") + let series = Grouping(displayTitle: L10n.series, id: "series") + return (series, [episodes, series]) default: + return nil + } + } + + func _supportedItemTypes(for grouping: Grouping?) -> [BaseItemKind] { + if collectionType == .folders { return BaseItemKind.supportedCases } + + if collectionType == .tvshows { + if let grouping, grouping == .episodes { + return [.episode] + } else { + return [.series] + } + } + + return BaseItemKind.supportedCases } - var isRecursiveCollection: Bool { - guard let collectionType, libraryType != .userView else { return true } + func _isRecursiveCollection(for grouping: Grouping?) -> Bool { + guard let collectionType, type != .userView else { return true } + + if let grouping, grouping == .episodes { + return true + } return ![.tvshows, .boxsets].contains(collectionType) } diff --git a/Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto+Poster.swift b/Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto+Poster.swift index c25d042049..e54ef3633c 100644 --- a/Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto+Poster.swift +++ b/Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto+Poster.swift @@ -11,10 +11,26 @@ import Foundation import JellyfinAPI import SwiftUI -// MARK: Poster +private let landscapeWidth: CGFloat = 110 +private let portraitWidth: CGFloat = 60 extension BaseItemDto: Poster { + struct Environment: WithDefaultValue, WithViewContext { + let useParent: Bool + var viewContext: ViewContext + + init( + useParent: Bool = false, + viewContext: ViewContext = .init() + ) { + self.useParent = useParent + self.viewContext = viewContext + } + + static let `default`: Self = .init() + } + var preferredPosterDisplayType: PosterDisplayType { type?.preferredPosterDisplayType ?? .portrait } @@ -30,15 +46,6 @@ extension BaseItemDto: Poster { } } - var showTitle: Bool { - switch type { - case .episode, .series, .movie, .boxSet, .collectionFolder: - Defaults[.Customization.showPosterLabels] - default: - true - } - } - var systemImage: String { switch type { case .audio, .musicAlbum: @@ -49,7 +56,7 @@ extension BaseItemDto: Poster { "tv" case .episode, .movie, .series, .video: "film" - case .folder: + case .collectionFolder, .folder, .userView: "folder.fill" case .musicVideo: "music.note.tv.fill" @@ -60,17 +67,32 @@ extension BaseItemDto: Poster { } } - func portraitImageSources(maxWidth: CGFloat? = nil, quality: Int? = nil) -> [ImageSource] { + func portraitImageSources( + maxWidth: CGFloat? = nil, + quality: Int? = nil, + environment: Environment + ) -> [ImageSource] { switch type { case .episode: - [seriesImageSource(.primary, maxWidth: maxWidth, quality: quality)] - case .boxSet, .channel, .liveTvChannel, .movie, .musicArtist, .person, .series, .tvChannel: - [imageSource(.primary, maxWidth: maxWidth, quality: quality)] + seriesImageSource(.primary, maxWidth: maxWidth, quality: quality) + case .boxSet, .channel, .liveTvChannel, .movie, .musicArtist, .series, .tvChannel: + imageSource( + .primary, + maxWidth: maxWidth, + quality: quality + ) + case .person: + imageSource( + .primary, + maxWidth: maxWidth, + quality: quality, + requireTag: false + ) default: // TODO: cleanup // parentBackdropItemID seems good enough if extraType != nil, let parentBackdropItemID { - [.init( + .init( url: _imageURL( .primary, maxWidth: maxWidth, @@ -79,75 +101,264 @@ extension BaseItemDto: Poster { itemID: parentBackdropItemID, requireTag: false ) - )] - } else { - [] + ) } } } - func landscapeImageSources(maxWidth: CGFloat? = nil, quality: Int? = nil) -> [ImageSource] { + func landscapeImageSources( + maxWidth: CGFloat? = nil, + quality: Int? = nil, + environment: Environment + ) -> [ImageSource] { switch type { case .episode: - if Defaults[.Customization.Episodes.useSeriesLandscapeBackdrop] { - [ - seriesImageSource(.thumb, maxWidth: maxWidth, quality: quality), - seriesImageSource(.backdrop, maxWidth: maxWidth, quality: quality), - imageSource(.primary, maxWidth: maxWidth, quality: quality), - ] + if environment.useParent { + if environment.viewContext.contains(.isThumb) { + seriesImageSource(.thumb, maxWidth: maxWidth, quality: quality) + } + seriesImageSource(.backdrop, maxWidth: maxWidth, quality: quality) + imageSource(.primary, maxWidth: maxWidth, quality: quality) } else { - [imageSource(.primary, maxWidth: maxWidth, quality: quality)] + imageSource(.primary, maxWidth: maxWidth, quality: quality) } - case .folder, .program, .musicVideo, .video: - [imageSource(.primary, maxWidth: maxWidth, quality: quality)] - default: - [ - imageSource(.thumb, maxWidth: maxWidth, quality: quality), - imageSource(.backdrop, maxWidth: maxWidth, quality: quality), - ] - } - } - - func cinematicImageSources(maxWidth: CGFloat? = nil, quality: Int? = nil) -> [ImageSource] { - switch type { - case .episode: - [seriesImageSource(.backdrop, maxWidth: maxWidth, quality: quality)] + case .collectionFolder, .folder, .musicVideo, .liveTvProgram, .program, .userView, .video: + imageSource(.primary, maxWidth: maxWidth, quality: quality) default: - [imageSource(.backdrop, maxWidth: maxWidth, quality: quality)] + if environment.viewContext.contains(.isThumb) { + imageSource(.thumb, maxWidth: maxWidth, quality: quality) + } + imageSource(.backdrop, maxWidth: maxWidth, quality: quality) } } - func squareImageSources(maxWidth: CGFloat?, quality: Int? = nil) -> [ImageSource] { + func squareImageSources( + maxWidth: CGFloat?, + quality: Int? = nil, + environment: Environment + ) -> [ImageSource] { switch type { case .audio, .channel, .musicAlbum, .tvChannel: - [imageSource(.primary, maxWidth: maxWidth, quality: quality)] + // TODO: generalize blurhash retrieval + imageSource(.primary, maxWidth: maxWidth, quality: quality) + imageSource( + id: albumID, + blurHash: imageBlurHashes?.primary?.first?.value, + .primary, + maxWidth: maxWidth, + quality: quality + ) + case .program: + if let channelID { + imageSource( + id: channelID, + .primary, + maxWidth: maxWidth, + quality: quality + ) + } default: [] } } - func thumbImageSources() -> [ImageSource] { - switch preferredPosterDisplayType { - case .portrait: - portraitImageSources(maxWidth: 200, quality: 90) - case .landscape: - landscapeImageSources(maxWidth: 200, quality: 90) - case .square: - squareImageSources(maxWidth: 200, quality: 90) - } - } - @ViewBuilder - func transform(image: Image) -> some View { + func transform(image: Image, displayType: PosterDisplayType) -> some View { switch type { case .channel, .tvChannel: ContainerRelativeView(ratio: 0.95) { image .aspectRatio(contentMode: .fit) } + case .program: + if displayType == .square { + // Using channel from above + ContainerRelativeView(ratio: 0.95) { + image + .aspectRatio(contentMode: .fit) + } + } else { + image + .aspectRatio(contentMode: .fill) + } default: image .aspectRatio(contentMode: .fill) } } + + @ViewBuilder + var posterLabel: some View { + _BaseItemPosterLabel(item: self) + } + + @ViewBuilder + func posterOverlay(for displayType: PosterDisplayType) -> some View { + WithEnvironment(\.viewContext) { viewContext in + if viewContext.contains(.isInResume) { + PosterIndicatorsOverlay( + item: self, + indicators: .progress, + posterDisplayType: displayType + ) + } else { + WithDefaults(.Customization.Indicators.enabled) { indicators in + PosterIndicatorsOverlay( + item: self, + indicators: indicators, + posterDisplayType: displayType + ) + } + } + } + } +} + +private struct _BaseItemPosterLabel: View { + + @Default(.Customization.Episodes.useSeriesLandscapeBackdrop) + private var useSeriesLandscapeBackdrop + + let item: BaseItemDto + + var body: some View { + switch item.type { + case .liveTvProgram, .program, .tvProgram: + VStack(alignment: .leading) { + + Text(item.channelName ?? .emptyDash) + .font(.footnote) + .fontWeight(.semibold) + .foregroundStyle(.secondary) + .lineLimit(1) + + Text(item.displayTitle) + .font(.callout) + .lineLimit(1) + + SeparatorHStack { + Text("-") + } content: { + if let startDate = item.startDate { + Text(startDate, style: .time) + } else { + Text(String.emptyDash) + } + + if let endDate = item.endDate { + Text(endDate, style: .time) + } else { + Text(String.emptyDash) + } + } + .font(.footnote) + .foregroundStyle(.secondary) + } + case .episode: + TitleSubtitleContentView( + title: item.seriesName ?? L10n.unknown + ) { + AlternateLayoutView(alignment: .leading) { + Text(" ") + .frame(maxWidth: .infinity) + } content: { + DotHStack(padding: 2) { + if let seasonEpisodeLabel = item.seasonEpisodeLabel { + Text(seasonEpisodeLabel) + } + } + } + } + default: + TitleSubtitleContentView( + title: item.displayTitle + ) { + AlternateLayoutView(alignment: .leading) { + Text(" ") + .frame(maxWidth: .infinity) + } content: { + if let subtitle = item.subtitle { + Text(subtitle) + } + } + } + } + } +} + +extension BaseItemDto: LibraryElement { + + @MainActor + func libraryDidSelectElement(router: Router.Wrapper, in namespace: Namespace.ID) { + switch type { + case .collectionFolder, .folder, .userView: + let library = ItemLibrary(parent: self) + router.route(to: .library(library: library), in: namespace) + default: + router.route(to: .item(item: self), in: namespace) + } + } + + func makeGridBody(libraryStyle: LibraryStyle) -> some View { + WithRouter { router in + PosterButton( + item: self, + type: libraryStyle.posterDisplayType + ) { namespace in + libraryDidSelectElement(router: router, in: namespace) + } + } + } + + func makeListBody(libraryStyle: LibraryStyle) -> some View { + WithNamespace { namespace in + WithRouter { router in + ListRow(insets: .init(vertical: 8, horizontal: EdgeInsets.edgePadding)) { + libraryDidSelectElement(router: router, in: namespace) + } leading: { + PosterImage( + item: self, + type: libraryStyle.posterDisplayType, + contentMode: .fill + ) + .posterShadow() + .frame(width: libraryStyle.posterDisplayType == .landscape ? landscapeWidth : portraitWidth) + } content: { + VStack(alignment: .leading, spacing: 5) { + Text(displayTitle) + .font(.callout) + .fontWeight(.semibold) + .foregroundStyle(.primary) + .lineLimit(2) + .multilineTextAlignment(.leading) + + accessoryView + .font(.caption) + .foregroundStyle(.secondary) + } + } + .backport + .matchedTransitionSource(id: "item", in: namespace) + } + } + } + + @ViewBuilder + private var accessoryView: some View { + DotHStack { + if type == .episode, let seasonEpisodeLocator = seasonEpisodeLabel { + Text(seasonEpisodeLocator) + } else if let premiereYear = premiereDateYear { + Text(premiereYear) + } + + if let runtime { + Text(runtime, format: .runtime) + } + + if let officialRating { + Text(officialRating) + } + } + } } diff --git a/Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto.swift b/Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto.swift index 65c3b9d4a0..99681e0fa9 100644 --- a/Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto.swift +++ b/Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto.swift @@ -19,6 +19,7 @@ import SwiftUI extension BaseItemDto { + // TODO: remove? init(person: BaseItemPerson) { self.init( id: person.id, @@ -35,13 +36,6 @@ extension BaseItemDto: Displayable { } } -extension BaseItemDto: LibraryIdentifiable { - - var unwrappedIDHashOrZero: Int { - id?.hashValue ?? 0 - } -} - extension BaseItemDto { var avMetadata: [AVMetadataItem] { @@ -73,55 +67,6 @@ extension BaseItemDto { } } - func nowPlayableStaticMetadata(_ image: UIImage? = nil) -> NowPlayableStaticMetadata { - - let mediaType: MPNowPlayingInfoMediaType = { - switch type { - case .audio, .audioBook: .audio - default: .video - } - }() - - let title: String = { - if type == .episode, - let seriesName - { - seriesName - } else { - displayTitle - } - }() - - let albumArtist: String? = { - switch type { - case .audio: - artists?.joined(separator: ", ") - default: - nil - } - }() - - let albumTitle: String? = { - switch type { - case .audio: - album - default: - nil - } - }() - - // TODO: only fill artist, albumArtist, and albumTitle if audio type - return .init( - mediaType: mediaType, - isLiveStream: isLiveStream, - title: title, - artist: subtitle, - artwork: image.map { image in MPMediaItemArtwork(boundsSize: image.size) { _ in image }}, - albumArtist: albumArtist, - albumTitle: albumTitle - ) - } - var birthday: Date? { guard type == .person else { return nil } return premiereDate @@ -147,6 +92,27 @@ extension BaseItemDto { return genres.map(ItemGenre.init) } + var itemStudios: [ItemStudio]? { + guard let studios else { return nil } + return studios.compactMap { pair in + guard let id = pair.id else { return nil } + + return ItemStudio( + displayTitle: pair.displayTitle, + value: id + ) + } + } + + var isIdentifiable: Bool { + switch type { + case .boxSet, .movie, .person, .series: + true + default: + false + } + } + /// Differs from `isLive` to indicate an item /// would be streaming from a live source. var isLiveStream: Bool { @@ -173,7 +139,11 @@ extension BaseItemDto { /// image used in the now playing system. @MainActor func getNowPlayingImage() async -> UIImage? { - let imageSources = thumbImageSources() + let imageSources = imageSources( + for: preferredPosterDisplayType, + size: .small, + environment: .init(useParent: true) + ) guard let firstImage = await ImagePipeline.Swiftfin.other.loadFirstImage(from: imageSources) else { let failedSystemContentView = SystemImageContentView( @@ -191,7 +161,7 @@ extension BaseItemDto { Rectangle() .fill(Color.secondarySystemFill) - transform(image: image) + transform(image: image, displayType: preferredPosterDisplayType) } .posterAspectRatio(preferredPosterDisplayType, contentMode: .fit) .frame(width: 400) @@ -241,14 +211,77 @@ extension BaseItemDto { return response.value.items?.first } - var runtime: Duration? { - guard let ticks = runTimeTicks else { return nil } - return Duration.ticks(ticks) + func nowPlayableStaticMetadata(_ image: UIImage? = nil) -> NowPlayableStaticMetadata { + + let mediaType: MPNowPlayingInfoMediaType = { + switch type { + case .audio, .audioBook: .audio + default: .video + } + }() + + let title: String = { + if type == .episode, + let seriesName + { + seriesName + } else { + displayTitle + } + }() + + let albumArtist: String? = { + switch type { + case .audio: + artists?.joined(separator: ", ") + default: + nil + } + }() + + let albumTitle: String? = { + switch type { + case .audio: + album + default: + nil + } + }() + + // TODO: only fill artist, albumArtist, and albumTitle if audio type + return .init( + mediaType: mediaType, + isLiveStream: isLiveStream, + title: title, + artist: subtitle, + artwork: image.map { image in MPMediaItemArtwork(boundsSize: image.size) { _ in image }}, + albumArtist: albumArtist, + albumTitle: albumTitle + ) } - var startSeconds: Duration? { - guard let ticks = userData?.playbackPositionTicks else { return nil } - return Duration.ticks(ticks) + var programDuration: TimeInterval? { + guard let startDate, let endDate else { return nil } + return endDate.timeIntervalSince(startDate) + } + + func programProgress(relativeTo other: Date) -> Double? { + guard let startDate, let endDate else { return nil } + + let length = endDate.timeIntervalSince(startDate) + let progress = other.timeIntervalSince(startDate) + + return progress / length + } + + var progress: Double? { + guard let startSeconds, let runtime, startSeconds > .zero, startSeconds < runtime else { return nil } + return startSeconds / runtime + } + + var runtime: Duration? { + guard let runTimeTicks else { return nil } + return Duration.ticks(runTimeTicks) } var seasonEpisodeLabel: String? { @@ -256,8 +289,14 @@ extension BaseItemDto { return L10n.seasonAndEpisode(String(seasonNo), String(episodeNo)) } + @available(*, deprecated, message: "Use `userData?.playbackPosition` instead") + var startSeconds: Duration? { + userData?.playbackPosition + } + // MARK: Calculations + @available(*, deprecated, message: "remove, use a formatter instead") var runTimeLabel: String? { let timeHMSFormatter: DateComponentsFormatter = { let formatter = DateComponentsFormatter() @@ -272,58 +311,18 @@ extension BaseItemDto { return text } - var progressLabel: String? { - guard let playbackPositionTicks = userData?.playbackPositionTicks, - let totalTicks = runTimeTicks, - playbackPositionTicks != 0, - totalTicks != 0 else { return nil } - - let remainingSeconds = (totalTicks - playbackPositionTicks) / 10_000_000 - - let formatter = DateComponentsFormatter() - formatter.allowedUnits = [.hour, .minute] - formatter.unitsStyle = .abbreviated - - return formatter.string(from: .init(remainingSeconds)) - } - - var programDuration: TimeInterval? { - guard let startDate, let endDate else { return nil } - return endDate.timeIntervalSince(startDate) - } - - var programProgress: Double? { - guard let startDate, let endDate else { return nil } - - let length = endDate.timeIntervalSince(startDate) - let progress = Date.now.timeIntervalSince(startDate) - - return progress / length - } - - func programProgress(relativeTo other: Date) -> Double? { - guard let startDate, let endDate else { return nil } - - let length = endDate.timeIntervalSince(startDate) - let progress = other.timeIntervalSince(startDate) - - return progress / length + var audioStreams: [MediaStream] { + mediaStreams?.filter { $0.type == .audio } ?? [] } var subtitleStreams: [MediaStream] { mediaStreams?.filter { $0.type == .subtitle } ?? [] } - var audioStreams: [MediaStream] { - mediaStreams?.filter { $0.type == .audio } ?? [] - } - var videoStreams: [MediaStream] { mediaStreams?.filter { $0.type == .video } ?? [] } - // MARK: Missing and Unaired - var isMissing: Bool { locationType == .virtual } @@ -343,7 +342,6 @@ extension BaseItemDto { var premiereDateLabel: String? { guard let premiereDate else { return nil } - let dateFormatter = DateFormatter() dateFormatter.dateStyle = .medium return dateFormatter.string(from: premiereDate) @@ -356,11 +354,6 @@ extension BaseItemDto { return dateFormatter.string(from: premiereDate) } - var hasExternalLinks: Bool { - guard let externalURLs else { return false } - return externalURLs.isNotEmpty - } - var hasRatings: Bool { [ criticRating, @@ -368,8 +361,7 @@ extension BaseItemDto { ].contains { $0 != nil } } - // MARK: Chapter Images - + // TODO: take userSession parameter var fullChapterInfo: [ChapterInfo.FullInfo]? { guard let chapters = chapters? @@ -473,8 +465,15 @@ extension BaseItemDto { return L10n.missing } - if let progressLabel { - return progressLabel + if let seasonEpisodeLabel { + return seasonEpisodeLabel + } + + if let runtime, + let playbackPosition = userData?.playbackPosition, + playbackPosition > .zero + { + return (runtime - playbackPosition).formatted(RuntimeUnitsFormatStyle(width: .narrow)) } return L10n.play @@ -498,6 +497,8 @@ extension BaseItemDto { switch type { case .audio: album + case .musicAlbum: + albumArtists?.first?.name case .episode: seriesName case .musicAlbum: @@ -524,7 +525,7 @@ extension BaseItemDto { throw ErrorMessage(L10n.unknownError) } - let request = Paths.getItem(itemID: id, userID: userSession.user.id) + let request = Paths.getItem(itemID: id) let response = try await userSession.client.send(request) // A check against `id` would typically be done, but a plugin @@ -537,4 +538,9 @@ extension BaseItemDto { return response.value } + + static func getItem(id: String, userSession: UserSession) async throws -> BaseItemDto { + try await .init(id: id) + .getFullItem(userSession: userSession) + } } diff --git a/Shared/Extensions/JellyfinAPI/BaseItemKind.swift b/Shared/Extensions/JellyfinAPI/BaseItemKind.swift index 8b4a2708ce..7a087c2177 100644 --- a/Shared/Extensions/JellyfinAPI/BaseItemKind.swift +++ b/Shared/Extensions/JellyfinAPI/BaseItemKind.swift @@ -195,7 +195,7 @@ extension BaseItemKind { switch self { case .audio, .channel, .musicAlbum, .tvChannel: .square - case .folder, .program, .musicVideo, .video, .userView: + case .episode, .folder, .liveTvProgram, .program, .musicVideo, .video, .userView: .landscape default: .portrait diff --git a/Shared/Extensions/JellyfinAPI/BaseItemPerson/BaseItemPerson+Poster.swift b/Shared/Extensions/JellyfinAPI/BaseItemPerson/BaseItemPerson+Poster.swift index a5d44fc099..c674ffe4e1 100644 --- a/Shared/Extensions/JellyfinAPI/BaseItemPerson/BaseItemPerson+Poster.swift +++ b/Shared/Extensions/JellyfinAPI/BaseItemPerson/BaseItemPerson+Poster.swift @@ -17,8 +17,11 @@ extension BaseItemPerson: Poster { .portrait } - var unwrappedIDHashOrZero: Int { - id?.hashValue ?? 0 + var posterLabel: some View { + TitleSubtitleContentView( + title: displayTitle, + subtitle: role ?? "" + ) } var subtitle: String? { @@ -29,13 +32,17 @@ extension BaseItemPerson: Poster { "person.fill" } - func portraitImageSources(maxWidth: CGFloat? = nil, quality: Int? = nil) -> [ImageSource] { - + func portraitImageSources( + maxWidth: CGFloat?, + quality: Int?, + environment: Empty + ) -> [ImageSource] { guard let client = Container.shared.currentUserSession()?.client else { return [] } // TODO: figure out what to do about screen scaling with .main being deprecated // - maxWidth assume already scaled? let scaleWidth: Int? = maxWidth == nil ? nil : UIScreen.main.scale(maxWidth!) + guard let primaryImageTag else { return [] } let imageRequestParameters = Paths.GetItemImageParameters( maxWidth: scaleWidth ?? Int(maxWidth), @@ -57,8 +64,59 @@ extension BaseItemPerson: Poster { blurHash: blurHash )] } +} + +extension BaseItemPerson: LibraryElement { + + @MainActor + func libraryDidSelectElement(router: Router.Wrapper, in namespace: Namespace.ID) { + BaseItemDto(person: self) + .libraryDidSelectElement(router: router, in: namespace) + } + + func makeGridBody(libraryStyle: LibraryStyle) -> some View { + WithRouter { router in + PosterButton( + item: self, + type: .portrait + ) { namespace in + libraryDidSelectElement(router: router, in: namespace) + } + } + } + + func makeListBody(libraryStyle: LibraryStyle) -> some View { + WithNamespace { namespace in + WithRouter { router in + ListRow(insets: .init(vertical: 8, horizontal: EdgeInsets.edgePadding)) { + libraryDidSelectElement(router: router, in: namespace) + } leading: { + PosterImage( + item: self, + type: .portrait, + contentMode: .fill + ) + .posterShadow() + .frame(width: 60) + } content: { + VStack(alignment: .leading, spacing: 5) { + Text(displayTitle) + .font(.callout) + .fontWeight(.semibold) + .foregroundStyle(.primary) + .lineLimit(2) + .multilineTextAlignment(.leading) - func transform(image: Image) -> some View { - image + if let role { + Text(role) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + .backport + .matchedTransitionSource(id: "item", in: namespace) + } + } } } diff --git a/Shared/Extensions/JellyfinAPI/BaseItemPerson/BaseItemPerson.swift b/Shared/Extensions/JellyfinAPI/BaseItemPerson/BaseItemPerson.swift index 6c1fecbcb7..b091b1ce7f 100644 --- a/Shared/Extensions/JellyfinAPI/BaseItemPerson/BaseItemPerson.swift +++ b/Shared/Extensions/JellyfinAPI/BaseItemPerson/BaseItemPerson.swift @@ -16,12 +16,12 @@ extension BaseItemPerson: Displayable { } } -extension BaseItemPerson: LibraryParent { - - var libraryType: BaseItemKind? { - .person - } -} +// extension BaseItemPerson: LibraryParent { +// +// var libraryType: BaseItemKind? { +// .person +// } +// } extension BaseItemPerson { diff --git a/Shared/Extensions/JellyfinAPI/ChapterInfo.swift b/Shared/Extensions/JellyfinAPI/ChapterInfo.swift index d8cfa3fc25..88f7390863 100644 --- a/Shared/Extensions/JellyfinAPI/ChapterInfo.swift +++ b/Shared/Extensions/JellyfinAPI/ChapterInfo.swift @@ -38,7 +38,6 @@ extension ChapterInfo { let imageSource: ImageSource let preferredPosterDisplayType: PosterDisplayType = .landscape let systemImage: String = "film" - let unwrappedIDHashOrZero: Int var subtitle: String? var showTitle: Bool = true @@ -51,14 +50,13 @@ extension ChapterInfo { self.displayTitle = chapterInfo.displayTitle self.id = chapterInfo.hashValue self.imageSource = imageSource - self.unwrappedIDHashOrZero = chapterInfo.hashValue } func landscapeImageSources(maxWidth: CGFloat?, quality: Int?) -> [ImageSource] { [imageSource] } - func transform(image: Image) -> some View { + func transform(image: Image, displayType: PosterDisplayType) -> some View { ZStack { Color.black diff --git a/Shared/Extensions/JellyfinAPI/ImageInfo.swift b/Shared/Extensions/JellyfinAPI/ImageInfo.swift index db3fae1124..05ec79d81b 100644 --- a/Shared/Extensions/JellyfinAPI/ImageInfo.swift +++ b/Shared/Extensions/JellyfinAPI/ImageInfo.swift @@ -8,6 +8,7 @@ import Foundation import JellyfinAPI +import SwiftUI extension ImageInfo: @retroactive Identifiable { @@ -16,16 +17,94 @@ extension ImageInfo: @retroactive Identifiable { } } +extension ImageInfo: Poster { + + struct Environment: WithDefaultValue { + let itemID: String + let client: JellyfinClient + + static let `default` = Environment( + itemID: "", + client: .init( + configuration: .init( + url: URL(string: "/")!, + client: "unknown", + deviceName: "unknown", + deviceID: "unknown", + version: "unknown" + ) + ) + ) + + static func == (lhs: Environment, rhs: Environment) -> Bool { + lhs.itemID == rhs.itemID && lhs.client === rhs.client + } + } + + var preferredPosterDisplayType: PosterDisplayType { + guard let height, let width else { + return .square + } + + if height == width { + return .square + } + + return width > height ? .landscape : .portrait + } + + var displayTitle: String { + imageType?.displayTitle ?? L10n.unknown + } + + var systemImage: String { + "photo" + } + + func imageSources( + for displayType: PosterDisplayType, + size: PosterDisplayType.Size, + environment: Environment + ) -> [ImageSource] { + guard let imageSource = itemImageSource( + itemID: environment.itemID, + client: environment.client + ) else { + return [] + } + + return [imageSource] + } + + @ViewBuilder + func transform(image: Image, displayType: PosterDisplayType) -> some View { + switch imageType { + case .logo: + ContainerRelativeView(ratio: 0.95) { + image + .aspectRatio(contentMode: .fit) + } + default: + image + .aspectRatio(contentMode: .fill) + } + } +} + extension ImageInfo { - func itemImageSource(itemID: String, client: JellyfinClient) -> ImageSource { + func itemImageSource(itemID: String, client: JellyfinClient) -> ImageSource? { + guard let imageType else { + return nil + } + let parameters = Paths.GetItemImageParameters( tag: imageTag, imageIndex: imageIndex ) let request = Paths.getItemImage( itemID: itemID, - imageType: imageType?.rawValue ?? "", + imageType: imageType.rawValue, parameters: parameters ) diff --git a/Shared/ViewModels/ItemViewModel/MovieItemViewModel.swift b/Shared/Extensions/JellyfinAPI/ImageProviderInfo.swift similarity index 71% rename from Shared/ViewModels/ItemViewModel/MovieItemViewModel.swift rename to Shared/Extensions/JellyfinAPI/ImageProviderInfo.swift index b9e14dd95c..42f3ac7787 100644 --- a/Shared/ViewModels/ItemViewModel/MovieItemViewModel.swift +++ b/Shared/Extensions/JellyfinAPI/ImageProviderInfo.swift @@ -6,8 +6,11 @@ // Copyright (c) 2026 Jellyfin & Jellyfin Contributors // -import Combine -import Foundation import JellyfinAPI -final class MovieItemViewModel: ItemViewModel {} +extension ImageProviderInfo: @retroactive Identifiable { + + public var id: String? { + name + } +} diff --git a/Shared/Extensions/JellyfinAPI/ItemFilter+ItemTrait.swift b/Shared/Extensions/JellyfinAPI/ItemFilter+ItemTrait.swift index b89cca1670..5995fdb2d6 100644 --- a/Shared/Extensions/JellyfinAPI/ItemFilter+ItemTrait.swift +++ b/Shared/Extensions/JellyfinAPI/ItemFilter+ItemTrait.swift @@ -14,16 +14,7 @@ import JellyfinAPI /// - Important: Make sure to use the correct `filters` parameter for item calls! typealias ItemTrait = JellyfinAPI.ItemFilter -extension ItemTrait: ItemFilter { - - var value: String { - rawValue - } - - init(from anyFilter: AnyItemFilter) { - self.init(rawValue: anyFilter.value)! - } -} +extension ItemTrait: ItemFilter {} extension ItemTrait: Displayable { var displayTitle: String { diff --git a/Shared/Extensions/JellyfinAPI/NameGuidPair.swift b/Shared/Extensions/JellyfinAPI/NameGuidPair.swift index 27eb5fa0b3..0e0147ea5b 100644 --- a/Shared/Extensions/JellyfinAPI/NameGuidPair.swift +++ b/Shared/Extensions/JellyfinAPI/NameGuidPair.swift @@ -15,11 +15,3 @@ extension NameGuidPair: Displayable { name ?? .emptyDash } } - -// TODO: strong type studios and implement as `LibraryParent` -extension NameGuidPair: LibraryParent { - - var libraryType: BaseItemKind? { - .studio - } -} diff --git a/Shared/Extensions/JellyfinAPI/RemoteImageInfo.swift b/Shared/Extensions/JellyfinAPI/RemoteImageInfo.swift index 70a34c811c..5aa5d0c7aa 100644 --- a/Shared/Extensions/JellyfinAPI/RemoteImageInfo.swift +++ b/Shared/Extensions/JellyfinAPI/RemoteImageInfo.swift @@ -13,17 +13,17 @@ import SwiftUI extension RemoteImageInfo: @retroactive Identifiable, Poster { var preferredPosterDisplayType: PosterDisplayType { - .portrait + guard let height, let width else { + return .square + } + + return width > height ? .landscape : .portrait } var displayTitle: String { providerName ?? L10n.unknown } - var unwrappedIDHashOrZero: Int { - id - } - var subtitle: String? { language } @@ -36,7 +36,25 @@ extension RemoteImageInfo: @retroactive Identifiable, Poster { hashValue } - func transform(image: Image) -> some View { - image + func imageSources( + for displayType: PosterDisplayType, + size: PosterDisplayType.Size, + environment: Empty + ) -> [ImageSource] { + [.init(url: url?.url)] + } + + @ViewBuilder + func transform(image: Image, displayType: PosterDisplayType) -> some View { + switch type { + case .logo: + ContainerRelativeView(ratio: 0.95) { + image + .aspectRatio(contentMode: .fit) + } + default: + image + .aspectRatio(contentMode: .fill) + } } } diff --git a/Shared/Extensions/JellyfinAPI/SortOrder+ItemSortOrder.swift b/Shared/Extensions/JellyfinAPI/SortOrder+ItemSortOrder.swift index 49211ec305..30422c8899 100644 --- a/Shared/Extensions/JellyfinAPI/SortOrder+ItemSortOrder.swift +++ b/Shared/Extensions/JellyfinAPI/SortOrder+ItemSortOrder.swift @@ -24,13 +24,4 @@ extension ItemSortOrder: Displayable { } } -extension ItemSortOrder: ItemFilter { - - var value: String { - rawValue - } - - init(from anyFilter: AnyItemFilter) { - self.init(rawValue: anyFilter.value)! - } -} +extension ItemSortOrder: ItemFilter {} diff --git a/Shared/Extensions/JellyfinAPI/UserItemDataDto.swift b/Shared/Extensions/JellyfinAPI/UserItemDataDto.swift new file mode 100644 index 0000000000..3b615f9778 --- /dev/null +++ b/Shared/Extensions/JellyfinAPI/UserItemDataDto.swift @@ -0,0 +1,35 @@ +// +// 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 Factory +import JellyfinAPI + +extension Container { + + var userItemCache: Factory> { + self { @MainActor in HashCache() } + .singleton + } +} + +protocol WithUserData { + var userData: UserItemDataDto? { get set } +} + +extension UserItemDataDto { + + var playbackPosition: Duration? { + get { + guard let playbackPositionTicks else { return nil } + return Duration.ticks(playbackPositionTicks) + } + set { + playbackPositionTicks = newValue?.ticks + } + } +} diff --git a/Shared/Extensions/JellyfinAPI/Video3DFormat.swift b/Shared/Extensions/JellyfinAPI/Video3DFormat.swift index e9807412bd..b248a589eb 100644 --- a/Shared/Extensions/JellyfinAPI/Video3DFormat.swift +++ b/Shared/Extensions/JellyfinAPI/Video3DFormat.swift @@ -9,7 +9,8 @@ import Foundation import JellyfinAPI -extension Video3DFormat { +extension Video3DFormat: Displayable { + var displayTitle: String { switch self { case .halfSideBySide: diff --git a/Shared/Extensions/LabelStyle/MaterialLabelStyle.swift b/Shared/Extensions/LabelStyle/MaterialLabelStyle.swift new file mode 100644 index 0000000000..2cddc319e6 --- /dev/null +++ b/Shared/Extensions/LabelStyle/MaterialLabelStyle.swift @@ -0,0 +1,36 @@ +// +// 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 SwiftUI + +extension LabelStyle where Self == MaterialLabelStyle { + static var material: MaterialLabelStyle { + MaterialLabelStyle() + } +} + +struct MaterialLabelStyle: LabelStyle { + + @Environment(\.isSelected) + private var isSelected + @Environment(\.isEnabled) + private var isEnabled + + func makeBody(configuration: Configuration) -> some View { + ZStack { + RoundedRectangle(cornerRadius: 10) + .fill(Material.thin) + + HStack { + configuration.icon + .foregroundColor(.secondary) + configuration.title + } + } + } +} diff --git a/Shared/Extensions/ButtonStyle/TintedMaterialButtonStyle.swift b/Shared/Extensions/LabelStyle/TintedMaterialLabelStyle.swift similarity index 58% rename from Shared/Extensions/ButtonStyle/TintedMaterialButtonStyle.swift rename to Shared/Extensions/LabelStyle/TintedMaterialLabelStyle.swift index 91fe4e3861..fbb2fec890 100644 --- a/Shared/Extensions/ButtonStyle/TintedMaterialButtonStyle.swift +++ b/Shared/Extensions/LabelStyle/TintedMaterialLabelStyle.swift @@ -8,24 +8,56 @@ import SwiftUI -// TODO: on tvOS focus, find way to disable brightness effect +#if os(tvOS) +struct _BasicHoverButtonStyle: ButtonStyle { -extension ButtonStyle where Self == TintedMaterialButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .hoverEffect(.lift) + } +} +#else +typealias _BasicHoverButtonStyle = BorderlessButtonStyle +#endif + +struct ActionLabelStyle: LabelStyle { + + @Environment(\.isHighlighted) + private var isHighlighted - // TODO: just be `Material` backed instead of `TintedMaterial` - static var material: TintedMaterialButtonStyle { - TintedMaterialButtonStyle(tint: Color.clear, foregroundColor: Color.primary) + func makeBody(configuration: Configuration) -> some View { + ZStack { + if isHighlighted { + Rectangle() + .fill(.secondary) + } else { + VisualEffectView( + blur: .regular, + tint: Color.gray.opacity(0.3) + ) + } + + configuration.icon + .symbolRenderingMode(.monochrome) + .foregroundStyle(.primary) + } + #if os(iOS) + .cornerRadius(10) + #endif } +} + +extension LabelStyle where Self == TintedMaterialLabelStyle { - static func tintedMaterial(tint: Color, foregroundColor: Color) -> TintedMaterialButtonStyle { - TintedMaterialButtonStyle( + static func tintedMaterial(tint: Color, foregroundColor: Color) -> TintedMaterialLabelStyle { + TintedMaterialLabelStyle( tint: tint, foregroundColor: foregroundColor ) } } -struct TintedMaterialButtonStyle: ButtonStyle { +struct TintedMaterialLabelStyle: LabelStyle { @Environment(\.isSelected) private var isSelected @@ -37,21 +69,6 @@ struct TintedMaterialButtonStyle: ButtonStyle { let tint: Color let foregroundColor: Color - func makeBody(configuration: Configuration) -> some View { - ZStack { - TintedMaterial(tint: buttonTint) - .id(isSelected) - #if !os(tvOS) - .cornerRadius(10) - #endif - - configuration.label - .foregroundStyle(foregroundStyle) - .symbolRenderingMode(.monochrome) - } - .hoverEffect(.lift) - } - private var buttonTint: Color { if isEnabled && isSelected { tint @@ -71,4 +88,18 @@ struct TintedMaterialButtonStyle: ButtonStyle { AnyShapeStyle(Color.gray.opacity(0.3)) } } + + func makeBody(configuration: Configuration) -> some View { + ZStack { + TintedMaterial(tint: buttonTint) + .id(isSelected) + #if !os(tvOS) + .cornerRadius(10) + #endif + + Label(configuration) + .foregroundStyle(foregroundStyle) + .symbolRenderingMode(.monochrome) + } + } } diff --git a/Shared/Extensions/LinearGradient.swift b/Shared/Extensions/LinearGradient.swift new file mode 100644 index 0000000000..80538694ea --- /dev/null +++ b/Shared/Extensions/LinearGradient.swift @@ -0,0 +1,13 @@ +// +// 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 SwiftUI + +extension LinearGradient { + typealias Stop = (location: CGFloat, opacity: CGFloat) +} diff --git a/Shared/Extensions/Picker.swift b/Shared/Extensions/Picker.swift index aeb531151c..e5890db729 100644 --- a/Shared/Extensions/Picker.swift +++ b/Shared/Extensions/Picker.swift @@ -16,7 +16,7 @@ struct _OptionalCaseIterablePickerContent: View { var body: some View { - ForEach(Element.allCases.asArray, id: \.hashValue) { + ForEach(Array(Element.allCases), id: \.hashValue) { Text($0.displayTitle) .tag($0 as Element) } @@ -38,7 +38,7 @@ struct _SupportedCaseIterablePickerContent, - noneStyle: NoneStyle = .text + noneStyle: NoneStyle? = .text ) where SelectionValue == E?, Content == _OptionalSourcesPickerContent, Data.Element == E { self.init(title, selection: selection) { _OptionalSourcesPickerContent(sources: sources, noneStyle: noneStyle) diff --git a/Shared/Extensions/ProgressViewStyle/PlaybackProgressViewStyle.swift b/Shared/Extensions/ProgressViewStyle/PlaybackProgressViewStyle.swift index 4209c71062..cd1e350b66 100644 --- a/Shared/Extensions/ProgressViewStyle/PlaybackProgressViewStyle.swift +++ b/Shared/Extensions/ProgressViewStyle/PlaybackProgressViewStyle.swift @@ -23,13 +23,15 @@ struct PlaybackProgressViewStyle: ProgressViewStyle { @ViewBuilder private func buildCapsule(for progress: Double) -> some View { - Rectangle() - .cornerRadius( - cornerStyle == .round ? contentSize.height / 2 : 0, - corners: [.topLeft, .bottomLeft] - ) - .frame(width: contentSize.width * clamp(progress, min: 0, max: 1) + contentSize.height) - .offset(x: -contentSize.height) + Group { + if cornerStyle == .round { + Capsule() + } else { + Rectangle() + } + } + .frame(width: contentSize.width * clamp(progress, min: 0, max: 1) + contentSize.height) + .offset(x: -contentSize.height) } func makeBody(configuration: Configuration) -> some View { diff --git a/Shared/Extensions/Section.swift b/Shared/Extensions/Section.swift index e6dcb860bd..4a7eadb9ca 100644 --- a/Shared/Extensions/Section.swift +++ b/Shared/Extensions/Section.swift @@ -28,7 +28,7 @@ extension Section where Parent == Text, Footer == Text, Content: View { func Section( _ title: String, @ViewBuilder content: @escaping () -> some View, - @LabeledContentBuilder learnMore: @escaping () -> AnyView + @LabeledContentBuilder learnMore: @escaping () -> some View ) -> some View { Section( title, @@ -42,7 +42,7 @@ func Section( _ title: String, footer: String, @ViewBuilder content: @escaping () -> some View, - @LabeledContentBuilder learnMore: @escaping () -> AnyView + @LabeledContentBuilder learnMore: @escaping () -> some View ) -> some View { Section( title, @@ -56,7 +56,7 @@ func Section( _ title: String, @ViewBuilder content: @escaping () -> some View, @ViewBuilder footer: @escaping () -> some View, - @LabeledContentBuilder learnMore: @escaping () -> AnyView + @LabeledContentBuilder learnMore: @escaping () -> some View ) -> some View { InlinePlatformView { Section { @@ -76,7 +76,7 @@ func Section( } tvOSView: { Section { content() - .focusedValue(\.formLearnMore, learnMore()) + .focusedValue(\.formLearnMore, learnMore().eraseToAnyView()) } header: { Text(title) } footer: { @@ -88,17 +88,17 @@ func Section( // MARK: - LearnMoreButton // TODO: Rename to `LearnMoreButton` once the original `LearnMoreButton` is removed -private struct _LearnMoreButton: View { +private struct _LearnMoreButton: View { @State private var isPresented = false - private let content: AnyView + private let content: Content private let title: String init( _ title: String, - @LabeledContentBuilder learnMore: @escaping () -> AnyView + @LabeledContentBuilder learnMore: @escaping () -> Content ) { self.content = learnMore() self.title = title diff --git a/Shared/Extensions/Set.swift b/Shared/Extensions/Set.swift deleted file mode 100644 index 8cfdc6e129..0000000000 --- a/Shared/Extensions/Set.swift +++ /dev/null @@ -1,26 +0,0 @@ -// -// 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 Foundation - -extension Set { - - mutating func toggle(value: Element) { - if contains(value) { - remove(value) - } else { - insert(value) - } - } - - mutating func insert(contentsOf elements: [Element]) { - for element in elements { - insert(element) - } - } -} diff --git a/Shared/Extensions/SetAlgebra.swift b/Shared/Extensions/SetAlgebra.swift new file mode 100644 index 0000000000..58cd5a25db --- /dev/null +++ b/Shared/Extensions/SetAlgebra.swift @@ -0,0 +1,58 @@ +// +// 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 Foundation + +extension SetAlgebra { + + mutating func toggle(value: Element) { + if contains(value) { + remove(value) + } else { + insert(value) + } + } + + mutating func insert(contentsOf elements: [Element]) { + for element in elements { + insert(element) + } + } + + func inserting(_ element: Element) -> Self { + var copy = self + copy.insert(element) + return copy + } + + func inserting(_ element: Element, if condition: Bool) -> Self { + if condition { + var copy = self + copy.insert(element) + return copy + } else { + return self + } + } + + func removing(_ element: Element) -> Self { + var copy = self + copy.remove(element) + return copy + } + + func removing(_ element: Element, if condition: Bool) -> Self { + if condition { + var copy = self + copy.remove(element) + return copy + } else { + return self + } + } +} diff --git a/Shared/Extensions/UIApplication.swift b/Shared/Extensions/UIApplication.swift index 25ca1a36a1..dfe1371f95 100644 --- a/Shared/Extensions/UIApplication.swift +++ b/Shared/Extensions/UIApplication.swift @@ -6,6 +6,7 @@ // Copyright (c) 2026 Jellyfin & Jellyfin Contributors // +import JellyfinAPI import UIKit extension UIApplication { @@ -29,6 +30,16 @@ extension UIApplication { } } + func open(_ mediaURL: MediaURL) throws { + guard let url = URL(string: mediaURL.url), + UIApplication.shared.canOpenURL(url) + else { + throw ErrorMessage(L10n.unableToOpenTrailer) + } + + open(url) + } + // TODO: change to all windows? func setAccentColor(_ newColor: UIColor) { keyWindow?.tintColor = newColor diff --git a/Shared/Extensions/UIColor.swift b/Shared/Extensions/UIColor.swift index e1b83fcefe..fe8bd141c5 100644 --- a/Shared/Extensions/UIColor.swift +++ b/Shared/Extensions/UIColor.swift @@ -9,6 +9,7 @@ import UIKit extension UIColor { + var overlayColor: UIColor { var red: CGFloat = 0 var green: CGFloat = 0 diff --git a/Shared/Extensions/UIImage.swift b/Shared/Extensions/UIImage.swift index d2db5de3c1..6d4662dc7b 100644 --- a/Shared/Extensions/UIImage.swift +++ b/Shared/Extensions/UIImage.swift @@ -18,18 +18,11 @@ extension UIImage { let x = index % columns let y = index / columns - // Check if the tile index is within the valid range -// guard x >= 0, y >= 0, x < columns, y < rows else { -// return nil -// } - - // Use integer arithmetic for tile dimensions and positions let imageWidth = Int(size.width) let imageHeight = Int(size.height) let tileWidth = imageWidth / columns let tileHeight = imageHeight / rows - // Calculate the rectangle using integer values let rect = CGRect( x: x * tileWidth, y: y * tileHeight, @@ -37,45 +30,10 @@ extension UIImage { height: tileHeight ) - // This check is now redundant because of the earlier guard statement - // guard rect.maxX <= imageWidth && rect.maxY <= imageHeight else { - // return nil - // } - if let cgImage = cgImage?.cropping(to: rect) { return UIImage(cgImage: cgImage) } return nil - -// guard index >= 0 else { -// return nil -// } -// -// let imageWidth = size.width -// let imageHeight = size.height -// -// let tileWidth = imageWidth / CGFloat(columns) -// let tileHeight = imageHeight / CGFloat(rows) -// -// let x = (index % columns) -// let y = (index / columns) -// -// let rect = CGRect( -// x: CGFloat(x) * tileWidth, -// y: CGFloat(y) * tileHeight, -// width: tileWidth, -// height: tileHeight -// ) -// -// guard rect.maxX <= imageWidth && rect.maxY <= imageHeight else { -// return nil -// } -// -// if let cgImage = cgImage?.cropping(to: rect) { -// return UIImage(cgImage: cgImage) -// } -// -// return nil } } diff --git a/Shared/Extensions/ViewExtensions/Backport/Backport.swift b/Shared/Extensions/ViewExtensions/Backport/Backport.swift index 456ebacf6f..cd37ec544a 100644 --- a/Shared/Extensions/ViewExtensions/Backport/Backport.swift +++ b/Shared/Extensions/ViewExtensions/Backport/Backport.swift @@ -92,43 +92,26 @@ extension Backport where Content: View { } } - @available(tvOS, unavailable) + /// - Important: This does nothing on tvOS. @ViewBuilder func searchFocused( _ isSearchFocused: FocusState.Binding ) -> some View { + #if os(iOS) if #available(iOS 18.0, *) { content.searchFocused(isSearchFocused) } else { content } + #else + content + #endif } -} - -// MARK: ButtonBorderShape -enum ButtonBorderShape { - case automatic - case capsule - case roundedRectangle - case circle - - var swiftUIValue: SwiftUI.ButtonBorderShape { - switch self { - case .automatic: .automatic - case .capsule: .capsule - case .roundedRectangle: .roundedRectangle - case .circle: - if #available(iOS 17, *) { - .circle - } else { - .roundedRectangle - } - } + @available(iOS 9999, *) + @ViewBuilder + func tabViewStyle(_ style: TabViewStyle) -> some View { + content.tabViewStyle(style.swiftUIValue) + .eraseToAnyView() } } - -enum NavigationTransition: Hashable { - case automatic - case zoom(sourceID: String, namespace: Namespace.ID) -} diff --git a/Shared/Extensions/ViewExtensions/Backport/ButtonBorderShape.swift b/Shared/Extensions/ViewExtensions/Backport/ButtonBorderShape.swift new file mode 100644 index 0000000000..1aae935740 --- /dev/null +++ b/Shared/Extensions/ViewExtensions/Backport/ButtonBorderShape.swift @@ -0,0 +1,30 @@ +// +// 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 SwiftUI + +enum ButtonBorderShape { + case automatic + case capsule + case roundedRectangle + case circle + + var swiftUIValue: SwiftUI.ButtonBorderShape { + switch self { + case .automatic: .automatic + case .capsule: .capsule + case .roundedRectangle: .roundedRectangle + case .circle: + if #available(iOS 17, *) { + .circle + } else { + .roundedRectangle + } + } + } +} diff --git a/Shared/Extensions/ViewExtensions/Backport/NavigationTransition.swift b/Shared/Extensions/ViewExtensions/Backport/NavigationTransition.swift new file mode 100644 index 0000000000..7302e009f9 --- /dev/null +++ b/Shared/Extensions/ViewExtensions/Backport/NavigationTransition.swift @@ -0,0 +1,14 @@ +// +// 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 SwiftUI + +enum NavigationTransition: Hashable { + case automatic + case zoom(sourceID: String, namespace: Namespace.ID) +} diff --git a/Shared/Extensions/ViewExtensions/Backport/TabViewStyle.swift b/Shared/Extensions/ViewExtensions/Backport/TabViewStyle.swift new file mode 100644 index 0000000000..e04f659e9a --- /dev/null +++ b/Shared/Extensions/ViewExtensions/Backport/TabViewStyle.swift @@ -0,0 +1,36 @@ +// +// 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 SwiftUI + +enum TabViewStyle { + + case automatic + case page + case sidebarAdaptable + case tabBarOnly + + var swiftUIValue: any SwiftUI.TabViewStyle { + switch self { + case .automatic: .automatic + case .page: .page + case .sidebarAdaptable: + if #available(iOS 18, tvOS 18, *) { + .sidebarAdaptable + } else { + .automatic + } + case .tabBarOnly: + if #available(iOS 18, tvOS 18, *) { + .tabBarOnly + } else { + .automatic + } + } + } +} diff --git a/Shared/Extensions/ViewExtensions/ContextMenuRegistry.swift b/Shared/Extensions/ViewExtensions/ContextMenuRegistry.swift index 43673bb359..21673b54fb 100644 --- a/Shared/Extensions/ViewExtensions/ContextMenuRegistry.swift +++ b/Shared/Extensions/ViewExtensions/ContextMenuRegistry.swift @@ -6,11 +6,11 @@ // Copyright (c) 2026 Jellyfin & Jellyfin Contributors // +import Engine import SwiftUI -// TODO: removeContextMenu - -typealias ContextMenuRegistry = TypeValueRegistry<(Any) -> (items: AnyView, preview: AnyView?)> +typealias EnvironmentContextMenuPair = (items: AnyView, preview: AnyView?) +typealias ContextMenuRegistry = TypeKeyedDictionary<(Any) -> EnvironmentContextMenuPair> extension EnvironmentValues { @@ -18,76 +18,16 @@ extension EnvironmentValues { var contextMenuRegistry: ContextMenuRegistry = .init() } -enum EnvironmentContextMenu { - - struct Registar: ViewModifier { - - @Environment(\.contextMenuRegistry) - private var contextMenuRegistry - - let menuContent: (Any) -> (items: AnyView, preview: AnyView?) - - init(menuContent: @escaping (Value) -> (menuContent: AnyView, preview: AnyView?)) { - self.menuContent = { value in - guard let value = value as? Value else { - return (AnyView(EmptyView()), nil) - } - let content = menuContent(value) - return (AnyView(content.menuContent), AnyView(content.preview)) - } - } - - func body(content: Content) -> some View { - content - .environment(\.contextMenuRegistry, contextMenuRegistry.insertOrReplace(menuContent, for: Value.self)) - } - } - - struct Extractor: ViewModifier { - - @Environment(\.contextMenuRegistry) - private var contextMenuRegistry - - private let value: Value - private let previewOverride: ((Value) -> AnyView)? - - init( - value: Value, - preview: ((Value) -> AnyView)? = nil - ) { - self.value = value - self.previewOverride = preview - } - - func body(content: Content) -> some View { +extension View { - if let contextMenu = contextMenuRegistry.getvalue(for: Value.self)?(value) { - if let previewOverride { - content - .contextMenu( - menuItems: { contextMenu.0 }, - preview: { previewOverride(value) } - ) - } else if let preview = contextMenu.1 { - content - .contextMenu( - menuItems: { contextMenu.0 }, - preview: { preview } - ) - } else { - content - .contextMenu( - menuItems: { contextMenu.0 } - ) - } - } else { - content - } - } + func removeContextMenu(for type: V.Type) -> some View { + modifier( + ForTypeInEnvironment EnvironmentContextMenuPair>.SetValue( + { _ in { _ in (AnyView(EmptyView()), nil) } }, + for: \.contextMenuRegistry + ) + ) } -} - -extension View { /// Associates a context menu with a data type for use within /// subviews within the container. @@ -95,30 +35,71 @@ extension View { for type: V.Type, @ViewBuilder content: @escaping (V) -> some View ) -> some View { - modifier( - EnvironmentContextMenu.Registar( - menuContent: { - let menuContent = content($0) - return (AnyView(menuContent), nil) - } - ) + contextMenu( + for: type, + content: { _, v in content(v) } ) } + func contextMenu( + for type: V.Type, + @ViewBuilder content: @escaping (V, NavigationCoordinator.Router) -> some View + ) -> some View { + EnvironmentValueReader(\.router) { router in + contextMenu( + for: type, + content: { _, v in content(v, router) } + ) + } + } + /// Associates a context menu and preview with a data type for /// use within subviews within the container. func contextMenu( for type: V.Type, @ViewBuilder content: @escaping (V) -> some View, @ViewBuilder preview: @escaping (V) -> some View + ) -> some View { + contextMenu( + for: type, + content: { _, v in content(v) }, + preview: { _, v in preview(v) } + ) + } + + func contextMenu( + for type: V.Type, + @ViewBuilder content: @escaping (EnvironmentContextMenuPair?, V) -> some View ) -> some View { modifier( - EnvironmentContextMenu.Registar( - menuContent: { - let menuContent = content($0) - let previewContent = preview($0) - return (AnyView(menuContent), AnyView(previewContent)) - } + ForTypeInEnvironment EnvironmentContextMenuPair>.SetValue( + { existing in + { value in + let content = content(existing?(value as! V), value as! V) + return (AnyView(content), nil) + } + }, + for: \.contextMenuRegistry + ) + ) + } + + func contextMenu( + for type: V.Type, + @ViewBuilder content: @escaping (EnvironmentContextMenuPair?, V) -> some View, + @ViewBuilder preview: @escaping (EnvironmentContextMenuPair?, V) -> some View + ) -> some View { + modifier( + ForTypeInEnvironment EnvironmentContextMenuPair>.SetValue( + { existing in + { value in + let content = content(existing?(value as! V), value as! V) + let preview = preview(existing?(value as! V), value as! V) + + return (AnyView(content), AnyView(preview)) + } + }, + for: \.contextMenuRegistry ) ) } @@ -126,27 +107,49 @@ extension View { /// Identifies this view as the source of a context menu /// associated with the data type when used with `contextMenu(for:content:)` /// or `contextMenu(for:content:preview:)`. - func matchedContextMenu(for value: some Any) -> some View { + func matchedContextMenu(for value: V) -> some View { modifier( - EnvironmentContextMenu.Extractor( - value: value - ) + ForTypeInEnvironment EnvironmentContextMenuPair>.GetValue( + for: \.contextMenuRegistry + ) { contextMenuFunction in + let evaluatedContextMenu = contextMenuFunction(value) + + if let preview = evaluatedContextMenu.preview { + self + .contextMenu( + menuItems: { evaluatedContextMenu.items }, + preview: { preview } + ) + } else { + self + .contextMenu( + menuItems: { evaluatedContextMenu.items } + ) + } + } ) } /// Identifies this view as the source of a context menu /// associated with the data type when used with `contextMenu(for:content:)` - /// or `contextMenu(for:content:preview:)` but allows local preview + /// or `contextMenu(for:content:preview:)` with local preview /// creation. - func matchedContextMenu( - for value: some Any, + func matchedContextMenu( + for value: V, @ViewBuilder preview: @escaping () -> some View ) -> some View { modifier( - EnvironmentContextMenu.Extractor( - value: value, - preview: { _ in AnyView(preview()) } - ) + ForTypeInEnvironment EnvironmentContextMenuPair>.GetValue( + for: \.contextMenuRegistry + ) { contextMenuFunction in + let evaluatedContextMenu = contextMenuFunction(value) + + self + .contextMenu( + menuItems: { evaluatedContextMenu.items }, + preview: preview + ) + } ) } } diff --git a/Shared/Extensions/ViewExtensions/LibraryStyle.swift b/Shared/Extensions/ViewExtensions/LibraryStyle.swift new file mode 100644 index 0000000000..e25dde400b --- /dev/null +++ b/Shared/Extensions/ViewExtensions/LibraryStyle.swift @@ -0,0 +1,61 @@ +// +// 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 SwiftUI + +struct LibraryStyle: WithDefaultValue, Hashable, Storable { + + var displayType: LibraryDisplayType + var posterDisplayType: PosterDisplayType + var listColumnCount: Int + + static let `default`: LibraryStyle = .init( + displayType: .grid, + posterDisplayType: .portrait, + listColumnCount: 1 + ) +} + +extension EnvironmentValues { + + @Entry + var libraryStyleRegistry: TypeKeyedDictionary<(Any) -> ( + LibraryStyle, + Binding? + )> = .init() +} + +extension View { + + @ViewBuilder + func libraryStyle( + for type: V, + source: Binding, + style: @escaping (V) -> (LibraryStyle, Binding) + ) -> some View { + libraryStyle(for: type) { _, v in + style(v) + } + } + + @ViewBuilder + func libraryStyle( + for type: V, + style: @escaping ((LibraryStyle, Binding?), V) -> ( + LibraryStyle, + Binding? + ) + ) -> some View { + modifier( + ForTypeInEnvironment (LibraryStyle, Binding?)>.SetValue( + { existing in { v in style(existing?(v as! V) ?? (.default, nil), v as! V) } }, + for: \.libraryStyleRegistry + ) + ) + } +} diff --git a/Shared/Extensions/ViewExtensions/Modifiers/BackgroundParallaxHeaderModifier.swift b/Shared/Extensions/ViewExtensions/Modifiers/BackgroundParallaxHeaderModifier.swift index af3a4d8260..f9e8f900cf 100644 --- a/Shared/Extensions/ViewExtensions/Modifiers/BackgroundParallaxHeaderModifier.swift +++ b/Shared/Extensions/ViewExtensions/Modifiers/BackgroundParallaxHeaderModifier.swift @@ -8,43 +8,112 @@ import SwiftUI -struct BackgroundParallaxHeaderModifier: ViewModifier { +extension View { - @Binding - private var scrollViewOffset: CGFloat + func backgroundParallaxHeader( + multiplier: CGFloat = 1, + _backgroundColor: Color? = nil, + @ViewBuilder header: @escaping () -> some View + ) -> some View { + modifier( + BackgroundParallaxHeaderModifier( + multiplier: multiplier, + _backgroundColor: _backgroundColor, + header: header + ) + ) + } +} + +struct BackgroundParallaxHeaderModifier: ViewModifier { + + @Environment(\.frameForParentView) + private var frameForParentView @State - private var contentSize: CGSize = .zero + private var headerFrame: CGRect = .zero - private let height: CGFloat + private let _backgroundColor: Color? + private let background: Background private let multiplier: CGFloat - private let header: () -> Header init( - _ scrollViewOffset: Binding, - height: CGFloat, multiplier: CGFloat = 1, - @ViewBuilder header: @escaping () -> Header + _backgroundColor: Color? = nil, + @ViewBuilder header: @escaping () -> Background ) { - self._scrollViewOffset = scrollViewOffset - self.height = height + self.background = header() + self._backgroundColor = _backgroundColor self.multiplier = multiplier - self.header = header + } + + private var contentFrame: CGRect { + frameForParentView[.scrollViewHeader, default: .zero].frame + } + + private var scrollViewOffset: CGFloat { + -frameForParentView[.scrollViewHeader, default: .zero].frame.minY + } + + private var scrollViewSafeAreaInsets: EdgeInsets { + frameForParentView[.scrollView, default: .zero].safeAreaInsets + } + + private var maskHeight: CGFloat { + max(0, contentFrame.height + scrollViewSafeAreaInsets.top - offset) + } + + private var offset: CGFloat { + let position = scrollViewOffset + frameForParentView[.navigationStack, default: .zero].safeAreaInsets.top + + return if scrollViewOffset < 0, abs(scrollViewOffset) >= scrollViewSafeAreaInsets.top { + position + } else { + position - (abs(scrollViewOffset + scrollViewSafeAreaInsets.top) * multiplier) + } + } + + private var navigationBarHeight: CGFloat { + abs(frameForParentView[.navigationStack, default: .zero].safeAreaInsets.top - scrollViewSafeAreaInsets.top) + } + + private var adjustedHeaderHeight: CGFloat { + headerFrame.height + max( + 0, navigationBarHeight + ) + } + + private var scaleEffect: CGFloat { + var t: CGFloat { + if scrollViewOffset < 0, abs(scrollViewOffset) >= scrollViewSafeAreaInsets.top { + (adjustedHeaderHeight + abs(scrollViewOffset + scrollViewSafeAreaInsets.top)) / headerFrame.height + } else { + adjustedHeaderHeight / headerFrame.height + } + } + + return max(1, t) } func body(content: Content) -> some View { content - .trackingSize($contentSize) .background(alignment: .top) { - header() - .offset(y: scrollViewOffset > 0 ? -scrollViewOffset * multiplier : 0) - .scaleEffect(scrollViewOffset < 0 ? (height - scrollViewOffset) / height : 1, anchor: .top) - .frame(width: contentSize.width) - .mask(alignment: .top) { - Color.black - .frame(height: max(0, height - scrollViewOffset)) + MirrorExtensionView(edges: .top) { + background + } + .onFrameChanged { frame, _ in + if headerFrame == .zero || headerFrame.height.isNaN { + headerFrame = frame } - .ignoresSafeArea() + } + .scaleEffect(scaleEffect, anchor: .top) + .mask(alignment: .top) { + Color.black + .frame(height: maskHeight) + .offset(y: -scrollViewSafeAreaInsets.top) + } + .offset(y: offset) } + .background(_backgroundColor) } } diff --git a/Shared/Extensions/ViewExtensions/Modifiers/BottomEdgeGradientModifier.swift b/Shared/Extensions/ViewExtensions/Modifiers/BottomEdgeGradientModifier.swift deleted file mode 100644 index d89296d69b..0000000000 --- a/Shared/Extensions/ViewExtensions/Modifiers/BottomEdgeGradientModifier.swift +++ /dev/null @@ -1,29 +0,0 @@ -// -// 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 SwiftUI - -struct BottomEdgeGradientModifier: ViewModifier { - - let bottomColor: Color - - func body(content: Content) -> some View { - VStack(spacing: 0) { - content - .overlay { - bottomColor - .maskLinearGradient { - (location: 0.8, opacity: 0) - (location: 0.95, opacity: 1) - } - } - - bottomColor - } - } -} diff --git a/Shared/Extensions/ViewExtensions/Modifiers/ExtendedBackgroundModifier.swift b/Shared/Extensions/ViewExtensions/Modifiers/ExtendedBackgroundModifier.swift index 01a48d8a3d..133fe0d1df 100644 --- a/Shared/Extensions/ViewExtensions/Modifiers/ExtendedBackgroundModifier.swift +++ b/Shared/Extensions/ViewExtensions/Modifiers/ExtendedBackgroundModifier.swift @@ -10,6 +10,20 @@ import SwiftUI extension View { + func background( + alignment: Alignment = .center, + extendedBy insets: EdgeInsets, + @ViewBuilder background: () -> some View + ) -> some View { + modifier( + ExtendedBackgroundModifier( + alignment: alignment, + insets: insets, + background: background + ) + ) + } + func mask( alignment: Alignment = .center, extendedBy insets: EdgeInsets, @@ -25,6 +39,38 @@ extension View { } } +struct ExtendedBackgroundModifier: ViewModifier { + + @State + private var contentFrame: CGRect = .zero + + private let alignment: Alignment + private let background: Background + private let insets: EdgeInsets + + init( + alignment: Alignment, + insets: EdgeInsets = .init(), + @ViewBuilder background: () -> Background + ) { + self.alignment = alignment + self.background = background() + self.insets = insets + } + + func body(content: Content) -> some View { + content + .trackingFrame($contentFrame) + .background(alignment: alignment) { + background + .frame( + width: contentFrame.width + insets.leading + insets.trailing, + height: contentFrame.height + insets.top + insets.bottom + ) + } + } +} + struct ExtendedMaskModifier: ViewModifier { @State diff --git a/Shared/Extensions/ViewExtensions/Modifiers/OnFinalDisappearModifier.swift b/Shared/Extensions/ViewExtensions/Modifiers/OnFinalDisappearModifier.swift deleted file mode 100644 index bbc23a5b9e..0000000000 --- a/Shared/Extensions/ViewExtensions/Modifiers/OnFinalDisappearModifier.swift +++ /dev/null @@ -1,39 +0,0 @@ -// -// 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 SwiftUI - -struct OnFinalDisappearModifier: ViewModifier { - - @StateObject - private var observer: Observer - - init(action: @escaping () -> Void) { - _observer = StateObject(wrappedValue: Observer(action: action)) - } - - func body(content: Content) -> some View { - content - .background { - Color.clear - } - } - - private class Observer: ObservableObject { - - private let action: () -> Void - - init(action: @escaping () -> Void) { - self.action = action - } - - deinit { - action() - } - } -} diff --git a/Shared/Extensions/ViewExtensions/Modifiers/OnScenePhaseChangedModifier.swift b/Shared/Extensions/ViewExtensions/Modifiers/OnScenePhaseChangedModifier.swift index 72a4b0af7c..654e366995 100644 --- a/Shared/Extensions/ViewExtensions/Modifiers/OnScenePhaseChangedModifier.swift +++ b/Shared/Extensions/ViewExtensions/Modifiers/OnScenePhaseChangedModifier.swift @@ -17,10 +17,12 @@ struct OnScenePhaseChangedModifier: ViewModifier { let action: () -> Void func body(content: Content) -> some View { - content.onChange(of: scenePhase) { newValue in - if newValue == phase { - action() + content + .backport + .onChange(of: scenePhase) { _, newValue in + if newValue == phase { + action() + } } - } } } diff --git a/Shared/Extensions/ViewExtensions/Modifiers/OnSizeChangedModifier.swift b/Shared/Extensions/ViewExtensions/Modifiers/OnSizeChangedModifier.swift deleted file mode 100644 index 99323871c8..0000000000 --- a/Shared/Extensions/ViewExtensions/Modifiers/OnSizeChangedModifier.swift +++ /dev/null @@ -1,23 +0,0 @@ -// -// 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 SwiftUI - -struct OnSizeChangedModifier: ViewModifier { - - @State - private var size: CGSize = .zero - - @ViewBuilder - var wrapped: (CGSize) -> Wrapped - - func body(content: Content) -> some View { - wrapped(size) - .trackingSize($size) - } -} diff --git a/Shared/Extensions/ViewExtensions/Modifiers/OpacityLinearGradientModifier.swift b/Shared/Extensions/ViewExtensions/Modifiers/OpacityLinearGradientModifier.swift deleted file mode 100644 index 60d9fa243f..0000000000 --- a/Shared/Extensions/ViewExtensions/Modifiers/OpacityLinearGradientModifier.swift +++ /dev/null @@ -1,32 +0,0 @@ -// -// 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 SwiftUI - -struct OpacityLinearGradientModifier: ViewModifier { - - typealias Stop = (location: CGFloat, opacity: CGFloat) - - let stops: [Stop] - - func body(content: Content) -> some View { - content - .mask { - LinearGradient( - stops: stops.map { - Gradient.Stop( - color: Color.black.opacity($0.opacity), - location: $0.location - ) - }, - startPoint: .top, - endPoint: .bottom - ) - } - } -} diff --git a/Shared/Extensions/ViewExtensions/Modifiers/ScrollIfLargerThanContainerModifier.swift b/Shared/Extensions/ViewExtensions/Modifiers/ScrollIfLargerThanContainerModifier.swift index db5bb81309..3d0d2bc9ce 100644 --- a/Shared/Extensions/ViewExtensions/Modifiers/ScrollIfLargerThanContainerModifier.swift +++ b/Shared/Extensions/ViewExtensions/Modifiers/ScrollIfLargerThanContainerModifier.swift @@ -10,8 +10,11 @@ import SwiftUI struct ScrollIfLargerThanContainerModifier: ViewModifier { + @ViewContextContains(.withConstrainedSize) + private var withConstrainedSize: Bool + @State - private var contentSize: CGSize = .zero + private var contentFrame: CGRect = .zero let axes: Axis.Set let padding: CGFloat @@ -22,16 +25,16 @@ struct ScrollIfLargerThanContainerModifier: ViewModifier { Color.clear } content: { layoutSize in - let isHorizontallyLarger: Bool = (contentSize.width + padding >= layoutSize.width) && axes.contains(.horizontal) - let isVerticallyLarger: Bool = (contentSize.height + padding >= layoutSize.height) && axes.contains(.vertical) + let isHorizontallyLarger: Bool = (contentFrame.width + padding >= layoutSize.width) && axes.contains(.horizontal) + let isVerticallyLarger: Bool = (contentFrame.height + padding >= layoutSize.height) && axes.contains(.vertical) ScrollView(axes) { content - .trackingSize($contentSize) + .trackingFrame($contentFrame) } .frame( - maxWidth: axes.contains(.horizontal) ? (isHorizontallyLarger ? .infinity : contentSize.width) : nil, - maxHeight: axes.contains(.vertical) ? (isVerticallyLarger ? .infinity : contentSize.height) : nil, + maxWidth: axes.contains(.horizontal) ? (isHorizontallyLarger ? .infinity : contentFrame.width) : nil, + maxHeight: axes.contains(.vertical) ? (isVerticallyLarger ? .infinity : contentFrame.height) : nil, alignment: alignment ) .backport // iOS 17 @@ -39,5 +42,8 @@ struct ScrollIfLargerThanContainerModifier: ViewModifier { .scrollDisabled((axes.contains(.horizontal) && !isHorizontallyLarger) || (axes.contains(.vertical) && !isVerticallyLarger)) .scrollIndicators(.never) } + .if(withConstrainedSize) { view in + view.frame(maxWidth: contentFrame.width) + } } } diff --git a/Shared/Extensions/ViewExtensions/Modifiers/ScrollViewOffsetModifier.swift b/Shared/Extensions/ViewExtensions/Modifiers/ScrollViewOffsetModifier.swift index b8dca8d184..357af4eeb4 100644 --- a/Shared/Extensions/ViewExtensions/Modifiers/ScrollViewOffsetModifier.swift +++ b/Shared/Extensions/ViewExtensions/Modifiers/ScrollViewOffsetModifier.swift @@ -21,10 +21,11 @@ struct ScrollViewOffsetModifier: ViewModifier { func body(content: Content) -> some View { content.introspect( .scrollView, - on: .iOS(.v15...), - .tvOS(.v15...) + on: .iOS(.v16...), + .tvOS(.v16...) ) { scrollView in scrollView.delegate = scrollViewDelegate + scrollViewDelegate.scrollViewOffset.wrappedValue = scrollView.contentOffset.y } } diff --git a/Shared/Extensions/ViewExtensions/Modifiers/TrackingFrameModifier.swift b/Shared/Extensions/ViewExtensions/Modifiers/TrackingFrameModifier.swift new file mode 100644 index 0000000000..e509d39419 --- /dev/null +++ b/Shared/Extensions/ViewExtensions/Modifiers/TrackingFrameModifier.swift @@ -0,0 +1,81 @@ +// +// 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 SwiftUI + +struct ScrollViewHeaderFrameKey: PreferenceKey { + static let defaultValue: FrameAndSafeAreaInsets = .zero + static func reduce(value: inout Value, nextValue: () -> Value) { + value = nextValue() + } +} + +struct EmptyCGRectPreferenceKey: PreferenceKey { + static let defaultValue: FrameAndSafeAreaInsets = .zero + static func reduce(value: inout Value, nextValue: () -> Value) { + value = .zero + } +} + +struct TrackingFrameModifier: ViewModifier where Key.Value == FrameAndSafeAreaInsets { + + @Environment(\.frameForParentView) + private var frameForParentView + + @State + private var frame: CGRect = .zero + @State + private var safeAreaInsets: EdgeInsets = .zero + + private let containerCoordinateSpace: CoordinateSpace + private let coordinateSpace: CoordinateSpace + private let key: Key.Type? + + init( + containerCoordinateSpace: CoordinateSpace = .global, + coordinateSpace: CoordinateSpace, + key: Key.Type? = nil + ) { + self.containerCoordinateSpace = containerCoordinateSpace + self.coordinateSpace = coordinateSpace + self.key = key + } + + @ViewBuilder + private func attachingFramePreference( + @ViewBuilder to content: @escaping () -> some View + ) -> some View { + if let key { + content() + .preference( + key: key, + value: .init(frame: frame, safeAreaInsets: safeAreaInsets) + ) + } else { + content() + } + } + + func body(content: Content) -> some View { + attachingFramePreference { + content + .trackingFrame( + in: containerCoordinateSpace, + $frame, + $safeAreaInsets + ) + .environment( + \.frameForParentView, + frameForParentView.inserting( + value: .init(frame: frame, safeAreaInsets: safeAreaInsets), + for: coordinateSpace + ) + ) + } + } +} diff --git a/Shared/Extensions/ViewExtensions/PosterStyleRegistry.swift b/Shared/Extensions/ViewExtensions/PosterStyleRegistry.swift new file mode 100644 index 0000000000..15c2ae97b7 --- /dev/null +++ b/Shared/Extensions/ViewExtensions/PosterStyleRegistry.swift @@ -0,0 +1,33 @@ +// +// 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 SwiftUI + +// TODO: rework to `PosterEnvironment` + +extension EnvironmentValues { + + @Entry + var customEnvironmentValueRegistry: TypeKeyedDictionary<(Any) -> any WithDefaultValue> = .init() +} + +extension View { + + @ViewBuilder + func customEnvironment( + for type: P.Type, + value: P.Environment + ) -> some View where P.Environment: WithDefaultValue { + modifier( + ForTypeInEnvironment any WithDefaultValue>.SetValue( + { _ in { _ in value } }, + for: \.customEnvironmentValueRegistry + ) + ) + } +} diff --git a/Shared/Extensions/ViewExtensions/TypeViewRegistry/PosterOverlayRegistry.swift b/Shared/Extensions/ViewExtensions/TypeViewRegistry/PosterOverlayRegistry.swift deleted file mode 100644 index bbd1a01311..0000000000 --- a/Shared/Extensions/ViewExtensions/TypeViewRegistry/PosterOverlayRegistry.swift +++ /dev/null @@ -1,30 +0,0 @@ -// -// 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 SwiftUI - -extension EnvironmentValues { - - @Entry - var posterOverlayRegistry: TypeViewRegistry = .init() -} - -extension View { - - func posterOverlay( - for type: V.Type, - @ViewBuilder content: @escaping (V) -> some View - ) -> some View { - modifier( - EnvironmentView.Registar( - content: { AnyView(content($0)) }, - keyPath: \.posterOverlayRegistry - ) - ) - } -} diff --git a/Shared/Extensions/ViewExtensions/TypeViewRegistry/TypeViewRegistry.swift b/Shared/Extensions/ViewExtensions/TypeViewRegistry/TypeViewRegistry.swift deleted file mode 100644 index 0ecc24e615..0000000000 --- a/Shared/Extensions/ViewExtensions/TypeViewRegistry/TypeViewRegistry.swift +++ /dev/null @@ -1,58 +0,0 @@ -// -// 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 SwiftUI - -typealias TypeViewRegistry = TypeValueRegistry<(Any) -> AnyView> - -@propertyWrapper -struct EnvironmentTypeValue: DynamicProperty { - - @Environment - private var registry: TypeViewRegistry - - init(_ keyPath: WritableKeyPath) { - self._registry = Environment(keyPath) - } - - var wrappedValue: ((Value) -> AnyView)? { - registry.getvalue(for: Value.self) - } -} - -enum EnvironmentView { - - struct Registar: ViewModifier { - - @Environment - private var environmentRegistry: TypeViewRegistry - - private let content: (Any) -> AnyView - private let keyPath: WritableKeyPath - - init( - content: @escaping (Value) -> AnyView, - keyPath: WritableKeyPath - ) { - self.content = { value in - guard let value = value as? Value else { - return AnyView(EmptyView()) - } - return AnyView(content(value)) - } - - self.keyPath = keyPath - self._environmentRegistry = Environment(keyPath) - } - - func body(content: Content) -> some View { - content - .environment(keyPath, environmentRegistry.insertOrReplace(self.content, for: Value.self)) - } - } -} diff --git a/Shared/Extensions/ViewExtensions/ViewExtensions.swift b/Shared/Extensions/ViewExtensions/ViewExtensions.swift index 0bb6b236af..f14261f712 100644 --- a/Shared/Extensions/ViewExtensions/ViewExtensions.swift +++ b/Shared/Extensions/ViewExtensions/ViewExtensions.swift @@ -40,7 +40,10 @@ extension View { /// Instead, use a native `if` statement. @ViewBuilder @inlinable - func `if`(_ condition: Bool, @ViewBuilder transform: (Self) -> some View) -> some View { + func `if`( + _ condition: Bool, + @ViewBuilder transform: (Self) -> some View + ) -> some View { if condition { transform(self) } else { @@ -52,10 +55,10 @@ extension View { /// Instead, use a native `if/else` statement. @ViewBuilder @inlinable - func `if`( + func `if`( _ condition: Bool, - @ViewBuilder transformIf: (Self) -> Content, - @ViewBuilder transformElse: (Self) -> Content + @ViewBuilder transformIf: (Self) -> some View, + @ViewBuilder transformElse: (Self) -> some View ) -> some View { if condition { transformIf(self) @@ -83,10 +86,10 @@ extension View { /// Instead, use a native `if let/else` statement. @ViewBuilder @inlinable - func ifLet( + func ifLet( _ value: Value?, - @ViewBuilder transformIf: (Self, Value) -> Content, - @ViewBuilder transformElse: (Self) -> Content + @ViewBuilder transformIf: (Self, Value) -> some View, + @ViewBuilder transformElse: (Self) -> some View ) -> some View { if let value { transformIf(self, value) @@ -95,6 +98,8 @@ extension View { } } + // TODO: rename `posterDisplayStyle` + /// Applies the aspect ratio, corner radius, and border for the given `PosterType` /// /// Note: will not apply `posterShadow` @@ -106,13 +111,13 @@ extension View { switch type { case .landscape: posterAspectRatio(type, contentMode: contentMode) - #if !os(tvOS) + #if os(iOS) .posterBorder() .posterCornerRadius(type) #endif case .portrait: posterAspectRatio(type, contentMode: contentMode) - #if !os(tvOS) + #if os(iOS) .posterBorder() .posterCornerRadius(type) #endif @@ -144,18 +149,15 @@ extension View { func posterCornerRadius( _ type: PosterDisplayType ) -> some View { - #if !os(tvOS) switch type { case .landscape: cornerRadius(ratio: 1 / 30, of: \.width) case .portrait, .square: cornerRadius(ratio: 0.0375, of: \.width) } - #else - self - #endif } + @ViewBuilder func posterBorder() -> some View { overlay { ContainerRelativeShape() @@ -167,25 +169,22 @@ extension View { } } + @ViewBuilder func posterShadow() -> some View { shadow(radius: 4, y: 2) } + @ViewBuilder func scrollViewOffset(_ scrollViewOffset: Binding) -> some View { - modifier(ScrollViewOffsetModifier(scrollViewOffset: scrollViewOffset)) - } - - func backgroundParallaxHeader( - _ scrollViewOffset: Binding, - height: CGFloat, - multiplier: CGFloat = 1, - @ViewBuilder header: @escaping () -> some View - ) -> some View { - modifier(BackgroundParallaxHeaderModifier(scrollViewOffset, height: height, multiplier: multiplier, header: header)) - } - - func bottomEdgeGradient(bottomColor: Color) -> some View { - modifier(BottomEdgeGradientModifier(bottomColor: bottomColor)) + if #available(iOS 18, tvOS 18, *) { + onScrollGeometryChange(for: CGFloat.self) { geometry in + geometry.contentOffset.y + } action: { _, newValue in + scrollViewOffset.wrappedValue = newValue + } + } else { + modifier(ScrollViewOffsetModifier(scrollViewOffset: scrollViewOffset)) + } } // TODO: rename `errorAlert` @@ -250,36 +249,104 @@ extension View { corners: RectangleCorner = .allCorners, style: RoundedCornerStyle = .circular ) -> some View { - modifier( - OnSizeChangedModifier { size in - let radius = size[keyPath: side] * ratio - self.cornerRadius(radius, corners: corners, style: style, container: true) - } - ) + WithFrame { frame in + self.cornerRadius( + frame.frame.size[keyPath: side] * ratio, + corners: corners, + style: style, + container: true + ) + } } - func onFrameChanged(perform action: @escaping (CGRect, EdgeInsets) -> Void) -> some View { - onGeometryChange(for: OnFrameChangedValue.self) { proxy in - let frame = proxy.frame(in: .global) - let safeAreaInsets = proxy.safeAreaInsets - - return .init( - frame: frame, - safeAreaInsets: safeAreaInsets + @ViewBuilder + func onFrameChanged( + in containerCoordinateSpace: CoordinateSpace = .global, + perform action: @escaping (CGRect, EdgeInsets) -> Void + ) -> some View { + onGeometryChange(for: FrameAndSafeAreaInsets.self) { proxy in + .init( + frame: proxy.frame(in: containerCoordinateSpace), + safeAreaInsets: proxy.safeAreaInsets ) } action: { newValue in action(newValue.frame, newValue.safeAreaInsets) } } - func trackingFrame(_ binding: Binding) -> some View { - onFrameChanged { newFrame, _ in - binding.wrappedValue = newFrame + @ViewBuilder + func trackingFrame( + in containerCoordinateSpace: CoordinateSpace = .global, + _ frameBinding: Binding, + _ safeaAreaInsetsBinding: Binding = .constant(.zero) + ) -> some View { + onFrameChanged(in: containerCoordinateSpace) { + frameBinding.wrappedValue = $0 + safeaAreaInsetsBinding.wrappedValue = $1 } } + @ViewBuilder + func trackingFrame( + in containerCoordinateSpace: CoordinateSpace = .global, + named name: String + ) -> some View { + modifier( + TrackingFrameModifier( + containerCoordinateSpace: containerCoordinateSpace, + coordinateSpace: .named(name), + key: nil + ) + ) + } + + @ViewBuilder + func trackingFrame( + in containerCoordinateSpace: CoordinateSpace = .global, + for coordinateSpace: CoordinateSpace + ) -> some View { + modifier( + TrackingFrameModifier( + containerCoordinateSpace: containerCoordinateSpace, + coordinateSpace: coordinateSpace, + key: nil + ) + ) + } + + @ViewBuilder + func trackingFrame( + in containerCoordinateSpace: CoordinateSpace = .global, + named name: String, + key: K.Type + ) -> some View where K.Value == FrameAndSafeAreaInsets { + modifier( + TrackingFrameModifier( + containerCoordinateSpace: containerCoordinateSpace, + coordinateSpace: .named(name), + key: key + ) + ) + } + + @ViewBuilder + func trackingFrame( + in containerCoordinateSpace: CoordinateSpace = .global, + for coordinateSpace: CoordinateSpace, + key: K.Type + ) -> some View where K.Value == FrameAndSafeAreaInsets { + modifier( + TrackingFrameModifier( + containerCoordinateSpace: containerCoordinateSpace, + coordinateSpace: coordinateSpace, + key: key + ) + ) + } + + @available(*, deprecated, message: "Use `onFrameChanged` instead") func onSizeChanged(perform action: @escaping (CGSize, EdgeInsets) -> Void) -> some View { - onGeometryChange(for: OnFrameChangedValue.self) { proxy in + onGeometryChange(for: FrameAndSafeAreaInsets.self) { proxy in let size = proxy.size let safeAreaInsets = proxy.safeAreaInsets @@ -292,12 +359,13 @@ extension View { } } + @available(*, deprecated, message: "Use `trackingFrame` instead") func trackingSize( _ sizeBinding: Binding, _ safeAreaInsetBinding: Binding = .constant(.zero) ) -> some View { - onSizeChanged { - sizeBinding.wrappedValue = $0 + onFrameChanged { + sizeBinding.wrappedValue = $0.size safeAreaInsetBinding.wrappedValue = $1 } } @@ -337,12 +405,7 @@ extension View { } } - func blurred(style: UIBlurEffect.Style = .regular) -> some View { - overlay { - BlurView(style: style) - } - } - + @available(*, deprecated, message: "Use `Router` and `NavigationRoute` instead") func blurredFullScreenCover( isPresented: Binding, onDismiss: (() -> Void)? = nil, @@ -371,11 +434,6 @@ extension View { Backport(content: self) } - /// Perform an action on the final disappearance of a `View`. - func onFinalDisappear(perform action: @escaping () -> Void) -> some View { - modifier(OnFinalDisappearModifier(action: action)) - } - /// Perform an action on the first appearance of a `View`. func onFirstAppear(perform action: @escaping () -> Void) -> some View { modifier(OnFirstAppearModifier(action: action)) @@ -403,12 +461,7 @@ extension View { } func onNotification

(_ key: Notifications.Key

, perform action: @escaping (P) -> Void) -> some View { - modifier( - OnReceiveNotificationModifier( - key: key, - onReceive: action - ) - ) + onReceive(key.publisher, perform: action) } func onAppDidEnterBackground(_ action: @escaping () -> Void) -> some View { @@ -435,6 +488,14 @@ extension View { onNotification(.sceneWillEnterForeground, perform: action) } + @ViewBuilder + func preference( + key: Key.Type, + @ArrayBuilder value: () -> [V] + ) -> some View where Key.Value == [V] { + preference(key: Key.self, value: value()) + } + func scrollIfLargerThanContainer(axes: Axis.Set = .vertical, padding: CGFloat = 0, alignment: Alignment = .center) -> some View { modifier(ScrollIfLargerThanContainerModifier(axes: axes, padding: padding, alignment: alignment)) } @@ -445,10 +506,49 @@ extension View { ) } + @ViewBuilder + func scrollViewHeaderOffsetOpacity( + start: CGFloat = 100, + end: CGFloat = 25 + ) -> some View { + #if os(iOS) + WithEnvironment(\.frameForParentView) { frameForParentView in + var opacity: CGFloat { + let end = frameForParentView[.scrollView, default: .zero].safeAreaInsets.top + end + let start = end + start + let offset = frameForParentView[.scrollViewHeader, default: .zero].frame.maxY + + return clamp((offset - end) / (start - end), min: 0, max: 1) + } + + self.overlay { + Color.systemBackground + .opacity(1 - opacity) + } + } + #else + self + #endif + } + + /// Masks the view with a linear gradient from top to bottom. func maskLinearGradient( - @ArrayBuilder stops: () -> [OpacityLinearGradientModifier.Stop] + @ArrayBuilder stops: () -> [LinearGradient.Stop] = { + [(location: 0, opacity: 1), (location: 1, opacity: 0)] + } ) -> some View { - modifier(OpacityLinearGradientModifier(stops: stops())) + mask { + LinearGradient( + stops: stops().map { + Gradient.Stop( + color: Color.black.opacity($0.opacity), + location: $0.location + ) + }, + startPoint: .top, + endPoint: .bottom + ) + } } // TODO: look at changing to symbolEffect @@ -456,9 +556,27 @@ extension View { transition(.opacity.combined(with: .scale).animation(.snappy)) } - // MARK: debug + func overlay( + alignment: Alignment = .center, + ratio: CGFloat, + @ViewBuilder content: @escaping () -> some View + ) -> some View { + overlay { + ContainerRelativeView( + alignment: alignment, + ratio: ratio, + content: content + ) + } + } - // Useful modifiers during development for layout without RocketSim + func navigationTitle(_ title: String) -> some View { + self + .environment(\._navigationTitle, title) + .navigationTitle(Text(title)) + } + + // MARK: debug #if DEBUG func debugBackground(_ fill: some ShapeStyle = .red.opacity(0.5)) -> some View { @@ -498,8 +616,3 @@ extension View { } #endif } - -private struct OnFrameChangedValue: Equatable { - let frame: CGRect - let safeAreaInsets: EdgeInsets -} diff --git a/Shared/Objects/BindingBox.swift b/Shared/Objects/BindingBox.swift index 4f4ea6757f..59a25fd51f 100644 --- a/Shared/Objects/BindingBox.swift +++ b/Shared/Objects/BindingBox.swift @@ -42,12 +42,3 @@ class BindingBox: ObservableObject { valueObserver = nil } } - -extension Publisher where Failure == Never { - - func assign(to binding: Binding) -> AnyCancellable { - self.sink { value in - binding.wrappedValue = value - } - } -} diff --git a/Shared/Objects/ChannelProgram.swift b/Shared/Objects/ChannelProgram.swift index dfe6c3ac11..c68afe8d89 100644 --- a/Shared/Objects/ChannelProgram.swift +++ b/Shared/Objects/ChannelProgram.swift @@ -6,19 +6,30 @@ // Copyright (c) 2026 Jellyfin & Jellyfin Contributors // -import Foundation +import CollectionVGrid import JellyfinAPI import SwiftUI -// Note: assumes programs are sorted by start date -// TODO: rethink implementation - -/// Structure that has a channel and associated programs. +/// Channel with associated programs struct ChannelProgram: Hashable, Identifiable { let channel: BaseItemDto let programs: [BaseItemDto] + init(channel: BaseItemDto, programs: [BaseItemDto]) { + self.channel = channel + + self.programs = programs + .sorted { first, second in + guard let firstStart = first.startDate, + let secondStart = second.startDate + else { + return false + } + return firstStart < secondStart + } + } + var currentProgram: BaseItemDto? { programs.first { program in guard let start = program.startDate, @@ -28,13 +39,11 @@ struct ChannelProgram: Hashable, Identifiable { } } - func programAfterCurrent(offset: Int) -> BaseItemDto? { - guard let currentStart = currentProgram?.startDate else { return nil } - - return programs.filter { program in - guard let start = program.startDate else { return false } - return start > currentStart - }[safe: offset] + func program(after other: BaseItemDto) -> BaseItemDto? { + guard let i = programs.firstIndex(of: other), i < programs.endIndex - 1 else { + return nil + } + return programs[i.advanced(by: 1)] } var id: String? { @@ -46,12 +55,20 @@ struct ChannelProgram: Hashable, Identifiable { extension ChannelProgram: Poster { - var preferredPosterDisplayType: PosterDisplayType { - .square + func squareImageSources( + maxWidth: CGFloat?, + quality: Int?, + environment: Empty + ) -> [ImageSource] { + channel.squareImageSources( + maxWidth: maxWidth, + quality: quality, + environment: .default + ) } - var unwrappedIDHashOrZero: Int { - channel.id?.hashValue ?? 0 + var preferredPosterDisplayType: PosterDisplayType { + .square } var displayTitle: String { @@ -62,7 +79,145 @@ extension ChannelProgram: Poster { channel.systemImage } - func transform(image: Image) -> some View { - image + func transform(image: Image, displayType: PosterDisplayType) -> some View { + channel.transform(image: image, displayType: displayType) + } +} + +extension ChannelProgram: @MainActor LibraryElement { + + static func layout(for libraryStyle: LibraryStyle) -> CollectionVGridLayout { + var padLayout: CollectionVGridLayout { + switch libraryStyle.displayType { + case .grid: + .minWidth(150) + case .list: + .minWidth(250) + } + } + + var phoneLayout: CollectionVGridLayout { + switch libraryStyle.displayType { + case .grid: + .columns(3) + case .list: + .columns(1) + } + } + + return UIDevice.isPhone ? phoneLayout : padLayout + } + + @MainActor + func libraryDidSelectElement(router: Router.Wrapper, in namespace: Namespace.ID) { + router.route(to: .item(item: channel), in: namespace) + } + + func makeGridBody(libraryStyle: LibraryStyle) -> some View { + WithRouter { router in + PosterButton( + item: channel, + type: .square + ) { namespace in + libraryDidSelectElement(router: router, in: namespace) + } + } + } + + @ViewBuilder + func makeListBody(libraryStyle: LibraryStyle) -> some View { + WithNamespace { namespace in + WithRouter { router in + ListRow(insets: .init(vertical: 8, horizontal: EdgeInsets.edgePadding)) { + libraryDidSelectElement(router: router, in: namespace) + } leading: { + VStack { + PosterImage( + item: channel, + type: .square + ) + + Text(channel.number ?? "") + .font(.footnote) + .fontWeight(.semibold) + .lineLimit(1, reservesSpace: true) + .foregroundStyle(Color.accentColor) + } + .frame(width: 60) + } content: { + ChannelLibraryBody(channelProgram: self) + } + .backport + .matchedTransitionSource(id: "item", in: namespace) + } + } + } + + private struct ChannelLibraryBody: View { + + @CurrentDate + private var currentDate: Date + + let channelProgram: ChannelProgram + + @ViewBuilder + private var programListView: some View { + VStack(alignment: .leading, spacing: 0) { + if let currentProgram = channelProgram.currentProgram { + ProgressView(value: currentProgram.programProgress(relativeTo: currentDate) ?? 0) + .progressViewStyle(.playback) + .frame(height: 5) + .padding(.bottom, 5) + .foregroundStyle(.primary) + + programLabel(for: currentProgram) + .fontWeight(.bold) + + Group { + if let nextProgram = channelProgram.program(after: currentProgram) { + programLabel(for: nextProgram) + + if let futureProgram = channelProgram.program(after: nextProgram) { + programLabel(for: futureProgram) + } + } + } + .foregroundStyle(.secondary) + } + } + .font(.footnote) + } + + @ViewBuilder + private func programLabel(for program: BaseItemDto) -> some View { + HStack { + AlternateLayoutView(alignment: .leading) { + Text("00:00 AAA") + } content: { + if let startDate = program.startDate { + Text(startDate, style: .time) + } else { + Text(String.emptyRuntime) + } + } + .monospacedDigit() + + Text(program.displayTitle) + } + .lineLimit(1) + } + + var body: some View { + VStack(alignment: .leading, spacing: 5) { + Text(channelProgram.displayTitle) + .font(.body) + .fontWeight(.semibold) + .lineLimit(1) + + if channelProgram.programs.isNotEmpty { + programListView + } + } + } } } diff --git a/Shared/Objects/ContentGroup/ContentGroup.swift b/Shared/Objects/ContentGroup/ContentGroup.swift new file mode 100644 index 0000000000..8307d0e205 --- /dev/null +++ b/Shared/Objects/ContentGroup/ContentGroup.swift @@ -0,0 +1,37 @@ +// +// 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 SwiftUI + +typealias ContentGroupBuilder = ArrayBuilder + +@MainActor +protocol ContentGroup: Identifiable { + + associatedtype Body: View + associatedtype ViewModel: WithRefresh + + var id: String { get } + var viewModel: ViewModel { get } + var _shouldBeResolved: Bool { get } + + @ViewBuilder + func body(with viewModel: ViewModel) -> Body +} + +extension ContentGroup { + var _shouldBeResolved: Bool { + true + } +} + +extension ContentGroup where ViewModel == Empty { + var viewModel: Empty { + .init() + } +} diff --git a/Shared/Objects/ContentGroup/ContentGroupProvider.swift b/Shared/Objects/ContentGroup/ContentGroupProvider.swift new file mode 100644 index 0000000000..68d86cc83d --- /dev/null +++ b/Shared/Objects/ContentGroup/ContentGroupProvider.swift @@ -0,0 +1,26 @@ +// +// 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 +// + +@MainActor +protocol ContentGroupProvider: Displayable { + + associatedtype Environment = Empty + + var environment: Environment { get set } + var id: String { get } + + @ContentGroupBuilder + func makeGroups(environment: Environment) async throws -> [any ContentGroup] +} + +extension ContentGroupProvider where Environment == Empty { + var environment: Empty { + get { .init() } + set {} + } +} diff --git a/Shared/Objects/ContentGroup/ContentGroupSetting.swift b/Shared/Objects/ContentGroup/ContentGroupSetting.swift new file mode 100644 index 0000000000..0829ef7870 --- /dev/null +++ b/Shared/Objects/ContentGroup/ContentGroupSetting.swift @@ -0,0 +1,127 @@ +// +// 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 Foundation +import JellyfinAPI + +@MainActor +enum ContentGroupProviderSetting: Equatable, Hashable, Storable { + + case `default` + case custom(StoredContentGroupProvider) + + var provider: any ContentGroupProvider { + switch self { + case .default: + DefaultContentGroupProvider() + case let .custom(provider): + provider + } + } +} + +@MainActor +enum ContentGroupSetting: Equatable, Hashable, Storable { + + case continueWatching( + id: String, + posterDisplayType: PosterDisplayType = .landscape, + posterSize: PosterDisplayType.Size = .medium + ) + + case nextUp( + id: String, + posterDisplayType: PosterDisplayType = .portrait, + posterSize: PosterDisplayType.Size = .medium + ) + + case library( + id: String, + displayTitle: String, + libraryID: String, + filters: ItemFilterCollection = .init(), + posterDisplayType: PosterDisplayType = .portrait, + posterSize: PosterDisplayType.Size = .medium + ) + + var group: any ContentGroup { + switch self { + case let .continueWatching( + id: id, + posterDisplayType: posterDisplayType, + posterSize: posterSize + ): + PosterGroup( + id: id, + library: ResumeItemsLibrary(mediaTypes: [.video]), + posterDisplayType: posterDisplayType, + posterSize: posterSize + ) + case let .nextUp( + id: id, + posterDisplayType: posterDisplayType, + posterSize: posterSize + ): + PosterGroup( + id: id, + library: NextUpLibrary(), + posterDisplayType: posterDisplayType, + posterSize: posterSize + ) + case let .library( + id: id, + displayTitle: displayTitle, + libraryID: libraryID, + filters: filters, + posterDisplayType: posterDisplayType, + posterSize: posterSize + ): + PosterGroup( + id: id, + library: ItemLibrary( + parent: .init(id: libraryID, name: displayTitle), + filters: filters + ), + posterDisplayType: posterDisplayType, + posterSize: posterSize + ) + } + } +} + +struct StoredContentGroupProvider: ContentGroupProvider, Equatable, Hashable, Storable { + + var displayTitle: String + var id: String + var groups: [ContentGroupSetting] + + func makeGroups(environment: Empty) async throws -> [any ContentGroup] { + groups.map(\.group) + } +} + +extension StoredValues.Keys.User { + + static func customContentGroup(id: String) -> StoredValues.Key { + StoredValues.Keys.CurrentUserKey( + "__customContentGroup_\(id)", + field: "__customContentGroup_\(id)", + default: .custom( + .init( + displayTitle: "Custom \(id)", + id: id, + groups: [.nextUp( + id: UUID().uuidString, + posterDisplayType: .portrait, + posterSize: .small + )] + ) + ) + ) + } +} diff --git a/Shared/Objects/ContentGroup/EpisodeGroup.swift b/Shared/Objects/ContentGroup/EpisodeGroup.swift new file mode 100644 index 0000000000..d3b1230690 --- /dev/null +++ b/Shared/Objects/ContentGroup/EpisodeGroup.swift @@ -0,0 +1,69 @@ +// +// 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 JellyfinAPI +import SwiftUI + +struct EpisodeGroup: ContentGroup where Library.Element == BaseItemDto { + + let displayTitle: String + let id: String + let library: Library + let viewModel: PagingLibraryViewModel + + var _shouldBeResolved: Bool { + viewModel.elements.isNotEmpty + } + + init( + library: Library + ) { + self.displayTitle = library.parent.displayTitle + self.id = UUID().uuidString + self.library = library + self.viewModel = .init(library: library, pageSize: 20) + } + + @ViewBuilder + func body(with viewModel: PagingLibraryViewModel) -> some View { + WithRouter { router in + EpisodeHStack( + viewModel: viewModel, + playButtonItemID: nil + ) { + #if os(tvOS) + Text(viewModel.library.parent.displayTitle) + .font(.title2) + .fontWeight(.semibold) + .lineLimit(1) + .accessibilityAddTraits(.isHeader) + .edgePadding(.horizontal) + #else + Button { + router.route(to: .library(library: viewModel.library)) + } label: { + HStack(spacing: 3) { + Text(viewModel.library.parent.displayTitle) + .font(.title2) + .lineLimit(1) + + Image(systemName: "chevron.forward") + .font(.title3) + .foregroundStyle(.secondary) + } + .fontWeight(.semibold) + } + .foregroundStyle(.primary, .secondary) + .accessibilityAddTraits(.isHeader) + .accessibilityAction(named: Text("Open library")) { router.route(to: .library(library: viewModel.library)) } + .edgePadding(.horizontal) + #endif + } + } + } +} diff --git a/Shared/Objects/ContentGroup/PillGroup.swift b/Shared/Objects/ContentGroup/PillGroup.swift new file mode 100644 index 0000000000..14b3ef5019 --- /dev/null +++ b/Shared/Objects/ContentGroup/PillGroup.swift @@ -0,0 +1,49 @@ +// +// 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 JellyfinAPI +import SwiftUI + +struct PillGroup: ContentGroup { + + let action: (Router.Wrapper, Element) -> Void + let displayTitle: String + let elements: [Element] + let id: String + + var _shouldBeResolved: Bool { + elements.isNotEmpty + } + + init( + displayTitle: String, + id: String, + elements: [Element], + action: @escaping (Router.Wrapper, Element) -> Void + ) { + self.action = action + self.displayTitle = displayTitle + self.id = id + self.elements = elements + } + + func body(with viewModel: Empty) -> some View { + #if os(tvOS) + EmptyView() + #else + WithRouter { router in + PillHStack( + title: displayTitle, + data: elements + ) { element in + action(router, element) + } + } + #endif + } +} diff --git a/Shared/Objects/ContentGroup/PosterGroup.swift b/Shared/Objects/ContentGroup/PosterGroup.swift new file mode 100644 index 0000000000..5aefd52162 --- /dev/null +++ b/Shared/Objects/ContentGroup/PosterGroup.swift @@ -0,0 +1,50 @@ +// +// 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 SwiftUI + +struct PosterGroup: ContentGroup where Library.Element: LibraryElement { + + let displayTitle: String + let id: String + let library: Library + let posterDisplayType: PosterDisplayType + let posterSize: PosterDisplayType.Size + let viewModel: PagingLibraryViewModel + + let _viewContext: ViewContext? + + var _shouldBeResolved: Bool { + viewModel.elements.isNotEmpty + } + + init( + id: String = UUID().uuidString, + library: Library, + posterDisplayType: PosterDisplayType = .portrait, + posterSize: PosterDisplayType.Size = .small, + _viewContext: ViewContext? = nil + ) { + self.displayTitle = library.parent.displayTitle + self.id = id + self.library = library + self.posterDisplayType = posterDisplayType + self.posterSize = posterSize + self.viewModel = .init(library: library, pageSize: 20) + self._viewContext = _viewContext + } + + @ViewBuilder + func body(with viewModel: PagingLibraryViewModel) -> some View { + PosterHStackLibrarySection( + viewModel: viewModel, + group: self + ) + .withViewContext(_viewContext ?? .init()) + } +} diff --git a/Shared/Objects/ContentGroup/SeriesEpisodeContentGroup.swift b/Shared/Objects/ContentGroup/SeriesEpisodeContentGroup.swift new file mode 100644 index 0000000000..2a5877071e --- /dev/null +++ b/Shared/Objects/ContentGroup/SeriesEpisodeContentGroup.swift @@ -0,0 +1,147 @@ +// +// 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 JellyfinAPI +import SwiftUI + +struct SeriesEpisodeContentGroup: ContentGroup, Identifiable { + + let viewModel: ItemViewModel + var id: String { + "\(viewModel.item.libraryID)-episodeSelector" + } + + func body(with viewModel: ItemViewModel) -> some View { + SeriesEpisodeSelector(viewModel: viewModel) + } +} + +// TODO: refactor to not take ItemViewModel + +struct SeriesEpisodeSelector: View { + + @ObservedObject + var viewModel: ItemViewModel + + @State + private var didSelectPlayButtonSeason = false + @State + private var selectionID: PagingSeasonViewModel.ID? + + @StateObject + private var seasonsViewModel: PagingLibraryViewModel + + private var selectionViewModel: PagingSeasonViewModel? { + guard let id = selectionID else { return nil } + return seasonsViewModel.elements[id: id] + } + + init(viewModel: ItemViewModel) { + self.viewModel = viewModel + self._seasonsViewModel = .init(wrappedValue: .init(library: .init(series: viewModel.item))) + } + + @ViewBuilder + private var seasonSelectorMenu: some View { + #if os(tvOS) + SeasonsHStack( + viewModel: seasonsViewModel, + selection: $selectionID + ) + #else + AlternateLayoutView(alignment: .leading) { + Text(" ") + .frame(maxWidth: .infinity) + .font(.title2) + .fontWeight(.semibold) + } content: { + if let selectionViewModel { + if seasonsViewModel.elements.count > 1 { + Menu( + selectionViewModel.library.parent.displayTitle, + systemImage: "chevron.down" + ) { + Picker(L10n.seasons, selection: $selectionID) { + ForEach(seasonsViewModel.elements, id: \.id) { season in + Text(season.library.parent.displayTitle) + .tag(season.id as PagingSeasonViewModel.ID?) + } + } + } + .labelStyle(.episodeSelector) + } else { + Text(selectionViewModel.library.parent.displayTitle) + .font(.title2) + .fontWeight(.semibold) + .lineLimit(2) + } + } else { + Text(String.random(count: 8)) + .font(.title2) + .fontWeight(.semibold) + .redacted(reason: .placeholder) + } + } + .edgePadding(.horizontal) + #endif + } + + var body: some View { + if seasonsViewModel.state != .content || seasonsViewModel.elements.isNotEmpty { + ZStack { + EpisodeHStack( + viewModel: .init(library: EpisodeLibrary(season: .init())), + playButtonItemID: nil + ) { + seasonSelectorMenu + } + .opacity(0) + + if let selectionViewModel { + EpisodeHStack( + viewModel: selectionViewModel, + playButtonItemID: viewModel.playButtonItem?.id + ) { + seasonSelectorMenu + } + } else { + EpisodeHStack( + viewModel: .init(library: EpisodeLibrary(season: .init())), + playButtonItemID: nil + ) { + seasonSelectorMenu + } + } + } + .transition(.opacity.animation(.linear(duration: 0.2))) + .withViewContext(.isInParent) + .onReceive(viewModel.playButtonItem.publisher) { newValue in + + guard selectionID == nil else { return } + guard let playButtonSeasonID = newValue.seasonID else { return } + + if let playButtonSeason = seasonsViewModel.elements[id: playButtonSeasonID] { + selectionID = playButtonSeason.id + } else { + selectionID = seasonsViewModel.elements.first?.id + } + } + .onFirstAppear { + seasonsViewModel.refresh() + } + .backport + .onChange(of: selectionID) { _, _ in + guard let selectionViewModel else { return } + + if selectionViewModel.state == .initial { + selectionViewModel.refresh() + } + } + } + } +} diff --git a/Shared/Objects/ContentGroup/TestCarouselWithLibraryPosterGroup.swift b/Shared/Objects/ContentGroup/TestCarouselWithLibraryPosterGroup.swift new file mode 100644 index 0000000000..48c17755d8 --- /dev/null +++ b/Shared/Objects/ContentGroup/TestCarouselWithLibraryPosterGroup.swift @@ -0,0 +1,240 @@ +// +// 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 CollectionHStack +import JellyfinAPI +import SwiftUI + +struct TestCarouselWithLibraryPosterGroup: ContentGroup { + + let id: String = "test" + let displayTitle: String = "" + let filters: ItemFilterCollection + let viewModel: PagingLibraryViewModel + + init(filters: ItemFilterCollection) { + self.filters = filters + self.viewModel = PagingLibraryViewModel( + library: ItemLibrary( + parent: .init(), + filters: filters + ) + ) + } + + func body(with viewModel: PagingLibraryViewModel) -> some View { + PagingCarouselPosterHStack( + library: viewModel + ) + } +} + +struct PagingCarouselPosterHStack: View where P.Element: Poster { + + @Router + private var router + + @State + private var currentIndex = 0 + @State + private var isAutoScrolling = false + + @StateObject + private var collectionHStackProxy = CollectionHStackProxy() + + @ObservedObject + private var viewModel: PagingLibraryViewModel

+ + @StateObject + private var scrollTimer = PokeIntervalTimer(defaultInterval: 2) + + private var pageCount: Int { + min(20, viewModel.elements.count) + } + + private var currentItem: P.Element? { + viewModel.elements[safe: currentIndex] + } + + init(library: PagingLibraryViewModel

) { + _viewModel = ObservedObject(wrappedValue: library) + } + + var body: some View { + VStack { + carouselView + + if pageCount > 1 { + CarouselPageIndicator( + numberOfPages: pageCount, + currentPage: currentIndex, + maxDotsShown: 5, + onSelect: { index in + scrollTimer.stop() + scrollTo(index: index, animated: true) + } + ) + } + } + .onReceive(scrollTimer) { _ in + advancePage() + } + .onFirstAppear { + restartAutoScroll() + } + .backport + .onChange(of: viewModel.elements.count) { _, _ in + clampCurrentIndex() + } + } + + @ViewBuilder + private var carouselView: some View { + CollectionHStack( + uniqueElements: viewModel.elements, + layout: .grid(columns: 1, rows: 1, columnTrailingInset: 0) + ) { item in + WithNamespace { namespace in + Button { + if let item = item as? BaseItemDto { + scrollTimer.stop() + router.route(to: .item(item: item), in: namespace) + } + } label: { + VStack(alignment: .leading) { + PosterImage( + item: item, + type: .landscape + ) + .withViewContext(.isThumb) + .backport + .matchedTransitionSource(id: "item", in: namespace) + + Text(item.displayTitle) + .font(.headline) + .lineLimit(1) + } + } + .foregroundStyle(.primary, .secondary) + } + } + .proxy(collectionHStackProxy) + .dataPrefix(20) + .insets(horizontal: EdgeInsets.edgePadding) + .itemSpacing(EdgeInsets.edgePadding / 2) + .scrollBehavior(.columnPaging) + ._didScrollToElements { items in + currentIndex = items.map(\.indexPath.row).min() ?? 0 + if !isAutoScrolling { + scrollTimer.stop() + } + isAutoScrolling = false + } + ._didEndDecelerating { + restartAutoScroll() + } + } + + private func clampCurrentIndex() { + guard pageCount > 0 else { + currentIndex = 0 + return + } + currentIndex = min(currentIndex, pageCount - 1) + } + + private func advancePage() { + guard pageCount > 1 else { return } + let nextIndex = (currentIndex + 1) % pageCount + scrollTo(index: nextIndex, animated: true) + } + + private func scrollTo(index: Int, animated: Bool) { + guard viewModel.elements.indices.contains(index) else { return } + isAutoScrolling = true + + if animated { + withAnimation(.snappy) { + collectionHStackProxy._scrollTo(index: index) + } + } else { + collectionHStackProxy._scrollTo(index: index) + } + } + + private func restartAutoScroll() { + scrollTimer.poke() + } +} + +struct CarouselPageIndicator: View { + + let numberOfPages: Int + let currentPage: Int + let maxDotsShown: Int + let onSelect: (Int) -> Void + + private let dotSize: CGFloat = 8 + private let spacing: CGFloat = 8 + private let activeScale: CGFloat = 1.2 + private let fadeScale: CGFloat = 0.6 + + private var visibleIndices: Range { + let total = numberOfPages + let window = maxDotsShown + + guard total > window else { + return 0 ..< total + } + + let halfWindow = window / 2 + let startLockout = halfWindow + let endLockout = total - window + halfWindow + + if currentPage < startLockout { + return 0 ..< window + } + if currentPage >= endLockout { + return (total - window) ..< total + } + return (currentPage - halfWindow) ..< (currentPage - halfWindow + window) + } + + private func scale(for index: Int) -> CGFloat { + if index == currentPage { + return activeScale + } + + if numberOfPages > maxDotsShown { + let window = visibleIndices + if index == window.lowerBound, window.lowerBound > 0 { + return fadeScale + } + if index == window.upperBound - 1, window.upperBound < numberOfPages { + return fadeScale + } + } + + return 1.0 + } + + var body: some View { + HStack(spacing: spacing) { + ForEach(visibleIndices, id: \.self) { index in + Circle() + .fill(index == currentPage ? HierarchicalShapeStyle.primary : HierarchicalShapeStyle.secondary) + .frame(width: dotSize, height: dotSize) + .scaleEffect(scale(for: index)) + .animation(.linear(duration: 0.1), value: currentPage) + .onTapGesture { + onSelect(index) + } + } + } + } +} diff --git a/Shared/Objects/CurrentDate.swift b/Shared/Objects/CurrentDate.swift index e835d2c348..db8e848a96 100644 --- a/Shared/Objects/CurrentDate.swift +++ b/Shared/Objects/CurrentDate.swift @@ -44,7 +44,7 @@ extension CurrentDate { private var publisher: AnyCancellable? init(interval: TimeInterval) { - publisher = Timer.publish(every: 1, on: .main, in: .common) + publisher = Timer.publish(every: interval, on: .main, in: .common) .autoconnect() .sink { [weak self] _ in if let self { diff --git a/Shared/Objects/TimeStampType.swift b/Shared/Objects/Empty.swift similarity index 52% rename from Shared/Objects/TimeStampType.swift rename to Shared/Objects/Empty.swift index 920635791b..26f0e66d8c 100644 --- a/Shared/Objects/TimeStampType.swift +++ b/Shared/Objects/Empty.swift @@ -6,20 +6,23 @@ // Copyright (c) 2026 Jellyfin & Jellyfin Contributors // -import Defaults -import Foundation - -enum TimestampType: String, CaseIterable, Defaults.Serializable, Displayable { - - case split - case compact +struct Empty {} +extension Empty: LibraryGrouping { var displayTitle: String { - switch self { - case .split: - L10n.split - case .compact: - L10n.compact - } + "" + } + + var id: String { + "" } } + +extension Empty: WithDefaultValue { + static var `default`: Empty = .init() +} + +extension Empty: WithRefresh { + func refresh() {} + func refresh() async throws {} +} diff --git a/Shared/Objects/EventPublisher.swift b/Shared/Objects/EventPublisher.swift deleted file mode 100644 index 75445a63b3..0000000000 --- a/Shared/Objects/EventPublisher.swift +++ /dev/null @@ -1,26 +0,0 @@ -// -// 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 Combine - -// TODO: remove - -struct LegacyEventPublisher: Publisher { - typealias Output = T - typealias Failure = Never - - private let subject = PassthroughSubject() - - func receive(subscriber: S) where Never == S.Failure, T == S.Input { - subject.receive(subscriber: subscriber) - } - - func send(_ value: T) { - subject.send(value) - } -} diff --git a/Shared/Objects/Eventful.swift b/Shared/Objects/Eventful.swift index d543403091..73a8dc78f1 100644 --- a/Shared/Objects/Eventful.swift +++ b/Shared/Objects/Eventful.swift @@ -8,8 +8,7 @@ import Combine -// TODO: remove, apply the Stateful macro - +@available(*, deprecated, message: "Apply the `Stateful` macro instead") protocol Eventful { associatedtype Event diff --git a/Shared/Objects/ForTypeInEnvironment.swift b/Shared/Objects/ForTypeInEnvironment.swift new file mode 100644 index 0000000000..40966acdf1 --- /dev/null +++ b/Shared/Objects/ForTypeInEnvironment.swift @@ -0,0 +1,89 @@ +// +// 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 SwiftUI + +@propertyWrapper +struct ForTypeInEnvironment: DynamicProperty { + + @Environment + private var registry: TypeKeyedDictionary + + init( + _ keyPath: WritableKeyPath> + ) { + self._registry = Environment(keyPath) + } + + var wrappedValue: Value? { + registry[KeyType.self] + } + + struct SetValue: ViewModifier { + + @Environment + private var dictionary: TypeKeyedDictionary + + private let content: ((TypeKeyedDictionary) -> Value)? + private let keyPath: WritableKeyPath> + + init( + _ value: ((Value?) -> Value)?, + for keyPath: WritableKeyPath> + ) { + self.keyPath = keyPath + self._dictionary = Environment(keyPath) + + if let value { + self.content = { existingDictionary in + let existingValue = existingDictionary[KeyType.self] + return value(existingValue) + } + } else { + self.content = nil + } + } + + func body(content: Content) -> some View { + content + .environment( + keyPath, + dictionary.inserting( + type: KeyType.self, + value: self.content?(dictionary) + ) + ) + } + } + + struct GetValue: ViewModifier { + + @Environment + private var dictionary: TypeKeyedDictionary + + private let contentWithExtracted: (Value) -> ContentWithExtracted + private let keyPath: WritableKeyPath> + + init( + for keyPath: WritableKeyPath>, + @ViewBuilder content: @escaping (Value) -> ContentWithExtracted + ) { + self._dictionary = Environment(keyPath) + self.contentWithExtracted = content + self.keyPath = keyPath + } + + func body(content: Content) -> some View { + if let environmentValue = dictionary[KeyType.self] { + contentWithExtracted(environmentValue) + } else { + content + } + } + } +} diff --git a/Shared/Objects/FrameAndSafeAreaInsets.swift b/Shared/Objects/FrameAndSafeAreaInsets.swift new file mode 100644 index 0000000000..42dc872e38 --- /dev/null +++ b/Shared/Objects/FrameAndSafeAreaInsets.swift @@ -0,0 +1,16 @@ +// +// 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 SwiftUI + +struct FrameAndSafeAreaInsets: Equatable { + let frame: CGRect + let safeAreaInsets: EdgeInsets + + static let zero: Self = .init(frame: .zero, safeAreaInsets: .zero) +} diff --git a/Shared/Objects/HashCache.swift b/Shared/Objects/HashCache.swift new file mode 100644 index 0000000000..0cf6f85623 --- /dev/null +++ b/Shared/Objects/HashCache.swift @@ -0,0 +1,48 @@ +// +// 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 OrderedCollections + +@MainActor +final class HashCache { + + private var hashes: OrderedDictionary = [:] + + /// - Returns: `true` if `value` for `key` was previously inserted and updated, `false` otherwise + @discardableResult + func touch(key: String, value: T) -> Bool { + let newHash = value.hashValue + + guard let existingHash = hashes[key] else { + store(key: key, value: value) + return false + } + + guard existingHash != newHash else { + hashes.removeValue(forKey: key) + hashes[key] = existingHash + return false + } + + store(key: key, value: value) + return true + } + + private func store(key: String, value: T) { + if hashes[key] != nil { + hashes.removeValue(forKey: key) + } + hashes[key] = value.hashValue + + let d = hashes.count - 2000 + + if d > 0 { + hashes.removeFirst(d) + } + } +} diff --git a/Shared/Objects/ItemFilter/ItemFilter.swift b/Shared/Objects/ItemFilter/ItemFilter.swift index 631b70e5c4..5cc20100ad 100644 --- a/Shared/Objects/ItemFilter/ItemFilter.swift +++ b/Shared/Objects/ItemFilter/ItemFilter.swift @@ -13,8 +13,6 @@ protocol ItemFilter: Displayable { var value: String { get } - // TODO: Should this be optional if the concrete type - // can't be constructed? init(from anyFilter: AnyItemFilter) } diff --git a/Shared/Objects/ItemFilter/ItemFilterCollection.swift b/Shared/Objects/ItemFilter/ItemFilterCollection.swift index 93e4662873..5496b4c5d7 100644 --- a/Shared/Objects/ItemFilter/ItemFilterCollection.swift +++ b/Shared/Objects/ItemFilter/ItemFilterCollection.swift @@ -6,20 +6,26 @@ // Copyright (c) 2026 Jellyfin & Jellyfin Contributors // +import Foundation import JellyfinAPI +// TODO: take parent id for libraries? + /// A structure representing a collection of item filters struct ItemFilterCollection: Hashable, Storable { var genres: [ItemGenre] = [] var itemTypes: [BaseItemKind] = [] var letter: [ItemLetter] = [] + var mediaTypes: [MediaType] = [] var sortBy: [ItemSortBy] = [ItemSortBy.sortName] var sortOrder: [ItemSortOrder] = [ItemSortOrder.ascending] var tags: [ItemTag] = [] var traits: [ItemTrait] = [] var years: [ItemYear] = [] + var query: String? + /// The default collection of filters static let `default`: ItemFilterCollection = .init() @@ -47,6 +53,11 @@ struct ItemFilterCollection: Hashable, Storable { } var hasQueryableFilters: Bool { - genres.isNotEmpty || itemTypes.isNotEmpty || letter.isNotEmpty || tags.isNotEmpty || traits.isNotEmpty || years.isNotEmpty + genres.isNotEmpty || + itemTypes.isNotEmpty || + letter.isNotEmpty || + tags.isNotEmpty || + traits.isNotEmpty || + years.isNotEmpty } } diff --git a/Shared/Objects/ItemFilter/ItemGenre.swift b/Shared/Objects/ItemFilter/ItemGenre.swift index 8f1e093cd4..e621da6a98 100644 --- a/Shared/Objects/ItemFilter/ItemGenre.swift +++ b/Shared/Objects/ItemFilter/ItemGenre.swift @@ -8,7 +8,7 @@ import Foundation -struct ItemGenre: Codable, ExpressibleByStringLiteral, Hashable, ItemFilter { +struct ItemGenre: Codable, Hashable, ItemFilter { let value: String @@ -16,7 +16,7 @@ struct ItemGenre: Codable, ExpressibleByStringLiteral, Hashable, ItemFilter { value } - init(stringLiteral value: String) { + init(value: String) { self.value = value } diff --git a/Shared/Objects/ItemFilter/ItemLetter.swift b/Shared/Objects/ItemFilter/ItemLetter.swift index 9ee03494a5..3885a1585f 100644 --- a/Shared/Objects/ItemFilter/ItemLetter.swift +++ b/Shared/Objects/ItemFilter/ItemLetter.swift @@ -9,7 +9,7 @@ import Foundation import UIKit -struct ItemLetter: CaseIterable, Codable, ExpressibleByStringLiteral, Hashable, ItemFilter { +struct ItemLetter: CaseIterable, Codable, Hashable, ItemFilter { let value: String @@ -17,7 +17,7 @@ struct ItemLetter: CaseIterable, Codable, ExpressibleByStringLiteral, Hashable, value } - init(stringLiteral value: String) { + init(value: String) { self.value = value } diff --git a/Shared/Objects/ItemFilter/ItemSortBy.swift b/Shared/Objects/ItemFilter/ItemSortBy.swift index 7cb101f79b..0aaa0319fa 100644 --- a/Shared/Objects/ItemFilter/ItemSortBy.swift +++ b/Shared/Objects/ItemFilter/ItemSortBy.swift @@ -129,13 +129,4 @@ extension ItemSortBy: Displayable, SupportedCaseIterable { } } -extension ItemSortBy: ItemFilter { - - var value: String { - rawValue - } - - init(from anyFilter: AnyItemFilter) { - self.init(rawValue: anyFilter.value)! - } -} +extension ItemSortBy: ItemFilter {} diff --git a/Shared/Objects/LibraryParent/TitledLibraryParent.swift b/Shared/Objects/ItemFilter/ItemStudio.swift similarity index 54% rename from Shared/Objects/LibraryParent/TitledLibraryParent.swift rename to Shared/Objects/ItemFilter/ItemStudio.swift index daa0e0b136..0584589ad4 100644 --- a/Shared/Objects/LibraryParent/TitledLibraryParent.swift +++ b/Shared/Objects/ItemFilter/ItemStudio.swift @@ -7,17 +7,19 @@ // import Foundation -import JellyfinAPI -/// A basic structure conforming to `LibraryParent` that is meant to only define its `displayTitle` -struct TitledLibraryParent: LibraryParent { +struct ItemStudio: Codable, Hashable, ItemFilter { let displayTitle: String - let id: String? - let libraryType: BaseItemKind? = nil + let value: String - init(displayTitle: String, id: String? = nil) { + init(displayTitle: String, value: String) { self.displayTitle = displayTitle - self.id = id + self.value = value + } + + init(from anyFilter: AnyItemFilter) { + self.displayTitle = anyFilter.displayTitle + self.value = anyFilter.value } } diff --git a/Shared/Objects/ItemFilter/ItemTag.swift b/Shared/Objects/ItemFilter/ItemTag.swift index 5891415442..327f046eb9 100644 --- a/Shared/Objects/ItemFilter/ItemTag.swift +++ b/Shared/Objects/ItemFilter/ItemTag.swift @@ -8,7 +8,7 @@ import Foundation -struct ItemTag: Codable, ExpressibleByStringLiteral, Hashable, ItemFilter { +struct ItemTag: Codable, Hashable, ItemFilter { let value: String @@ -16,7 +16,7 @@ struct ItemTag: Codable, ExpressibleByStringLiteral, Hashable, ItemFilter { value } - init(stringLiteral value: String) { + init(value: String) { self.value = value } diff --git a/Shared/Objects/ItemFilter/ItemYear.swift b/Shared/Objects/ItemFilter/ItemYear.swift index 18f92b1da4..6b28df7f0a 100644 --- a/Shared/Objects/ItemFilter/ItemYear.swift +++ b/Shared/Objects/ItemFilter/ItemYear.swift @@ -8,7 +8,7 @@ import Foundation -struct ItemYear: Codable, ExpressibleByIntegerLiteral, Hashable, ItemFilter { +struct ItemYear: Codable, Hashable, ItemFilter { let value: String @@ -20,7 +20,7 @@ struct ItemYear: Codable, ExpressibleByIntegerLiteral, Hashable, ItemFilter { Int(value)! } - init(integerLiteral value: IntegerLiteralType) { + init(value: IntegerLiteralType) { self.value = "\(value)" } diff --git a/Shared/Objects/ItemUserDataHandler.swift b/Shared/Objects/ItemUserDataHandler.swift new file mode 100644 index 0000000000..e840af5326 --- /dev/null +++ b/Shared/Objects/ItemUserDataHandler.swift @@ -0,0 +1,99 @@ +// +// 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 Combine +import Factory +import Foundation +import JellyfinAPI + +extension Container { + + var itemUserDataHandler: Factory { + self { ItemUserDataHandler() } + .singleton + } +} + +@MainActor +class ItemUserDataHandler: ViewModel { + + private var favoriteItemTasks: [String: Task] = [:] + private var playedItemTasks: [String: Task] = [:] + private var progressTasks: [String: Task] = [:] + + func setFavoriteStatus( + for item: BaseItemDto, + isFavorited: Bool + ) { + guard let itemID = item.id else { return } + + favoriteItemTasks[itemID]?.cancel() + + let task = Task { + do { + + let newUserData: UserItemDataDto = if isFavorited { + try await userSession.client.send( + Paths.markFavoriteItem(itemID: itemID) + ).value + } else { + try await userSession.client.send( + Paths.unmarkFavoriteItem(itemID: itemID) + ).value + } + + Notifications[.itemUserDataDidChange].post(newUserData) + } catch { + print("Failed to update favorite status for item \(itemID): \(error)") + } + + favoriteItemTasks[itemID] = nil + } + + favoriteItemTasks[itemID] = task + } + + func setPlaybackProgress( + for item: BaseItemDto, + progress: Duration + ) { + guard var newUserData = item.userData else { return } + newUserData.playbackPosition = progress + + Notifications[.itemUserDataDidChange].post(newUserData) + } + + func setPlayedStatus( + for item: BaseItemDto, + isPlayed: Bool + ) { + guard let itemID = item.id else { return } + + playedItemTasks[itemID]?.cancel() + + let task = Task { + do { + let newUserData: UserItemDataDto = if isPlayed { + try await userSession.client.send( + Paths.markPlayedItem(itemID: itemID) + ).value + } else { + try await userSession.client.send( + Paths.markUnplayedItem(itemID: itemID) + ).value + } + + Notifications[.itemUserDataDidChange].post(newUserData) + } catch { + print("Failed to update played status for item \(itemID): \(error)") + } + } + + playedItemTasks[itemID] = task + } +} diff --git a/Shared/Objects/ItemViewType.swift b/Shared/Objects/ItemViewType.swift index 308bc8e949..e02f713856 100644 --- a/Shared/Objects/ItemViewType.swift +++ b/Shared/Objects/ItemViewType.swift @@ -8,18 +8,15 @@ enum ItemViewType: String, CaseIterable, Displayable, Storable { - case compactPoster - case compactLogo - case cinematic + case enhanced + case simple var displayTitle: String { switch self { - case .compactPoster: - L10n.compactPoster - case .compactLogo: - L10n.compactLogo - case .cinematic: - L10n.cinematic + case .enhanced: + "Enhanced" + case .simple: + "Simple" } } } diff --git a/Shared/Objects/LabeledContentBuilder.swift b/Shared/Objects/LabeledContentBuilder.swift index 50be752ea0..43b6c47b59 100644 --- a/Shared/Objects/LabeledContentBuilder.swift +++ b/Shared/Objects/LabeledContentBuilder.swift @@ -11,15 +11,17 @@ import SwiftUI @resultBuilder struct LabeledContentBuilder { + @ViewBuilder static func buildBlock( _ content: repeat LabeledContent - ) -> AnyView { - .init(TupleView((repeat each content))) + ) -> some View { + TupleView((repeat each content)) } + @ViewBuilder static func buildBlock( _ content: ForEach?> - ) -> AnyView { - .init(content) + ) -> some View { + content } } diff --git a/Shared/ViewModels/ChannelLibraryViewModel.swift b/Shared/Objects/Libraries/ChannelProgramLibrary.swift similarity index 64% rename from Shared/ViewModels/ChannelLibraryViewModel.swift rename to Shared/Objects/Libraries/ChannelProgramLibrary.swift index 093df9bd02..029a23364f 100644 --- a/Shared/ViewModels/ChannelLibraryViewModel.swift +++ b/Shared/Objects/Libraries/ChannelProgramLibrary.swift @@ -10,24 +10,39 @@ import Factory import Foundation import JellyfinAPI -final class ChannelLibraryViewModel: PagingLibraryViewModel { +struct ChannelProgramLibrary: PagingLibrary { - override func get(page: Int) async throws -> [ChannelProgram] { + let parent: _TitledLibraryParent = .init( + displayTitle: L10n.channels, + libraryID: "channels" + ) + func retrievePage( + environment: Empty, + pageState: LibraryPageState + ) async throws -> [ChannelProgram] { var parameters = Paths.GetLiveTvChannelsParameters() + parameters.userID = pageState.userSession.user.id parameters.fields = .MinimumFields parameters.sortBy = [ItemSortBy.name] - parameters.limit = pageSize - parameters.startIndex = page * pageSize + parameters.limit = pageState.pageSize + parameters.startIndex = pageState.pageOffset let request = Paths.getLiveTvChannels(parameters: parameters) - let response = try await userSession.client.send(request) + let response = try await pageState.userSession.client.send(request) + + guard let channels = response.value.items, channels.isNotEmpty else { + return [] + } - return try await getPrograms(for: response.value.items ?? []) + return try await getPrograms( + for: channels, + userSession: pageState.userSession + ) } - private func getPrograms(for channels: [BaseItemDto]) async throws -> [ChannelProgram] { + private func getPrograms(for channels: [BaseItemDto], userSession: UserSession) async throws -> [ChannelProgram] { guard let minEndDate = Calendar.current.date(byAdding: .hour, value: -1, to: .now), let maxStartDate = Calendar.current.date(byAdding: .hour, value: 6, to: .now) else { return [] } diff --git a/Shared/Objects/Libraries/CountryLibrary.swift b/Shared/Objects/Libraries/CountryLibrary.swift new file mode 100644 index 0000000000..f327d57cc3 --- /dev/null +++ b/Shared/Objects/Libraries/CountryLibrary.swift @@ -0,0 +1,28 @@ +// +// 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 JellyfinAPI + +struct CountryLibrary: PagingLibrary { + + let hasNextPage: Bool = false + let parent: _TitledLibraryParent = .init( + displayTitle: "", + libraryID: "country" + ) + + func retrievePage( + environment: Empty, + pageState: LibraryPageState + ) async throws -> [CountryInfo] { + let request = Paths.getCountries + let response = try await pageState.userSession.client.send(request) + + return response.value + } +} diff --git a/Shared/Objects/Libraries/CultureLibrary.swift b/Shared/Objects/Libraries/CultureLibrary.swift new file mode 100644 index 0000000000..d39ff08f97 --- /dev/null +++ b/Shared/Objects/Libraries/CultureLibrary.swift @@ -0,0 +1,28 @@ +// +// 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 JellyfinAPI + +struct CultureLibrary: PagingLibrary { + + let hasNextPage: Bool = false + let parent: _TitledLibraryParent = .init( + displayTitle: "", + libraryID: "cultures" + ) + + func retrievePage( + environment: Empty, + pageState: LibraryPageState + ) async throws -> [CultureDto] { + let request = Paths.getCultures + let response = try await pageState.userSession.client.send(request) + + return response.value + } +} diff --git a/Shared/Objects/Libraries/EpisodeLibrary.swift b/Shared/Objects/Libraries/EpisodeLibrary.swift new file mode 100644 index 0000000000..dfc57713a4 --- /dev/null +++ b/Shared/Objects/Libraries/EpisodeLibrary.swift @@ -0,0 +1,45 @@ +// +// 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 Defaults +import JellyfinAPI + +// TODO: just be item library with season parent? + +struct EpisodeLibrary: PagingLibrary { + + let parent: BaseItemDto + + init(season: BaseItemDto) { + self.parent = season + } + + func retrievePage( + environment: Empty, + pageState: LibraryPageState + ) async throws -> [BaseItemDto] { + + var parameters = Paths.GetEpisodesParameters() + parameters.fields = [.overview] + parameters.enableUserData = true + parameters.isMissing = Defaults[.Customization.shouldShowMissingEpisodes] ? nil : false + + parameters.seasonID = parent.id + +// parameters.limit = pageState.pageSize +// parameters.startIndex = pageState.page * pageState.pageSize + + let request = Paths.getEpisodes( + seriesID: parent.id!, + parameters: parameters + ) + let response = try await pageState.userSession.client.send(request) + + return response.value.items ?? [] + } +} diff --git a/Shared/Objects/Libraries/GenresLibrary.swift b/Shared/Objects/Libraries/GenresLibrary.swift new file mode 100644 index 0000000000..805bd928d5 --- /dev/null +++ b/Shared/Objects/Libraries/GenresLibrary.swift @@ -0,0 +1,32 @@ +// +// 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 JellyfinAPI + +struct GenresLibrary: PagingLibrary { + + let parent: _TitledLibraryParent = .init( + displayTitle: L10n.genres, + libraryID: "genres" + ) + + func retrievePage( + environment: Empty, + pageState: LibraryPageState + ) async throws -> [BaseItemDto] { + var parameters = Paths.GetGenresParameters() + + parameters.limit = pageState.pageSize + parameters.startIndex = pageState.pageOffset + + let request = Paths.getGenres(parameters: parameters) + let response = try await pageState.userSession.client.send(request) + + return response.value.items ?? [] + } +} diff --git a/Shared/Objects/Libraries/ItemLibrary.swift b/Shared/Objects/Libraries/ItemLibrary.swift new file mode 100644 index 0000000000..c45d71f3e1 --- /dev/null +++ b/Shared/Objects/Libraries/ItemLibrary.swift @@ -0,0 +1,310 @@ +// +// 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 Defaults +import JellyfinAPI +import SwiftUI + +@MainActor +struct ItemLibrary: PagingLibrary, WithRandomElementLibrary { + + struct Environment: WithDefaultValue { + let grouping: Parent.Grouping? + let filters: ItemFilterCollection + let fields: [ItemFields]? + + static var `default`: Self { + .init( + grouping: nil, + filters: .default, + fields: nil + ) + } + } + + let environment: Environment? + let filterViewModel: FilterViewModel + let parent: BaseItemDto + + init( + parent: Parent, + filters: ItemFilterCollection? = nil, + fields: [ItemFields]? = nil + ) { + if parent.groupings?.defaultSelection != nil || filters != nil || fields != nil { + environment = .init( + grouping: parent.groupings?.defaultSelection, + filters: filters ?? .default, + fields: fields + ) + } else { + environment = nil + } + + self.filterViewModel = .init( + parent: parent, + currentFilters: environment?.filters ?? .default + ) + + self.parent = parent + } + + func retrievePage( + environment: Environment, + pageState: LibraryPageState + ) async throws -> [BaseItemDto] { + + var parameters = attachPage( + to: attachFilters( + to: makeBaseItemParameters(environment: environment), +// using: self.environment?.filters ?? environment.filters, + using: environment.filters, + pageState: pageState + ), + pageState: pageState + ) + parameters.userID = pageState.userSession.user.id + + let request = Paths.getItems(parameters: parameters) + let response = try await pageState.userSession.client.send( + request + ) + + // TODO: cleanup below + + // 1 - only keep collections that hold valid items + // 2 - if parent is type `folder`, then we are in a folder-view + // context so change `collectionFolder` types to `folder` + // for better view handling + return response.value.items ?? [] +// .filter { $0.collectionType?.isSupported == true } + // .filter { $0.collectionType?.isSupported ?? true } + // .map { item in + // if parent.libraryType == .folder, item.type == .collectionFolder { + // return item.mutating(\.type, with: .folder) + // } + // + // return item + // } + } + + private func makeBaseItemParameters( + environment: Environment + ) -> Paths.GetItemsParameters { + + var parameters = Paths.GetItemsParameters() + parameters.enableUserData = true + parameters.fields = environment.fields + + // Default values, expected to be overridden + // by parent or filters + parameters.includeItemTypes = BaseItemKind.supportedCases + parameters.sortOrder = [.ascending] + parameters.sortBy = [.name] + + /// Recursive should only apply to parents/folders and not to baseItems + parameters.isRecursive = parent._isRecursiveCollection(for: environment.grouping) + parameters.includeItemTypes = parent._supportedItemTypes(for: environment.grouping) + + if let parentID = parent.id, let parentType = parent.type { + switch parentType { + case .boxSet, .collectionFolder, .userView: + parameters.parentID = parentID + case .folder: + parameters.parentID = parentID + parameters.isRecursive = nil + case .person: + parameters.personIDs = [parentID] + case .studio: + parameters.studioIDs = [parentID] + default: () + } + } + + return parameters + } + + func attachFilters( + to parameters: Paths.GetItemsParameters, + using filters: ItemFilterCollection, + pageState: LibraryPageState + ) -> Paths.GetItemsParameters { + + var parameters = parameters + parameters.filters = filters.traits.nilIfEmpty + parameters.genres = filters.genres.map(\.value).nilIfEmpty + parameters.searchTerm = filters.query + parameters.sortBy = filters.sortBy.nilIfEmpty + parameters.sortOrder = filters.sortOrder.nilIfEmpty + parameters.tags = filters.tags.map(\.value).nilIfEmpty + parameters.years = filters.years.compactMap { Int($0.value) }.nilIfEmpty + + // Only set filtering on item types if selected + if filters.itemTypes.isNotEmpty { + parameters.includeItemTypes = filters.itemTypes.nilIfEmpty + } + + if filters.mediaTypes.isNotEmpty { + parameters.mediaTypes = filters.mediaTypes.nilIfEmpty + } + + if filters.letter.first?.value == "#" { + parameters.nameLessThan = "A" + } else if filters.letter.isNotEmpty { + parameters.nameStartsWith = filters.letter + .map(\.value) + .filter { $0 != "#" } + .first + } + + return parameters + } + + private func attachPage( + to parameters: Paths.GetItemsParameters, + pageState: LibraryPageState + ) -> Paths.GetItemsParameters { + var parameters = parameters + parameters.limit = pageState.pageSize + parameters.startIndex = pageState.pageOffset + return parameters + } + + func retrieveRandomElement( + environment: Environment, + pageState: LibraryPageState + ) async throws -> BaseItemDto? { + var parameters = attachFilters( + to: makeBaseItemParameters(environment: environment), + using: self.environment?.filters ?? environment.filters, + pageState: pageState + ) + + parameters.limit = 1 + parameters.sortBy = [.random] + parameters.userID = pageState.userSession.user.id + + let request = Paths.getItems(parameters: parameters) + let response = try? await pageState.userSession.client.send(request) + + return response?.value.items?.first + } + + @ViewBuilder + func makeLibraryBody( + viewModel: PagingLibraryViewModel, + @ViewBuilder content: @escaping () -> some View + ) -> AnyView { + ItemLibraryBody( + content: content(), + filterViewModel: filterViewModel, + viewModel: viewModel + ) + .eraseToAnyView() + } + + @MenuContentGroupBuilder + func menuContent(environment: Binding) -> [MenuContentGroup] { + if let groupings = parent.groupings, groupings.elements.isNotEmpty { + MenuContentGroup(id: "grouping") { + + let binding = Binding( + get: { environment.wrappedValue.grouping }, + set: { environment.wrappedValue = Environment( + grouping: $0, + filters: environment.wrappedValue.filters, + fields: environment.wrappedValue.fields + ) } + ) + + Picker(selection: binding) { + ForEach(groupings.elements) { grouping in + Text(grouping.displayTitle) + .tag(grouping as Parent.Grouping?) + } + } label: { + Text("Grouping") + + if let grouping = environment.wrappedValue.grouping { + Text(grouping.displayTitle) + } + } + .pickerStyle(.menu) + } + } + } +} + +extension ItemLibrary { + + struct ItemLibraryBody: View { + + @Default(.Customization.Library.enabledDrawerFilters) + private var enabledDrawerFilters + @Default(.Customization.Library.letterPickerEnabled) + private var isLetterPickerEnabled + @Default(.Customization.Library.letterPickerOrientation) + private var letterPickerOrientation + + @ObservedObject + private var viewModel: PagingLibraryViewModel + + private let content: Content + private let filterViewModel: FilterViewModel + + init( + content: Content, + filterViewModel: FilterViewModel, + viewModel: PagingLibraryViewModel + ) { + self.content = content + self.filterViewModel = filterViewModel + self.viewModel = viewModel + } + + var body: some View { + HStack( + reversed: letterPickerOrientation == .leading, + spacing: 0 + ) { + + content + .id(isLetterPickerEnabled) + + if isLetterPickerEnabled { + LetterPickerBar( + viewModel: filterViewModel + ) + .padding(letterPickerOrientation == .leading ? .leading : .trailing, 10) + } + } + .onFirstAppear { + filterViewModel.getQueryFilters() + } + .navigationBarFilterDrawer( + viewModel: filterViewModel, + types: enabledDrawerFilters + ) + .onReceive( + filterViewModel.$currentFilters + .dropFirst() + .removeDuplicates() + .debounce(for: 1, scheduler: RunLoop.main) + ) { newValue in + let newEnvironment: ItemLibrary.Environment = .init( + grouping: viewModel.environment.grouping, + filters: newValue, + fields: viewModel.environment.fields + ) + + viewModel.environment = newEnvironment + viewModel.refresh() + } + } + } +} diff --git a/Shared/Objects/Libraries/LatestInLibrary.swift b/Shared/Objects/Libraries/LatestInLibrary.swift new file mode 100644 index 0000000000..f397b4b596 --- /dev/null +++ b/Shared/Objects/Libraries/LatestInLibrary.swift @@ -0,0 +1,36 @@ +// +// 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 JellyfinAPI + +struct LatestInLibrary: PagingLibrary { + + let parent: _TitledLibraryParent + + init(library: BaseItemDto) { + self.parent = _TitledLibraryParent( + displayTitle: L10n.latestWithString(library.displayTitle), + libraryID: library.libraryID + ) + } + + func retrievePage( + environment: Empty, + pageState: LibraryPageState + ) async throws -> [BaseItemDto] { + var parameters = Paths.GetLatestMediaParameters() + parameters.userID = pageState.userSession.user.id + parameters.parentID = parent.libraryID + parameters.enableUserData = true + parameters.limit = pageState.pageSize + + let request = Paths.getLatestMedia(parameters: parameters) + let response = try await pageState.userSession.client.send(request) + return response.value + } +} diff --git a/Shared/Objects/Libraries/LocalTrailersLibrary.swift b/Shared/Objects/Libraries/LocalTrailersLibrary.swift new file mode 100644 index 0000000000..573a761b4d --- /dev/null +++ b/Shared/Objects/Libraries/LocalTrailersLibrary.swift @@ -0,0 +1,35 @@ +// +// 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 JellyfinAPI + +struct LocalTrailerLibrary: PagingLibrary { + + let parent: _TitledLibraryParent + let hasNextPage: Bool = false + + init(parentID: String) { + self.parent = .init( + displayTitle: "", + libraryID: parentID + ) + } + + func retrievePage( + environment: Empty, + pageState: LibraryPageState + ) async throws -> [BaseItemDto] { + let request = Paths.getLocalTrailers( + itemID: parent.libraryID, + userID: pageState.userSession.user.id + ) + let response = try await pageState.userSession.client.send(request) + + return response.value + } +} diff --git a/Shared/Objects/Libraries/MediaLibrary.swift b/Shared/Objects/Libraries/MediaLibrary.swift new file mode 100644 index 0000000000..f35487d6fa --- /dev/null +++ b/Shared/Objects/Libraries/MediaLibrary.swift @@ -0,0 +1,33 @@ +// +// 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 JellyfinAPI + +struct MediaLibrary: PagingLibrary { + + let hasNextPage: Bool = false + let parent: _TitledLibraryParent = .init( + displayTitle: L10n.media, + libraryID: "media-library" + ) + + func retrievePage( + environment: Empty, + pageState: LibraryPageState + ) async throws -> [BaseItemDto] { + let parameters = Paths.GetUserViewsParameters(userID: pageState.userSession.user.id) + let userViewsPath = Paths.getUserViews(parameters: parameters) + let userViews = try await pageState.userSession.client.send(userViewsPath) + let excludedLibraryIDs = pageState.userSession.user.data.configuration?.latestItemsExcludes ?? [] + + return (userViews.value.items ?? []) + .coalesced(property: \.collectionType, with: .folders) + .intersecting(CollectionType.supportedCases, using: \.collectionType) + .subtracting(excludedLibraryIDs, using: \.id) + } +} diff --git a/Shared/Objects/Libraries/NextUpLibrary.swift b/Shared/Objects/Libraries/NextUpLibrary.swift new file mode 100644 index 0000000000..d0c0d20ba7 --- /dev/null +++ b/Shared/Objects/Libraries/NextUpLibrary.swift @@ -0,0 +1,71 @@ +// +// 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 Foundation +import JellyfinAPI + +struct NextUpLibrary: PagingLibrary { + + struct Environment: WithDefaultValue { + let enableRewatching: Bool + let maxNextUp: TimeInterval + + static var `default`: Self { + .init( + enableRewatching: false, + maxNextUp: 0 + ) + } + } + + let parent: _TitledLibraryParent = .init( + displayTitle: L10n.nextUp, + libraryID: "next-up" + ) + + func retrievePage( + environment: Environment, + pageState: LibraryPageState + ) async throws -> [BaseItemDto] { + var parameters = Paths.GetNextUpParameters() + parameters.enableRewatching = environment.enableRewatching + parameters.enableResumable = false + parameters.enableUserData = true + + if environment.maxNextUp > 0 { + parameters.nextUpDateCutoff = Date.now.addingTimeInterval(-environment.maxNextUp) + } + + parameters.limit = pageState.pageSize + parameters.startIndex = pageState.pageOffset + + let request = Paths.getNextUp(parameters: parameters) + let response = try await pageState.userSession.client.send(request) + + return response.value.items ?? [] + } + + func onItemUserDataChanged( + viewModel: PagingLibraryViewModel, + userData: UserItemDataDto + ) { + guard let itemID = userData.itemID else { return } + + if viewModel.elements.ids.contains(itemID) { + viewModel.scheduleRefreshForItemUserData(minimumInterval: 3) + return + } + + let canAffectMembership = userData.playbackPosition.map { $0 > .zero } == true + || userData.isPlayed != nil + + guard canAffectMembership else { return } + + viewModel.scheduleRefreshForItemUserData(minimumInterval: 30) + } +} diff --git a/Shared/Objects/Libraries/ParentalRatingLibrary.swift b/Shared/Objects/Libraries/ParentalRatingLibrary.swift new file mode 100644 index 0000000000..1e373d1573 --- /dev/null +++ b/Shared/Objects/Libraries/ParentalRatingLibrary.swift @@ -0,0 +1,28 @@ +// +// 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 JellyfinAPI + +struct ParentalRatingLibrary: PagingLibrary { + + let hasNextPage: Bool = false + let parent: _TitledLibraryParent = .init( + displayTitle: "", + libraryID: "parental-ratings" + ) + + func retrievePage( + environment: Empty, + pageState: LibraryPageState + ) async throws -> [ParentalRating] { + let request = Paths.getParentalRatings + let response = try await pageState.userSession.client.send(request) + + return response.value + } +} diff --git a/Shared/Objects/Libraries/PeopleLibrary.swift b/Shared/Objects/Libraries/PeopleLibrary.swift new file mode 100644 index 0000000000..89757f3a8f --- /dev/null +++ b/Shared/Objects/Libraries/PeopleLibrary.swift @@ -0,0 +1,45 @@ +// +// 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 JellyfinAPI + +struct PeopleLibrary: PagingLibrary { + + struct Environment: WithDefaultValue { + var query: String? + + static var `default`: Self { + .init() + } + } + + let environment: Environment + let parent: _TitledLibraryParent = .init( + displayTitle: L10n.people, + libraryID: "people" + ) + + init(environment: Environment = .default) { + self.environment = environment + } + + func retrievePage( + environment: Environment, + pageState: LibraryPageState + ) async throws -> [BaseItemDto] { + + var parameters = Paths.GetPersonsParameters() + parameters.limit = pageState.pageSize + parameters.searchTerm = environment.query + + let request = Paths.getPersons(parameters: parameters) + let response = try await pageState.userSession.client.send(request) + + return response.value.items ?? [] + } +} diff --git a/Shared/Objects/Libraries/ProgramsLibrary.swift b/Shared/Objects/Libraries/ProgramsLibrary.swift new file mode 100644 index 0000000000..2ff82309ce --- /dev/null +++ b/Shared/Objects/Libraries/ProgramsLibrary.swift @@ -0,0 +1,45 @@ +// +// 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 JellyfinAPI + +struct ProgramsLibrary: PagingLibrary { + + let parent: _TitledLibraryParent + let section: ProgramSection + + init(section: ProgramSection) { + self.parent = _TitledLibraryParent( + displayTitle: section.displayTitle, + libraryID: "programs-\(section.rawValue)" + ) + self.section = section + } + + func retrievePage( + environment: Empty, + pageState: LibraryPageState + ) async throws -> [BaseItemDto] { + var parameters = Paths.GetLiveTvProgramsParameters() + parameters.fields = [.channelInfo] + parameters.hasAired = false + parameters.limit = pageState.pageSize + parameters.userID = pageState.userSession.user.id + + parameters.isKids = section == .kids + parameters.isMovie = section == .movies + parameters.isNews = section == .news + parameters.isSeries = section == .series + parameters.isSports = section == .sports + + let request = Paths.getLiveTvPrograms(parameters: parameters) + let response = try await pageState.userSession.client.send(request) + + return response.value.items ?? [] + } +} diff --git a/Shared/Objects/Libraries/RecommendedProgramsLibrary.swift b/Shared/Objects/Libraries/RecommendedProgramsLibrary.swift new file mode 100644 index 0000000000..adb85d204f --- /dev/null +++ b/Shared/Objects/Libraries/RecommendedProgramsLibrary.swift @@ -0,0 +1,33 @@ +// +// 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 JellyfinAPI + +struct RecommendedProgramsLibrary: PagingLibrary { + + let parent: _TitledLibraryParent = .init( + displayTitle: L10n.onNow, + libraryID: "programs-recommended" + ) + + func retrievePage( + environment: Empty, + pageState: LibraryPageState + ) async throws -> [BaseItemDto] { + var parameters = Paths.GetRecommendedProgramsParameters() + parameters.fields = [.channelInfo] + parameters.isAiring = true + parameters.limit = pageState.pageSize + parameters.userID = pageState.userSession.user.id + + let request = Paths.getRecommendedPrograms(parameters: parameters) + let response = try await pageState.userSession.client.send(request) + + return response.value.items ?? [] + } +} diff --git a/Shared/Objects/Libraries/RemoteImageLibrary.swift b/Shared/Objects/Libraries/RemoteImageLibrary.swift new file mode 100644 index 0000000000..d7618708da --- /dev/null +++ b/Shared/Objects/Libraries/RemoteImageLibrary.swift @@ -0,0 +1,51 @@ +// +// 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 JellyfinAPI + +struct RemoteImageLibrary: PagingLibrary { + + struct Environment: Equatable, WithDefaultValue { + var includeAllLanguages: Bool = false + var provider: String? + + static var `default`: Self { + .init() + } + } + + let imageType: ImageType + let parent: _TitledLibraryParent + + // TODO: wrong ids + init(imageType: ImageType, itemID: String) { + self.imageType = imageType + self.parent = .init( + displayTitle: imageType.displayTitle, + libraryID: itemID + ) + } + + func retrievePage( + environment: Environment, + pageState: LibraryPageState + ) async throws -> [RemoteImageInfo] { + var parameters = Paths.GetRemoteImagesParameters() + parameters.isIncludeAllLanguages = environment.includeAllLanguages + parameters.providerName = environment.provider + parameters.type = imageType + + parameters.limit = pageState.pageSize + parameters.startIndex = pageState.pageOffset + + let request = Paths.getRemoteImages(itemID: parent.libraryID, parameters: parameters) + let response = try await pageState.userSession.client.send(request) + + return response.value.images ?? [] + } +} diff --git a/Shared/Objects/Libraries/RemoteImageProvidersLibrary.swift b/Shared/Objects/Libraries/RemoteImageProvidersLibrary.swift new file mode 100644 index 0000000000..6b6be938c3 --- /dev/null +++ b/Shared/Objects/Libraries/RemoteImageProvidersLibrary.swift @@ -0,0 +1,32 @@ +// +// 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 JellyfinAPI + +struct RemoteImageProvidersLibrary: PagingLibrary { + + let hasNextPage: Bool = false + let parent: _TitledLibraryParent + + // TODO: wrong ids + init(itemID: String) { + self.parent = .init( + displayTitle: "Providers", + libraryID: itemID + ) + } + + func retrievePage( + environment: Empty, + pageState: LibraryPageState + ) async throws -> [ImageProviderInfo] { + let request = Paths.getRemoteImageProviders(itemID: parent.libraryID) + let response = try await pageState.userSession.client.send(request) + return response.value + } +} diff --git a/Shared/Objects/Libraries/ResumeItemsLibrary.swift b/Shared/Objects/Libraries/ResumeItemsLibrary.swift new file mode 100644 index 0000000000..bc3bdb8f75 --- /dev/null +++ b/Shared/Objects/Libraries/ResumeItemsLibrary.swift @@ -0,0 +1,53 @@ +// +// 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 JellyfinAPI + +struct ResumeItemsLibrary: PagingLibrary { + + let mediaTypes: [MediaType] + let parent: _TitledLibraryParent = .init( + displayTitle: "Continue Watching", + libraryID: "continue-watching" + ) + + func retrievePage( + environment: Empty, + pageState: LibraryPageState + ) async throws -> [BaseItemDto] { + var parameters = Paths.GetResumeItemsParameters() + parameters.userID = pageState.userSession.user.id + parameters.enableUserData = true + parameters.mediaTypes = mediaTypes + + parameters.limit = pageState.pageSize + parameters.startIndex = pageState.pageOffset + + let request = Paths.getResumeItems(parameters: parameters) + let response = try await pageState.userSession.client.send(request) + + return response.value.items ?? [] + } + + func onItemUserDataChanged( + viewModel: PagingLibraryViewModel, + userData: UserItemDataDto + ) { + guard let itemID = userData.itemID else { return } + + if userData.isPlayed == true { + viewModel.elements.remove(id: itemID) + return + } + + guard !viewModel.elements.ids.contains(itemID) else { return } + guard userData.playbackPosition.map({ $0 > .zero }) == true else { return } + + viewModel.scheduleRefreshForItemUserData(minimumInterval: 30) + } +} diff --git a/Shared/Objects/Libraries/SeasonLibrary.swift b/Shared/Objects/Libraries/SeasonLibrary.swift new file mode 100644 index 0000000000..c6bb6ae37c --- /dev/null +++ b/Shared/Objects/Libraries/SeasonLibrary.swift @@ -0,0 +1,66 @@ +// +// 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 Defaults +import JellyfinAPI + +// TODO: rename +typealias PagingSeasonViewModel = PagingLibraryViewModel + +@MainActor +struct SeasonViewModelLibrary: PagingLibrary { + + let parent: BaseItemDto + + init(series: BaseItemDto) { + self.parent = series + } + + func retrievePage( + environment: Empty, + pageState: LibraryPageState + ) async throws -> [PagingSeasonViewModel] { + try await SeasonLibrary(series: parent) + .retrievePage( + environment: environment, + pageState: pageState + ) + .map { PagingLibraryViewModel(library: EpisodeLibrary(season: $0)) } + } +} + +struct SeasonLibrary: PagingLibrary { + + let parent: BaseItemDto + + init(series: BaseItemDto) { + self.parent = series + } + + func retrievePage( + environment: Empty, + pageState: LibraryPageState + ) async throws -> [BaseItemDto] { + + guard let seriesID = parent.id else { + throw ErrorMessage(L10n.unknownError) + } + + var parameters = Paths.GetSeasonsParameters() + parameters.isMissing = Defaults[.Customization.shouldShowMissingSeasons] ? nil : false + parameters.userID = pageState.userSession.user.id + + let request = Paths.getSeasons( + seriesID: seriesID, + parameters: parameters + ) + let response = try await pageState.userSession.client.send(request) + + return response.value.items ?? [] + } +} diff --git a/Shared/Objects/Libraries/ServerActivityLibrary.swift b/Shared/Objects/Libraries/ServerActivityLibrary.swift new file mode 100644 index 0000000000..4a400f185e --- /dev/null +++ b/Shared/Objects/Libraries/ServerActivityLibrary.swift @@ -0,0 +1,45 @@ +// +// 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 Foundation +import JellyfinAPI + +struct ServerActivityLibrary: PagingLibrary { + + struct Environment: WithDefaultValue { + var hasUserID: Bool? + var minDate: Date? + + static var `default`: Self { + .init() + } + } + + let parent: _TitledLibraryParent = .init( + displayTitle: L10n.activity, + libraryID: "server-activity" + ) + + func retrievePage( + environment: Environment, + pageState: LibraryPageState + ) async throws -> [ActivityLogEntry] { + + var parameters = Paths.GetLogEntriesParameters() + parameters.hasUserID = environment.hasUserID + parameters.minDate = environment.minDate + + parameters.limit = pageState.pageSize + parameters.startIndex = pageState.pageOffset + + let request = Paths.getLogEntries(parameters: parameters) + let response = try await pageState.userSession.client.send(request) + + return response.value.items ?? [] + } +} diff --git a/Shared/Objects/Libraries/ServerUsersLibrary.swift b/Shared/Objects/Libraries/ServerUsersLibrary.swift new file mode 100644 index 0000000000..9a79e3e4af --- /dev/null +++ b/Shared/Objects/Libraries/ServerUsersLibrary.swift @@ -0,0 +1,36 @@ +// +// 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 JellyfinAPI + +struct ServerUsersLibrary: PagingLibrary { + + struct Environment: WithDefaultValue { + var isHidden: Bool? + var isDisabled: Bool? + + static var `default`: Self { + .init() + } + } + + let parent: _TitledLibraryParent = .init( + displayTitle: L10n.users, + libraryID: "server-users" + ) + + func retrievePage( + environment: Environment, + pageState: LibraryPageState + ) async throws -> [UserDto] { + let request = Paths.getUsers() + let response = try await pageState.userSession.client.send(request) + + return response.value + } +} diff --git a/Shared/Objects/Libraries/SimilarItemsLibrary.swift b/Shared/Objects/Libraries/SimilarItemsLibrary.swift new file mode 100644 index 0000000000..190e0f114c --- /dev/null +++ b/Shared/Objects/Libraries/SimilarItemsLibrary.swift @@ -0,0 +1,35 @@ +// +// 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 JellyfinAPI + +struct SimilarItemsLibrary: PagingLibrary { + + let itemID: String + let parent: _TitledLibraryParent = .init( + displayTitle: L10n.recommended, + libraryID: "similar-items" + ) + + func retrievePage( + environment: Empty, + pageState: LibraryPageState + ) async throws -> [BaseItemDto] { + + var parameters = Paths.GetSimilarItemsParameters() + parameters.limit = pageState.pageSize + + let request = Paths.getSimilarItems( + itemID: itemID, + parameters: parameters + ) + let response = try await pageState.userSession.client.send(request) + + return response.value.items ?? [] + } +} diff --git a/Shared/Objects/Libraries/SpecialFeaturesLibrary.swift b/Shared/Objects/Libraries/SpecialFeaturesLibrary.swift new file mode 100644 index 0000000000..4809e53e69 --- /dev/null +++ b/Shared/Objects/Libraries/SpecialFeaturesLibrary.swift @@ -0,0 +1,32 @@ +// +// 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 JellyfinAPI + +struct SpecialFeaturesLibrary: PagingLibrary { + + let itemID: String + let parent: _TitledLibraryParent = .init( + displayTitle: L10n.specialFeatures, + libraryID: "special-features" + ) + + func retrievePage( + environment: Empty, + pageState: LibraryPageState + ) async throws -> [BaseItemDto] { + + let request = Paths.getSpecialFeatures(itemID: itemID) + let response = try await pageState.userSession.client.send(request) + + return response.value + +// return (response?.value ?? []) +// .filter { $0.extraType?.isVideo ?? false } + } +} diff --git a/Shared/Objects/Libraries/StaticLibrary.swift b/Shared/Objects/Libraries/StaticLibrary.swift new file mode 100644 index 0000000000..eccdcb7f56 --- /dev/null +++ b/Shared/Objects/Libraries/StaticLibrary.swift @@ -0,0 +1,33 @@ +// +// 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 +// + +struct StaticLibrary: PagingLibrary { + + let elements: [Element] + let parent: _TitledLibraryParent + let hasNextPage: Bool = false + + init( + title: String, + id: String, + elements: [Element] + ) { + self.elements = elements + self.parent = .init( + displayTitle: title, + libraryID: id + ) + } + + func retrievePage( + environment: Empty, + pageState: LibraryPageState + ) async throws -> [Element] { + elements + } +} diff --git a/Shared/Objects/LibraryParent/LibraryParent.swift b/Shared/Objects/LibraryParent/LibraryParent.swift deleted file mode 100644 index fd9c8081d1..0000000000 --- a/Shared/Objects/LibraryParent/LibraryParent.swift +++ /dev/null @@ -1,62 +0,0 @@ -// -// 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 JellyfinAPI - -protocol LibraryParent: Displayable, Hashable, Identifiable { - - /// The type of the library, reusing `BaseItemKind` for some - /// ease of provided variety like `folder` and `userView`. - var libraryType: BaseItemKind? { get } - - /// The `BaseItemKind` types that this library parent - /// support. Mainly used for `.folder` support. - /// - /// When using filters, this is used to determine the initial - /// set of supported types and then - var supportedItemTypes: [BaseItemKind] { get } - - /// Modifies the parameters for the items request per this library parent. - func setParentParameters(_ parameters: Paths.GetItemsParameters) -> Paths.GetItemsParameters -} - -extension LibraryParent { - - var supportedItemTypes: [BaseItemKind] { - switch libraryType { - case .folder: - BaseItemKind.supportedCases - .appending([.folder, .collectionFolder]) - default: - BaseItemKind.supportedCases - } - } - - func setParentParameters(_ parameters: Paths.GetItemsParameters) -> Paths.GetItemsParameters { - - guard let id else { return parameters } - - var parameters = parameters - parameters.includeItemTypes = supportedItemTypes - - switch libraryType { - case .boxSet, .collectionFolder, .userView: - parameters.parentID = id - case .folder: - parameters.parentID = id - parameters.isRecursive = nil - case .person: - parameters.personIDs = [id] - case .studio: - parameters.studioIDs = [id] - default: () - } - - return parameters - } -} diff --git a/Shared/Objects/MediaPlayerManager/MediaPlayerItem/MediaPlayerItem+Build.swift b/Shared/Objects/MediaPlayerManager/MediaPlayerItem/MediaPlayerItem+Build.swift index 50b14e11ed..870d899278 100644 --- a/Shared/Objects/MediaPlayerManager/MediaPlayerItem/MediaPlayerItem+Build.swift +++ b/Shared/Objects/MediaPlayerManager/MediaPlayerItem/MediaPlayerItem+Build.swift @@ -89,7 +89,15 @@ extension MediaPlayerItem { let mediaSource: MediaSourceInfo? = { - guard let mediaSources = response.value.mediaSources else { return nil } + guard let mediaSources = response.value.mediaSources, mediaSources.isNotEmpty else { + logger.error( + "No media sources returned for item \(itemID)!", + metadata: ["mediaSourceID": .string( + initialMediaSource.id ?? "nil" + )] + ) + return nil + } if let matchingTag = mediaSources.first(where: { $0.eTag == initialMediaSource.eTag }) { return matchingTag diff --git a/Shared/Objects/MediaPlayerManager/MediaPlayerManager.swift b/Shared/Objects/MediaPlayerManager/MediaPlayerManager.swift index 87eb2ed5f0..6a79282582 100644 --- a/Shared/Objects/MediaPlayerManager/MediaPlayerManager.swift +++ b/Shared/Objects/MediaPlayerManager/MediaPlayerManager.swift @@ -8,6 +8,7 @@ import Combine import Defaults +import Engine import Factory import Foundation import JellyfinAPI @@ -15,7 +16,7 @@ import VLCUI // TODO: proper error catching -typealias MediaPlayerManagerPublisher = LegacyEventPublisher +typealias MediaPlayerManagerPublisher = PassthroughSubject extension Scope { static let session = Cached() @@ -43,8 +44,6 @@ extension Container { } } -import StatefulMacros - @MainActor @Stateful final class MediaPlayerManager: ViewModel { @@ -310,7 +309,7 @@ final class MediaPlayerManager: ViewModel { } @Function(\Action.Cases.stop) - private func _stop() async throws { + private func _stop() async { await self.cancel() // TODO: remove playback item? diff --git a/Shared/Objects/MediaPlayerManager/MediaProgressObserver.swift b/Shared/Objects/MediaPlayerManager/MediaProgressObserver.swift index da0dfa7f75..a82dbd993b 100644 --- a/Shared/Objects/MediaPlayerManager/MediaProgressObserver.swift +++ b/Shared/Objects/MediaPlayerManager/MediaProgressObserver.swift @@ -8,15 +8,18 @@ import Combine import Defaults +import Factory import Foundation import JellyfinAPI // TODO: respond properly to end of playback // - when item changes -// TODO: only send stop on manager stop, not per-item class MediaProgressObserver: ViewModel, MediaPlayerObserver { + @Injected(\.itemUserDataHandler) + private var itemUserDataHandler: ItemUserDataHandler + weak var manager: MediaPlayerManager? { didSet { if let manager { @@ -147,10 +150,17 @@ class MediaProgressObserver: ViewModel, MediaPlayerObserver { private func sendProgressReport(for item: MediaPlayerItem, seconds: Duration?, isPaused: Bool = false) { + guard let seconds else { return } + #if DEBUG guard Defaults[.sendProgressReports] else { return } #endif + itemUserDataHandler.setPlaybackProgress( + for: item.baseItem, + progress: seconds + ) + Task { var info = PlaybackProgressInfo() info.audioStreamIndex = item.selectedAudioStreamIndex @@ -158,7 +168,7 @@ class MediaProgressObserver: ViewModel, MediaPlayerObserver { info.itemID = item.baseItem.id info.mediaSourceID = item.mediaSource.id info.playSessionID = item.playSessionID - info.positionTicks = seconds?.ticks + info.positionTicks = seconds.ticks info.sessionID = item.playSessionID info.subtitleStreamIndex = item.selectedSubtitleStreamIndex diff --git a/Shared/Objects/MediaPlayerManager/Supplements/EpisodeMediaPlayerQueue.swift b/Shared/Objects/MediaPlayerManager/Supplements/EpisodeMediaPlayerQueue.swift index 28c342f331..6bab8676cb 100644 --- a/Shared/Objects/MediaPlayerManager/Supplements/EpisodeMediaPlayerQueue.swift +++ b/Shared/Objects/MediaPlayerManager/Supplements/EpisodeMediaPlayerQueue.swift @@ -53,13 +53,13 @@ class EpisodeMediaPlayerQueue: ViewModel, MediaPlayerQueue { lazy var previousItemPublisher: Published.Publisher = $previousItem private var currentAdjacentEpisodesTask: AnyCancellable? - private let seriesViewModel: SeriesItemViewModel + private let seriesViewModel: PagingLibraryViewModel init(episode: BaseItemDto) { - self.seriesViewModel = SeriesItemViewModel(episode: episode) + self.seriesViewModel = .init(library: .init(series: .init(id: episode.seriesID))) super.init() - seriesViewModel.send(.refresh) + seriesViewModel.refresh() } var videoPlayerBody: some PlatformView { @@ -86,7 +86,6 @@ class EpisodeMediaPlayerQueue: ViewModel, MediaPlayerQueue { let parameters = Paths.GetEpisodesParameters( userID: userSession.user.id, - fields: .MinimumFields, adjacentTo: item.id!, limit: 3 ) @@ -159,25 +158,20 @@ extension EpisodeMediaPlayerQueue { private var manager: MediaPlayerManager @ObservedObject - var viewModel: SeriesItemViewModel + var viewModel: PagingLibraryViewModel @State - private var selection: SeasonItemViewModel.ID? + private var selection: PagingSeasonViewModel.ID? - private var selectionViewModel: SeasonItemViewModel? { + private var selectionViewModel: PagingSeasonViewModel? { guard let selection else { return nil } - return viewModel.seasons[id: selection] + return viewModel.elements[id: selection] } private func select(episode: BaseItemDto) { - let provider = MediaPlayerItemProvider(item: episode) { item in - let mediaSource = item.mediaSources?.first - - return try await MediaPlayerItem.build( - for: item, - mediaSource: mediaSource! - ) - } + let provider = episode.getPlaybackItemProvider( + userSession: manager.userSession + ) manager.playNewItem(provider: provider) } @@ -202,13 +196,13 @@ extension EpisodeMediaPlayerQueue { } .environmentObject(viewModel) .onAppear { - if let seasonID = manager.item.seasonID, let season = viewModel.seasons[id: seasonID] { + if let seasonID = manager.item.seasonID, let season = viewModel.elements[id: seasonID] { if season.elements.isEmpty { - season.send(.refresh) + season.refresh() } selection = season.id } else { - selection = viewModel.seasons.first?.id + selection = viewModel.elements.first?.id } } } @@ -217,18 +211,18 @@ extension EpisodeMediaPlayerQueue { private struct CompactSeasonStackObserver: View { @EnvironmentObject - private var seriesViewModel: SeriesItemViewModel + private var seriesViewModel: PagingLibraryViewModel - private let selection: Binding + private let selection: Binding private let action: (BaseItemDto) -> Void - private var selectionViewModel: SeasonItemViewModel? { + private var selectionViewModel: PagingSeasonViewModel? { guard let id = selection.wrappedValue else { return nil } - return seriesViewModel.seasons[id: id] + return seriesViewModel.elements[id: id] } init( - selection: Binding, + selection: Binding, action: @escaping (BaseItemDto) -> Void ) { self.selection = selection @@ -238,7 +232,7 @@ extension EpisodeMediaPlayerQueue { private struct _Body: View { @ObservedObject - var selectionViewModel: SeasonItemViewModel + var selectionViewModel: PagingSeasonViewModel let action: (BaseItemDto) -> Void @@ -274,18 +268,18 @@ extension EpisodeMediaPlayerQueue { private var safeAreaInsets: EdgeInsets @EnvironmentObject - private var seriesViewModel: SeriesItemViewModel + private var seriesViewModel: PagingLibraryViewModel - private let selection: Binding + private let selection: Binding private let action: (BaseItemDto) -> Void - private var selectionViewModel: SeasonItemViewModel? { + private var selectionViewModel: PagingSeasonViewModel? { guard let id = selection.wrappedValue else { return nil } - return seriesViewModel.seasons[id: id] + return seriesViewModel.elements[id: id] } init( - selection: Binding, + selection: Binding, action: @escaping (BaseItemDto) -> Void ) { self.selection = selection @@ -298,14 +292,13 @@ extension EpisodeMediaPlayerQueue { private var safeAreaInsets: EdgeInsets @ObservedObject - var selectionViewModel: SeasonItemViewModel + var selectionViewModel: PagingSeasonViewModel let action: (BaseItemDto) -> Void var body: some View { CollectionHStack( - uniqueElements: selectionViewModel.elements, - id: \.unwrappedIDHashOrZero + uniqueElements: selectionViewModel.elements ) { item in EpisodeButton(episode: item) { action(item) @@ -334,14 +327,14 @@ extension EpisodeMediaPlayerQueue { @EnvironmentObject private var manager: MediaPlayerManager @EnvironmentObject - private var seriesViewModel: SeriesItemViewModel + private var seriesViewModel: PagingLibraryViewModel - let selection: Binding - let selectionViewModel: SeasonItemViewModel + let selection: Binding + let selectionViewModel: PagingSeasonViewModel init( - selection: Binding, - selectionViewModel: SeasonItemViewModel + selection: Binding, + selectionViewModel: PagingSeasonViewModel ) { self.selection = selection self.selectionViewModel = selectionViewModel @@ -349,32 +342,32 @@ extension EpisodeMediaPlayerQueue { var body: some View { VStack { - Menu { - ForEach(seriesViewModel.seasons, id: \.season.id) { season in - Button { - selection.wrappedValue = season.id - if season.elements.isEmpty { - season.send(.refresh) - } - } label: { - if season.id == selection.wrappedValue { - Label(season.season.displayTitle, systemImage: "checkmark") - } else { - Text(season.season.displayTitle) - } - } - } - } label: { - ZStack { - RoundedRectangle(cornerRadius: 7) - .foregroundStyle(.white) - - Label(selectionViewModel.season.displayTitle, systemImage: "chevron.down") - .fontWeight(.semibold) - .foregroundStyle(.black) - } - } - .frame(maxHeight: .infinity) +// Menu { +// ForEach(seriesViewModel.seasons, id: \.season.id) { season in +// Button { +// selection.wrappedValue = season.id +// if season.elements.isEmpty { +// season.send(.refresh) +// } +// } label: { +// if season.id == selection.wrappedValue { +// Label(season.season.displayTitle, systemImage: "checkmark") +// } else { +// Text(season.season.displayTitle) +// } +// } +// } +// } label: { +// ZStack { +// RoundedRectangle(cornerRadius: 7) +// .foregroundStyle(.white) +// +// Label(selectionViewModel.season.displayTitle, systemImage: "chevron.down") +// .fontWeight(.semibold) +// .foregroundStyle(.black) +// } +// } +// .frame(maxHeight: .infinity) Button { guard let nextItem = manager.queue?.nextItem else { return } @@ -461,8 +454,8 @@ extension EpisodeMediaPlayerQueue { Text(seasonEpisodeLabel) } - if let runtime = episode.runTimeLabel { - Text(runtime) + if let runtime = episode.runtime { + Text(runtime, format: .hourMinuteAbbreviated) } } .font(.caption) @@ -486,7 +479,7 @@ extension EpisodeMediaPlayerQueue { } var body: some View { - ListRow(insets: .init(horizontal: EdgeInsets.edgePadding)) { + ListRow(insets: .init(horizontal: EdgeInsets.edgePadding), action: action) { EpisodePreview(episode: episode) .frame(width: 110) .padding(.vertical, 8) @@ -503,7 +496,6 @@ extension EpisodeMediaPlayerQueue { } .frame(maxWidth: .infinity, alignment: .leading) } - .onSelect(perform: action) .isSelected(isCurrentEpisode) } } diff --git a/Shared/Objects/MediaPlayerManager/Supplements/MediaChaptersSupplement.swift b/Shared/Objects/MediaPlayerManager/Supplements/MediaChaptersSupplement.swift index 8cd0ca56ef..ce71ac8623 100644 --- a/Shared/Objects/MediaPlayerManager/Supplements/MediaChaptersSupplement.swift +++ b/Shared/Objects/MediaPlayerManager/Supplements/MediaChaptersSupplement.swift @@ -220,7 +220,10 @@ extension MediaChaptersSupplement { } var body: some View { - ListRow(insets: .init(horizontal: EdgeInsets.edgePadding)) { + ListRow( + insets: .init(horizontal: EdgeInsets.edgePadding), + action: action + ) { ChapterPreview( chapter: chapter ) @@ -229,7 +232,6 @@ extension MediaChaptersSupplement { } content: { ChapterContent(chapter: chapter) } - .onSelect(perform: action) .assign(manager.secondsBox.$value, to: $activeSeconds) .isSelected(isCurrentChapter) } diff --git a/Shared/Objects/MediaPlayerManager/Supplements/MediaInfoSupplement.swift b/Shared/Objects/MediaPlayerManager/Supplements/MediaInfoSupplement.swift index 52937e2e30..1fa314a1a0 100644 --- a/Shared/Objects/MediaPlayerManager/Supplements/MediaInfoSupplement.swift +++ b/Shared/Objects/MediaPlayerManager/Supplements/MediaInfoSupplement.swift @@ -60,17 +60,22 @@ extension MediaInfoSupplement { @ViewBuilder private var fromBeginningButton: some View { - Button(L10n.fromBeginning, systemImage: "play.fill") { + Button { manager.proxy?.setSeconds(.zero) manager.setPlaybackRequestStatus(status: .playing) containerState.select(supplement: nil) + } label: { + ZStack { + RoundedRectangle(cornerRadius: 10) + .fill(.white) + .frame(maxWidth: .infinity, maxHeight: .infinity) + + Label(L10n.fromBeginning, systemImage: "play.fill") + .font(.subheadline) + .fontWeight(.semibold) + .foregroundStyle(.black) + } } - #if os(iOS) - .buttonStyle(.material) - #endif - .frame(width: 200, height: 50) - .font(.subheadline) - .fontWeight(.semibold) } // TODO: may need to be a layout for correct overview frame @@ -111,22 +116,8 @@ extension MediaInfoSupplement { .allowsHitTesting(false) if !item.isLiveStream { - Button { - manager.proxy?.setSeconds(.zero) - manager.setPlaybackRequestStatus(status: .playing) - containerState.select(supplement: nil) - } label: { - ZStack { - RoundedRectangle(cornerRadius: 7) - .foregroundStyle(.white) - - Label(L10n.fromBeginning, systemImage: "play.fill") - .fontWeight(.semibold) - .foregroundStyle(.black) - } - } - .frame(maxWidth: .infinity) - .frame(height: 40) + fromBeginningButton + .frame(height: 44) } } .frame(maxWidth: .infinity, alignment: .topLeading) @@ -134,15 +125,16 @@ extension MediaInfoSupplement { @ViewBuilder private var iOSRegularView: some View { - HStack(alignment: .bottom, spacing: EdgeInsets.edgePadding) { - // TODO: determine what to do with non-portrait (channel, home video) images - // - use aspect ratio? + HStack(spacing: EdgeInsets.edgePadding) { PosterImage( item: item, - type: item.preferredPosterDisplayType, + type: .portrait, contentMode: .fit ) - .environment(\.isOverComplexContent, true) + .withViewContext(.isOverComplexContent) +// .frame( +// maxWidth: item.preferredPosterDisplayType == .portrait ? nil : 170 +// ) VStack(alignment: .leading, spacing: 5) { Text(item.displayTitle) @@ -162,14 +154,24 @@ extension MediaInfoSupplement { .font(.caption) .foregroundStyle(.secondary) } - .frame(maxWidth: .infinity, alignment: .leading) + .frame( + maxWidth: .infinity, + maxHeight: .infinity, + alignment: .bottomLeading + ) if !item.isLiveStream { VStack { fromBeginningButton + .frame(width: 200, height: 50) } + .frame( + maxHeight: .infinity, + alignment: .bottomLeading + ) } } + .frame(maxHeight: .infinity, alignment: .bottom) } var tvOSView: some View { diff --git a/Shared/Objects/MediaPlayerManager/Supplements/MediaPeopleSupplement.swift b/Shared/Objects/MediaPlayerManager/Supplements/MediaPeopleSupplement.swift index 6f62092002..1aa95ea2cb 100644 --- a/Shared/Objects/MediaPlayerManager/Supplements/MediaPeopleSupplement.swift +++ b/Shared/Objects/MediaPlayerManager/Supplements/MediaPeopleSupplement.swift @@ -79,14 +79,21 @@ extension MediaPeopleSupplement { type: .portrait ) { _ in } label: { - PosterButton.TitleSubtitleContentView(item: person) + TitleSubtitleContentView( + title: person.displayTitle, + subtitle: person.firstRole ?? "" + ) } #else PosterButton( item: person, type: .portrait - ) {} label: { - PosterButton.TitleSubtitleContentView(item: person) + ) { _ in + } label: { + TitleSubtitleContentView( + title: person.displayTitle, + subtitle: person.firstRole ?? "" + ) } #endif } @@ -95,7 +102,7 @@ extension MediaPeopleSupplement { private var iOSRegularView: some View { CollectionHStack( uniqueElements: people, - id: \.unwrappedIDHashOrZero, + id: \.hashValue, layout: .minimumWidth(columnWidth: 80, rows: 1) ) { person in personView(for: person) @@ -109,7 +116,7 @@ extension MediaPeopleSupplement { var tvOSView: some View { CollectionHStack( uniqueElements: people, - id: \.unwrappedIDHashOrZero, + id: \.hashValue, columns: 7 ) { person in personView(for: person) @@ -149,7 +156,7 @@ extension MediaPeopleSupplement { let person: BaseItemPerson var body: some View { - ListRow(insets: .init(horizontal: EdgeInsets.edgePadding)) { + ListRow(insets: .init(horizontal: EdgeInsets.edgePadding), action: {}) { PosterImage( item: person, type: .portrait, diff --git a/Shared/Objects/MenuContentGroup.swift b/Shared/Objects/MenuContentGroup.swift new file mode 100644 index 0000000000..0c8a817023 --- /dev/null +++ b/Shared/Objects/MenuContentGroup.swift @@ -0,0 +1,41 @@ +// +// 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 SwiftUI + +typealias MenuContentGroupBuilder = ArrayBuilder + +struct MenuContentGroup: Identifiable, Equatable { + + let id: String + let content: AnyView + let isPrimary: Bool + + init( + id: String = UUID().uuidString, + isPrimary: Bool = false, + @ViewBuilder content: () -> some View + ) { + self.id = id + self.isPrimary = isPrimary + self.content = AnyView(content()) + } + + static func == (lhs: MenuContentGroup, rhs: MenuContentGroup) -> Bool { + lhs.id == rhs.id + } +} + +struct MenuContentKey: PreferenceKey { + + static var defaultValue: [MenuContentGroup] = [] + + static func reduce(value: inout [MenuContentGroup], nextValue: () -> [MenuContentGroup]) { + value.append(contentsOf: nextValue()) + } +} diff --git a/Shared/Objects/NotificationSet.swift b/Shared/Objects/NotificationSet.swift deleted file mode 100644 index cc74d2dda0..0000000000 --- a/Shared/Objects/NotificationSet.swift +++ /dev/null @@ -1,25 +0,0 @@ -// -// 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 -// - -/// A container for `Notifications.Key`. -struct NotificationSet { - - private var names: Set = [] - - func contains(_ key: Notifications.Key) -> Bool { - names.contains(key.name.rawValue) - } - - mutating func insert(_ key: Notifications.Key) { - names.insert(key.name.rawValue) - } - - mutating func remove(_ key: Notifications.Key) { - names.remove(key.name.rawValue) - } -} diff --git a/Shared/Objects/ObservedPublisher.swift b/Shared/Objects/ObservedPublisher.swift index 0968a1369d..c38890e05e 100644 --- a/Shared/Objects/ObservedPublisher.swift +++ b/Shared/Objects/ObservedPublisher.swift @@ -31,7 +31,7 @@ final class ObservedPublisher: ObservableObject { self.wrappedValue = wrappedValue publisher - .receive(on: DispatchQueue.main) + .receive(on: RunLoop.main) .sink { [weak self] newValue in self?.wrappedValue = newValue } diff --git a/Shared/Objects/OverlayType.swift b/Shared/Objects/OverlayType.swift deleted file mode 100644 index a4b0f6f067..0000000000 --- a/Shared/Objects/OverlayType.swift +++ /dev/null @@ -1,37 +0,0 @@ -// -// 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 -// - -enum OverlayType: String, CaseIterable, Storable { - - case normal - case compact - - var label: String { - switch self { - case .normal: - L10n.normal - case .compact: - L10n.compact - } - } -} - -enum PlaybackButtonType: String, CaseIterable, Displayable, Storable { - - case large - case compact - - var displayTitle: String { - switch self { - case .large: - L10n.large - case .compact: - L10n.compact - } - } -} diff --git a/Shared/Objects/PagingLibrary/LibraryParent.swift b/Shared/Objects/PagingLibrary/LibraryParent.swift new file mode 100644 index 0000000000..9bfbe2c44b --- /dev/null +++ b/Shared/Objects/PagingLibrary/LibraryParent.swift @@ -0,0 +1,27 @@ +// +// 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 +// + +protocol _LibraryParent: Displayable { + + associatedtype Grouping: LibraryGrouping = Empty + + var groupings: (defaultSelection: Grouping, elements: [Grouping])? { get } + var libraryID: String { get } +} + +extension _LibraryParent where Grouping == Empty { + var groupings: (defaultSelection: Grouping, elements: [Grouping])? { + nil + } +} + +struct _TitledLibraryParent: _LibraryParent { + + let displayTitle: String + let libraryID: String +} diff --git a/Shared/Objects/PagingLibrary/PagingLibrary.swift b/Shared/Objects/PagingLibrary/PagingLibrary.swift new file mode 100644 index 0000000000..3d5414bf23 --- /dev/null +++ b/Shared/Objects/PagingLibrary/PagingLibrary.swift @@ -0,0 +1,86 @@ +// +// 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 JellyfinAPI +import SwiftUI + +struct LibraryPageState { + let pageOffset: Int + let pageSize: Int + let userSession: UserSession +} + +@MainActor +protocol PagingLibrary { + + associatedtype Element: Identifiable + associatedtype Environment: WithDefaultValue = Empty + associatedtype Parent: _LibraryParent = _TitledLibraryParent + + /// The initial environment configuration for the library. + /// This can be used to define a static environment, disallowing + /// mutating changes to a library's environment. + var environment: Environment? { get } + var parent: Parent { get } + + var hasNextPage: Bool { get } + + func retrievePage( + environment: Environment, + pageState: LibraryPageState + ) async throws -> [Element] + + @ViewBuilder + func makeLibraryBody( + viewModel: PagingLibraryViewModel, + @ViewBuilder content: @escaping () -> some View + ) -> AnyView + + @MenuContentGroupBuilder + func menuContent(environment: Binding) -> [MenuContentGroup] + + func onItemUserDataChanged( + viewModel: PagingLibraryViewModel, + userData: UserItemDataDto + ) +} + +extension PagingLibrary { + + var environment: Environment? { + nil + } + + var hasNextPage: Bool { + true + } + + func makeLibraryBody( + viewModel: PagingLibraryViewModel, + @ViewBuilder content: @escaping () -> some View + ) -> AnyView { + content() + .eraseToAnyView() + } + + @MenuContentGroupBuilder + func menuContent(environment: Binding) -> [MenuContentGroup] {} + + func onItemUserDataChanged( + viewModel: PagingLibraryViewModel, + userData: UserItemDataDto + ) {} +} + +protocol WithRandomElementLibrary: PagingLibrary { + + func retrieveRandomElement( + environment: Environment, + pageState: LibraryPageState + ) async throws -> Element? +} diff --git a/Shared/Objects/PagingLibrary/PagingLibraryView/LibraryStyleSection.swift b/Shared/Objects/PagingLibrary/PagingLibraryView/LibraryStyleSection.swift new file mode 100644 index 0000000000..412dd18f41 --- /dev/null +++ b/Shared/Objects/PagingLibrary/PagingLibraryView/LibraryStyleSection.swift @@ -0,0 +1,66 @@ +// +// 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 SwiftUI + +extension PagingLibraryView { + + struct LibraryStyleSection: View { + + @StateObject + private var box: PublishedBox + + private var libraryStyle: Binding { + $box.value + } + + init(libraryStyle: Binding) { + self._box = StateObject(wrappedValue: PublishedBox(source: libraryStyle)) + } + + var body: some View { + Picker(selection: libraryStyle.displayType) { + ForEach(LibraryDisplayType.allCases, id: \.self) { displayType in + Label( + displayType.displayTitle, + systemImage: displayType.systemImage + ) + .tag(displayType) + } + } label: { + Text(L10n.layout) + + Text(libraryStyle.wrappedValue.displayType.displayTitle) + + Image(systemName: libraryStyle.wrappedValue.displayType.systemImage) + } + .pickerStyle(.menu) + + if libraryStyle.wrappedValue.displayType == .list, UIDevice.isPad { + // TODO: tvOS +// Stepper( +// L10n.columnsWithCount(libraryStyle.wrappedValue.listColumnCount), +// value: libraryStyle.listColumnCount, +// in: 1 ... 3 +// ) + } + + Picker(selection: libraryStyle.posterDisplayType) { + ForEach(PosterDisplayType.allCases, id: \.self) { displayType in + Text(displayType.displayTitle) + .tag(displayType) + } + } label: { + Text(L10n.posters) + + Text(libraryStyle.wrappedValue.posterDisplayType.displayTitle) + } + .pickerStyle(.menu) + } + } +} diff --git a/Shared/Objects/PagingLibrary/PagingLibraryView/PagingLibraryView+ElementsView.swift b/Shared/Objects/PagingLibrary/PagingLibraryView/PagingLibraryView+ElementsView.swift new file mode 100644 index 0000000000..717871254b --- /dev/null +++ b/Shared/Objects/PagingLibrary/PagingLibraryView/PagingLibraryView+ElementsView.swift @@ -0,0 +1,98 @@ +// +// 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 + +extension PagingLibraryView { + + struct ElementsView: View { + + @ForTypeInEnvironment (LibraryStyle, Binding?)>(\.libraryStyleRegistry) + private var libraryStyleRegistry + + @Namespace + private var namespace + + @ObservedObject + private var viewModel: PagingLibraryViewModel + + @Router + private var router + + private var libraryStyleBinding: Binding? + + init( + viewModel: PagingLibraryViewModel + ) { + self._libraryStyleRegistry = ForTypeInEnvironment(\.libraryStyleRegistry) + self.viewModel = viewModel + + if let libraryStyleBinding { + self.libraryStyleBinding = libraryStyleBinding + } + } + + private var resolvedStyle: (LibraryStyle, Binding?) { + libraryStyleRegistry?(Element.self) ?? (.default, nil) + } + + private var layout: CollectionVGridLayout { + Element.layout(for: libraryStyle) + } + + private var libraryStyle: LibraryStyle { + resolvedStyle.0 + } + + var body: some View { + CollectionVGrid( + uniqueElements: viewModel.elements, + layout: layout + ) { element, _ in + switch libraryStyle.displayType { + case .grid: + element.makeGridBody(libraryStyle: libraryStyle) + .withViewContext(.isThumb) + case .list: + element.makeListBody(libraryStyle: libraryStyle) + .withViewContext(.isThumb) + } + } + .onReachedBottomEdge(offset: .offset(300)) { + viewModel.retrieveNextPage() + } + .scrollIndicators(.hidden) + .onReceive(viewModel.events) { event in + switch event { + case let .retrievedRandomElement(element): + element.libraryDidSelectElement(router: router, in: namespace) + } + } + .preference(key: MenuContentKey.self) { + if let libraryStyleBinding = resolvedStyle.1 { + MenuContentGroup( + id: "library-style" + ) { + LibraryStyleSection(libraryStyle: libraryStyleBinding) + } + } + + viewModel.library.menuContent(environment: $viewModel.environment) + + MenuContentGroup( + id: "retrieve-random-element" + ) { + Button(L10n.random, systemImage: "dice.fill") { + viewModel.retrieveRandomElement() + } + } + } + } + } +} diff --git a/Shared/Objects/PagingLibrary/PagingLibraryView/PagingLibraryView.swift b/Shared/Objects/PagingLibrary/PagingLibraryView/PagingLibraryView.swift new file mode 100644 index 0000000000..069ec754b5 --- /dev/null +++ b/Shared/Objects/PagingLibrary/PagingLibraryView/PagingLibraryView.swift @@ -0,0 +1,94 @@ +// +// 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 Defaults +import JellyfinAPI +import SwiftUI + +struct PagingLibraryView: View where Library.Element: LibraryElement { + + typealias Element = Library.Element + + @Default(.Customization.Library.rememberLayout) + private var rememberIndividualLibraryStyle + + @StoredValue(.User.libraryStyle(id: nil)) + private var defaultLibraryStyle: LibraryStyle + @StoredValue + private var parentLibraryStyle: LibraryStyle + + @ForTypeInEnvironment (LibraryStyle, Binding?)>(\.libraryStyleRegistry) + private var libraryStyleRegistry + + @Namespace + private var namespace + + @Router + private var router + + @StateObject + private var viewModel: PagingLibraryViewModel + + private var libraryStyle: LibraryStyle { + libraryStyleRegistry?(Element.self).0 ?? .default + } + + init(library: Library) { + self._parentLibraryStyle = StoredValue(.User.libraryStyle(id: library.parent.libraryID)) + self._viewModel = StateObject(wrappedValue: PagingLibraryViewModel(library: library)) + } + + @ViewBuilder + private var contentView: some View { + switch viewModel.state { + case .initial, .refreshing: + ProgressView() + case .content: + if viewModel.elements.isEmpty { + Text(L10n.noResults) + } else { + ElementsView(viewModel: viewModel) + .ignoresSafeArea() + .libraryStyle(for: Element.self) { environment, _ in + if rememberIndividualLibraryStyle { + (parentLibraryStyle, $parentLibraryStyle) + } else { + environment + } + } + } + case .error: + viewModel.error.map(ErrorView.init) + } + } + + var body: some View { + viewModel.library.makeLibraryBody(viewModel: viewModel) { + contentView + .frame(maxWidth: .infinity) + } + .animation(.linear(duration: 0.2), value: viewModel.state) + .navigationTitle(viewModel.library.parent.displayTitle) + .backport + .toolbarTitleDisplayMode(.inline) + .backport + .onChange(of: viewModel.environment) { _, _ in + viewModel.refresh() + } + .onFirstAppear { + if viewModel.state == .initial { + viewModel.refresh() + } + } + #if os(iOS) + .navigationBarMenuButton( + isLoading: viewModel.background.is(.retrievingNextPage) + ) {} + #endif + } +} diff --git a/Shared/Objects/PagingLibrary/PagingLibraryViewModel.swift b/Shared/Objects/PagingLibrary/PagingLibraryViewModel.swift new file mode 100644 index 0000000000..361174c2eb --- /dev/null +++ b/Shared/Objects/PagingLibrary/PagingLibraryViewModel.swift @@ -0,0 +1,243 @@ +// +// 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 Combine +import Factory +import Foundation +import IdentifiedCollections +import JellyfinAPI +import SwiftUI + +private let DefaultPageSize = 50 + +@MainActor +protocol __PagingLibaryViewModel<_PagingLibrary>: AnyObject, Identifiable, +WithRefresh where Environment == _PagingLibrary.Environment { + + associatedtype _PagingLibrary: PagingLibrary + associatedtype Environment + + var id: String { get } + var elements: IdentifiedArrayOf<_PagingLibrary.Element> { get set } + var environment: Environment { get set } + var library: _PagingLibrary { get } +} + +@MainActor +@Stateful(conformances: [WithRefresh.self]) +class PagingLibraryViewModel<_PagingLibrary: PagingLibrary>: ViewModel, @MainActor __PagingLibaryViewModel { + + typealias Background = _BackgroundActions + typealias Element = _PagingLibrary.Element + typealias Environment = _PagingLibrary.Environment + + @CasePathable + enum Action { + case refresh + case retrieveNextPage + case retrieveRandomElement + + case _actuallyRetrieveNextPage + + var transition: Transition { + switch self { + case .refresh: + .to(.refreshing, then: .content) + .whenBackground(.refreshing) + case .retrieveNextPage: + .none + case ._actuallyRetrieveNextPage: + .background(.retrievingNextPage) + case .retrieveRandomElement: + .background(.retrievingRandomElement) + } + } + } + + enum BackgroundState { + case refreshing + case retrievingNextPage + case retrievingRandomElement + } + + enum Event { + case retrievedRandomElement(Element) + } + + enum State { + case content + case error + case initial + case refreshing + } + + @Published + var elements: IdentifiedArrayOf + @Published + var environment: Environment + + private var hasNextPage = true + private var itemUserDataRefreshTask: Task? + private var lastItemUserDataRefresh = Date.distantPast + + let library: _PagingLibrary + let pageSize: Int + + var id: String { + library.parent.libraryID + } + + init( + library: _PagingLibrary, + pageSize: Int = DefaultPageSize + ) { + self.environment = library.environment ?? .default + self.library = library + self.elements = IdentifiedArray( + [], + uniquingIDsWith: { x, _ in x } + ) + self.hasNextPage = library.hasNextPage + self.pageSize = pageSize + + super.init() + + Notifications[.itemUserDataDidChange] + .publisher + .sink { [weak self] userData in + self?.updateItemUserData(userData) + + guard let self else { return } + + library.onItemUserDataChanged( + viewModel: self, + userData: userData + ) + } + .store(in: &cancellables) + } + + // TODO: somehow make item checks generic? + private func updateItemUserData(_ userData: UserItemDataDto) { + guard let itemID = userData.itemID else { return } + + guard let index = (elements as? IdentifiedArrayOf)?.index(id: itemID) else { return } + guard var item = elements[index] as? BaseItemDto else { return } + item.userData = userData + elements[index] = item as! Element + } + + private func notifyUserDataChanges(in elements: [Element]) { + for element in elements { + guard let item = element as? BaseItemDto, + let itemID = item.id, + let userData = item.userData + else { continue } + + let shouldNotify = Container.shared.userItemCache() + .touch(key: itemID, value: userData) + + if shouldNotify { + Notifications[.itemUserDataDidChange].post(userData) + } + } + } + + func scheduleRefreshForItemUserData( + debounce: TimeInterval = 0.35, + minimumInterval: TimeInterval = 5 + ) { + guard Date.now.timeIntervalSince(lastItemUserDataRefresh) >= minimumInterval else { + return + } + + itemUserDataRefreshTask?.cancel() + + itemUserDataRefreshTask = Task { @MainActor [weak self] in + guard let self else { return } + + if debounce > 0 { + try? await Task.sleep(for: .seconds(1)) + } + + guard !Task.isCancelled else { return } + + await self.background.refresh() + self.lastItemUserDataRefresh = Date.now + self.itemUserDataRefreshTask = nil + } + } + + @Function(\Action.Cases.refresh) + private func _refresh() async throws { + hasNextPage = true + elements.removeAll() + try await __actuallyRetrieveNextPage() + } + + @Function(\Action.Cases.retrieveNextPage) + private func _retrieveNextPage() async throws { + guard hasNextPage else { return } + await self._actuallyRetrieveNextPage() + } + + @Function(\Action.Cases._actuallyRetrieveNextPage) + private func __actuallyRetrieveNextPage() async throws { + guard hasNextPage else { return } + + let pageState = LibraryPageState( + pageOffset: elements.count, + pageSize: pageSize, + userSession: userSession + ) + + let nextPageElements = try await library.retrievePage( + environment: environment, + pageState: pageState + ) + + guard !Task.isCancelled else { return } + + notifyUserDataChanges(in: nextPageElements) + + hasNextPage = !(nextPageElements.count < pageSize) + + elements.append(contentsOf: nextPageElements) + } + + @Function(\Action.Cases.retrieveRandomElement) + private func _retrieveRandomElement() async throws { + + let randomElement: Element? + + if let withRandomElementLibrary = library as? any WithRandomElementLibrary { + let pageState = LibraryPageState( + pageOffset: 0, + pageSize: 0, + userSession: userSession + ) + + func inner( + _ _library: some WithRandomElementLibrary + ) async throws -> Element? { + try await _library.retrieveRandomElement( + environment: environment, + pageState: pageState + ) + } + + randomElement = try await inner(withRandomElementLibrary) + } else { + randomElement = elements.randomElement() + } + + guard !Task.isCancelled, let randomElement else { return } + + events.send(.retrievedRandomElement(randomElement)) + } +} diff --git a/Shared/Objects/Poster/AnyPoster.swift b/Shared/Objects/Poster/AnyPoster.swift index 66f9efd427..cf72646d32 100644 --- a/Shared/Objects/Poster/AnyPoster.swift +++ b/Shared/Objects/Poster/AnyPoster.swift @@ -25,14 +25,6 @@ struct AnyPoster: Poster { _poster.displayTitle } - var unwrappedIDHashOrZero: Int { - _poster.unwrappedIDHashOrZero - } - - var subtitle: String? { - _poster.subtitle - } - var systemImage: String { _poster.systemImage } @@ -41,35 +33,50 @@ struct AnyPoster: Poster { AnyHashable(_poster).hashValue } + var posterLabel: some View { + _poster.posterLabel + .eraseToAnyView() + } + func hash(into hasher: inout Hasher) { - hasher.combine(_poster.unwrappedIDHashOrZero) hasher.combine(_poster.displayTitle) - hasher.combine(_poster.subtitle) hasher.combine(_poster.systemImage) } - var showTitle: Bool { - _poster.showTitle - } - - func portraitImageSources(maxWidth: CGFloat?, quality: Int?) -> [ImageSource] { - _poster.portraitImageSources(maxWidth: maxWidth, quality: quality) - } - - func landscapeImageSources(maxWidth: CGFloat?, quality: Int?) -> [ImageSource] { - _poster.landscapeImageSources(maxWidth: maxWidth, quality: quality) - } - - func cinematicImageSources(maxWidth: CGFloat?, quality: Int?) -> [ImageSource] { - _poster.cinematicImageSources(maxWidth: maxWidth, quality: quality) + func landscapeImageSources( + maxWidth: CGFloat?, + quality: Int?, + environment: Empty + ) -> [ImageSource] { + func inner(_ poster: some Poster) -> [ImageSource] { + poster.landscapeImageSources( + maxWidth: maxWidth, + quality: quality, + environment: .default + ) + } + + return inner(_poster) } - func squareImageSources(maxWidth: CGFloat?, quality: Int?) -> [ImageSource] { - _poster.squareImageSources(maxWidth: maxWidth, quality: quality) + func portraitImageSources( + maxWidth: CGFloat?, + quality: Int?, + environment: Empty + ) -> [ImageSource] { + func inner(_ poster: some Poster) -> [ImageSource] { + poster.portraitImageSources( + maxWidth: maxWidth, + quality: quality, + environment: .default + ) + } + + return inner(_poster) } - func transform(image: Image) -> some View { - _poster.transform(image: image) + func transform(image: Image, displayType: PosterDisplayType) -> some View { + _poster.transform(image: image, displayType: displayType) .eraseToAnyView() } diff --git a/Shared/Objects/Poster/Poster.swift b/Shared/Objects/Poster/Poster.swift index af9c5d67f3..0ef2fe256f 100644 --- a/Shared/Objects/Poster/Poster.swift +++ b/Shared/Objects/Poster/Poster.swift @@ -12,47 +12,59 @@ import SwiftUI // TODO: create environment for image sources // - for when to have episode use series // - pass in folder context -// - thumb -// - could remove cinematic, just use landscape + +typealias ImageSourceBuilder = ArrayBuilder /// A type that is displayed as a poster -protocol Poster: Displayable, Hashable, LibraryIdentifiable, SystemImageable { +protocol Poster: Displayable, Hashable, Identifiable, SystemImageable { - associatedtype ImageBody: View + associatedtype Environment: WithDefaultValue = Empty + associatedtype ImageBody: View = Image + associatedtype LabelBody: View = EmptyView + associatedtype OverlayBody: View = EmptyView var preferredPosterDisplayType: PosterDisplayType { get } - - /// Optional subtitle when used as a poster var subtitle: String? { get } - /// Show the title - var showTitle: Bool { get } - - func portraitImageSources( - maxWidth: CGFloat?, - quality: Int? - ) -> [ImageSource] - + @ImageSourceBuilder func landscapeImageSources( maxWidth: CGFloat?, - quality: Int? + quality: Int?, + environment: Environment ) -> [ImageSource] - func cinematicImageSources( + @ImageSourceBuilder + func portraitImageSources( maxWidth: CGFloat?, - quality: Int? + quality: Int?, + environment: Environment ) -> [ImageSource] + @ImageSourceBuilder func squareImageSources( maxWidth: CGFloat?, - quality: Int? + quality: Int?, + environment: Environment + ) -> [ImageSource] + + @ImageSourceBuilder + func imageSources( + for displayType: PosterDisplayType, + size: PosterDisplayType.Size, + environment: Environment ) -> [ImageSource] - func thumbImageSources() -> [ImageSource] + @MainActor + @ViewBuilder + func transform(image: Image, displayType: PosterDisplayType) -> ImageBody @MainActor @ViewBuilder - func transform(image: Image) -> ImageBody + var posterLabel: LabelBody { get } + + @MainActor + @ViewBuilder + func posterOverlay(for displayType: PosterDisplayType) -> OverlayBody } extension Poster where ImageBody == Image { @@ -69,40 +81,98 @@ extension Poster { nil } - var showTitle: Bool { - true - } - - func portraitImageSources( + func landscapeImageSources( maxWidth: CGFloat? = nil, - quality: Int? = nil + quality: Int? = nil, + environment: Environment ) -> [ImageSource] { [] } - func landscapeImageSources( + func portraitImageSources( maxWidth: CGFloat? = nil, - quality: Int? = nil + quality: Int? = nil, + environment: Environment ) -> [ImageSource] { [] } - func cinematicImageSources( + func squareImageSources( maxWidth: CGFloat?, - quality: Int? = nil + quality: Int? = nil, + environment: Environment ) -> [ImageSource] { [] } - func squareImageSources( - maxWidth: CGFloat?, - quality: Int? = nil + func imageSources( + for displayType: PosterDisplayType, + size: PosterDisplayType.Size, + environment: Environment ) -> [ImageSource] { - [] + let maxWidth = size.width(for: displayType) + let quality = size.quality + + return switch displayType { + case .landscape: + landscapeImageSources( + maxWidth: maxWidth, + quality: quality, + environment: environment + ) + case .portrait: + portraitImageSources( + maxWidth: maxWidth, + quality: quality, + environment: environment + ) + case .square: + squareImageSources( + maxWidth: maxWidth, + quality: quality, + environment: environment + ) + } } +} - // TODO: change to observe preferred poster display type - func thumbImageSources() -> [ImageSource] { - [] +extension Poster where ImageBody == Image { + + @MainActor + @ViewBuilder + func transform(image: Image, displayType: PosterDisplayType) -> ImageBody { + image + } +} + +extension Poster where LabelBody == EmptyView { + + @MainActor + @ViewBuilder + var posterLabel: LabelBody { + EmptyView() + } +} + +extension Poster where OverlayBody == EmptyView { + + @MainActor + @ViewBuilder + func posterOverlay(for displayType: PosterDisplayType) -> OverlayBody { + EmptyView() + } +} + +extension Poster where Environment == Empty { + + func imageSources( + for displayType: PosterDisplayType, + size: PosterDisplayType.Size + ) -> [ImageSource] { + imageSources( + for: displayType, + size: size, + environment: .default + ) } } diff --git a/Shared/Objects/PosterDisplayConfiguration.swift b/Shared/Objects/PosterDisplayConfiguration.swift new file mode 100644 index 0000000000..d323a80a08 --- /dev/null +++ b/Shared/Objects/PosterDisplayConfiguration.swift @@ -0,0 +1,20 @@ +// +// 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 Foundation + +struct PosterDisplayConfiguration: Equatable, WithDefaultValue, Storable { + + var displayType: PosterDisplayType + var size: PosterDisplayType.Size + + static let `default`: PosterDisplayConfiguration = .init( + displayType: .portrait, + size: .small + ) +} diff --git a/Shared/Objects/PosterDisplayType.swift b/Shared/Objects/PosterDisplayType.swift index 69c8278f4b..b0a0d206e4 100644 --- a/Shared/Objects/PosterDisplayType.swift +++ b/Shared/Objects/PosterDisplayType.swift @@ -6,7 +6,45 @@ // Copyright (c) 2026 Jellyfin & Jellyfin Contributors // -enum PosterDisplayType: String, CaseIterable, Displayable, Storable, SystemImageable { +import Foundation + +#if os(iOS) +private let landscapeMaxWidth: CGFloat = 300 +private let portraitMaxWidth: CGFloat = 200 +#else +private let landscapeMaxWidth: CGFloat = 500 +private let portraitMaxWidth: CGFloat = 500 +#endif + +enum PosterDisplayType: String, CaseIterable, Displayable, Storable { + + enum Size: CaseIterable, Displayable, Storable { + + case small + case medium + + var displayTitle: String { + switch self { + case .small: + "Small" + case .medium: + "Medium" + } + } + + var quality: Int? { + 90 + } + + func width(for displayType: PosterDisplayType) -> CGFloat? { + switch displayType { + case .landscape: + landscapeMaxWidth + case .portrait, .square: + portraitMaxWidth + } + } + } case landscape case portrait @@ -22,23 +60,4 @@ enum PosterDisplayType: String, CaseIterable, Displayable, Storable, SystemImage L10n.square } } - - var systemImage: String { - switch self { - case .landscape: - "rectangle.fill" - case .portrait: - "rectangle.portrait.fill" - case .square: - "square.fill" - } - } -} - -// TODO: remove after library views support all types -extension PosterDisplayType: SupportedCaseIterable { - - static var supportedCases: [PosterDisplayType] { - [.landscape, .portrait] - } } diff --git a/Shared/Objects/PosterIndicator.swift b/Shared/Objects/PosterIndicator.swift new file mode 100644 index 0000000000..83a0edaee6 --- /dev/null +++ b/Shared/Objects/PosterIndicator.swift @@ -0,0 +1,17 @@ +// +// 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 +// + +struct PosterIndicator: OptionSet, Storable { + + let rawValue: Int + + static let favorited = PosterIndicator(rawValue: 1 << 0) + static let played = PosterIndicator(rawValue: 1 << 1) + static let progress = PosterIndicator(rawValue: 1 << 2) + static let unplayed = PosterIndicator(rawValue: 1 << 3) +} diff --git a/Shared/Extensions/PublishedBox.swift b/Shared/Objects/PreferenceKey/IsStatusBarHiddenKey.swift similarity index 56% rename from Shared/Extensions/PublishedBox.swift rename to Shared/Objects/PreferenceKey/IsStatusBarHiddenKey.swift index 2a1e318dd3..fbbba9b106 100644 --- a/Shared/Extensions/PublishedBox.swift +++ b/Shared/Objects/PreferenceKey/IsStatusBarHiddenKey.swift @@ -6,15 +6,12 @@ // Copyright (c) 2026 Jellyfin & Jellyfin Contributors // -import Combine +import SwiftUI -/// A box for a `Published` value -class PublishedBox: ObservableObject { +struct IsStatusBarHiddenKey: PreferenceKey { + static var defaultValue: Bool = false - @Published - var value: Value - - init(initialValue: Value) { - self.value = initialValue + static func reduce(value: inout Bool, nextValue: () -> Bool) { + value = nextValue() || value } } diff --git a/Shared/Objects/PreviewImageScrubbingOption.swift b/Shared/Objects/PreviewImageScrubbingOption.swift index 35c8d8bf18..5411689b3f 100644 --- a/Shared/Objects/PreviewImageScrubbingOption.swift +++ b/Shared/Objects/PreviewImageScrubbingOption.swift @@ -6,10 +6,9 @@ // Copyright (c) 2026 Jellyfin & Jellyfin Contributors // -// TODO: chapters fallback enum PreviewImageScrubbingOption: CaseIterable, Displayable, Hashable, Storable { - case trickplay(fallbackToChapters: Bool = true) + case trickplay(fallbackToChapters: Bool) case chapters case disabled @@ -24,8 +23,6 @@ enum PreviewImageScrubbingOption: CaseIterable, Displayable, Hashable, Storable } } - // TODO: enhance full screen determination - // - allow checking against image size? var supportsFullscreen: Bool { switch self { case .trickplay: true @@ -34,6 +31,6 @@ enum PreviewImageScrubbingOption: CaseIterable, Displayable, Hashable, Storable } static var allCases: [PreviewImageScrubbingOption] { - [.trickplay(), .chapters, .disabled] + [.trickplay(fallbackToChapters: false), .chapters, .disabled] } } diff --git a/Shared/Objects/ProgramSection.swift b/Shared/Objects/ProgramSection.swift new file mode 100644 index 0000000000..586e65cc8c --- /dev/null +++ b/Shared/Objects/ProgramSection.swift @@ -0,0 +1,31 @@ +// +// 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 +// + +enum ProgramSection: String, CaseIterable, Displayable { + + case kids + case movies + case news + case series + case sports + + var displayTitle: String { + switch self { + case .kids: + L10n.kids + case .movies: + L10n.movies + case .news: + L10n.news + case .series: + L10n.series + case .sports: + L10n.sports + } + } +} diff --git a/Shared/Objects/PublishedBox.swift b/Shared/Objects/PublishedBox.swift new file mode 100644 index 0000000000..373c1e0574 --- /dev/null +++ b/Shared/Objects/PublishedBox.swift @@ -0,0 +1,51 @@ +// +// 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 Combine +import SwiftUI + +/// Utility class to act as an intermediary for a `Binding` value or +/// the source of a single value where `State` is not appropriate. +/// +/// Useful when: +/// - a view is passed a `Binding` that may not be able +/// to respond to view updates from the source +/// - the source of information that would typically be in a `State` +/// variable, or other publishing source, cause view update issues +class PublishedBox: ObservableObject { + + @Published + var value: Wrapped + + private var source: Binding? + private var valueObserver: AnyCancellable! + + init(source: Binding) { + self.source = source + self.value = source.wrappedValue + valueObserver = nil + + valueObserver = $value + .assign(to: source) + } + + init(initialValue: Wrapped) { + source = nil + value = initialValue + valueObserver = nil + } +} + +extension Publisher where Failure == Never { + + func assign(to binding: Binding) -> AnyCancellable { + self.sink { value in + binding.wrappedValue = value + } + } +} diff --git a/Shared/Objects/RepeatingTimer.swift b/Shared/Objects/RepeatingTimer.swift deleted file mode 100644 index d692cc89d0..0000000000 --- a/Shared/Objects/RepeatingTimer.swift +++ /dev/null @@ -1,41 +0,0 @@ -// -// 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 Foundation - -class RepeatingTimer { - - let action: () -> Void - private let interval: TimeInterval - private var timer: Timer? - - init(interval: TimeInterval, _ action: @escaping () -> Void) { - self.action = action - self.interval = interval - } - - @objc - private func runAction() { - action() - } - - func start() { - self.timer = Timer.scheduledTimer( - timeInterval: interval, - target: self, - selector: #selector(runAction), - userInfo: nil, - repeats: true - ) - } - - func stop() { - self.timer?.invalidate() - self.timer = nil - } -} diff --git a/Shared/Objects/Stateful.swift b/Shared/Objects/Stateful.swift index 710d8f52a9..f14255ff1d 100644 --- a/Shared/Objects/Stateful.swift +++ b/Shared/Objects/Stateful.swift @@ -6,8 +6,7 @@ // Copyright (c) 2026 Jellyfin & Jellyfin Contributors // -// TODO: remove, apply the Stateful macro - +@available(*, deprecated, message: "Apply the `Stateful` macro instead") protocol Stateful: AnyObject { associatedtype Action: Equatable diff --git a/Shared/Objects/TypeKeyedDictionary.swift b/Shared/Objects/TypeKeyedDictionary.swift new file mode 100644 index 0000000000..189a66c775 --- /dev/null +++ b/Shared/Objects/TypeKeyedDictionary.swift @@ -0,0 +1,62 @@ +// +// 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 +// + +struct TypeKeyedDictionary { + + private var elements: [(key: Any.Type, value: Value)] + + init() { + self.elements = [] + } + + subscript(type: (some Any).Type) -> Value? { + get { + elements.first(where: { $0.key == type })?.value + } + set { + if let index = elements.firstIndex(where: { $0.key == type }) { + if let newValue { + elements[index].value = newValue + } else { + elements.remove(at: index) + } + } else if let newValue { + elements.append((key: type, value: newValue)) + } + } + } + + func inserting(type: (some Any).Type, value: Value?) -> Self { + if let value { + var copy = self + copy[type] = value + return copy + } else { + var copy = self + copy[type] = nil + return copy + } + } +} + +extension TypeKeyedDictionary: Equatable where Value: Equatable { + + static func == (lhs: TypeKeyedDictionary, rhs: TypeKeyedDictionary) -> Bool { + guard lhs.elements.count == rhs.elements.count else { return false } + + for (key, value) in lhs.elements { + guard let matchingRHS = rhs.elements.first(where: { $0.key == key }) else { return false } + + if matchingRHS.value != value { + return false + } + } + + return true + } +} diff --git a/Shared/Objects/TypeValueRegistry.swift b/Shared/Objects/TypeValueRegistry.swift deleted file mode 100644 index 7791152ca6..0000000000 --- a/Shared/Objects/TypeValueRegistry.swift +++ /dev/null @@ -1,28 +0,0 @@ -// -// 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 -// - -// TODO: remove - -struct TypeValueRegistry { - - private var registry: [(key: Any.Type, value: Value)] = [] - - func getvalue(for otherType: (some Any).Type) -> Value? { - registry.first(where: { $0.key == otherType })?.value - } - - func insertOrReplace(_ value: Value, for type: Any.Type) -> Self { - var newRegistry = self - if let existing = newRegistry.registry.firstIndex(where: { $0.key == type }) { - newRegistry.registry[existing].value = value - } else { - newRegistry.registry.append((key: type, value: value)) - } - return newRegistry - } -} diff --git a/Shared/Objects/ViewContext.swift b/Shared/Objects/ViewContext.swift new file mode 100644 index 0000000000..d66774d4e0 --- /dev/null +++ b/Shared/Objects/ViewContext.swift @@ -0,0 +1,63 @@ +// +// 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 SwiftUI + +protocol WithViewContext { + var viewContext: ViewContext { get set } +} + +struct ViewContext: OptionSet { + + let rawValue: Int + + static let isInMenu = ViewContext(rawValue: 1 << 0) + static let isThumb = ViewContext(rawValue: 1 << 1) + static let isOverComplexContent = ViewContext(rawValue: 1 << 2) + static let isInParent = ViewContext(rawValue: 1 << 3) + static let isInResume = ViewContext(rawValue: 1 << 4) + static let withConstrainedSize = ViewContext(rawValue: 1 << 5) +} + +extension EnvironmentValues { + + @Entry + var viewContext: ViewContext = .init() +} + +@propertyWrapper +struct ViewContextContains: DynamicProperty { + + @Environment(\.viewContext) + private var oldValue: ViewContext + + private let viewContext: ViewContext + + init(_ viewContext: ViewContext) { + self.viewContext = viewContext + } + + var wrappedValue: Bool { + oldValue.contains(viewContext) + } +} + +extension View { + + func withViewContext(_ context: ViewContext) -> some View { + WithEnvironment(\.viewContext) { oldValue in + self.environment(\.viewContext, oldValue.inserting(context)) + } + } + + func removingViewContext(_ context: ViewContext) -> some View { + WithEnvironment(\.viewContext) { oldValue in + self.environment(\.viewContext, oldValue.removing(context)) + } + } +} diff --git a/Swiftfin/Objects/DeepLink.swift b/Shared/Objects/WithDefaultValue.swift similarity index 75% rename from Swiftfin/Objects/DeepLink.swift rename to Shared/Objects/WithDefaultValue.swift index 7d844fdd4f..a613a8759f 100644 --- a/Swiftfin/Objects/DeepLink.swift +++ b/Shared/Objects/WithDefaultValue.swift @@ -6,9 +6,6 @@ // Copyright (c) 2026 Jellyfin & Jellyfin Contributors // -import Foundation -import JellyfinAPI - -enum DeepLink { - case item(BaseItemDto) +protocol WithDefaultValue: Equatable { + static var `default`: Self { get } } diff --git a/Shared/Objects/WithRefresh.swift b/Shared/Objects/WithRefresh.swift new file mode 100644 index 0000000000..1538b6d389 --- /dev/null +++ b/Shared/Objects/WithRefresh.swift @@ -0,0 +1,26 @@ +// +// 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 +// + +@MainActor +protocol WithRefresh { + + associatedtype Background: WithRefresh = Empty + + func refresh() + func refresh() async + + var background: Background { get set } +} + +extension WithRefresh where Background == Empty { + + var background: Empty { + get { .init() } + set {} + } +} diff --git a/Shared/Services/DownloadManager.swift b/Shared/Services/DownloadManager.swift deleted file mode 100644 index 0f0be90e18..0000000000 --- a/Shared/Services/DownloadManager.swift +++ /dev/null @@ -1,98 +0,0 @@ -// -// 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 Factory -import Files -import Foundation -import JellyfinAPI -import Logging - -extension Container { - var downloadManager: Factory { - self { DownloadManager() }.shared - } -} - -class DownloadManager: ObservableObject { - - private let logger = Logger.swiftfin() - - @Published - private(set) var downloads: [DownloadTask] = [] - - func clearTmp() { - do { - try Folder(path: URL.temporaryDirectory.path).files.delete() - - logger.trace("Cleared tmp directory") - } catch { - logger.error("Unable to clear tmp directory: \(error.localizedDescription)") - } - } - - func download(task: DownloadTask) { - guard !downloads.contains(where: { $0.item == task.item }) else { return } - - downloads.append(task) - - task.download() - } - - func task(for item: BaseItemDto) -> DownloadTask? { - if let currentlyDownloading = downloads.first(where: { $0.item == item }) { - return currentlyDownloading - } else { - var isDir: ObjCBool = true - guard let downloadFolder = item.downloadFolder else { return nil } - guard FileManager.default.fileExists(atPath: downloadFolder.path, isDirectory: &isDir) else { return nil } - - return parseDownloadItem(with: item.id!) - } - } - - func cancel(task: DownloadTask) { - guard downloads.contains(where: { $0.item == task.item }) else { return } - - task.cancel() - - remove(task: task) - } - - func remove(task: DownloadTask) { - downloads.removeAll(where: { $0.item == task.item }) - } - - func downloadedItems() -> [DownloadTask] { - do { - let downloadContents = try FileManager.default.contentsOfDirectory(atPath: URL.downloadsDirectory.path) - return downloadContents.compactMap(parseDownloadItem(with:)) - } catch { - logger.error("Error retrieving all downloads: \(error.localizedDescription)") - - return [] - } - } - - private func parseDownloadItem(with id: String) -> DownloadTask? { - - let itemMetadataFile = URL.downloadsDirectory - .appendingPathComponent(id) - .appendingPathComponent("Metadata") - .appendingPathComponent("Item.json") - - guard let itemMetadataData = FileManager.default.contents(atPath: itemMetadataFile.path) else { return nil } - - let jsonDecoder = JSONDecoder() - - guard let offlineItem = try? jsonDecoder.decode(BaseItemDto.self, from: itemMetadataData) else { return nil } - - let task = DownloadTask(item: offlineItem) - task.state = .complete - return task - } -} diff --git a/Shared/Services/DownloadTask.swift b/Shared/Services/DownloadTask.swift deleted file mode 100644 index 002cca65d1..0000000000 --- a/Shared/Services/DownloadTask.swift +++ /dev/null @@ -1,306 +0,0 @@ -// -// 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 Factory -import Files -import Foundation -import Get -import JellyfinAPI -import Logging - -// TODO: Only move items if entire download successful -// TODO: Better state for which stage of downloading - -class DownloadTask: NSObject, ObservableObject { - - enum DownloadError: Error { - - case notEnoughStorage - - var localizedDescription: String { - switch self { - case .notEnoughStorage: - "Not enough storage" - } - } - } - - enum State { - - case cancelled - case complete - case downloading(Double) - case error(Error) - case ready - } - - private let logger = Logger.swiftfin() - @Injected(\.currentUserSession) - private var userSession: UserSession! - - @Published - var state: State = .ready - - private var downloadTask: Task? - - let item: BaseItemDto - - var imagesFolder: URL? { - item.downloadFolder?.appendingPathComponent("Images") - } - - var metadataFolder: URL? { - item.downloadFolder?.appendingPathComponent("Metadata") - } - - init(item: BaseItemDto) { - self.item = item - } - - func createFolder() throws { - guard let downloadFolder = item.downloadFolder else { return } - try FileManager.default.createDirectory(at: downloadFolder, withIntermediateDirectories: true) - } - - func download() { - - let task = Task { - - deleteRootFolder() - - // TODO: Look at TaskGroup for parallel calls - do { - try await downloadMedia() - } catch { - await MainActor.run { - self.state = .error(error) - - Container.shared.downloadManager.reset() - } - return - } - await downloadBackdropImage() - await downloadPrimaryImage() - - saveMetadata() - - await MainActor.run { - self.state = .complete - } - } - - self.downloadTask = task - } - - func cancel() { - self.downloadTask?.cancel() - self.state = .cancelled - - logger.trace("Cancelled download for: \(item.displayTitle)") - } - - func deleteRootFolder() { - guard let downloadFolder = item.downloadFolder else { return } - try? FileManager.default.removeItem(at: downloadFolder) - } - - func encodeMetadata() -> Data { - try! JSONEncoder().encode(item) - } - - private func downloadMedia() async throws { - - guard let downloadFolder = item.downloadFolder else { return } - - let request = Paths.getDownload(itemID: item.id!) - let response = try await userSession.client.download(for: request, delegate: self) - - let subtype = response.response.mimeSubtype - let mediaExtension = subtype == nil ? "" : ".\(subtype!)" - - do { - try FileManager.default.createDirectory(at: downloadFolder, withIntermediateDirectories: true) - - try FileManager.default.moveItem( - at: response.value, - to: downloadFolder.appendingPathComponent("Media\(mediaExtension)") - ) - } catch { - logger.error("Error downloading media for: \(item.displayTitle) with error: \(error.localizedDescription)") - } - } - - private func downloadBackdropImage() async { - - guard let type = item.type else { return } - - let imageURL: URL - - // TODO: move to BaseItemDto - switch type { - case .movie, .series: - guard let url = item.imageSource(.backdrop, maxWidth: 600).url else { return } - imageURL = url - case .episode: - guard let url = item.imageSource(.primary, maxWidth: 600).url else { return } - imageURL = url - default: - return - } - - guard let response = try? await userSession.client.download( - for: .init(url: imageURL).withResponse(URL.self), - delegate: self - ) else { return } - - let filename = getImageFilename(from: response, secondary: "Backdrop") - saveImage(from: response, filename: filename) - } - - private func downloadPrimaryImage() async { - - guard let type = item.type else { return } - - let imageURL: URL - - switch type { - case .movie, .series: - guard let url = item.imageSource(.primary, maxWidth: 300).url else { return } - imageURL = url - default: - return - } - - guard let response = try? await userSession.client.download( - for: .init(url: imageURL).withResponse(URL.self), - delegate: self - ) else { return } - - let filename = getImageFilename(from: response, secondary: "Primary") - saveImage(from: response, filename: filename) - } - - private func saveImage(from response: Response?, filename: String) { - - guard let response, let imagesFolder else { return } - - do { - try FileManager.default.createDirectory(at: imagesFolder, withIntermediateDirectories: true) - - try FileManager.default.moveItem( - at: response.value, - to: imagesFolder.appendingPathComponent(filename) - ) - } catch { - logger.error("Error saving image: \(error.localizedDescription)") - } - } - - private func getImageFilename(from response: Response, secondary: String) -> String { - - if let suggestedFilename = response.response.suggestedFilename { - return suggestedFilename - } else { - let imageExtension = response.response.mimeSubtype ?? "png" - return "\(secondary).\(imageExtension)" - } - } - - private func saveMetadata() { - guard let metadataFolder else { return } - - let jsonEncoder = JSONEncoder() - jsonEncoder.outputFormatting = .prettyPrinted - - let itemJsonData = try! jsonEncoder.encode(item) - let itemJson = String(data: itemJsonData, encoding: .utf8) - let itemFileURL = metadataFolder.appendingPathComponent("Item.json") - - do { - try FileManager.default.createDirectory(at: metadataFolder, withIntermediateDirectories: true) - - try itemJson?.write(to: itemFileURL, atomically: true, encoding: .utf8) - } catch { - logger.error("Error saving item metadata: \(error.localizedDescription)") - } - } - - func getImageURL(name: String) -> URL? { - do { - guard let imagesFolder else { return nil } - let images = try FileManager.default.contentsOfDirectory(atPath: imagesFolder.path) - - guard let imageFilename = images.first(where: { $0.starts(with: name) }) else { return nil } - - return imagesFolder.appendingPathComponent(imageFilename) - } catch { - return nil - } - } - - func getMediaURL() -> URL? { - do { - guard let downloadFolder = item.downloadFolder else { return nil } - let contents = try FileManager.default.contentsOfDirectory(atPath: downloadFolder.path) - - guard let mediaFilename = contents.first(where: { $0.starts(with: "Media") }) else { return nil } - - return downloadFolder.appendingPathComponent(mediaFilename) - } catch { - return nil - } - } -} - -// MARK: URLSessionDownloadDelegate - -extension DownloadTask: URLSessionDownloadDelegate { - - func urlSession( - _ session: URLSession, - downloadTask: URLSessionDownloadTask, - didWriteData bytesWritten: Int64, - totalBytesWritten: Int64, - totalBytesExpectedToWrite: Int64 - ) { - let progress = Double(totalBytesWritten) / Double(totalBytesExpectedToWrite) - - DispatchQueue.main.async { - self.state = .downloading(progress) - } - } - - func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {} - - func urlSession(_ session: URLSession, didBecomeInvalidWithError error: Error?) { - guard let error else { return } - - DispatchQueue.main.async { - self.state = .error(error) - - Container.shared.downloadManager.reset() - } - } - - func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { - guard let error else { return } - - DispatchQueue.main.async { - self.state = .error(error) - - Container.shared.downloadManager.reset() - } - } -} - -extension DownloadTask: Identifiable { - - var id: String { - item.id! - } -} diff --git a/Shared/Services/Notifications.swift b/Shared/Services/Notifications.swift index ece2bc48c5..e4739e38f1 100644 --- a/Shared/Services/Notifications.swift +++ b/Shared/Services/Notifications.swift @@ -11,6 +11,7 @@ import Combine import Factory import Foundation import JellyfinAPI +import OrderedCollections import UIKit extension Container { @@ -123,14 +124,6 @@ extension Notifications.Key { // MARK: - App Flow - static var processDeepLink: Key { - Key("processDeepLink") - } - - static var didPurge: Key { - Key("didPurge") - } - static var didChangeCurrentServerURL: Key { Key("didChangeCurrentServerURL") } @@ -139,10 +132,6 @@ extension Notifications.Key { Key("didSendStopReport") } - static var didRequestGlobalRefresh: Key { - Key("didRequestGlobalRefresh") - } - static var didFailMigration: Key { Key("didFailMigration") } @@ -151,6 +140,10 @@ extension Notifications.Key { // TODO: come up with a cleaner, more defined way for item update notifications + static var itemUserDataDidChange: Key { + Key("itemUserDataDidChange") + } + /// - Payload: The new item with updated metadata. static var itemMetadataDidChange: Key { Key("itemMetadataDidChange") @@ -187,16 +180,6 @@ extension Notifications.Key { Key("didAddServerUser") } - // MARK: - Playback - - static var didStartPlayback: Key { - Key("didStartPlayback") - } - - static var interruption: Key { - Key(AVAudioSession.interruptionNotification) - } - // MARK: - UIApplication static var applicationDidEnterBackground: Key { diff --git a/Shared/Services/SwiftfinDefaults.swift b/Shared/Services/SwiftfinDefaults.swift index 91144bed93..865b0a24b6 100644 --- a/Shared/Services/SwiftfinDefaults.swift +++ b/Shared/Services/SwiftfinDefaults.swift @@ -110,7 +110,7 @@ extension Defaults.Keys { enum Customization { static var itemViewType: Key { - UserKey("itemViewType", default: .compactLogo) + UserKey("mediaItemViewType", default: .enhanced) } static var showPosterLabels: Key { @@ -162,6 +162,13 @@ extension Defaults.Keys { enum Indicators { + static var enabled: Key { + UserKey( + "enabledPosterIndicators", + default: [.favorited, .played, .progress, .unplayed] + ) + } + static var showFavorited: Key { UserKey("showFavoritedIndicator", default: true) } @@ -193,7 +200,11 @@ extension Defaults.Keys { } static var letterPickerOrientation: Key { - UserKey("letterPickerOrientation", default: .disabled) + UserKey("letterPickerOrientation", default: .trailing) + } + + static var letterPickerEnabled: Key { + UserKey("letterPickerEnabled", default: false) } static var displayType: Key { @@ -223,6 +234,10 @@ extension Defaults.Keys { static var rememberSort: Key { UserKey("libraryRememberSort", default: false) } + + static var _libraryStyle: Key { + UserKey("libraryStyle", default: .default) + } } enum Home { diff --git a/Shared/SwiftfinStore/StoredValue/StoredValues+User.swift b/Shared/SwiftfinStore/StoredValue/StoredValues+User.swift index 44953ae45d..c81942a9af 100644 --- a/Shared/SwiftfinStore/StoredValue/StoredValues+User.swift +++ b/Shared/SwiftfinStore/StoredValue/StoredValues+User.swift @@ -112,6 +112,22 @@ extension StoredValues.Keys { ) } + static func libraryStyle(id: String?) -> Key { + if let id { + CurrentUserKey( + id, + field: "setting-libraryStyle", + default: .default + ) + } else { + CurrentUserKey( + "swiftfin-default", + field: "setting-libraryStyle", + default: .default + ) + } + } + static func libraryDisplayType(parentID: String?) -> Key { CurrentUserKey( parentID, @@ -128,6 +144,14 @@ extension StoredValues.Keys { ) } + static func posterButtonStyle(parentID: String?) -> Key { + CurrentUserKey( + parentID, + field: "setting-posterButtonStyle", + default: .default + ) + } + static func libraryPosterType(parentID: String?) -> Key { CurrentUserKey( parentID, @@ -162,6 +186,7 @@ extension StoredValues.Keys { ) } + // TODO: move edit/delete item + edit collections to an OptionSet static var enableItemEditing: Key { CurrentUserKey( field: "enableItemEditing", diff --git a/Shared/ViewModels/AdminDashboard/ActiveSessionsViewModel.swift b/Shared/ViewModels/AdminDashboard/ActiveSessionsViewModel.swift index b25896c78e..17559f2b40 100644 --- a/Shared/ViewModels/AdminDashboard/ActiveSessionsViewModel.swift +++ b/Shared/ViewModels/AdminDashboard/ActiveSessionsViewModel.swift @@ -49,7 +49,7 @@ final class ActiveSessionsViewModel: ViewModel { } @Published - private(set) var sessions: OrderedDictionary> = [:] + private(set) var sessions: OrderedDictionary> = [:] @Function(\Action.Cases.refresh) private func _refresh() async throws { @@ -80,7 +80,7 @@ final class ActiveSessionsViewModel: ViewModel { return !sessions.keys.contains(id) } .map { s in - BindingBox( + PublishedBox( source: .init( get: { s }, set: { _ in } diff --git a/Shared/ViewModels/AdminDashboard/ServerActivityViewModel.swift b/Shared/ViewModels/AdminDashboard/ServerActivityViewModel.swift index 8ae174b08d..17e9f214a3 100644 --- a/Shared/ViewModels/AdminDashboard/ServerActivityViewModel.swift +++ b/Shared/ViewModels/AdminDashboard/ServerActivityViewModel.swift @@ -6,77 +6,28 @@ // Copyright (c) 2026 Jellyfin & Jellyfin Contributors // -import Combine import Foundation import IdentifiedCollections import JellyfinAPI -// TODO: Change with PagingLibraryViewModel changes -@MainActor -final class ServerActivityViewModel: PagingLibraryViewModel { +// TODO: use `ServerUsersLibrary` - @Published - var hasUserId: Bool? { - didSet { - self.send(.refresh) - } - } - - @Published - var minDate: Date? { - didSet { - self.send(.refresh) - } - } +final class ServerActivityViewModel: PagingLibraryViewModel { private(set) var users: IdentifiedArrayOf = [] - private var userTask: AnyCancellable? - - override func respond(to action: Action) -> State { - - switch action { - case .refresh: - userTask?.cancel() - userTask = Task { - do { - let users = try await getUsers() + init() { + super.init(library: .init()) - await MainActor.run { - self.users = users - _ = super.respond(to: action) - } - } catch { - await MainActor.run { - self.send(.error(.init(L10n.unknownError))) - } - } - } - .asAnyCancellable() - - return .refreshing - default: - return super.respond(to: action) + self.core.addFunction(for: \.refresh) { [weak self] in + try await self?.getUsers() } } - override func get(page: Int) async throws -> [ActivityLogEntry] { - var parameters = Paths.GetLogEntriesParameters() - parameters.limit = pageSize - parameters.hasUserID = hasUserId - parameters.minDate = minDate - parameters.startIndex = page * pageSize - - let request = Paths.getLogEntries(parameters: parameters) - let response = try await userSession.client.send(request) - - return response.value.items ?? [] - } - - private func getUsers() async throws -> IdentifiedArrayOf { + private func getUsers() async throws { let request = Paths.getUsers() let response = try await userSession.client.send(request) - return IdentifiedArray(uniqueElements: response.value) + self.users = IdentifiedArray(uniqueElements: response.value) } } diff --git a/Shared/ViewModels/BaseFetchViewModel.swift b/Shared/ViewModels/BaseFetchViewModel.swift deleted file mode 100644 index 65c76f8760..0000000000 --- a/Shared/ViewModels/BaseFetchViewModel.swift +++ /dev/null @@ -1,46 +0,0 @@ -// -// 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 Combine -import Foundation - -@MainActor -@Stateful -class BaseFetchViewModel: ViewModel { - - @CasePathable - enum Action { - case refresh - - var transition: Transition { - .loop(.refreshing) - } - } - - enum State { - case initial - case refreshing - } - - @Published - private(set) var value: Value - - init(initialValue: Value) { - self.value = initialValue - super.init() - } - - @Function(\Action.Cases.refresh) - private func _refresh() async throws { - self.value = try await getValue() - } - - func getValue() async throws -> Value { - fatalError("This method should be overridden in subclasses") - } -} diff --git a/Shared/ViewModels/ContentGroupViewModel/ContentGroupViewModel.swift b/Shared/ViewModels/ContentGroupViewModel/ContentGroupViewModel.swift new file mode 100644 index 0000000000..4d25a891bd --- /dev/null +++ b/Shared/ViewModels/ContentGroupViewModel/ContentGroupViewModel.swift @@ -0,0 +1,127 @@ +// +// 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 Combine +import Foundation +import JellyfinAPI + +// TODO: allow in-place changes in groups + +@MainActor +@Stateful +final class ContentGroupViewModel: ViewModel { + + @CasePathable + enum Action { + case refresh + + var transition: Transition { + .to(.refreshing, then: .content) + } + } + + enum BackgroundState { + case refreshing + } + + enum State { + case content + case error + case initial + case refreshing + } + + @Published + private(set) var groups: [any ContentGroup] = [] + + private var lastRefreshDate = Date.distantPast + private var lastRefreshSignalDate = Date.distantPast + + private var hasPendingRefreshSignals: Bool { + lastRefreshSignalDate > lastRefreshDate + } + + var provider: Provider + + init(provider: Provider) { + self.provider = provider + super.init() + +// Publishers.Merge2( +// Notifications[.itemUserDataDidChange].publisher.map { _ in () }, +// Notifications[.itemMetadataDidChange].publisher.map { _ in () } +// ) +// .sink { [weak self] _ in +// self?.lastRefreshSignalDate = Date.now +// } +// .store(in: &cancellables) + } + +// func refreshIfNeeded( +// sinceLastDisappear interval: TimeInterval, +// staleThreshold: TimeInterval = 60 +// ) { +// guard interval > staleThreshold || hasPendingRefreshSignals else { return } +// +// self.background.inPlaceRefresh() +// } +// +// func refreshIfPendingChanges() { +// guard hasPendingRefreshSignals else { return } +// +// refresh() +// } + + private func getViewModel(for group: some ContentGroup) -> any WithRefresh { + group.viewModel + } + + @Function(\Action.Cases.refresh) + private func _refresh() async throws { + if StateTask.isBackground { + try await backgroundRefresh() + } else { + try await fullRefresh() + } + } + + private func backgroundRefresh() async throws { + try await withThrowingTaskGroup(of: Void.self) { group in + for viewModel in groups.map({ getViewModel(for: $0) }) { + group.addTask { + await viewModel.background.refresh() + } + } + + try await group.waitForAll() + } + } + + private func fullRefresh() async throws { + + self.groups = [] + + let newGroups = try await provider.makeGroups(environment: provider.environment) + let viewModels = newGroups.map { getViewModel(for: $0) } + .uniqued { ObjectIdentifier($0 as AnyObject) } + + try await withThrowingTaskGroup(of: Void.self) { group in + + for viewModel in viewModels { + group.addTask { + await viewModel.refresh() + } + } + + try await group.waitForAll() + } + + self.groups = newGroups + .filter(\._shouldBeResolved) + } +} diff --git a/Shared/ViewModels/ContentGroupViewModel/DefaultContentGroupProvider.swift b/Shared/ViewModels/ContentGroupViewModel/DefaultContentGroupProvider.swift new file mode 100644 index 0000000000..1ccd478277 --- /dev/null +++ b/Shared/ViewModels/ContentGroupViewModel/DefaultContentGroupProvider.swift @@ -0,0 +1,81 @@ +// +// 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 Defaults +import Factory +import Foundation +import JellyfinAPI + +struct DefaultContentGroupProvider: ContentGroupProvider { + + @Injected(\.currentUserSession) + var userSession: UserSession? + + let displayTitle: String = L10n.home + let id: String = "default-content-group-provider" + + func makeGroups(environment: Empty) async throws -> [any ContentGroup] { + guard let userSession else { return [] } + let parameters = Paths.GetUserViewsParameters(userID: userSession.user.id) + let userViewsPath = Paths.getUserViews(parameters: parameters) + let userViews = try await userSession.client.send(userViewsPath) + let excludedLibraryIDs = userSession.user.data.configuration?.latestItemsExcludes ?? [] + + let resolvedUserViews = (userViews.value.items ?? []).subtracting(excludedLibraryIDs, using: \.id) + .intersecting( + [ + .homevideos, + .movies, + .musicvideos, + .tvshows, + ], + using: \.collectionType + ) + + return _makeGroups(userViews: resolvedUserViews) + } + + @ContentGroupBuilder + private func _makeGroups(userViews: [BaseItemDto]) -> [any ContentGroup] { + + PosterGroup( + library: ResumeItemsLibrary(mediaTypes: [.video]), + posterDisplayType: .landscape, + posterSize: .medium, + _viewContext: .isInResume + ) + + PosterGroup( + library: NextUpLibrary() + ) + + if Defaults[.Customization.Home.showRecentlyAdded] { + PosterGroup( + library: ItemLibrary( + parent: .init( + name: L10n.recentlyAdded + ), + filters: .init( + itemTypes: [.movie, .series], + sortBy: [.dateCreated], + sortOrder: [.descending] + ) + ) + ) + } + + userViews + .map(LatestInLibrary.init) + .map { + PosterGroup( + library: $0, + posterDisplayType: .landscape + ) + } + } +} diff --git a/Shared/ViewModels/ContentGroupViewModel/ItemTypeContentGroupProvider.swift b/Shared/ViewModels/ContentGroupViewModel/ItemTypeContentGroupProvider.swift new file mode 100644 index 0000000000..06e5b2d4d1 --- /dev/null +++ b/Shared/ViewModels/ContentGroupViewModel/ItemTypeContentGroupProvider.swift @@ -0,0 +1,80 @@ +// +// 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 Foundation +import JellyfinAPI + +struct ItemTypeContentGroupProvider: ContentGroupProvider { + + struct Environment: WithDefaultValue { + var filters: ItemFilterCollection + + static var `default`: Self { + .init(filters: .init()) + } + } + + let id: String + let displayTitle: String + var environment: Environment + let itemTypes: [BaseItemKind] + let parent: BaseItemDto? + + init( + itemTypes: [BaseItemKind], + parent: BaseItemDto? = nil, + environment: Environment = .default + ) { + self.id = UUID().uuidString + self.displayTitle = parent?.displayTitle ?? "" + self.itemTypes = itemTypes + self.environment = environment + self.parent = parent + } + + func makeGroups(environment: Environment) async throws -> [any ContentGroup] { + + guard environment.filters.isNotEmpty || parent != nil else { return [] } + + return itemTypes.map { itemType in + // Server will edit filters if only boxset, add userView as workaround. + let itemTypes = (itemType == .boxSet ? [.boxSet, .userView] : [itemType]) + + var filters = environment.filters + filters.itemTypes = itemTypes + + if itemType == .episode { + return EpisodeGroup( + library: ItemLibrary( + parent: .init( + id: parent?.id, + name: itemType.pluralDisplayTitle, + type: parent?.type + ), + filters: filters, + fields: [.overview] + ) + ) + } else { + return PosterGroup( + id: "\(parent?.id ?? "unknown")-\(itemType.rawValue)", + library: ItemLibrary( + parent: .init( + id: parent?.id, + name: itemType.pluralDisplayTitle, + type: parent?.type + ), + filters: filters, + fields: itemType == .liveTvProgram ? [.channelInfo] : nil + ), + posterDisplayType: itemType.preferredPosterDisplayType + ) + } + } + } +} diff --git a/Shared/ViewModels/ContentGroupViewModel/LiveTVGroupProvider.swift b/Shared/ViewModels/ContentGroupViewModel/LiveTVGroupProvider.swift new file mode 100644 index 0000000000..fc26d326ce --- /dev/null +++ b/Shared/ViewModels/ContentGroupViewModel/LiveTVGroupProvider.swift @@ -0,0 +1,78 @@ +// +// 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 +// + +struct LiveTVGroupProvider: ContentGroupProvider { + + let id: String = "live-tv" + let displayTitle: String = L10n.liveTV + + func makeGroups(environment: Empty) async throws -> [any ContentGroup] { + + LiveTVChannelsPillGroup() + + PosterGroup( + id: "programs-recommended", + library: RecommendedProgramsLibrary(), + posterDisplayType: .landscape, + posterSize: .small + ) + + [ + ProgramSection.series, + .movies, + .kids, + .sports, + .news, + ] + .map { section in + PosterGroup( + id: "programs-\(section.rawValue)", + library: ProgramsLibrary(section: section), + posterDisplayType: .landscape, + posterSize: .small + ) + } + } +} + +import SwiftUI + +struct LiveTVChannelsPillGroup: ContentGroup { + + let id: String = "asdf" + let displayTitle: String = "" + let viewModel: Empty = .init() + + @ViewBuilder + func body(with viewModel: Empty) -> some View { + WithRouter { router in + ScrollView(.horizontal) { + HStack { + Button { + router.route(to: .library(library: ChannelProgramLibrary())) + } label: { + Label( + L10n.channels, + systemImage: "play.square.stack" + ) + .font(.callout) + .fontWeight(.semibold) + .padding(8) + .background { + Color.systemFill + .cornerRadius(10) + } + } + .foregroundStyle(.primary, .secondary) + } + .edgePadding(.horizontal) + } + .scrollIndicators(.hidden) + } + } +} diff --git a/Shared/ViewModels/ContentGroupViewModel/SearchContentGroupProvider.swift b/Shared/ViewModels/ContentGroupViewModel/SearchContentGroupProvider.swift new file mode 100644 index 0000000000..6122947da8 --- /dev/null +++ b/Shared/ViewModels/ContentGroupViewModel/SearchContentGroupProvider.swift @@ -0,0 +1,50 @@ +// +// 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 Foundation +import JellyfinAPI + +struct SearchContentGroupProvider: ContentGroupProvider { + + struct Environment: WithDefaultValue { + var filters: ItemFilterCollection + + static var `default`: Self { + .init(filters: .init()) + } + } + + let id: String = "" + let displayTitle: String = "" + var environment: Environment = .default + + @ContentGroupBuilder + func makeGroups(environment: Environment) async throws -> [any ContentGroup] { + try await ItemTypeContentGroupProvider( + itemTypes: [ + BaseItemKind.movie, + .series, + .boxSet, + .episode, + .musicVideo, + .video, + .liveTvProgram, + .tvChannel, + .musicArtist, + ] + ) + .makeGroups(environment: .init(filters: environment.filters)) + + PosterGroup( + id: UUID().uuidString, + library: PeopleLibrary( + environment: .init(query: environment.filters.query) + ) + ) + } +} diff --git a/Shared/ViewModels/DownloadListViewModel.swift b/Shared/ViewModels/DownloadListViewModel.swift deleted file mode 100644 index dbd5781066..0000000000 --- a/Shared/ViewModels/DownloadListViewModel.swift +++ /dev/null @@ -1,25 +0,0 @@ -// -// 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 Factory -import SwiftUI - -class DownloadListViewModel: ViewModel { - - @Injected(\.downloadManager) - private var downloadManager - - @Published - var items: [DownloadTask] = [] - - override init() { - super.init() - - items = downloadManager.downloadedItems() - } -} diff --git a/Shared/ViewModels/FilterViewModel.swift b/Shared/ViewModels/FilterViewModel.swift index 4b8beae4eb..39e3054df2 100644 --- a/Shared/ViewModels/FilterViewModel.swift +++ b/Shared/ViewModels/FilterViewModel.swift @@ -40,10 +40,10 @@ final class FilterViewModel: ViewModel { @Published var currentFilters: ItemFilterCollection - private let parent: (any LibraryParent)? + private let parent: (any _LibraryParent)? init( - parent: (any LibraryParent)? = nil, + parent: (any _LibraryParent)? = nil, currentFilters: ItemFilterCollection = .default ) { self.parent = parent @@ -90,7 +90,7 @@ final class FilterViewModel: ViewModel { let parameters = Paths.GetQueryFiltersLegacyParameters( userID: userSession.user.id, - parentID: parent?.id + parentID: parent?.libraryID ) let request = Paths.getQueryFiltersLegacy(parameters: parameters) diff --git a/Shared/ViewModels/HomeViewModel.swift b/Shared/ViewModels/HomeViewModel.swift deleted file mode 100644 index 7804e30455..0000000000 --- a/Shared/ViewModels/HomeViewModel.swift +++ /dev/null @@ -1,234 +0,0 @@ -// -// 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 Combine -import CoreStore -import Factory -import Get -import JellyfinAPI -import OrderedCollections - -@MainActor -final class HomeViewModel: ViewModel, Stateful { - - // MARK: Action - - enum Action: Equatable { - case backgroundRefresh - case error(ErrorMessage) - case setIsPlayed(Bool, BaseItemDto) - case refresh - } - - // MARK: BackgroundState - - enum BackgroundState: Hashable { - case refresh - } - - // MARK: State - - enum State: Hashable { - case content - case error(ErrorMessage) - case initial - case refreshing - } - - @Published - private(set) var libraries: [LatestInLibraryViewModel] = [] - @Published - var resumeItems: OrderedSet = [] - - @Published - var backgroundStates: Set = [] - @Published - var state: State = .initial - - // TODO: replace with views checking what notifications were - // posted since last disappear - @Published - var notificationsReceived: NotificationSet = .init() - - private var backgroundRefreshTask: AnyCancellable? - private var refreshTask: AnyCancellable? - - var nextUpViewModel: NextUpLibraryViewModel = .init() - var recentlyAddedViewModel: RecentlyAddedLibraryViewModel = .init() - - override init() { - super.init() - - Notifications[.itemMetadataDidChange] - .publisher - .sink { _ in - // Necessary because when this notification is posted, even with asyncAfter, - // the view will cause layout issues since it will redraw while in landscape. - // TODO: look for better solution - DispatchQueue.main.async { - self.notificationsReceived.insert(.itemMetadataDidChange) - } - } - .store(in: &cancellables) - } - - func respond(to action: Action) -> State { - switch action { - case .backgroundRefresh: - - backgroundRefreshTask?.cancel() - backgroundStates.insert(.refresh) - - backgroundRefreshTask = Task { [weak self] in - do { - self?.nextUpViewModel.send(.refresh) - self?.recentlyAddedViewModel.send(.refresh) - - let resumeItems = try await self?.getResumeItems() ?? [] - - guard !Task.isCancelled else { return } - - await MainActor.run { - guard let self else { return } - self.resumeItems.elements = resumeItems - self.backgroundStates.remove(.refresh) - } - } catch is CancellationError { - // cancelled - } catch { - guard !Task.isCancelled else { return } - - await MainActor.run { - guard let self else { return } - self.backgroundStates.remove(.refresh) - self.send(.error(.init(error.localizedDescription))) - } - } - } - .asAnyCancellable() - - return state - case let .error(error): - return .error(error) - case let .setIsPlayed(isPlayed, item): () - Task { - try await setIsPlayed(isPlayed, for: item) - - self.send(.backgroundRefresh) - } - .store(in: &cancellables) - - return state - case .refresh: - backgroundRefreshTask?.cancel() - refreshTask?.cancel() - - refreshTask = Task { [weak self] in - do { - try await self?.refresh() - - guard !Task.isCancelled else { return } - - await MainActor.run { - guard let self else { return } - self.state = .content - } - } catch is CancellationError { - // cancelled - } catch { - guard !Task.isCancelled else { return } - - await MainActor.run { - guard let self else { return } - self.send(.error(.init(error.localizedDescription))) - } - } - } - .asAnyCancellable() - - return .refreshing - } - } - - private func refresh() async throws { - - await nextUpViewModel.send(.refresh) - await recentlyAddedViewModel.send(.refresh) - - let resumeItems = try await getResumeItems() - let libraries = try await getLibraries() - - for library in libraries { - await library.send(.refresh) - } - - await MainActor.run { - self.resumeItems.elements = resumeItems - self.libraries = libraries - } - } - - private func getResumeItems() async throws -> [BaseItemDto] { - var parameters = Paths.GetResumeItemsParameters() - parameters.enableUserData = true - parameters.fields = .MinimumFields - parameters.mediaTypes = [.video] - parameters.limit = 20 - - let request = Paths.getResumeItems(parameters: parameters) - let response = try await userSession.client.send(request) - - return response.value.items ?? [] - } - - private func getLibraries() async throws -> [LatestInLibraryViewModel] { - - let parameters = Paths.GetUserViewsParameters(userID: userSession.user.id) - let userViewsPath = Paths.getUserViews(parameters: parameters) - async let userViews = userSession.client.send(userViewsPath) - - async let excludedLibraryIDs = getExcludedLibraries() - - return try await (userViews.value.items ?? []) - .intersecting( - [ - .homevideos, - .movies, - .musicvideos, - .tvshows, - ], - using: \.collectionType - ) - .subtracting(excludedLibraryIDs, using: \.id) - .map { LatestInLibraryViewModel(parent: $0) } - } - - // TODO: use the more updated server/user data when implemented - private func getExcludedLibraries() async throws -> [String] { - let currentUserPath = Paths.getCurrentUser - let response = try await userSession.client.send(currentUserPath) - - return response.value.configuration?.latestItemsExcludes ?? [] - } - - private func setIsPlayed(_ isPlayed: Bool, for item: BaseItemDto) async throws { - let request: Request = if isPlayed { - Paths.markPlayedItem( - itemID: item.id!, - userID: userSession.user.id - ) - } else { - Paths.markUnplayedItem( - itemID: item.id!, - userID: userSession.user.id - ) - } - - _ = try await userSession.client.send(request) - } -} diff --git a/Shared/ViewModels/ItemAdministration/RemoteImageInfoViewModel.swift b/Shared/ViewModels/ItemAdministration/RemoteImageInfoViewModel.swift index 9ac013163f..35f280c90e 100644 --- a/Shared/ViewModels/ItemAdministration/RemoteImageInfoViewModel.swift +++ b/Shared/ViewModels/ItemAdministration/RemoteImageInfoViewModel.swift @@ -9,56 +9,31 @@ import Foundation import JellyfinAPI -final class RemoteImageInfoViewModel: PagingLibraryViewModel { - - // Image providers come from the paging call - @Published - private(set) var providers: [String] = [] - - @Published - var includeAllLanguages: Bool = false { - didSet { - DispatchQueue.main.async { - self.send(.refresh) - } - } - } - - @Published - var provider: String? = nil { - didSet { - DispatchQueue.main.async { - self.send(.refresh) - } - } +// TODO: Good example of a multi-library view model, find way to generalize +// - variadic generics when available? +// - be like server activity that just subclasses and then encapsulates multiple libraries? +// - need way to wait for initial results of all libraries + +@MainActor +final class RemoteImageInfoViewModel: ObservableObject { + + var remoteImageLibrary: PagingLibraryViewModel + let remoteImageProvidersLibrary: PagingLibraryViewModel + + init(itemID: String, imageType: ImageType) { + self.remoteImageLibrary = .init( + library: .init( + imageType: imageType, + itemID: itemID + ) + ) + self.remoteImageProvidersLibrary = .init( + library: .init(itemID: itemID) + ) } - let imageType: ImageType - - init(imageType: ImageType, parent: BaseItemDto) { - - self.imageType = imageType - - super.init(parent: parent) - } - - override func get(page: Int) async throws -> [RemoteImageInfo] { - guard let itemID = parent?.id else { return [] } - - var parameters = Paths.GetRemoteImagesParameters() - parameters.isIncludeAllLanguages = includeAllLanguages - parameters.limit = pageSize - parameters.providerName = provider - parameters.startIndex = page * pageSize - parameters.type = imageType - - let request = Paths.getRemoteImages(itemID: itemID, parameters: parameters) - let response = try await userSession.client.send(request) - - await MainActor.run { - providers = response.value.providers ?? [] - } - - return response.value.images ?? [] + func refresh() { + remoteImageLibrary.refresh() + remoteImageProvidersLibrary.refresh() } } diff --git a/Shared/ViewModels/ItemTypeCollection.swift b/Shared/ViewModels/ItemTypeCollection.swift deleted file mode 100644 index 9c5a12ef48..0000000000 --- a/Shared/ViewModels/ItemTypeCollection.swift +++ /dev/null @@ -1,110 +0,0 @@ -// -// 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 Combine -import JellyfinAPI -import OrderedCollections - -@MainActor -final class ItemTypeCollection: ViewModel, Stateful { - - enum Action { - case refresh - } - - enum State: Hashable { - case content - case refreshing - } - - @Published - var state: State = .content - - @Published - private(set) var elements: OrderedDictionary = [:] - - private var task: AnyCancellable? - - private let parent: any LibraryParent - private let itemTypes: [BaseItemKind] - - init( - parent: any LibraryParent, - itemTypes: [BaseItemKind] = BaseItemKind.supportedCases - ) { - self.parent = parent - self.itemTypes = itemTypes - } - - func respond(to action: Action) -> State { - switch action { - case .refresh: - task?.cancel() - - task = Task { - let newElements = await self.getNewElements() - - await MainActor.run { - self.elements = newElements - self.state = .content - } - } - .asAnyCancellable() - } - - return .refreshing - } - - private func getNewElements() async -> OrderedDictionary { - await withTaskGroup(of: (BaseItemKind, ItemLibraryViewModel).self) { group in - for kind in itemTypes { - group.addTask { - await (kind, self.getItems(for: kind)) - } - } - - let newElements = await group.reduce( - into: OrderedDictionary() - ) { result, element in - let (kind, viewModel) = element - if case .content = viewModel.state, viewModel.elements.isNotEmpty { - result[kind] = viewModel - } - } - - return newElements.sortedKeys(using: \.rawValue) - } - } - - private func getItems(for itemType: BaseItemKind) async -> ItemLibraryViewModel { - - /// Server will edit filters if only boxset, add userView as workaround. - let itemTypes = (itemType == .boxSet ? [.boxSet, .userView] : [itemType]) - - let viewModel = ItemLibraryViewModel( - parent: parent, - filters: .init(itemTypes: itemTypes), - pageSize: 20 - ) - - return await withCheckedContinuation { continuation in - var cancellable: AnyCancellable? - - cancellable = viewModel.$state - .filter { $0 != .initial && $0 != .refreshing } - .sink { _ in - cancellable?.cancel() - continuation.resume(returning: viewModel) - } - - Task { @MainActor in - viewModel.send(.refresh) - } - } - } -} diff --git a/Shared/ViewModels/ItemViewModel.swift b/Shared/ViewModels/ItemViewModel.swift new file mode 100644 index 0000000000..b1112bbeda --- /dev/null +++ b/Shared/ViewModels/ItemViewModel.swift @@ -0,0 +1,130 @@ +// +// 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 JellyfinAPI +import SwiftUI + +@Stateful +class ItemViewModel: ViewModel, WithRefresh { + + @CasePathable + enum Action { + case refresh + + var transition: Transition { + switch self { + case .refresh: + .to(.refreshing, then: .content) + } + } + } + + enum State: Hashable { + case content + case error + case initial + case refreshing + } + + @Published + var item: BaseItemDto = .init() + @Published + private(set) var playButtonItem: BaseItemDto? { + willSet { + selectedMediaSource = newValue?.mediaSources?.first + } + } + + @Published + var selectedMediaSource: MediaSourceInfo? + + @ObservedPublisher + var localTrailers: [BaseItemDto] + + private var localTrailerViewModel: PagingLibraryViewModel + + init(id: String) { + self.item = .init(id: id) + self.localTrailerViewModel = .init(library: .init(parentID: id)) + + self._localTrailers = .init( + wrappedValue: [], + observing: localTrailerViewModel.$elements.map(\.elements) + ) + + super.init() + + Notifications[.itemUserDataDidChange] + .publisher +// .filter { [weak self] userData in +// guard let self else { return false } +// return userData.itemId == self.item.id || +// userData.itemId == self.playButtonItem?.id +// } + .sink { [weak self] userData in + self?.updateItemUserData(userData) + } + .store(in: &cancellables) + } + + private func updateItemUserData(_ userData: UserItemDataDto) { + guard item.id == userData.itemID else { return } + item = item.mutating(\.userData, with: userData) + } + + private func notifyUserDataIfNeeded(for item: BaseItemDto) { +// guard let itemId = item.id, let userData = item.userData else { return } +// +// let shouldNotify = ItemUserDataCache.shared.updateIfNeeded( +// itemId: itemId, +// userData: userData +// ) +// +// if shouldNotify { +// Notifications[.itemUserDataDidChange].post(userData) +// } + } + + @Function(\Action.Cases.refresh) + private func _refresh() async throws { + let newItem = try await item.getFullItem(userSession: userSession) + item = newItem + notifyUserDataIfNeeded(for: newItem) + + Task { + localTrailerViewModel.refresh() + } + + if item.type == .series { + playButtonItem = try await getNextUp(seriesID: item.id) + } else { + playButtonItem = newItem + } + + if let playButtonItem { + notifyUserDataIfNeeded(for: playButtonItem) + } + } + + private func getNextUp(seriesID: String?) async throws -> BaseItemDto? { + var parameters = Paths.GetNextUpParameters() + parameters.enableUserData = true + parameters.fields = [.mediaSources] + parameters.seriesID = seriesID + parameters.userID = userSession.user.id + + let request = Paths.getNextUp(parameters: parameters) + let response = try await userSession.client.send(request) + + guard let item = response.value.items?.first, !item.isMissing else { + return nil + } + + return item + } +} diff --git a/Shared/ViewModels/ItemViewModel/CollectionItemViewModel.swift b/Shared/ViewModels/ItemViewModel/CollectionItemViewModel.swift deleted file mode 100644 index 89a3a86796..0000000000 --- a/Shared/ViewModels/ItemViewModel/CollectionItemViewModel.swift +++ /dev/null @@ -1,68 +0,0 @@ -// -// 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 Combine -import Foundation -import JellyfinAPI -import OrderedCollections - -@MainActor -final class CollectionItemViewModel: ItemViewModel { - - @ObservedPublisher - var sections: OrderedDictionary - - private let itemCollection: ItemTypeCollection - - @MainActor - override init(item: BaseItemDto) { - self.itemCollection = ItemTypeCollection( - parent: item, - itemTypes: BaseItemKind.supportedCases - .appending(.episode) - .appending(.person) - ) - self._sections = ObservedPublisher( - wrappedValue: [:], - observing: itemCollection.$elements - ) - - super.init(item: item) - } - - // MARK: - Override Response - - override func respond(to action: ItemViewModel.Action) -> ItemViewModel.State { - - switch action { - case .refresh, .backgroundRefresh: - itemCollection.send(.refresh) - default: () - } - - return super.respond(to: action) - } - - // TODO: possibly multiple items, for image source fallbacks - func randomItem() -> BaseItemDto? { - // Try to exclude episodes if possible - - if itemCollection.elements.elements.count == 1 { - return itemCollection.elements.elements.first?.value.elements.first - } - - return itemCollection.elements - .elements - .shuffled() - .filter { $0.key != .episode } - .randomElement()? - .value - .elements - .randomElement() - } -} diff --git a/Shared/ViewModels/ItemViewModel/EpisodeItemViewModel.swift b/Shared/ViewModels/ItemViewModel/EpisodeItemViewModel.swift deleted file mode 100644 index 2ab03346a4..0000000000 --- a/Shared/ViewModels/ItemViewModel/EpisodeItemViewModel.swift +++ /dev/null @@ -1,65 +0,0 @@ -// -// 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 Combine -import Foundation -import JellyfinAPI - -final class EpisodeItemViewModel: ItemViewModel { - - // MARK: - Published Episode Items - - @Published - private(set) var seriesItem: BaseItemDto? - - // MARK: - Task - - private var seriesItemTask: AnyCancellable? - - // MARK: - Override Response - - override func respond(to action: ItemViewModel.Action) -> ItemViewModel.State { - - switch action { - case .refresh, .backgroundRefresh: - seriesItemTask?.cancel() - - seriesItemTask = Task { - let seriesItem = try await self.getSeriesItem() - - await MainActor.run { - self.seriesItem = seriesItem - } - } - .asAnyCancellable() - default: () - } - - return super.respond(to: action) - } - - // MARK: - Get Series Items - - private func getSeriesItem() async throws -> BaseItemDto { - - guard let seriesID = item.seriesID else { throw ErrorMessage("Expected series ID missing") } - - var parameters = Paths.GetItemsParameters() - parameters.enableUserData = true - parameters.fields = .MinimumFields - parameters.ids = [seriesID] - parameters.limit = 1 - - let request = Paths.getItems(parameters: parameters) - let response = try await userSession.client.send(request) - - guard let seriesItem = response.value.items?.first else { throw ErrorMessage("Expected series item missing") } - - return seriesItem - } -} diff --git a/Shared/ViewModels/ItemViewModel/ItemViewModel.swift b/Shared/ViewModels/ItemViewModel/ItemViewModel.swift deleted file mode 100644 index e2aea962b4..0000000000 --- a/Shared/ViewModels/ItemViewModel/ItemViewModel.swift +++ /dev/null @@ -1,399 +0,0 @@ -// -// 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 Combine -import Factory -import Foundation -import Get -import JellyfinAPI -import OrderedCollections -import UIKit - -// TODO: come up with a cleaner, more defined way for item update notifications - -class ItemViewModel: ViewModel, Stateful { - - // MARK: Action - - enum Action: Equatable { - case backgroundRefresh - case error(ErrorMessage) - case refresh - case replace(BaseItemDto) - case toggleIsFavorite - case toggleIsPlayed - case selectMediaSource(MediaSourceInfo) - } - - // MARK: BackgroundState - - enum BackgroundState: Hashable { - case refresh - } - - // MARK: State - - enum State: Hashable { - case content - case error(ErrorMessage) - case initial - case refreshing - } - - // TODO: create value on `BaseItemDto` whether an item - // only has children as playable items - @Published - private(set) var item: BaseItemDto { - willSet { - if item.isPlayable { - playButtonItem = newValue - } - } - } - - @Published - var playButtonItem: BaseItemDto? { - willSet { - if let newValue { - selectedMediaSource = newValue.mediaSources?.first - } - } - } - - @Published - private(set) var selectedMediaSource: MediaSourceInfo? - @Published - private(set) var similarItems: [BaseItemDto] = [] - @Published - private(set) var specialFeatures: [BaseItemDto] = [] - @Published - private(set) var localTrailers: [BaseItemDto] = [] - @Published - private(set) var additionalParts: [BaseItemDto] = [] - - @Published - var backgroundStates: Set = [] - @Published - var state: State = .initial - - private var itemID: String { - get throws { - guard let id = item.id else { - logger.error("Item ID is nil") - throw ErrorMessage(L10n.unknownError) - } - return id - } - } - - // tasks - - private var toggleIsFavoriteTask: AnyCancellable? - private var toggleIsPlayedTask: AnyCancellable? - private var refreshTask: AnyCancellable? - - // MARK: init - - @MainActor - init(item: BaseItemDto) { - self.item = item - super.init() - - Notifications[.itemShouldRefreshMetadata] - .publisher - .sink { [weak self] itemID in - guard itemID == self?.item.id else { return } - - Task { - await self?.send(.backgroundRefresh) - } - } - .store(in: &cancellables) - - Notifications[.itemMetadataDidChange] - .publisher - .sink { [weak self] newItem in - guard let newItemID = newItem.id, newItemID == self?.item.id else { return } - - Task { - await self?.send(.replace(newItem)) - } - } - .store(in: &cancellables) - } - - @MainActor - convenience init(episode: BaseItemDto) { - let shellSeriesItem = BaseItemDto(id: episode.seriesID, name: episode.seriesName) - self.init(item: shellSeriesItem) - } - - // MARK: respond - - func respond(to action: Action) -> State { - switch action { - case .backgroundRefresh: - - backgroundStates.insert(.refresh) - - Task { [weak self] in - guard let self else { return } - do { - async let fullItem = getFullItem() - async let similarItems = getSimilarItems() - async let specialFeatures = getSpecialFeatures() - async let localTrailers = getLocalTrailers() - - let results = try await ( - fullItem: fullItem, - similarItems: similarItems, - specialFeatures: specialFeatures, - localTrailers: localTrailers - ) - - guard !Task.isCancelled else { return } - - await MainActor.run { - self.backgroundStates.remove(.refresh) - if results.fullItem.id != self.item.id || results.fullItem != self.item { - self.item = results.fullItem - } - - if !results.similarItems.elementsEqual(self.similarItems, by: { $0.id == $1.id }) { - self.similarItems = results.similarItems - } - - if !results.specialFeatures.elementsEqual(self.specialFeatures, by: { $0.id == $1.id }) { - self.specialFeatures = results.specialFeatures - } - - if !results.localTrailers.elementsEqual(self.localTrailers, by: { $0.id == $1.id }) { - self.localTrailers = results.localTrailers - } - } - } catch { - guard !Task.isCancelled else { return } - - await MainActor.run { - self.backgroundStates.remove(.refresh) - self.send(.error(.init(error.localizedDescription))) - } - } - } - .store(in: &cancellables) - - return state - case let .error(error): - return .error(error) - case .refresh: - - refreshTask?.cancel() - - refreshTask = Task { [weak self] in - guard let self else { return } - do { - async let fullItem = getFullItem() - async let similarItems = getSimilarItems() - async let specialFeatures = getSpecialFeatures() - async let localTrailers = getLocalTrailers() - async let additionalParts = getAdditionalParts() - - let results = try await ( - fullItem: fullItem, - similarItems: similarItems, - specialFeatures: specialFeatures, - localTrailers: localTrailers, - additionalParts: additionalParts - ) - - guard !Task.isCancelled else { return } - - await MainActor.run { - self.item = results.fullItem - self.similarItems = results.similarItems - self.specialFeatures = results.specialFeatures - self.localTrailers = results.localTrailers - self.additionalParts = results.additionalParts - - self.state = .content - } - } catch { - guard !Task.isCancelled else { return } - - await MainActor.run { - self.send(.error(.init(error.localizedDescription))) - } - } - } - .asAnyCancellable() - - return .refreshing - case let .replace(newItem): - - backgroundStates.insert(.refresh) - - Task { [weak self] in - guard let self else { return } - do { - await MainActor.run { - self.backgroundStates.remove(.refresh) - self.item = newItem - } - } - } - .store(in: &cancellables) - - return state - case .toggleIsFavorite: - - toggleIsFavoriteTask?.cancel() - - toggleIsFavoriteTask = Task { - - let beforeIsFavorite = item.userData?.isFavorite ?? false - - await MainActor.run { - item.userData?.isFavorite?.toggle() - } - - do { - try await setIsFavorite(!beforeIsFavorite) - } catch { - await MainActor.run { - item.userData?.isFavorite = beforeIsFavorite - // emit event that toggle unsuccessful - } - } - } - .asAnyCancellable() - - return state - case .toggleIsPlayed: - - toggleIsPlayedTask?.cancel() - - toggleIsPlayedTask = Task { - - let beforeIsPlayed = item.userData?.isPlayed ?? false - - await MainActor.run { - item.userData?.isPlayed?.toggle() - } - - do { - try await setIsPlayed(!beforeIsPlayed) - } catch { - await MainActor.run { - item.userData?.isPlayed = beforeIsPlayed - // emit event that toggle unsuccessful - } - } - } - .asAnyCancellable() - - return state - case let .selectMediaSource(newSource): - - selectedMediaSource = newSource - - return state - } - } - - private func getFullItem() async throws -> BaseItemDto { - try await item.getFullItem(userSession: userSession, sendNotification: true) - } - - private func getSimilarItems() async -> [BaseItemDto] { - - var parameters = Paths.GetSimilarItemsParameters() - parameters.fields = .MinimumFields - parameters.limit = 20 - - let request = Paths.getSimilarItems( - itemID: item.id!, - parameters: parameters - ) - - let response = try? await userSession.client.send(request) - - return response?.value.items ?? [] - } - - private func getSpecialFeatures() async -> [BaseItemDto] { - - let request = Paths.getSpecialFeatures( - itemID: item.id!, - userID: userSession.user.id - ) - let response = try? await userSession.client.send(request) - - return (response?.value ?? []) - .filter { $0.extraType?.isVideo ?? false } - } - - private func getLocalTrailers() async throws -> [BaseItemDto] { - - let request = try Paths.getLocalTrailers(itemID: itemID, userID: userSession.user.id) - let response = try? await userSession.client.send(request) - - return response?.value ?? [] - } - - private func getAdditionalParts() async throws -> [BaseItemDto] { - - guard let partCount = item.partCount, - partCount > 1, - let itemID = item.id else { return [] } - - let request = Paths.getAdditionalPart(itemID: itemID) - let response = try? await userSession.client.send(request) - - return response?.value.items ?? [] - } - - private func setIsPlayed(_ isPlayed: Bool) async throws { - - guard let itemID = item.id else { return } - - let request: Request = if isPlayed { - Paths.markPlayedItem( - itemID: item.id!, - userID: userSession.user.id - ) - } else { - Paths.markUnplayedItem( - itemID: item.id!, - userID: userSession.user.id - ) - } - - _ = try await userSession.client.send(request) - Notifications[.itemShouldRefreshMetadata].post(itemID) - } - - private func setIsFavorite(_ isFavorite: Bool) async throws { - - guard let itemID = item.id else { return } - - let request: Request = if isFavorite { - Paths.markFavoriteItem( - itemID: item.id!, - userID: userSession.user.id - ) - } else { - Paths.unmarkFavoriteItem( - itemID: item.id!, - userID: userSession.user.id - ) - } - - _ = try await userSession.client.send(request) - Notifications[.itemShouldRefreshMetadata].post(itemID) - } -} diff --git a/Shared/ViewModels/ItemViewModel/SeasonItemViewModel.swift b/Shared/ViewModels/ItemViewModel/SeasonItemViewModel.swift deleted file mode 100644 index 56af6af2d2..0000000000 --- a/Shared/ViewModels/ItemViewModel/SeasonItemViewModel.swift +++ /dev/null @@ -1,48 +0,0 @@ -// -// 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 Defaults -import Foundation -import JellyfinAPI - -// Since we don't view care to view seasons directly, this doesn't subclass from `ItemViewModel`. -// If we ever care for viewing seasons directly, subclass from that and have the library view model -// as a property. -final class SeasonItemViewModel: PagingLibraryViewModel, Identifiable { - - let season: BaseItemDto - - var id: String? { - season.id - } - - init(season: BaseItemDto) { - self.season = season - super.init(parent: season) - } - - override func get(page: Int) async throws -> [BaseItemDto] { - - var parameters = Paths.GetEpisodesParameters() - parameters.enableUserData = true - parameters.fields = .MinimumFields - parameters.isMissing = Defaults[.Customization.shouldShowMissingEpisodes] ? nil : false - parameters.seasonID = parent!.id - -// parameters.startIndex = page * pageSize -// parameters.limit = pageSize - - let request = Paths.getEpisodes( - seriesID: parent!.id!, - parameters: parameters - ) - let response = try await userSession.client.send(request) - - return response.value.items ?? [] - } -} diff --git a/Shared/ViewModels/ItemViewModel/SeriesItemViewModel.swift b/Shared/ViewModels/ItemViewModel/SeriesItemViewModel.swift deleted file mode 100644 index c7c49cc927..0000000000 --- a/Shared/ViewModels/ItemViewModel/SeriesItemViewModel.swift +++ /dev/null @@ -1,143 +0,0 @@ -// -// 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 Combine -import Defaults -import Factory -import Foundation -import IdentifiedCollections -import JellyfinAPI - -// TODO: care for one long episodes list? -// - after SeasonItemViewModel is bidirectional -// - would have to see if server returns right amount of episodes/season -final class SeriesItemViewModel: ItemViewModel { - - @Published - var seasons: IdentifiedArrayOf = [] - - // MARK: - Task - - private var seriesItemTask: AnyCancellable? - - // MARK: - Override Response - - override func respond(to action: ItemViewModel.Action) -> ItemViewModel.State { - - switch action { - case .backgroundRefresh, .refresh: - let parentState = super.respond(to: action) - - seriesItemTask?.cancel() - - Task { [weak self] in - guard let self else { return } - - await MainActor.run { - self.seasons.removeAll() - } - - do { - async let nextUp = getNextUp() - async let resume = getResumeItem() - async let firstAvailable = getFirstAvailableItem() - async let seasons = getSeasons() - - let newSeasons = try await seasons - .sorted { ($0.indexNumber ?? -1) < ($1.indexNumber ?? -1) } - .map(SeasonItemViewModel.init) - - await MainActor.run { - self.seasons.append(contentsOf: newSeasons) - } - - if let episodeItem = try await [nextUp, resume].compacted().first { - await MainActor.run { - self.playButtonItem = episodeItem - } - } else if let firstAvailable = try await firstAvailable { - await MainActor.run { - self.playButtonItem = firstAvailable - } - } - } - } - .store(in: &cancellables) - default: () - } - - return super.respond(to: action) - } - - // MARK: - Get Next Up Item - - private func getNextUp() async throws -> BaseItemDto? { - - var parameters = Paths.GetNextUpParameters() - parameters.fields = .MinimumFields - parameters.seriesID = item.id - - let request = Paths.getNextUp(parameters: parameters) - let response = try await userSession.client.send(request) - - guard let item = response.value.items?.first, !item.isMissing else { - return nil - } - - return item - } - - // MARK: - Get Resumable Item - - private func getResumeItem() async throws -> BaseItemDto? { - - var parameters = Paths.GetResumeItemsParameters() - parameters.fields = .MinimumFields - parameters.limit = 1 - parameters.parentID = item.id - - let request = Paths.getResumeItems(parameters: parameters) - let response = try await userSession.client.send(request) - - return response.value.items?.first - } - - // MARK: - Get First Available Item - - private func getFirstAvailableItem() async throws -> BaseItemDto? { - - var parameters = Paths.GetItemsParameters() - parameters.fields = .MinimumFields - parameters.includeItemTypes = [.episode] - parameters.isRecursive = true - parameters.limit = 1 - parameters.parentID = item.id - parameters.sortOrder = [.ascending] - - let request = Paths.getItems(parameters: parameters) - let response = try await userSession.client.send(request) - - return response.value.items?.first - } - - // MARK: - Get First Item Seasons - - private func getSeasons() async throws -> [BaseItemDto] { - - var parameters = Paths.GetSeasonsParameters() - parameters.isMissing = Defaults[.Customization.shouldShowMissingSeasons] ? nil : false - - let request = Paths.getSeasons( - seriesID: item.id!, - parameters: parameters - ) - let response = try await userSession.client.send(request) - - return response.value.items ?? [] - } -} diff --git a/Shared/ViewModels/LibraryViewModel/ItemLibraryViewModel.swift b/Shared/ViewModels/LibraryViewModel/ItemLibraryViewModel.swift deleted file mode 100644 index 227bc6dcc5..0000000000 --- a/Shared/ViewModels/LibraryViewModel/ItemLibraryViewModel.swift +++ /dev/null @@ -1,127 +0,0 @@ -// -// 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 Combine -import Defaults -import Get -import JellyfinAPI -import OrderedCollections -import SwiftUI - -@MainActor -final class ItemLibraryViewModel: PagingLibraryViewModel { - - // MARK: get - - override func get(page: Int) async throws -> [BaseItemDto] { - - let parameters = itemParameters(for: page) - let request = Paths.getItems(parameters: parameters) - let response = try await userSession.client.send(request) - - // 1 - only care to keep collections that hold valid items - // 2 - if parent is type `folder`, then we are in a folder-view - // context so change `collectionFolder` types to `folder` - // for better view handling - return (response.value.items ?? []) - .filter { item in - if let collectionType = item.collectionType { - return CollectionType.supportedCases.contains(collectionType) - } - - return true - } - .map { item in - if parent?.libraryType == .folder, item.type == .collectionFolder { - return item.mutating(\.type, with: .folder) - } - - return item - } - } - - // MARK: item parameters - - private func itemParameters(for page: Int?) -> Paths.GetItemsParameters { - - var parameters = Paths.GetItemsParameters() - - parameters.enableUserData = true - parameters.fields = .MinimumFields - - // Default values, expected to be overridden - // by parent or filters - parameters.includeItemTypes = BaseItemKind.supportedCases - parameters.sortOrder = [.ascending] - parameters.sortBy = [ItemSortBy.name] - - /// Recursive should only apply to parents/folders and not to baseItems - parameters.isRecursive = (parent as? BaseItemDto)?.isRecursiveCollection ?? true - - // Parent - if let parent { - parameters = parent.setParentParameters(parameters) - } - - // Page size - if let page { - parameters.limit = pageSize - parameters.startIndex = page * pageSize - } - - // Filters - if let filterViewModel { - let filters = filterViewModel.currentFilters - parameters.filters = filters.traits - parameters.genres = filters.genres.map(\.value) - parameters.sortBy = filters.sortBy - parameters.sortOrder = filters.sortOrder - parameters.tags = filters.tags.map(\.value) - parameters.years = filters.years.compactMap { Int($0.value) } - - // Only set filtering on item types if selected, where - // supported values should have been set by the parent. - if filters.itemTypes.isNotEmpty { - parameters.includeItemTypes = filters.itemTypes - } - - if filters.letter.first?.value == "#" { - parameters.nameLessThan = "A" - } else { - parameters.nameStartsWith = filters.letter - .map(\.value) - .filter { $0 != "#" } - .first - } - - // Random sort won't take into account previous items, so - // manual exclusion is necessary. This could possibly be - // a performance issue for loading pages after already loading - // many items, but there's nothing we can do about that. - if filters.sortBy.first == ItemSortBy.random { - parameters.excludeItemIDs = elements.compactMap(\.id) - } - } - - return parameters - } - - // MARK: getRandomItem - - override func getRandomItem() async -> BaseItemDto? { - - var parameters = itemParameters(for: nil) - parameters.limit = 1 - parameters.sortBy = [ItemSortBy.random] - - let request = Paths.getItems(parameters: parameters) - let response = try? await userSession.client.send(request) - - return response?.value.items?.first - } -} diff --git a/Shared/ViewModels/LibraryViewModel/LatestInLibraryViewModel.swift b/Shared/ViewModels/LibraryViewModel/LatestInLibraryViewModel.swift deleted file mode 100644 index b70bebf113..0000000000 --- a/Shared/ViewModels/LibraryViewModel/LatestInLibraryViewModel.swift +++ /dev/null @@ -1,33 +0,0 @@ -// -// 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 Foundation -import JellyfinAPI - -final class LatestInLibraryViewModel: PagingLibraryViewModel, Identifiable { - - override func get(page: Int) async throws -> [BaseItemDto] { - - let parameters = parameters() - let request = Paths.getLatestMedia(parameters: parameters) - let response = try await userSession.client.send(request) - - return response.value - } - - private func parameters() -> Paths.GetLatestMediaParameters { - - var parameters = Paths.GetLatestMediaParameters() - parameters.parentID = parent?.id - parameters.fields = .MinimumFields - parameters.enableUserData = true - parameters.limit = pageSize - - return parameters - } -} diff --git a/Shared/ViewModels/LibraryViewModel/NextUpLibraryViewModel.swift b/Shared/ViewModels/LibraryViewModel/NextUpLibraryViewModel.swift deleted file mode 100644 index cdb7e9cc49..0000000000 --- a/Shared/ViewModels/LibraryViewModel/NextUpLibraryViewModel.swift +++ /dev/null @@ -1,44 +0,0 @@ -// -// 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 Combine -import Defaults -import Foundation -import JellyfinAPI - -final class NextUpLibraryViewModel: PagingLibraryViewModel { - - init() { - super.init(parent: TitledLibraryParent(displayTitle: L10n.nextUp, id: "nextUp")) - } - - override func get(page: Int) async throws -> [BaseItemDto] { - - let parameters = parameters(for: page) - let request = Paths.getNextUp(parameters: parameters) - let response = try await userSession.client.send(request) - - return response.value.items ?? [] - } - - private func parameters(for page: Int) -> Paths.GetNextUpParameters { - - let maxNextUp = Defaults[.Customization.Home.maxNextUp] - var parameters = Paths.GetNextUpParameters() - parameters.enableUserData = true - parameters.fields = .MinimumFields - parameters.limit = pageSize - if maxNextUp > 0 { - parameters.nextUpDateCutoff = Date.now.addingTimeInterval(-maxNextUp) - } - parameters.enableRewatching = Defaults[.Customization.Home.resumeNextUp] - parameters.startIndex = page - - return parameters - } -} diff --git a/Shared/ViewModels/LibraryViewModel/PagingLibraryViewModel.swift b/Shared/ViewModels/LibraryViewModel/PagingLibraryViewModel.swift deleted file mode 100644 index 057fa1d4ff..0000000000 --- a/Shared/ViewModels/LibraryViewModel/PagingLibraryViewModel.swift +++ /dev/null @@ -1,375 +0,0 @@ -// -// 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 Combine -import Defaults -import Foundation -import Get -import IdentifiedCollections -import JellyfinAPI -import OrderedCollections -import UIKit - -/// Magic number for page sizes -private let DefaultPageSize = 50 - -/// A protocol for items to conform to if they may be present within a library. -/// -/// Similar to `Identifiable`, but `unwrappedIDHashOrZero` is an `Int`: the hash of the underlying `id` -/// value if it is not optional, or if it is optional it must return the hash of the wrapped value, -/// or 0 otherwise: -/// -/// struct Item: LibraryIdentifiable { -/// var id: String? { "id" } -/// -/// var unwrappedIDHashOrZero: Int { -/// // Gets the `hashValue` of the `String.hashValue`, not `Optional.hashValue`. -/// id?.hashValue ?? 0 -/// } -/// } -/// -/// This is necessary because if the `ID` is optional, then `Optional.hashValue` will be used instead -/// and result in differing hashes. -/// -/// This also helps if items already conform to `Identifiable`, but has an optionally-typed `id`. -protocol LibraryIdentifiable: Identifiable { - - var unwrappedIDHashOrZero: Int { get } -} - -// TODO: fix how `hasNextPage` is determined -// - some subclasses might not have "paging" and only have one call. This can be solved with -// a check if elements were actually appended to the set but that requires a redundant get -// TODO: this doesn't allow "scrolling" to an item if index > pageSize -// on refresh. Should make bidirectional/offset index start? -// - use startIndex/index ranges instead of pages -// - source of data doesn't guarantee that all items in 0 ..< startIndex exist -// TODO: have `filterViewModel` be private to the parent and the `get_` overrides recieve the -// current filters as a parameter -// TODO: need an ID - -/* - Note: if `rememberSort == true`, then will override given filters with stored sorts - for parent ID. This was just easy. See `PagingLibraryView` notes for lack of - `rememberSort` observation and `StoredValues.User.libraryFilters` for TODO - on remembering other filters. - */ - -@MainActor -class PagingLibraryViewModel: ViewModel, Eventful, Stateful { - - // MARK: Event - - enum Event { - case gotRandomItem(Element) - } - - // MARK: Action - - enum Action: Equatable { - case error(ErrorMessage) - case refresh - case getNextPage - case getRandomItem - } - - // MARK: BackgroundState - - enum BackgroundState: Hashable { - case gettingNextPage - } - - // MARK: State - - enum State: Hashable { - case content - case error(ErrorMessage) - case initial - case refreshing - } - - @Published - var backgroundStates: Set = [] - /// - Keys: the `hashValue` of the `Element.ID` - @Published - var elements: IdentifiedArray - @Published - var state: State = .initial - - final let filterViewModel: FilterViewModel? - final let parent: (any LibraryParent)? - - final var events: AnyPublisher { - eventSubject - .receive(on: RunLoop.main) - .eraseToAnyPublisher() - } - - let pageSize: Int - private(set) var currentPage = 0 - private(set) var hasNextPage = true - - private let eventSubject: PassthroughSubject = .init() - private let isStatic: Bool - - // tasks - - private var pagingTask: AnyCancellable? - private var randomItemTask: AnyCancellable? - - // MARK: init - - // static - init( - _ data: some Collection, - parent: (any LibraryParent)? = nil - ) { - self.filterViewModel = nil - self.elements = IdentifiedArray(data, id: \.unwrappedIDHashOrZero, uniquingIDsWith: { x, _ in x }) - self.isStatic = true - self.hasNextPage = false - self.pageSize = DefaultPageSize - self.parent = parent - - super.init() - - Notifications[.didDeleteItem] - .publisher - .receive(on: RunLoop.main) - .sink { id in - self.elements.remove(id: id.hashValue) - } - .store(in: &cancellables) - } - - convenience init( - title: String, - id: String?, - _ data: some Collection - ) { - self.init( - data, - parent: TitledLibraryParent( - displayTitle: title, - id: id - ) - ) - } - - // paging - init( - parent: (any LibraryParent)? = nil, - filters: ItemFilterCollection? = nil, - pageSize: Int = DefaultPageSize - ) { - self.elements = IdentifiedArray([], id: \.unwrappedIDHashOrZero, uniquingIDsWith: { x, _ in x }) - self.isStatic = false - self.pageSize = pageSize - self.parent = parent - - if var filters { - if let id = parent?.id, Defaults[.Customization.Library.rememberSort] { - // TODO: see `StoredValues.User.libraryFilters` for TODO - // on remembering other filters - - let storedFilters = StoredValues[.User.libraryFilters(parentID: id)] - - filters.sortBy = storedFilters.sortBy - filters.sortOrder = storedFilters.sortOrder - } - - self.filterViewModel = .init( - parent: parent, - currentFilters: filters - ) - } else { - self.filterViewModel = nil - } - - super.init() - - Notifications[.didDeleteItem] - .publisher - .sink { id in - self.elements.remove(id: id.hashValue) - } - .store(in: &cancellables) - - if let filterViewModel { - filterViewModel.$currentFilters - .dropFirst() - .debounce(for: 1, scheduler: RunLoop.main) - .removeDuplicates() - .sink { [weak self] _ in - guard let self else { return } - - Task { @MainActor in - self.send(.refresh) - } - } - .store(in: &cancellables) - } - } - - convenience init( - title: String, - id: String?, - filters: ItemFilterCollection? = nil, - pageSize: Int = DefaultPageSize - ) { - self.init( - parent: TitledLibraryParent( - displayTitle: title, - id: id - ), - filters: filters, - pageSize: pageSize - ) - } - - // MARK: respond - - @MainActor - func respond(to action: Action) -> State { - - if action == .refresh, isStatic { - return .content - } - - switch action { - case let .error(error): - - Task { @MainActor in - elements.removeAll() - } - - return .error(error) - case .refresh: - - pagingTask?.cancel() - randomItemTask?.cancel() - - filterViewModel?.getQueryFilters() - - pagingTask = Task { [weak self] in - guard let self else { return } - - do { - try await self.refresh() - - guard !Task.isCancelled else { return } - - await MainActor.run { - self.state = .content - } - } catch { - guard !Task.isCancelled else { return } - - await MainActor.run { - self.send(.error(.init(error.localizedDescription))) - } - } - } - .asAnyCancellable() - - return .refreshing - case .getNextPage: - - guard hasNextPage else { return state } - - backgroundStates.insert(.gettingNextPage) - - pagingTask = Task { [weak self] in - do { - try await self?.getNextPage() - - guard !Task.isCancelled else { return } - - await MainActor.run { - self?.backgroundStates.remove(.gettingNextPage) - self?.state = .content - } - } catch { - guard !Task.isCancelled else { return } - - await MainActor.run { - self?.backgroundStates.remove(.gettingNextPage) - self?.state = .error(.init(error.localizedDescription)) - } - } - } - .asAnyCancellable() - - return .content - case .getRandomItem: - - randomItemTask = Task { [weak self] in - do { - guard let randomItem = try await self?.getRandomItem() else { return } - - guard !Task.isCancelled else { return } - - self?.eventSubject.send(.gotRandomItem(randomItem)) - } catch { - // TODO: when a general toasting mechanism is implemented, add - // background errors for errors from other background tasks - } - } - .asAnyCancellable() - - return state - } - } - - // MARK: refresh - - final func refresh() async throws { - - currentPage = -1 - hasNextPage = true - - await MainActor.run { - elements.removeAll() - } - - try await getNextPage() - } - - /// Gets the next page of items or immediately returns if - /// there is not a next page. - /// - /// See `get(page:)` for the conditions that determine - /// if there is a next page or not. - final func getNextPage() async throws { - guard hasNextPage else { return } - - currentPage += 1 - - let pageItems = try await get(page: currentPage) - - hasNextPage = !(pageItems.count < DefaultPageSize) - - await MainActor.run { - elements.append(contentsOf: pageItems) - } - } - - /// Gets the items at the given page. If the number of items - /// is less than `DefaultPageSize`, then it is inferred that - /// there is not a next page and subsequent calls to `getNextPage` - /// will immediately return. - func get(page: Int) async throws -> [Element] { - [] - } - - /// Gets a random item from `elements`. Override if item should - /// come from another source instead. - func getRandomItem() async throws -> Element? { - elements.randomElement() - } -} diff --git a/Shared/ViewModels/LibraryViewModel/RecentlyAddedViewModel.swift b/Shared/ViewModels/LibraryViewModel/RecentlyAddedViewModel.swift deleted file mode 100644 index b71a55fa7c..0000000000 --- a/Shared/ViewModels/LibraryViewModel/RecentlyAddedViewModel.swift +++ /dev/null @@ -1,56 +0,0 @@ -// -// 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 Combine -import Foundation -import JellyfinAPI - -// TODO: verify this properly returns pages of items in correct date-added order -// *when* new episodes are added to a series? -final class RecentlyAddedLibraryViewModel: PagingLibraryViewModel { - - // Necessary because this is paginated and also used on home view - init(customPageSize: Int? = nil) { - - // Why doesn't `super.init(title:id:pageSize)` init work? - if let customPageSize { - super.init(parent: TitledLibraryParent(displayTitle: L10n.recentlyAdded, id: "recentlyAdded"), pageSize: customPageSize) - } else { - super.init(parent: TitledLibraryParent(displayTitle: L10n.recentlyAdded, id: "recentlyAdded")) - } - } - - override func get(page: Int) async throws -> [BaseItemDto] { - - let parameters = parameters(for: page) - let request = Paths.getItems(parameters: parameters) - let response = try await userSession.client.send(request) - - return response.value.items ?? [] - } - - private func parameters(for page: Int) -> Paths.GetItemsParameters { - - var parameters = Paths.GetItemsParameters() - parameters.enableUserData = true - parameters.fields = .MinimumFields - parameters.includeItemTypes = [.movie, .series] - parameters.isRecursive = true - parameters.limit = pageSize - parameters.sortBy = [ItemSortBy.dateCreated] - parameters.sortOrder = [.descending] - parameters.startIndex = page - - // Necessary to get an actual "next page" with this endpoint. - // Could be a performance issue for lots of items, but there's - // nothing we can do about it. - parameters.excludeItemIDs = elements.compactMap(\.id) - - return parameters - } -} diff --git a/Shared/ViewModels/Localization/CountriesViewModel.swift b/Shared/ViewModels/Localization/CountriesViewModel.swift deleted file mode 100644 index f0b626afd2..0000000000 --- a/Shared/ViewModels/Localization/CountriesViewModel.swift +++ /dev/null @@ -1,21 +0,0 @@ -// -// 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 Combine -import Foundation -import JellyfinAPI - -final class CountriesViewModel: BaseFetchViewModel<[CountryInfo]> { - - override func getValue() async throws -> [CountryInfo] { - let request = Paths.getCountries - let response = try await userSession.client.send(request) - - return response.value - } -} diff --git a/Shared/ViewModels/Localization/CulturesViewModel.swift b/Shared/ViewModels/Localization/CulturesViewModel.swift deleted file mode 100644 index 214675dcaa..0000000000 --- a/Shared/ViewModels/Localization/CulturesViewModel.swift +++ /dev/null @@ -1,21 +0,0 @@ -// -// 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 Combine -import Foundation -import JellyfinAPI - -final class CulturesViewModel: BaseFetchViewModel<[CultureDto]> { - - override func getValue() async throws -> [CultureDto] { - let request = Paths.getCultures - let response = try await userSession.client.send(request) - - return response.value - } -} diff --git a/Shared/ViewModels/Localization/ParentalRatingsViewModel.swift b/Shared/ViewModels/Localization/ParentalRatingsViewModel.swift deleted file mode 100644 index 280211080a..0000000000 --- a/Shared/ViewModels/Localization/ParentalRatingsViewModel.swift +++ /dev/null @@ -1,21 +0,0 @@ -// -// 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 Combine -import Foundation -import JellyfinAPI - -final class ParentalRatingsViewModel: BaseFetchViewModel<[ParentalRating]> { - - override func getValue() async throws -> [ParentalRating] { - let request = Paths.getParentalRatings - let response = try await userSession.client.send(request) - - return response.value - } -} diff --git a/Shared/ViewModels/MediaViewModel/MediaType.swift b/Shared/ViewModels/MediaViewModel/MediaType.swift index 66642fff92..aad7995337 100644 --- a/Shared/ViewModels/MediaViewModel/MediaType.swift +++ b/Shared/ViewModels/MediaViewModel/MediaType.swift @@ -14,7 +14,6 @@ extension MediaViewModel { enum MediaType: Displayable, Hashable, Identifiable { case collectionFolder(BaseItemDto) - case downloads case favorites case liveTV(BaseItemDto) @@ -22,8 +21,6 @@ extension MediaViewModel { switch self { case let .collectionFolder(item): item.displayTitle - case .downloads: - L10n.downloads case .favorites: L10n.favorites case .liveTV: @@ -35,8 +32,6 @@ extension MediaViewModel { switch self { case let .collectionFolder(item): item.id - case .downloads: - "downloads" case .favorites: "favorites" case let .liveTV(item): diff --git a/Shared/ViewModels/MediaViewModel/MediaViewModel.swift b/Shared/ViewModels/MediaViewModel/MediaViewModel.swift index b4cae7e45f..1fd40299eb 100644 --- a/Shared/ViewModels/MediaViewModel/MediaViewModel.swift +++ b/Shared/ViewModels/MediaViewModel/MediaViewModel.swift @@ -89,11 +89,6 @@ final class MediaViewModel: ViewModel { return [] } - // downloads doesn't have random - if mediaType == .downloads { - return [] - } - var parentID: String? if case let MediaType.collectionFolder(item) = mediaType { @@ -118,6 +113,6 @@ final class MediaViewModel: ViewModel { let response = try await userSession.client.send(request) return (response.value.items ?? []) - .flatMap { $0.landscapeImageSources(maxWidth: 200) } + .flatMap { $0.landscapeImageSources(maxWidth: 200, environment: .init()) } } } diff --git a/Shared/ViewModels/ProgramsViewModel.swift b/Shared/ViewModels/ProgramsViewModel.swift deleted file mode 100644 index 4960ce720f..0000000000 --- a/Shared/ViewModels/ProgramsViewModel.swift +++ /dev/null @@ -1,171 +0,0 @@ -// -// 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 Combine -import Foundation -import JellyfinAPI - -final class ProgramsViewModel: ViewModel, Stateful { - - enum ProgramSection: CaseIterable { - case kids - case movies - case news - case recommended - case series - case sports - } - - // MARK: Action - - enum Action: Equatable { - case error(ErrorMessage) - case refresh - } - - // MARK: State - - enum State: Hashable { - case content - case error(ErrorMessage) - case initial - case refreshing - } - - @Published - private(set) var kids: [BaseItemDto] = [] - @Published - private(set) var movies: [BaseItemDto] = [] - @Published - private(set) var news: [BaseItemDto] = [] - @Published - private(set) var recommended: [BaseItemDto] = [] - @Published - private(set) var series: [BaseItemDto] = [] - @Published - private(set) var sports: [BaseItemDto] = [] - - @Published - var state: State = .initial - - private var currentRefreshTask: AnyCancellable? - - var hasNoResults: Bool { - [ - kids, - movies, - news, - recommended, - series, - sports, - ].allSatisfy(\.isEmpty) - } - - func respond(to action: Action) -> State { - switch action { - case let .error(error): - return .error(error) - case .refresh: - currentRefreshTask?.cancel() - - currentRefreshTask = Task { [weak self] in - guard let self else { return } - - do { - let sections = try await getItemSections() - - guard !Task.isCancelled else { return } - - await MainActor.run { - self.kids = sections[.kids] ?? [] - self.movies = sections[.movies] ?? [] - self.news = sections[.news] ?? [] - self.recommended = sections[.recommended] ?? [] - self.series = sections[.series] ?? [] - self.sports = sections[.sports] ?? [] - - self.state = .content - } - } catch { - guard !Task.isCancelled else { return } - - await MainActor.run { - self.send(.error(.init(error.localizedDescription))) - } - } - } - .asAnyCancellable() - - return .refreshing - } - } - - private func getItemSections() async throws -> [ProgramSection: [BaseItemDto]] { - try await withThrowingTaskGroup( - of: (ProgramSection, [BaseItemDto]).self, - returning: [ProgramSection: [BaseItemDto]].self - ) { group in - - // sections - for section in ProgramSection.allCases { - group.addTask { - let items = try await self.getPrograms(for: section) - return (section, items) - } - } - - // recommended - group.addTask { - let items = try await self.getRecommendedPrograms() - return (ProgramSection.recommended, items) - } - - var programs: [ProgramSection: [BaseItemDto]] = [:] - - while let items = try await group.next() { - programs[items.0] = items.1 - } - - return programs - } - } - - private func getRecommendedPrograms() async throws -> [BaseItemDto] { - - var parameters = Paths.GetRecommendedProgramsParameters() - parameters.fields = .MinimumFields - .appending(.channelInfo) - parameters.isAiring = true - parameters.limit = 20 - - let request = Paths.getRecommendedPrograms(parameters: parameters) - let response = try await userSession.client.send(request) - - return response.value.items ?? [] - } - - private func getPrograms(for section: ProgramSection) async throws -> [BaseItemDto] { - - var parameters = Paths.GetLiveTvProgramsParameters() - parameters.fields = .MinimumFields - .appending(.channelInfo) - parameters.hasAired = false - parameters.limit = 20 - - parameters.isKids = section == .kids - parameters.isMovie = section == .movies - parameters.isNews = section == .news - parameters.isSeries = section == .series - parameters.isSports = section == .sports - - let request = Paths.getLiveTvPrograms(parameters: parameters) - let response = try await userSession.client.send(request) - - return response.value.items ?? [] - } -} diff --git a/Shared/ViewModels/SearchViewModel.swift b/Shared/ViewModels/SearchViewModel.swift index bf517ced98..6e7e1269be 100644 --- a/Shared/ViewModels/SearchViewModel.swift +++ b/Shared/ViewModels/SearchViewModel.swift @@ -9,7 +9,6 @@ import Combine import Foundation import JellyfinAPI -import OrderedCollections import SwiftUI @MainActor @@ -20,7 +19,7 @@ final class SearchViewModel: ViewModel { enum Action { case getSuggestions case search(query: String) - case actuallySearch(query: String) + case _actuallySearch(query: String) var transition: Transition { switch self { @@ -28,7 +27,7 @@ final class SearchViewModel: ViewModel { .none case let .search(query): query.isEmpty ? .to(.initial) : .to(.searching) - case .actuallySearch: + case ._actuallySearch: .to(.searching, then: .initial) .onRepeat(.cancel) } @@ -41,45 +40,60 @@ final class SearchViewModel: ViewModel { case searching } - @Published - private(set) var items: [BaseItemKind: [BaseItemDto]] = [:] @Published private(set) var suggestions: [BaseItemDto] = [] - private var searchQuery: CurrentValueSubject = .init("") - let filterViewModel: FilterViewModel + let itemContentGroupViewModel: ContentGroupViewModel + + private let searchQuery: CurrentValueSubject = .init("") + + var isEmpty: Bool { + func extract(_ group: some ContentGroup) -> Bool { + func inner(_ vm: some __PagingLibaryViewModel) -> Bool { + vm.elements.isEmpty + } + + if let libaryViewModel = group.viewModel as? any __PagingLibaryViewModel { + return inner(libaryViewModel) + } else { + return true + } + } + + return itemContentGroupViewModel.groups + .map { extract($0) } + .allSatisfy(\.self) + } - var hasNoResults: Bool { - items.values.allSatisfy(\.isEmpty) + var isNotEmpty: Bool { + !isEmpty } var canSearch: Bool { searchQuery.value.isNotEmpty || filterViewModel.currentFilters.hasQueryableFilters } - // MARK: init + init(filterViewModel: FilterViewModel? = nil) { + let filterViewModel = filterViewModel ?? .init() - @MainActor - init(filterViewModel: FilterViewModel) { self.filterViewModel = filterViewModel + self.itemContentGroupViewModel = .init(provider: .init()) + super.init() searchQuery .debounce(for: 0.5, scheduler: RunLoop.main) .sink { [weak self] query in - guard let self else { return } - - actuallySearch(query: query) + self?._actuallySearch(query: query) } .store(in: &cancellables) filterViewModel.$currentFilters .debounce(for: 0.5, scheduler: RunLoop.main) .sink { [weak self] _ in - guard let self else { return } - - actuallySearch(query: searchQuery.value) + guard let query = self?.searchQuery.value else { return } + self?._actuallySearch(query: query) } .store(in: &cancellables) } @@ -91,108 +105,30 @@ final class SearchViewModel: ViewModel { await cancel() } - @Function(\Action.Cases.actuallySearch) - private func _actuallySearch(_ query: String) async throws { + @Function(\Action.Cases._actuallySearch) + private func __actuallySearch(_ query: String) async throws { - guard self.canSearch else { - items.removeAll() - return - } - - let newItems = try await withThrowingTaskGroup( - of: (BaseItemKind, [BaseItemDto]).self, - returning: [BaseItemKind: [BaseItemDto]].self - ) { group in - - // Base items - let retrievingItemTypes: [BaseItemKind] = [ - .boxSet, - .episode, - .movie, - .musicArtist, - .musicVideo, - .liveTvProgram, - .series, - .tvChannel, - .video, - ] - - for type in retrievingItemTypes { - group.addTask { - let items = try await self._getItems(query: query, itemType: type) - return (type, items) - } - } - - // People - group.addTask { - let items = try await self._getPeople(query: query) - return (BaseItemKind.person, items) - } + guard canSearch else { return } - var result: [BaseItemKind: [BaseItemDto]] = [:] + func inner(_ vm: VM) where VM._PagingLibrary == ItemLibrary { + var filters = vm.environment.filters + filters.query = query - while let items = try await group.next() { - if items.1.isNotEmpty { - result[items.0] = items.1 - } - } - - return result + vm.environment = .init( + grouping: vm.environment.grouping, + filters: filters, + fields: nil + ) } - guard !Task.isCancelled else { return } - self.items = newItems - } + var filters = filterViewModel.currentFilters + filters.query = query - private func _getItems(query: String, itemType: BaseItemKind) async throws -> [BaseItemDto] { + itemContentGroupViewModel.provider.environment.filters = filters - var parameters = Paths.GetItemsParameters() - parameters.enableUserData = true - parameters.fields = .MinimumFields - parameters.includeItemTypes = [itemType] - parameters.isRecursive = true - parameters.limit = 20 - parameters.searchTerm = query - - // Filters - let filters = filterViewModel.currentFilters - parameters.filters = filters.traits - parameters.genres = filters.genres.map(\.value) - parameters.sortBy = filters.sortBy - parameters.sortOrder = filters.sortOrder - parameters.tags = filters.tags.map(\.value) - parameters.years = filters.years.map(\.intValue) - - if filters.letter.first?.value == "#" { - parameters.nameLessThan = "A" - } else { - parameters.nameStartsWith = filters.letter - .map(\.value) - .filter { $0 != "#" } - .first - } - - let request = Paths.getItems(parameters: parameters) - let response = try await userSession.client.send(request) - - return response.value.items ?? [] - } - - private func _getPeople(query: String) async throws -> [BaseItemDto] { - - var parameters = Paths.GetPersonsParameters() - parameters.limit = 20 - parameters.searchTerm = query - - let request = Paths.getPersons(parameters: parameters) - let response = try await userSession.client.send(request) - - return response.value.items ?? [] + await itemContentGroupViewModel.refresh() } - // MARK: suggestions - @Function(\Action.Cases.getSuggestions) private func _getSuggestions() async throws { diff --git a/Shared/ViewModels/UserSignInViewModel.swift b/Shared/ViewModels/UserSignInViewModel.swift index 17a6580da5..408854ccf5 100644 --- a/Shared/ViewModels/UserSignInViewModel.swift +++ b/Shared/ViewModels/UserSignInViewModel.swift @@ -64,12 +64,14 @@ final class UserSignInViewModel: ViewModel { switch self { case .cancel: .to(.initial) - case .error, .save, .saveExisting: + case .save, .saveExisting: .none case .getPublicData: .background(.gettingPublicData) case .signIn, .signInQuickConnect: .loop(.signingIn) + default: + .none } } } diff --git a/Shared/Views/ContentGroupView.swift b/Shared/Views/ContentGroupView.swift new file mode 100644 index 0000000000..d57249c548 --- /dev/null +++ b/Shared/Views/ContentGroupView.swift @@ -0,0 +1,128 @@ +// +// 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 Defaults +import Factory +import Foundation +import JellyfinAPI +import SwiftUI + +struct ContentGroupView: View { + + @Router + private var router + + @State + private var contentGroupOptions: ContentGroupParentOption = .init() + + @StateObject + private var viewModel: ContentGroupViewModel + + @TabItemSelected + private var tabItemSelected + + init(provider: Provider) { + _viewModel = StateObject(wrappedValue: ContentGroupViewModel(provider: provider)) + } + + init(viewModel: ContentGroupViewModel) { + _viewModel = StateObject(wrappedValue: viewModel) + } + + @ViewBuilder + private func makeGroupBody(_ group: some ContentGroup) -> some View { + group.body(with: group.viewModel) + } + + @ViewBuilder + private var contentView: some View { + ScrollViewReader { proxy in + ScrollView { + Color.clear + .frame(height: 0) + .id("top") + + VStack(alignment: .leading, spacing: 10) { + ForEach(Array(viewModel.groups.enumerated()), id: \.element.id) { _, group in + makeGroupBody(group) + .eraseToAnyView() + } + .onPreferenceChange(ContentGroupCustomizationKey.self) { value in + contentGroupOptions = value + } + } + .edgePadding( + .bottom.inserting( + .top, + if: contentGroupOptions.contains(.ignoreTopSafeArea) + ) + ) + .frame(maxWidth: .infinity, alignment: .leading) + } + .ignoresSafeArea( + edges: .horizontal.inserting( + .top, + if: contentGroupOptions.contains(.ignoreTopSafeArea) + ) + ) + .scrollIndicators(.hidden) + .refreshable { + await viewModel.background.refresh() + } + .onReceive(tabItemSelected) { event in + if event.isRepeat, event.isRoot { + withAnimation { + proxy.scrollTo("top", anchor: .top) + } + } + } + } + .trackingFrame(for: .scrollView) + } + + var body: some View { + ZStack { + switch viewModel.state { + case .content: + if viewModel.groups.isEmpty { + // TODO: non-error like empty view + ErrorView(error: ErrorMessage(L10n.noResults)) + .refreshable { + viewModel.refresh() + } + } else { + contentView + } + case .error: + viewModel.error.map(ErrorView.init) + case .initial, .refreshing: + ProgressView() + } + } + .animation(.linear(duration: 0.2), value: viewModel.state) + .animation(.linear(duration: 0.2), value: viewModel.background.states) + .navigationTitle(viewModel.provider.displayTitle) + .backport + .toolbarTitleDisplayMode(router.isRootOfPath ? .inlineLarge : .inline) + .onFirstAppear { + viewModel.refresh() + } + .sinceLastDisappear { _ in +// viewModel.refreshIfNeeded(sinceLastDisappear: interval) + } + .onSceneWillEnterForeground { +// viewModel.refreshIfPendingChanges() + } + .topBarTrailing { + + if viewModel.background.is(.refreshing) { + ProgressView() + } + } + } +} diff --git a/Shared/Views/FilterView.swift b/Shared/Views/FilterView.swift index 8c3524320d..13bad47879 100644 --- a/Shared/Views/FilterView.swift +++ b/Shared/Views/FilterView.swift @@ -6,53 +6,67 @@ // Copyright (c) 2026 Jellyfin & Jellyfin Contributors // +import JellyfinAPI import SwiftUI struct FilterView: View { - @ObservedObject - var viewModel: FilterViewModel - @Router private var router - let type: ItemFilterType + @ObservedObject + private var viewModel: FilterViewModel + + private let types: [ItemFilterType] + + init( + viewModel: FilterViewModel, + type: ItemFilterType + ) { + self.viewModel = viewModel + self.types = [type] + } + + init( + viewModel: FilterViewModel, + types: [ItemFilterType] + ) { + self.viewModel = viewModel + self.types = types + } @ViewBuilder - private func selector(group: ItemFilterType.Group) -> some View { - let source = viewModel.allFilters[keyPath: group.keyPath] + private func section(for type: ItemFilterType) -> some View { + ForEach(type.group, id: \.displayTitle) { group in + let source = viewModel.allFilters[keyPath: group.keyPath] - let selectionBinding: Binding<[AnyItemFilter]> = Binding { - viewModel.currentFilters[keyPath: group.keyPath] - } set: { - group.setter($0, viewModel) - } + let selectionBinding: Binding<[AnyItemFilter]> = Binding { + viewModel.currentFilters[keyPath: group.keyPath] + } set: { newValue in + group.setter(newValue, viewModel) + } - if source.isEmpty { - Text(L10n.none) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) - } else { - SelectorView( - selection: selectionBinding, - sources: source, - type: group.selectorType - ) + Section(group.displayTitle) { + if source.isEmpty { + Text(L10n.none) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + } else { + SelectorView( + selection: selectionBinding, + sources: source, + type: group.selectorType + ) + } + } } } var body: some View { - Form(systemImage: type.systemImage) { - ForEach(type.group) { element in - Section { - selector(group: element) - } header: { - if type.group.count > 1 { - Text(element.displayTitle) - } - } + Form(systemImage: "line.3.horizontal.decrease") { + ForEach(types, id: \.self) { type in + section(for: type) } } - .navigationTitle(type.displayTitle) .backport .toolbarTitleDisplayMode(.inline) .navigationBarCloseButton { @@ -60,9 +74,11 @@ struct FilterView: View { } .topBarTrailing { Button(L10n.reset) { - viewModel.reset(filterType: type) + for type in types { + viewModel.reset(filterType: type) + } } - .enabled(viewModel.isFilterSelected(type: type)) + .enabled(types.contains { viewModel.isFilterSelected(type: $0) }) } } } diff --git a/Shared/Views/ItemContentGroupView/AboutItemGroup.swift b/Shared/Views/ItemContentGroupView/AboutItemGroup.swift new file mode 100644 index 0000000000..b5e4e41caa --- /dev/null +++ b/Shared/Views/ItemContentGroupView/AboutItemGroup.swift @@ -0,0 +1,221 @@ +// +// 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 JellyfinAPI +import SwiftUI + +struct AboutItemGroup: ContentGroup { + + let displayTitle: String + let id: String + let item: BaseItemDto + + func body(with viewModel: Empty) -> Body { + Body(item: item) + } + + struct Body: View { + + @Router + private var router + + let item: BaseItemDto + + private struct AboutCard: View { + + let title: String + let subtitle: String? + let minWidth: CGFloat? + let content: Content + + init( + title: String, + subtitle: String? = nil, + minWidth: CGFloat? = nil, + @ViewBuilder content: () -> Content + ) { + self.title = title + self.subtitle = subtitle + self.minWidth = minWidth + self.content = content() + } + + var body: some View { + VStack(alignment: .leading) { + VStack(alignment: .leading, spacing: 5) { + Text(title) + .font(.title3) + .fontWeight(.semibold) + .lineLimit(2) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .topLeading) + + if let subtitle, subtitle.isNotEmpty { + Text(subtitle) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(2) + .multilineTextAlignment(.leading) + } + } + .frame(maxHeight: .infinity, alignment: .topLeading) + + content + } + .padding() + .frame(minWidth: minWidth, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(.complexSecondary) + ) + } + } + + @ViewBuilder + private var descriptionCard: some View { + let subtitle = item.taglines?.first + + Button { + router.route(to: .itemOverview(item: item)) + } label: { + AboutCard(title: item.displayTitle, subtitle: subtitle) { + if let overview = item.overview, overview.isNotEmpty { + SeeMoreText(overview) {} + .allowsHitTesting(false) + .font(.footnote) + .lineLimit(4) + .multilineTextAlignment(.leading) + } + } + } + .foregroundStyle(.primary, .secondary) + .buttonStyle(.card) + } + + @ViewBuilder + private func mediaSourceCard(for mediaSource: MediaSourceInfo, hasMultipleSources: Bool) -> some View { + let subtitle = hasMultipleSources ? mediaSource.displayTitle : nil + + Button { + router.route(to: .mediaSourceInfo(source: mediaSource)) + } label: { + AboutCard(title: L10n.media, subtitle: subtitle) { + if let mediaStreams = mediaSource.mediaStreams { + let text = mediaStreams.compactMap(\.displayTitle) + .joined(separator: ", ") + + SeeMoreText(text) {} + .allowsHitTesting(false) + .font(.footnote) + .lineLimit(4) + .multilineTextAlignment(.leading) + } + } + } + .foregroundStyle(.primary, .secondary) + .buttonStyle(.card) + } + + @ViewBuilder + private func ratingsCard(criticRating: Float, communityRating: Float) -> some View { + Button {} label: { + AboutCard(title: L10n.ratings, minWidth: 200) { + if criticRating > -1 { + HStack { + Group { + if criticRating >= 60 { + Image(.tomatoFresh) + .symbolRenderingMode(.multicolor) + .foregroundStyle(.green, .red) + } else { + Image(.tomatoRotten) + .symbolRenderingMode(.monochrome) + .foregroundColor(.green) + } + } + .font(.largeTitle) + + Text("\(criticRating, specifier: "%.0f")") + .fontWeight(.semibold) + } + } + + if communityRating > -1 { + HStack { + Image(systemName: "star.fill") + .symbolRenderingMode(.multicolor) + .foregroundStyle(.yellow) + .font(.largeTitle) + + Text("\(communityRating, specifier: "%.1f")") + .fontWeight(.semibold) + } + } + } + } + .foregroundStyle(.primary, .secondary) + .buttonStyle(.card) + } + + private func cardWidth(for frameWidth: CGFloat) -> CGFloat { + #if os(tvOS) + 700 + #else + if UIDevice.isPhone { + max(300, frameWidth - EdgeInsets.edgePadding * 2.5) + } else { + 400 + } + #endif + } + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Section { + WithFrame { frame in + ZStack { + ScrollView(.horizontal) { + HStack(spacing: UIDevice.isPhone ? EdgeInsets.edgePadding / 2 : 40) { + descriptionCard + .frame(width: cardWidth(for: frame.frame.width)) + + if let mediaSources = item.mediaSources { + ForEach(mediaSources) { source in + mediaSourceCard(for: source, hasMultipleSources: mediaSources.count > 1) + .frame(width: cardWidth(for: frame.frame.width)) + } + } + + if item.criticRating != nil || item.communityRating != nil { + ratingsCard( + criticRating: item.criticRating ?? -1, + communityRating: item.communityRating ?? -1 + ) + } + } + .edgePadding(.horizontal) + } + #if os(tvOS) + .scrollClipDisabled() + .withViewContext(.isOverComplexContent) + #endif + } + .frame(height: UIDevice.isTV ? 400 : 200) + .frame(maxWidth: .infinity) + } + } header: { + Text(L10n.about) + .font(.title2) + .fontWeight(.semibold) + .accessibilityAddTraits(.isHeader) + .edgePadding(.horizontal) + } + } + } + } +} diff --git a/Shared/Views/ItemContentGroupView/EnhancedItemViewHeader/EnhancedItemViewHeader-CompactBody.swift b/Shared/Views/ItemContentGroupView/EnhancedItemViewHeader/EnhancedItemViewHeader-CompactBody.swift new file mode 100644 index 0000000000..98410f36ff --- /dev/null +++ b/Shared/Views/ItemContentGroupView/EnhancedItemViewHeader/EnhancedItemViewHeader-CompactBody.swift @@ -0,0 +1,170 @@ +// +// 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 SwiftUI + +extension EnhancedItemViewHeader { + + struct CompactBody: View { + + @ObservedObject + var viewModel: ItemViewModel + + @Router + private var router + + @ViewBuilder + private var logo: some View { + ImageView(viewModel.item.imageURL(.logo, maxHeight: 70)) + .placeholder { _ in + EmptyView() + } + .failure { + Text(viewModel.item.displayTitle) + .fixedSize(horizontal: false, vertical: true) + .font(.largeTitle) + .fontWeight(.semibold) + .lineLimit(2) + .multilineTextAlignment(.center) + .foregroundStyle(.primary) + } + .aspectRatio(contentMode: .fit) + .frame(height: 70, alignment: .bottom) + } + + @ViewBuilder + private var overlay: some View { + VStack(alignment: .center, spacing: 10) { + AlternateLayoutView(alignment: .bottom) { + Color.clear + .aspectRatio(1.77, contentMode: .fit) + .padding(.bottom, 35) + } content: { + logo + } + .zIndex(10) + + VStack(alignment: .center, spacing: 10) { + DotHStack { + if let firstGenre = viewModel.item.genres?.first { + Text(firstGenre) + } + + if let premiereYear = viewModel.item.premiereDateYear { + Text(premiereYear) + } + + if let runtime = viewModel.item.runtime { + Text(runtime, format: .hourMinuteAbbreviated) + } + } + .font(.caption) + .fontWeight(.semibold) + .foregroundStyle(.secondary) + + VStack(alignment: .center, spacing: 5) { + if viewModel.item.presentPlayButton { + PlayButton(viewModel: viewModel) + } + + ActionButtonHStack( + item: viewModel.item, + localTrailers: viewModel.localTrailers + ) + } + .frame(maxWidth: 300) + + VStack(alignment: .leading, spacing: 5) { + if let tagline = viewModel.item.taglines?.first { + Text(tagline) + .fontWeight(.bold) + .lineLimit(2) + } + + if let overview = viewModel.item.overview { + SeeMoreText(overview) { + router.route(to: .itemOverview(item: viewModel.item)) + } + .font(.footnote) + .lineLimit(3) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + + AttributesHStack( + item: viewModel.item, + mediaSource: viewModel.selectedMediaSource + ) + .frame(maxWidth: .infinity, alignment: .leading) + } + .edgePadding(.bottom) + .background( + alignment: .bottom, + extendedBy: .init(vertical: 25, horizontal: EdgeInsets.edgePadding) + ) { + Rectangle() + .fill(Material.ultraThin) + .maskLinearGradient { + (location: 0, opacity: 0) + (location: 0.1, opacity: 0.7) + (location: 0.2, opacity: 1) + } + } + .zIndex(9) + } + } + + var body: some View { + + let backdropColor = viewModel.item.blurHash(for: .backdrop)?.averageLinearColor ?? Color.gray + + VStack { + overlay + .edgePadding(.horizontal) + .frame(maxWidth: .infinity) + .colorScheme(.dark) + } + .backgroundParallaxHeader( + multiplier: 0.3, + _backgroundColor: backdropColor + ) { + AlternateLayoutView { + Color.clear + } content: { + ImageView( + viewModel.item.landscapeImageSources(maxWidth: 1320, environment: .init(useParent: false)) + ) + } + .aspectRatio(1.77, contentMode: .fit) + .overlay(alignment: .bottom) { + backdropColor + .frame(height: 30) + .maskLinearGradient { + (location: 0, opacity: 0) + (location: 0.7, opacity: 1) + } + } + .accessibilityHidden(true) + } + .scrollViewHeaderOffsetOpacity() + .trackingFrame(for: .scrollViewHeader, key: ScrollViewHeaderFrameKey.self) + .preference(key: ContentGroupCustomizationKey.self, value: .useOffsetNavigationBar) + .preference(key: MenuContentKey.self) { + #if os(iOS) + if viewModel.item.canEditMetadata { + MenuContentGroup(id: "test") { + Button(L10n.edit, systemImage: "pencil") { + router.route(to: .editItem(viewModel.item)) + } + } + } + #endif + } + } + } +} diff --git a/Shared/Views/ItemContentGroupView/EnhancedItemViewHeader/EnhancedItemViewHeader-RegularBody.swift b/Shared/Views/ItemContentGroupView/EnhancedItemViewHeader/EnhancedItemViewHeader-RegularBody.swift new file mode 100644 index 0000000000..c0265a7495 --- /dev/null +++ b/Shared/Views/ItemContentGroupView/EnhancedItemViewHeader/EnhancedItemViewHeader-RegularBody.swift @@ -0,0 +1,171 @@ +// +// 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 SwiftUI + +extension EnhancedItemViewHeader { + + struct RegularBody: View { + + @Environment(\.frameForParentView) + private var frameForParentView + + @ObservedObject + var viewModel: ItemViewModel + + @Router + private var router + + @ViewBuilder + private var logo: some View { + ImageView(viewModel.item.imageURL(.logo, maxHeight: 70)) + .placeholder { _ in + EmptyView() + } + .failure { + Text(viewModel.item.displayTitle) + .fixedSize(horizontal: false, vertical: true) + .font(.largeTitle) + .fontWeight(.semibold) + .lineLimit(2) + .multilineTextAlignment(.leading) + .foregroundStyle(.primary) + } + .aspectRatio(contentMode: .fit) + .frame(height: 70, alignment: .bottom) + } + + @ViewBuilder + private var overlay: some View { + HStack(alignment: .bottom, spacing: EdgeInsets.edgePadding) { + VStack(alignment: .leading, spacing: 10) { + logo + + VStack(alignment: .leading, spacing: 5) { + if let tagline = viewModel.item.taglines?.first { + Text(tagline) + .fontWeight(.bold) + .lineLimit(2) + } + + if let overview = viewModel.item.overview { + SeeMoreText(overview) { + router.route(to: .itemOverview(item: viewModel.item)) + } + .font(.footnote) + .lineLimit(3) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + + HStack(alignment: .top) { + AttributesHStack( + item: viewModel.item, + mediaSource: viewModel.selectedMediaSource + ) + + DotHStack { + if let firstGenre = viewModel.item.genres?.first { + Text(firstGenre) + } + + if let premiereYear = viewModel.item.premiereDateYear { + Text(premiereYear) + } + + if let runtime = viewModel.item.runtime { + Text(runtime, format: .hourMinuteAbbreviated) + } + } + .font(.caption) + .fontWeight(.semibold) + .foregroundStyle(.secondary) + } + } + .frame(maxWidth: .infinity) + + VStack(alignment: .center, spacing: 5) { + if viewModel.item.presentPlayButton { + PlayButton(viewModel: viewModel) + } + + ActionButtonHStack( + item: viewModel.item, + localTrailers: viewModel.localTrailers + ) + } + #if os(tvOS) + .frame(width: 450) + #else + .frame(maxWidth: 300) + #endif + } + .edgePadding(.bottom) + .background( + alignment: .bottom, + extendedBy: .init(horizontal: EdgeInsets.edgePadding) + ) { + Rectangle() + .fill(Material.ultraThin) + .maskLinearGradient { + (location: 0, opacity: 0) + (location: 0.5, opacity: 1) + } + } + } + + var body: some View { + AlternateLayoutView(alignment: .bottom) { + Color.clear + .aspectRatio(2, contentMode: .fit) + } content: { + overlay + .edgePadding(.horizontal) + .frame(maxWidth: .infinity) + .colorScheme(.dark) + } + .backgroundParallaxHeader( + multiplier: 0.3 + ) { + AlternateLayoutView { + Color.clear + .aspectRatio(2, contentMode: .fit) + } content: { + ImageView( + viewModel.item.landscapeImageSources(maxWidth: 1320, environment: .init(useParent: false)) + ) + .aspectRatio(contentMode: .fit) + } + } + .scrollViewHeaderOffsetOpacity() + .trackingFrame( + in: .local, + for: .scrollViewHeader, + key: ScrollViewHeaderFrameKey.self + ) + .environment(\.frameForParentView, frameForParentView.removingValue(for: .navigationStack)) + .preference(key: ContentGroupCustomizationKey.self, value: .useOffsetNavigationBar) + .preference(key: MenuContentKey.self) { + // if viewModel.userSession.user.permissions.items.canEditMetadata(item: viewModel.item) { + #if os(iOS) + MenuContentGroup(id: "test") { + Button(L10n.edit, systemImage: "pencil") { + router.route(to: .editItem(viewModel.item)) + // router.route(to: .settings) + } + } + #endif + // } + } + } + } +} + +import Combine +import Factory +import JellyfinAPI diff --git a/Shared/Views/ItemContentGroupView/EnhancedItemViewHeader/EnhancedItemViewHeader.swift b/Shared/Views/ItemContentGroupView/EnhancedItemViewHeader/EnhancedItemViewHeader.swift new file mode 100644 index 0000000000..589973d0a5 --- /dev/null +++ b/Shared/Views/ItemContentGroupView/EnhancedItemViewHeader/EnhancedItemViewHeader.swift @@ -0,0 +1,25 @@ +// +// 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 JellyfinAPI +import SwiftUI + +struct EnhancedItemViewHeader: ContentGroup { + + let id = "item-view-header" + let viewModel: Empty = .init() + let itemViewModel: ItemViewModel + + func body(with viewModel: Empty) -> some View { + if UIDevice.isPhone { + CompactBody(viewModel: itemViewModel) + } else { + RegularBody(viewModel: itemViewModel) + } + } +} diff --git a/Shared/Views/ItemContentGroupView/ItemContentGroupView.swift b/Shared/Views/ItemContentGroupView/ItemContentGroupView.swift new file mode 100644 index 0000000000..9a31108e09 --- /dev/null +++ b/Shared/Views/ItemContentGroupView/ItemContentGroupView.swift @@ -0,0 +1,103 @@ +// +// 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 Defaults +import SwiftUI + +struct ItemContentGroupView: View { + + @Router + private var router + + @State + private var contentGroupOptions: ContentGroupParentOption = .init() + @State + private var carriedHeaderFrame: CGRect = .zero + + @StateObject + private var viewModel: ContentGroupViewModel + + private var carriedUseOffsetNavigationBar: Bool { + contentGroupOptions.contains(.useOffsetNavigationBar) + } + + init(provider: Provider) { + _viewModel = StateObject(wrappedValue: ContentGroupViewModel(provider: provider)) + } + + @ViewBuilder + private func makeGroupBody(_ group: some ContentGroup) -> some View { + group.body(with: group.viewModel) + } + + @ViewBuilder + private var contentView: some View { + OffsetNavigationBar(headerMaxY: carriedUseOffsetNavigationBar ? carriedHeaderFrame.maxY : nil) { + WithEnvironment(\.frameForParentView) { frameForParentView in + ScrollView { + VStack(alignment: .leading, spacing: UIDevice.isTV ? 40 : 10) { + + // SwiftUI bug causes preference key changes to not propagate any higher + ForEach(viewModel.groups, id: \.id) { group in + makeGroupBody(group) + .eraseToAnyView() + } + .onPreferenceChange(ContentGroupCustomizationKey.self) { value in + contentGroupOptions = value + } + .onPreferenceChange(ScrollViewHeaderFrameKey.self) { value in + carriedHeaderFrame = value.frame + } + } + .edgePadding( + .bottom.inserting( + .top, + if: !carriedUseOffsetNavigationBar + ) + ) + } + .ignoresSafeArea(edges: .horizontal) + .scrollIndicators(.hidden) + .overlay(alignment: .top) { + if carriedUseOffsetNavigationBar, UIDevice.isPhone { + Rectangle() + .fill(Material.ultraThin) + .maskLinearGradient() + .frame(height: frameForParentView[.navigationStack, default: .zero].safeAreaInsets.top) + .offset(y: -frameForParentView[.scrollView, default: .zero].safeAreaInsets.top) + .colorScheme(.dark) + .hidden(!carriedUseOffsetNavigationBar) + } + } + } + } + .trackingFrame(for: .scrollView) + } + + var body: some View { + ZStack { + switch viewModel.state { + case .content: + contentView + .navigationTitle(viewModel.provider.displayTitle) + case .error: + viewModel.error.map(ErrorView.init) + case .initial, .refreshing: + ProgressView() + } + } + .animation(.linear(duration: 0.2), value: viewModel.state) + .animation(.linear(duration: 0.2), value: viewModel.background.states) + .backport + .toolbarTitleDisplayMode(router.isRootOfPath ? .inlineLarge : .inline) + .onFirstAppear { + viewModel.refresh() + } + .navigationBarMenuButton(isLoading: viewModel.background.is(.refreshing)) {} + } +} diff --git a/Shared/Views/ItemContentGroupView/ItemGroupProvider.swift b/Shared/Views/ItemContentGroupView/ItemGroupProvider.swift new file mode 100644 index 0000000000..d9edecf735 --- /dev/null +++ b/Shared/Views/ItemContentGroupView/ItemGroupProvider.swift @@ -0,0 +1,188 @@ +// +// 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 Defaults +import JellyfinAPI +import SwiftUI + +struct ItemGroupProvider: ContentGroupProvider { + + let displayTitle: String + let id: String + + let viewModel: ItemViewModel + + init(displayTitle: String, id: String) { + self.displayTitle = displayTitle + self.id = id + + self.viewModel = .init(id: id) + } + + func makeGroups(environment: Empty) async throws -> [any ContentGroup] { + await viewModel.refresh() + + guard viewModel.error == nil else { + throw viewModel.error! + } + + return try await _makeGroups( + item: viewModel.item, + itemID: id + ) + } + + @ContentGroupBuilder + private func _makeGroups(item: BaseItemDto, itemID: String) async throws -> [any ContentGroup] { + + if UIDevice.isPad || UIDevice.isTV { + EnhancedItemViewHeader(itemViewModel: viewModel) + } else { + if item.type == .movie || item.type == .series, + Defaults[.Customization.itemViewType] == .enhanced, + item.backdropImageTags?.isNotEmpty == true + { + EnhancedItemViewHeader(itemViewModel: viewModel) + } else if item.type == .person || item.type == .musicArtist { + PortraitItemViewHeader(itemViewModel: viewModel) + } else { + SimpleItemViewHeader(itemViewModel: viewModel) + } + } + + // TODO: show age of person + if let birthday = item.birthday?.formatted(date: .long, time: .omitted) { + LabeledContentGroup( + L10n.born, + value: birthday + ) + } + + if let deathday = item.deathday?.formatted(date: .long, time: .omitted) { + LabeledContentGroup( + L10n.died, + value: deathday + ) + } + + if let birthplace = item.birthplace { + LabeledContentGroup( + L10n.birthplace, + value: birthplace + ) + } + + if item.type == .series { + SeriesEpisodeContentGroup(viewModel: viewModel) + } + + if let genres = item.itemGenres, genres.isNotEmpty { + PillGroup( + displayTitle: L10n.genres, + id: "genres", + elements: genres + ) { router, element in + router.route( + to: .contentGroup( + provider: ItemTypeContentGroupProvider( + itemTypes: [ + BaseItemKind.movie, + .series, + .boxSet, + .episode, + .musicVideo, + .video, + .liveTvProgram, + .tvChannel, + .person, + ], + parent: .init(name: element.displayTitle), + environment: .init(filters: .init(genres: [element])) + ) + ) + ) + } + } + + if let studios = item.itemStudios, studios.isNotEmpty { + PillGroup( + displayTitle: L10n.studios, + id: "studios", + elements: studios + ) { router, element in + router.route( + to: .contentGroup( + provider: ItemTypeContentGroupProvider( + itemTypes: [ + BaseItemKind.movie, + .series, + .boxSet, + .episode, + .musicVideo, + .video, + .liveTvProgram, + .tvChannel, + .person, + ], + parent: .init( + id: element.value, + name: element.displayTitle, + type: .studio + ) + ) + ) + ) + } + } + + switch item.type { + case .boxSet, .person, .musicArtist: + try await ItemTypeContentGroupProvider( + itemTypes: BaseItemKind.supportedCases + .appending(.episode) + .appending(.person), + parent: item + ) + .makeGroups(environment: .default) + default: [] + } + + if let castAndCrew = item.people, castAndCrew.isNotEmpty { + PosterGroup( + id: "cast-and-crew", + library: StaticLibrary( + title: L10n.castAndCrew.localizedCapitalized, + id: "cast-and-crew", + elements: castAndCrew + ), + posterDisplayType: .portrait, + posterSize: .small + ) + } + + PosterGroup( + id: "special-features", + library: SpecialFeaturesLibrary(itemID: itemID), + posterDisplayType: .landscape, + posterSize: .small + ) + + PosterGroup( + id: "similar-items", + library: SimilarItemsLibrary(itemID: itemID), + posterDisplayType: .landscape, + posterSize: .small + ) + + AboutItemGroup( + displayTitle: L10n.about, + id: "about", + item: item + ) + } +} diff --git a/Shared/Views/ItemContentGroupView/LabeledContentGroup.swift b/Shared/Views/ItemContentGroupView/LabeledContentGroup.swift new file mode 100644 index 0000000000..05f109dd85 --- /dev/null +++ b/Shared/Views/ItemContentGroupView/LabeledContentGroup.swift @@ -0,0 +1,38 @@ +// +// 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 SwiftUI + +struct LabeledContentGroup: ContentGroup { + + let displayTitle: String + let id: String + let value: String + + init( + _ title: String, + value: String + ) { + self.displayTitle = title + self.id = UUID().uuidString + self.value = value + } + + func body(with viewModel: Empty) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text(displayTitle) + .font(.headline) + .foregroundStyle(.primary) + + Text(value) + .font(.footnote) + .foregroundStyle(.secondary) + } + .edgePadding(.horizontal) + } +} diff --git a/Shared/Views/ItemContentGroupView/PortraitItemViewHeader.swift b/Shared/Views/ItemContentGroupView/PortraitItemViewHeader.swift new file mode 100644 index 0000000000..684b0b1e51 --- /dev/null +++ b/Shared/Views/ItemContentGroupView/PortraitItemViewHeader.swift @@ -0,0 +1,74 @@ +// +// 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 JellyfinAPI +import SwiftUI + +struct PortraitItemViewHeader: ContentGroup { + + let id = "item-view-header" + let viewModel: Empty = .init() + let itemViewModel: ItemViewModel + + func body(with viewModel: Empty) -> Body { + Body(viewModel: itemViewModel) + } + + struct Body: View { + + @ObservedObject + var viewModel: ItemViewModel + + @Router + private var router + + var body: some View { + VStack(spacing: 10) { + VStack(spacing: 10) { + HStack(alignment: .bottom, spacing: 12) { + PosterImage( + item: viewModel.item, + type: .portrait, + contentMode: .fit + ) + .withViewContext(.isOverComplexContent) + .frame(width: 130) + .accessibilityIgnoresInvertColors() + .posterShadow() + + Text(viewModel.item.displayTitle) + .font(.title2) + .lineLimit(4) + .fontWeight(.semibold) + .padding(.bottom, 4) + .frame(maxWidth: .infinity, alignment: .leading) + } + + ActionButtonHStack( + item: viewModel.item, + localTrailers: [] + ) + } + .frame(maxWidth: 300) + + Divider() + + if let overview = viewModel.item.overview { + SeeMoreText(overview) { + router.route(to: .itemOverview(item: viewModel.item)) + } + .font(.footnote) + .lineLimit(3) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .edgePadding([.bottom, .horizontal]) + } + } +} diff --git a/Shared/Views/ItemContentGroupView/SimpleItemViewHeader.swift b/Shared/Views/ItemContentGroupView/SimpleItemViewHeader.swift new file mode 100644 index 0000000000..6ece234d46 --- /dev/null +++ b/Shared/Views/ItemContentGroupView/SimpleItemViewHeader.swift @@ -0,0 +1,168 @@ +// +// 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 JellyfinAPI +import SwiftUI + +struct SimpleItemViewHeader: ContentGroup { + + let id = "item-view-header" + let viewModel: Empty = .init() + let itemViewModel: ItemViewModel + + func body(with viewModel: Empty) -> Body { + Body(viewModel: itemViewModel) + } + + struct Body: View { + + @ObservedObject + var viewModel: ItemViewModel + + @Router + private var router + + @ViewBuilder + private func parentButton(_ title: String, id: String) -> some View { + Button { + router.route( + to: .item( + displayTitle: title, + id: id + ) + ) + } label: { + HStack(spacing: 2) { + Text(title) + .font(.headline) + .multilineTextAlignment(.center) + .lineLimit(2) + + Image(systemName: "chevron.forward") + .font(.subheadline) + .foregroundStyle(.secondary) + } + .fontWeight(.semibold) + } + .foregroundStyle(.primary, .secondary) + } + + @ViewBuilder + private var titleAndAttributes: some View { + VStack(alignment: .center, spacing: 5) { + + switch viewModel.item.type { + case .episode: + if let parentID = viewModel.item.seriesID, let parentTitle = viewModel.item.parentTitle { + parentButton(parentTitle, id: parentID) + } + case .liveTvProgram: + if let channelID = viewModel.item.channelID, let channelName = viewModel.item.channelName { + parentButton(channelName, id: channelID) + } + default: + EmptyView() + } + + Text(viewModel.item.displayTitle) + .font(.title2) + .fontWeight(.semibold) + .multilineTextAlignment(.center) + .lineLimit(2) + + DotHStack { + if let firstGenre = viewModel.item.genres?.first { + Text(firstGenre) + } + + if let premiereYear = viewModel.item.premiereDateYear { + Text(premiereYear) + } + + if let runtime = viewModel.item.runtime { + Text(runtime, format: .hourMinuteAbbreviated) + } + + if let seasonEpisodeLabel = viewModel.item.seasonEpisodeLabel { + Text(seasonEpisodeLabel) + } + } + .font(.caption) + .fontWeight(.semibold) + .foregroundStyle(.secondary) + } + } + + private var headerImageDisplayType: PosterDisplayType { + viewModel.item.preferredPosterDisplayType == .portrait ? .landscape : viewModel.item.preferredPosterDisplayType + } + + @ViewBuilder + private var overlay: some View { + VStack(spacing: 10) { + PosterImage( + item: viewModel.item, + type: headerImageDisplayType, + contentMode: .fit + ) + .frame(maxWidth: headerImageDisplayType == .square ? 400 : .infinity) + .accessibilityIgnoresInvertColors() + .posterShadow() + .customEnvironment(for: BaseItemDto.self, value: .init(useParent: false)) + + titleAndAttributes + + VStack(alignment: .center, spacing: 5) { + if viewModel.item.presentPlayButton { + PlayButton(viewModel: viewModel) + } + + ActionButtonHStack( + item: viewModel.item, + localTrailers: viewModel.localTrailers + ) + } + .frame(maxWidth: 300) + + Divider() + + VStack(alignment: .leading, spacing: 5) { + if let tagline = viewModel.item.taglines?.first { + Text(tagline) + .fontWeight(.bold) + .lineLimit(2) + } + + if let overview = viewModel.item.overview { + SeeMoreText(overview) { + router.route(to: .itemOverview(item: viewModel.item)) + } + .font(.footnote) + .lineLimit(3) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + + AttributesHStack( + item: viewModel.item, + mediaSource: nil + ) + .frame(maxWidth: .infinity, alignment: .leading) + } + .edgePadding(.bottom) + } + + var body: some View { + VStack { + overlay + .edgePadding(.horizontal) + .frame(maxWidth: .infinity) + } + } + } +} diff --git a/Shared/Views/MediaInformation/ItemOverview.swift b/Shared/Views/ItemOverview.swift similarity index 100% rename from Shared/Views/MediaInformation/ItemOverview.swift rename to Shared/Views/ItemOverview.swift diff --git a/Shared/Views/MediaView/Components/MediaItem.swift b/Shared/Views/MediaView/Components/MediaItem.swift index 2119b8408f..ae6da7c55a 100644 --- a/Shared/Views/MediaView/Components/MediaItem.swift +++ b/Shared/Views/MediaView/Components/MediaItem.swift @@ -48,7 +48,6 @@ extension MediaView { private var useTitleLabel: Bool { useRandomImage || - mediaType == .downloads || mediaType == .favorites } @@ -72,9 +71,8 @@ extension MediaView { Text(mediaType.displayTitle) .font(.title2) .fontWeight(.semibold) - .lineLimit(1) + .lineLimit(2) .multilineTextAlignment(.center) - .frame(alignment: .center) } private func titleLabelOverlay(with content: some View) -> some View { @@ -105,19 +103,21 @@ extension MediaView { titleLabelOverlay(with: DefaultPlaceholderView(blurHash: imageSource.blurHash)) } .failure { - Color.secondarySystemFill - .opacity(0.75) - .overlay { + Rectangle() + .fill(.complexSecondary) + .overlay(ratio: 0.95) { titleLabel - .foregroundColor(.primary) + .foregroundStyle(.primary) } } .id(imageSources.hashValue) .frame(maxWidth: .infinity, maxHeight: .infinity) .posterStyle(.landscape) + .posterShadow() .backport .matchedTransitionSource(id: "item", in: namespace) } + .foregroundStyle(.primary, .secondary) .onFirstAppear(perform: setImageSources) .backport .onChange(of: useRandomImage) { _, _ in diff --git a/Shared/Views/MediaView/MediaView.swift b/Shared/Views/MediaView/MediaView.swift index 194001eb10..aa48487c78 100644 --- a/Shared/Views/MediaView/MediaView.swift +++ b/Shared/Views/MediaView/MediaView.swift @@ -7,11 +7,11 @@ // import CollectionVGrid -import Defaults -import Engine import JellyfinAPI import SwiftUI +// TODO: find way to consolidate with PagingLibraryView + struct MediaView: View { @Router @@ -39,23 +39,34 @@ struct MediaView: View { MediaItem(viewModel: viewModel, type: mediaType) { namespace in switch mediaType { case let .collectionFolder(item): - let viewModel = ItemLibraryViewModel( - parent: item, - filters: .default - ) - router.route(to: .library(viewModel: viewModel), in: namespace) - case .downloads: - router.route(to: .downloadList) + let pagingLibrary = ItemLibrary(parent: item) + router.route(to: .library(library: pagingLibrary), in: namespace) case .favorites: - // TODO: favorites should have its own view instead of a library - let viewModel = ItemLibraryViewModel( - title: L10n.favorites, - id: "favorites", - filters: .favorites + router.route( + to: .contentGroup( + provider: ItemTypeContentGroupProvider( + itemTypes: [ + BaseItemKind.movie, + .series, + .boxSet, + .episode, + .musicVideo, + .video, + .liveTvProgram, + .tvChannel, + .musicArtist, + .person, + ], + parent: .init(name: L10n.favorites), + environment: .init( + filters: .favorites + ) + ) + ), + in: namespace ) - router.route(to: .library(viewModel: viewModel), in: namespace) case .liveTV: - router.route(to: .liveTV) + router.route(to: .liveTV, in: namespace) } } } @@ -76,9 +87,11 @@ struct MediaView: View { ProgressView() } } - .animation(.linear(duration: 0.1), value: viewModel.state) + .animation(.linear(duration: 0.2), value: viewModel.state) .ignoresSafeArea() .navigationTitle(L10n.allMedia.localizedCapitalized) + .backport + .toolbarTitleDisplayMode(.inlineLarge) .refreshable { viewModel.refresh() } diff --git a/Shared/Views/SearchView.swift b/Shared/Views/SearchView.swift new file mode 100644 index 0000000000..2f32a4719e --- /dev/null +++ b/Shared/Views/SearchView.swift @@ -0,0 +1,113 @@ +// +// 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 Defaults +import JellyfinAPI +import SwiftUI + +struct SearchView: View { + + @Default(.Customization.Search.enabledDrawerFilters) + private var enabledDrawerFilters + + @FocusState + private var isSearchFocused: Bool + + @Router + private var router + + @State + private var searchQuery = "" + + @StateObject + private var viewModel = SearchViewModel() + + @TabItemSelected + private var tabItemSelected + + @ViewBuilder + private var suggestionsView: some View { + VStack(spacing: 20) { + ForEach(viewModel.suggestions) { item in + Button(item.displayTitle) { + searchQuery = item.displayTitle + } + #if os(tvOS) + .buttonStyle(.plain) + #endif + } + } + } + + @ViewBuilder + private func makeGroupBody(_ group: some ContentGroup) -> some View { + group.body(with: group.viewModel) + } + + @ViewBuilder + private var resultsView: some View { + ScrollView { + VStack(alignment: .leading, spacing: 10) { + ForEach(viewModel.itemContentGroupViewModel.groups, id: \.id) { group in + makeGroupBody(group) + .eraseToAnyView() + } + } + .edgePadding(.vertical) + } + .scrollIndicators(.hidden) + } + + var body: some View { + ZStack { + switch viewModel.state { + case .initial: + if viewModel.canSearch { + if viewModel.isEmpty { + Text(L10n.noResults) + } else { + resultsView + } + } else { + suggestionsView + } + case .error: + viewModel.error.map(ErrorView.init) + case .searching: + ProgressView() + } + } + .animation(.linear(duration: 0.2), value: viewModel.state) + .ignoresSafeArea(.keyboard, edges: .bottom) + .navigationTitle(L10n.search) + .backport + .toolbarTitleDisplayMode(.inline) +// .navigationBarFilterDrawer( +// viewModel: viewModel.filterViewModel, +// types: enabledDrawerFilters +// ) + .onFirstAppear { + viewModel.getSuggestions() + } + .backport + .onChange(of: searchQuery) { _, newValue in + viewModel.search(query: newValue) + } + .searchable( + text: $searchQuery, + prompt: L10n.search + ) + .backport + .searchFocused($isSearchFocused) + .onReceive(tabItemSelected) { event in + if event.isRepeat, event.isRoot { + isSearchFocused = true + } + } + } +} diff --git a/Shared/Views/SettingsView/CustomizeSettingsView.swift b/Shared/Views/SettingsView/CustomizeSettingsView.swift index d7d7a01a49..b0b5ba0a8b 100644 --- a/Shared/Views/SettingsView/CustomizeSettingsView.swift +++ b/Shared/Views/SettingsView/CustomizeSettingsView.swift @@ -274,7 +274,7 @@ struct CustomizeSettingsView: View { Section { PlatformPicker(L10n.type, selection: $itemViewType) - if itemViewType == .cinematic { + if itemViewType == .enhanced { Toggle(L10n.usePrimaryImage, isOn: $cinematicItemViewTypeUsePrimaryImage) } @@ -282,7 +282,7 @@ struct CustomizeSettingsView: View { } header: { Text(L10n.itemView) } footer: { - if itemViewType == .cinematic { + if itemViewType == .enhanced { Text(L10n.usePrimaryImageDescription) } } diff --git a/Shared/Views/SettingsView/CustomizeViewsSettings/Components/HomeSection.swift b/Shared/Views/SettingsView/CustomizeViewsSettings/Components/HomeSection.swift new file mode 100644 index 0000000000..869efa8b7b --- /dev/null +++ b/Shared/Views/SettingsView/CustomizeViewsSettings/Components/HomeSection.swift @@ -0,0 +1,52 @@ +// +// 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 Defaults +import SwiftUI + +extension CustomizeViewsSettings { + + struct HomeSection: View { + + @Default(.Customization.Home.showRecentlyAdded) + private var showRecentlyAdded + @Default(.Customization.Home.maxNextUp) + private var maxNextUp + @Default(.Customization.Home.resumeNextUp) + private var resumeNextUp + + var body: some View { + Section(L10n.home) { + + Toggle(L10n.recentlyAdded, isOn: $showRecentlyAdded) + + Toggle(L10n.nextUpRewatch, isOn: $resumeNextUp) + + ChevronButton( + L10n.nextUpDays, + subtitle: { + if maxNextUp > 0 { + let duration = Duration.seconds(TimeInterval(maxNextUp)) + return Text(duration, format: .units(allowed: [.days], width: .abbreviated)) + } else { + return Text(L10n.disabled) + } + }(), + description: L10n.nextUpDaysDescription + ) { + TextField( + L10n.days, + value: $maxNextUp, + format: .dayInterval(range: 0 ... 1000) + ) + .keyboardType(.numberPad) + } + } + } + } +} diff --git a/Shared/Views/SettingsView/CustomizeViewsSettings/Components/ItemSection.swift b/Shared/Views/SettingsView/CustomizeViewsSettings/Components/ItemSection.swift new file mode 100644 index 0000000000..c3a7688888 --- /dev/null +++ b/Shared/Views/SettingsView/CustomizeViewsSettings/Components/ItemSection.swift @@ -0,0 +1,98 @@ +// +// 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 Defaults +import Factory +import SwiftUI + +extension CustomizeViewsSettings { + + struct ItemSection: View { + + @Injected(\.currentUserSession) + private var userSession + + @Router + private var router + + @Default(.Customization.itemViewType) + private var itemViewType + + @StoredValue(.User.enabledTrailers) + private var enabledTrailers + + @StoredValue(.User.enableItemEditing) + private var enableItemEditing + @StoredValue(.User.enableItemDeletion) + private var enableItemDeletion + @StoredValue(.User.enableCollectionManagement) + private var enableCollectionManagement + + @Default(.Customization.shouldShowMissingSeasons) + private var shouldShowMissingSeasons + @Default(.Customization.shouldShowMissingEpisodes) + private var shouldShowMissingEpisodes + + var body: some View { + Form { + if UIDevice.isPhone { + Section { + Picker(L10n.items, selection: $itemViewType) + } + } + + Picker( + L10n.enabledTrailers, + selection: $enabledTrailers + ) + + Section(L10n.management) { + + /// Enabled Collection Management for collection managers + if userSession?.user.data.policy?.isAdministrator == true || + userSession?.user.data.policy?.enableCollectionManagement == true + { + Toggle(L10n.editCollections, isOn: $enableCollectionManagement) + } + /// Enabled Media Management when there are media elements that can be managed + if userSession?.user.data.policy?.isAdministrator == true { + Toggle(L10n.editMedia, isOn: $enableItemEditing) + } + /// Enabled Media Deletion for valid deletion users + if userSession?.user.data.policy?.isAdministrator == true || + userSession?.user.data.policy?.enableContentDeletion == true || + userSession?.user.data.policy?.enableContentDeletionFromFolders?.isNotEmpty == true + { + Toggle(L10n.deleteMedia, isOn: $enableItemDeletion) + } + } + + Section { + Toggle(L10n.showMissingSeasons, isOn: $shouldShowMissingSeasons) + Toggle(L10n.showMissingEpisodes, isOn: $shouldShowMissingEpisodes) + } header: { + Text(L10n.missing) + } + } image: { + WithEnvironment(\._navigationTitle) { navigationTitle in + VStack { + Image(systemName: "house.fill") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxWidth: 400) + + if let navigationTitle { + Text(navigationTitle) + } + } + } + } + .navigationTitle(L10n.items) + } + } +} diff --git a/Shared/Views/SettingsView/CustomizeViewsSettings/Components/LibrarySection.swift b/Shared/Views/SettingsView/CustomizeViewsSettings/Components/LibrarySection.swift new file mode 100644 index 0000000000..8a4dafa612 --- /dev/null +++ b/Shared/Views/SettingsView/CustomizeViewsSettings/Components/LibrarySection.swift @@ -0,0 +1,96 @@ +// +// 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 Defaults +import SwiftUI + +extension CustomizeViewsSettings { + + struct LibrarySection: View { + + @Default(.Customization.Library.showFavorites) + private var showFavorites + @Default(.Customization.Library.enabledDrawerFilters) + private var libraryEnabledDrawerFilters + @Default(.Customization.Library.randomImage) + private var libraryRandomImage + @Default(.Customization.Library._libraryStyle) + private var libraryStyle + + @Default(.Customization.Library.letterPickerEnabled) + private var letterPickerEnabled + @Default(.Customization.Library.letterPickerOrientation) + private var letterPickerOrientation + + @Default(.Customization.Library.rememberLayout) + private var rememberLibraryLayout + @Default(.Customization.Library.rememberSort) + private var rememberLibrarySort + + @Router + private var router + + var body: some View { + Form { + + Section { + Toggle(L10n.favorites, isOn: $showFavorites) + + Toggle(L10n.randomImage, isOn: $libraryRandomImage) + } footer: {} + + Section(L10n.filters) { + ChevronButton(L10n.filters) { + router.route( + to: .itemFilterDrawerSelector(selection: $libraryEnabledDrawerFilters) + ) + } + } + + Section { + Toggle(L10n.rememberSorting, isOn: $rememberLibrarySort) + } + + Section(L10n.layout) { + Picker(L10n.layout, selection: $libraryStyle.displayType) + + if libraryStyle.displayType == .list, !UIDevice.isPhone { + // TODO: tvOS +// Stepper( +// L10n.columns, +// value: $libraryStyle.listColumnCount, +// in: 1 ... 4, +// step: 1 +// ) + } + } + + Section { + Toggle(L10n.rememberLayout, isOn: $rememberLibraryLayout) + } + + Section(L10n.letterPicker) { + Toggle(L10n.letterPicker, isOn: $letterPickerEnabled) + + if letterPickerEnabled { + Picker( + "Orientation", + selection: $letterPickerOrientation + ) + } + } + } image: { + Image(systemName: "rectangle.stack.fill") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxWidth: 400) + } + .navigationTitle(L10n.libraries) + } + } +} diff --git a/Shared/Views/SettingsView/CustomizeViewsSettings/Components/PosterSection.swift b/Shared/Views/SettingsView/CustomizeViewsSettings/Components/PosterSection.swift new file mode 100644 index 0000000000..efe5457ece --- /dev/null +++ b/Shared/Views/SettingsView/CustomizeViewsSettings/Components/PosterSection.swift @@ -0,0 +1,132 @@ +// +// 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 Defaults +import JellyfinAPI +import SwiftUI + +extension CustomizeViewsSettings { + + struct PosterSection: View { + + enum PreviewItemState: CaseIterable, Displayable { + case inProgress + case played + case unplayed + + var displayTitle: String { + switch self { + case .inProgress: + "In progress" + case .played: + L10n.played + case .unplayed: + L10n.unplayed + } + } + } + + @Default(.Customization.Episodes.useSeriesLandscapeBackdrop) + private var useSeriesLandscapeBackdrop + + @Default(.Customization.Indicators.enabled) + private var indicators + + @State + private var previewItemState: PreviewItemState = .unplayed + + private let sampleItem: BaseItemDto = .init( + runTimeTicks: Duration.seconds(1800).ticks, + type: .movie, + userData: .init( + isFavorite: true, + isPlayed: true, + playbackPositionTicks: Duration.seconds(600).ticks + ) + ) + + @ViewBuilder + private func posterPreview(type: PosterDisplayType) -> some View { + VStack(alignment: .leading) { + PosterImage( + item: sampleItem, + type: type, + contentMode: .fit + ) + .overlay { + PosterIndicatorsOverlay( + item: sampleItem, + indicators: indicators + .removing(.progress, if: previewItemState != .inProgress) + .removing(.played, if: previewItemState != .played) + .removing(.unplayed, if: previewItemState != .unplayed), + posterDisplayType: type + ) + } + .posterCornerRadius(type) + + TitleSubtitleContentView( + title: "Example", + subtitle: "Subtitle" + ) + } + .animation(.linear(duration: 0.1), value: indicators) + .animation(.linear(duration: 0.1), value: previewItemState) + } + + var body: some View { + Form { + #if os(iOS) + Section("Preview") { + ScrollView(.horizontal) { + HStack(alignment: .bottom) { + posterPreview(type: .portrait) + .frame(width: 150) + + posterPreview(type: .landscape) + .frame(width: 200) + + posterPreview(type: .square) + .frame(width: 150) + } + .frame(maxWidth: .infinity) + .padding(.vertical) + } + .scrollIndicators(.hidden) + + Picker("Status", selection: $previewItemState) + } + #endif + + Section(L10n.indicators) { + + Toggle(L10n.favorited, isOn: $indicators.contains(.favorited)) + + Toggle(L10n.progress, isOn: $indicators.contains(.progress)) + + Toggle(L10n.played, isOn: $indicators.contains(.played)) + + Toggle(L10n.unplayed, isOn: $indicators.contains(.unplayed)) + } + + Section { + Toggle("Series backdrop", isOn: $useSeriesLandscapeBackdrop) + } header: { + // TODO: think of a better name + Text("Episode landscape poster") + } + } image: { + Image(systemName: "rectangle.portrait.on.rectangle.portrait.fill") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxWidth: 400) + } + .navigationTitle(L10n.posters) + } + } +} diff --git a/Shared/Views/SettingsView/CustomizeViewsSettings/CustomizeViewsSettings.swift b/Shared/Views/SettingsView/CustomizeViewsSettings/CustomizeViewsSettings.swift new file mode 100644 index 0000000000..9bdaabb027 --- /dev/null +++ b/Shared/Views/SettingsView/CustomizeViewsSettings/CustomizeViewsSettings.swift @@ -0,0 +1,48 @@ +// +// 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 Defaults +import SwiftUI + +struct CustomizeViewsSettings: View { + + @Default(.Customization.Search.enabledDrawerFilters) + private var searchEnabledDrawerFilters + + @Router + private var router + + var body: some View { + Form(systemImage: "gear") { + + Section { + ChevronButton(L10n.search) { + router.route(to: .itemFilterDrawerSelector(selection: $searchEnabledDrawerFilters)) + } + + } header: { + Text(L10n.filters) + } + + ChevronButton(L10n.items) { + router.route(to: .itemSettings) + } + + ChevronButton(L10n.libraries) { + router.route(to: .librarySettings) + } + + ChevronButton(L10n.posters) { + router.route(to: .posterSettings) + } + + HomeSection() + } + .navigationTitle(L10n.customize) + } +} diff --git a/Shared/Views/UserSignInView.swift b/Shared/Views/UserSignInView.swift index bbd6ad56b4..e022f63b14 100644 --- a/Shared/Views/UserSignInView.swift +++ b/Shared/Views/UserSignInView.swift @@ -90,14 +90,14 @@ struct UserSignInView: View { logger.critical("QuickConnect called without necessary action!") throw ErrorMessage(L10n.unknownError) } - await viewModel.signInQuickConnect( + try? await viewModel.signInQuickConnect( secret: secret ) } catch is CancellationError { // ignore } catch { logger.error("QuickConnect failed with error: \(error.localizedDescription)") - await viewModel.error(ErrorMessage(L10n.taskFailed)) + try? await viewModel.error(ErrorMessage(L10n.taskFailed)) } } } @@ -258,7 +258,7 @@ struct UserSignInView: View { password = "" focusedTextField = .password } - .environment(\.isOverComplexContent, true) + .withViewContext(.isOverComplexContent) } } #endif diff --git a/Swiftfin tvOS/App/SwiftfinApp.swift b/Swiftfin tvOS/App/SwiftfinApp.swift index 9c7da8e981..722cfdd6c8 100644 --- a/Swiftfin tvOS/App/SwiftfinApp.swift +++ b/Swiftfin tvOS/App/SwiftfinApp.swift @@ -57,6 +57,7 @@ struct SwiftfinApp: App { // UIKit UINavigationBar.appearance().titleTextAttributes = [.foregroundColor: UIColor.label] + UINavigationBar.appearance().isHidden = true // don't keep last user id if Defaults[.signOutOnClose] { @@ -83,3 +84,12 @@ struct SwiftfinApp: App { } } } + +extension UINavigationController { + + // Remove back button text + override open func viewWillLayoutSubviews() { +// navigationBar.topItem?.backButtonDisplayMode = .minimal + isNavigationBarHidden = true + } +} diff --git a/Swiftfin tvOS/Components/CinematicBackgroundView.swift b/Swiftfin tvOS/Components/CinematicBackgroundView.swift deleted file mode 100644 index cc5380e197..0000000000 --- a/Swiftfin tvOS/Components/CinematicBackgroundView.swift +++ /dev/null @@ -1,61 +0,0 @@ -// -// 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 Combine -import JellyfinAPI -import SwiftUI - -struct CinematicBackgroundView: View { - - @ObservedObject - var viewModel: Proxy - - @StateObject - private var proxy: RotateContentView.Proxy = .init() - - var initialItem: (any Poster)? - - var body: some View { - RotateContentView(proxy: proxy) - .onChange(of: viewModel.currentItem) { _, newItem in - proxy.update { - ImageView(newItem?.cinematicImageSources(maxWidth: nil) ?? []) - .placeholder { _ in - Color.clear - } - .failure { - Color.clear - } - .aspectRatio(contentMode: .fill) - } - } - } - - class Proxy: ObservableObject { - - @Published - var currentItem: AnyPoster? - - private var cancellables = Set() - private var currentItemSubject = CurrentValueSubject(nil) - - init() { - currentItemSubject - .debounce(for: 0.5, scheduler: DispatchQueue.main) - .removeDuplicates() - .sink { newItem in - self.currentItem = newItem - } - .store(in: &cancellables) - } - - func select(item: some Poster) { - currentItemSubject.send(AnyPoster(item)) - } - } -} diff --git a/Swiftfin tvOS/Components/CinematicItemSelector.swift b/Swiftfin tvOS/Components/CinematicItemSelector.swift deleted file mode 100644 index 1978a8f9f6..0000000000 --- a/Swiftfin tvOS/Components/CinematicItemSelector.swift +++ /dev/null @@ -1,113 +0,0 @@ -// -// 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 Combine -import JellyfinAPI -import SwiftUI - -// TODO: make new protocol for cinematic view image provider -// TODO: better name - -struct CinematicItemSelector: View { - - @FocusState - private var isSectionFocused - - @FocusedValue(\.focusedPoster) - private var focusedPoster - - @StateObject - private var viewModel: CinematicBackgroundView.Proxy = .init() - - private var topContent: (Item) -> any View - private var itemContent: (Item) -> any View - private var trailingContent: () -> any View - private var onSelect: (Item) -> Void - - let items: [Item] - - var body: some View { - VStack(alignment: .leading, spacing: 10) { - - if let focusedPoster, let focusedItem = focusedPoster._poster as? Item { - topContent(focusedItem) - .eraseToAnyView() - .id(focusedItem.hashValue) - .transition(.opacity) - } - - // TODO: fix intrinsic content sizing without frame - PosterHStack( - type: .landscape, - items: items, - action: onSelect, - label: itemContent - ) - .frame(height: 400) - } - .frame(height: UIScreen.main.bounds.height - 75, alignment: .bottomLeading) - .frame(maxWidth: .infinity) - .background(alignment: .top) { - CinematicBackgroundView( - viewModel: viewModel, - initialItem: items.first - ) - .overlay { - Color.black - .maskLinearGradient { - (location: 0.5, opacity: 0) - (location: 0.6, opacity: 0.4) - (location: 1, opacity: 1) - } - } - .frame(height: UIScreen.main.bounds.height) - .maskLinearGradient { - (location: 0.9, opacity: 1) - (location: 1, opacity: 0) - } - } - .onChange(of: focusedPoster) { - guard let focusedPoster, isSectionFocused else { return } - viewModel.select(item: focusedPoster) - } - .focusSection() - .focused($isSectionFocused) - } -} - -extension CinematicItemSelector { - - init(items: [Item]) { - self.init( - topContent: { _ in EmptyView() }, - itemContent: { _ in EmptyView() }, - trailingContent: { EmptyView() }, - onSelect: { _ in }, - items: items - ) - } -} - -extension CinematicItemSelector { - - func topContent(@ViewBuilder _ content: @escaping (Item) -> any View) -> Self { - copy(modifying: \.topContent, with: content) - } - - func content(@ViewBuilder _ content: @escaping (Item) -> any View) -> Self { - copy(modifying: \.itemContent, with: content) - } - - func trailingContent(@ViewBuilder _ content: @escaping () -> some View) -> Self { - copy(modifying: \.trailingContent, with: content) - } - - func onSelect(_ action: @escaping (Item) -> Void) -> Self { - copy(modifying: \.onSelect, with: action) - } -} diff --git a/Swiftfin tvOS/Components/EnumPickerView.swift b/Swiftfin tvOS/Components/EnumPickerView.swift deleted file mode 100644 index 84ada94d05..0000000000 --- a/Swiftfin tvOS/Components/EnumPickerView.swift +++ /dev/null @@ -1,60 +0,0 @@ -// -// 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 SwiftUI - -struct EnumPickerView: View { - - @Binding - private var selection: EnumType - - private var descriptionView: () -> any View - private var title: String? - - var body: some View { - SplitFormWindowView() - .descriptionView(descriptionView) - .contentView { - Section { - ForEach(EnumType.allCases.asArray, id: \.hashValue) { item in - Button { - selection = item - } label: { - HStack { - Text(item.displayTitle) - - Spacer() - - if selection == item { - Image(systemName: "checkmark.circle.fill") - } - } - } - } - } - } - } -} - -extension EnumPickerView { - - init( - title: String? = nil, - selection: Binding - ) { - self.init( - selection: selection, - descriptionView: { EmptyView() }, - title: title - ) - } - - func descriptionView(@ViewBuilder _ content: @escaping () -> any View) -> Self { - copy(modifying: \.descriptionView, with: content) - } -} diff --git a/Swiftfin tvOS/Components/LandscapePosterProgressBar.swift b/Swiftfin tvOS/Components/LandscapePosterProgressBar.swift deleted file mode 100644 index 3b373cfd72..0000000000 --- a/Swiftfin tvOS/Components/LandscapePosterProgressBar.swift +++ /dev/null @@ -1,46 +0,0 @@ -// -// 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 SwiftUI - -struct LandscapePosterProgressBar: View { - - private let title: String? - private let progress: Double - - init(title: String? = nil, progress: Double) { - self.title = title - self.progress = progress - } - - var body: some View { - ZStack(alignment: .bottom) { - LinearGradient( - stops: [ - .init(color: .clear, location: 0.7), - .init(color: .black.opacity(0.7), location: 1), - ], - startPoint: .top, - endPoint: .bottom - ) - - VStack(alignment: .leading, spacing: 3) { - - if let title { - Text(title) - .font(.subheadline) - .foregroundColor(.white) - } - - ProgressBar(progress: progress) - .frame(height: 5) - } - .padding(10) - } - } -} diff --git a/Swiftfin tvOS/Components/LargePosterGroup.swift b/Swiftfin tvOS/Components/LargePosterGroup.swift new file mode 100644 index 0000000000..c55d86d4c2 --- /dev/null +++ b/Swiftfin tvOS/Components/LargePosterGroup.swift @@ -0,0 +1,90 @@ +// +// 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 JellyfinAPI +import SwiftUI + +struct ContentGroupParentOption: OptionSet { + + let rawValue: Int + + static let ignoreTopSafeArea = Self(rawValue: 1 << 0) + static let useOffsetNavigationBar = Self(rawValue: 1 << 1) +} + +struct ContentGroupCustomizationKey: PreferenceKey { + static var defaultValue: ContentGroupParentOption = [] + + static func reduce( + value: inout ContentGroupParentOption, + nextValue: () -> ContentGroupParentOption + ) { + value.formUnion(nextValue()) + } +} + +struct LargePosterGroup: ContentGroup { + + let id = "item-view-header" + let viewModel: Empty = .init() + + func body(with viewModel: Empty) -> some View { + EmptyView() + } +} + +struct LargePosterHStack< + Element: Poster, + Data: Collection +>: View where Data.Element == Element, Data.Index == Int { + + @Environment(\.frameForParentView) + private var frameForParentView + + @FocusState + private var isSectionFocused + + @FocusedValue(\.focusedPoster) + private var focusedPoster + + let elements: Data + + var body: some View { + ZStack(alignment: .bottom) { + Color.clear + + PosterHStack( + elements: elements, + type: .landscape + ) { _, _ in + print("Focused Poster: \(String(describing: focusedPoster))") + } header: { + EmptyView() + } + .frame(height: 400, alignment: .bottomLeading) + .debugBackground(Color.blue.opacity(0.5)) + } + .ifLet(frameForParentView[.scrollView]) { view, frame in + if frame.frame.height > 0 { + view.frame(height: frame.frame.height) + .onAppear { + print("Setting height to \(frame.frame.height)") + } + } else { + view.aspectRatio(1.77, contentMode: .fit) + } + } transformElse: { view in + view.aspectRatio(1.77, contentMode: .fit) + } + .debugBackground() + .preference( + key: ContentGroupCustomizationKey.self, + value: [.ignoreTopSafeArea] + ) + } +} diff --git a/Swiftfin tvOS/Components/NonePosterButton.swift b/Swiftfin tvOS/Components/NonePosterButton.swift deleted file mode 100644 index 01e3d178ad..0000000000 --- a/Swiftfin tvOS/Components/NonePosterButton.swift +++ /dev/null @@ -1,37 +0,0 @@ -// -// 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 SwiftUI - -struct NonePosterButton: View { - - let type: PosterDisplayType - - var body: some View { - Button {} label: { - ZStack { - ZStack { - Color(UIColor.darkGray) - .opacity(0.5) - - VStack(spacing: 20) { - Image(systemName: "minus.circle") - .font(.title) - .foregroundColor(.secondary) - - Text(L10n.none) - .font(.title3) - .foregroundColor(.secondary) - } - } - .posterStyle(type) - } - } - .buttonStyle(.card) - } -} diff --git a/Swiftfin tvOS/Components/PosterButton.swift b/Swiftfin tvOS/Components/PosterButton.swift deleted file mode 100644 index cc6df1f513..0000000000 --- a/Swiftfin tvOS/Components/PosterButton.swift +++ /dev/null @@ -1,232 +0,0 @@ -// -// 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 Defaults -import JellyfinAPI -import SwiftUI - -private let landscapeMaxWidth: CGFloat = 500 -private let portraitMaxWidth: CGFloat = 500 - -struct PosterButton: View { - - @EnvironmentTypeValue(\.posterOverlayRegistry) - private var posterOverlayRegistry - - @State - private var posterSize: CGSize = .zero - - private var horizontalAlignment: HorizontalAlignment - private let item: Item - private let type: PosterDisplayType - private let label: any View - private let action: () -> Void - - @ViewBuilder - private func poster(overlay: some View) -> some View { - PosterImage(item: item, type: type) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .overlay { overlay } - .contentShape(.contextMenuPreview, Rectangle()) - .posterStyle(type) - .posterShadow() - .hoverEffect(.highlight) - } - - var body: some View { - Button(action: action) { - let overlay = posterOverlayRegistry?(item) ?? - PosterButton.DefaultOverlay(item: item) - .eraseToAnyView() - - poster(overlay: overlay) - .trackingSize($posterSize) - - label - .eraseToAnyView() - } - .buttonStyle(.borderless) - .buttonBorderShape(.roundedRectangle) - .focusedValue(\.focusedPoster, AnyPoster(item)) - .accessibilityLabel(item.displayTitle) - .matchedContextMenu(for: item) { - EmptyView() - } - } -} - -extension PosterButton { - - init( - item: Item, - type: PosterDisplayType, - action: @escaping () -> Void, - @ViewBuilder label: @escaping () -> any View - ) { - self.item = item - self.type = type - self.action = action - self.label = label() - self.horizontalAlignment = .leading - } - - func horizontalAlignment(_ alignment: HorizontalAlignment) -> Self { - copy(modifying: \.horizontalAlignment, with: alignment) - } -} - -// TODO: Shared default content with iOS? -// - check if content is generally same - -extension PosterButton { - - // MARK: Default Content - - struct TitleContentView: View { - - let item: Item - - var body: some View { - Text(item.displayTitle) - .font(.footnote.weight(.regular)) - .foregroundColor(.primary) - .accessibilityLabel(item.displayTitle) - } - } - - struct SubtitleContentView: View { - - let item: Item - - var body: some View { - Text(item.subtitle ?? "") - .font(.caption.weight(.medium)) - .foregroundColor(.secondary) - } - } - - struct TitleSubtitleContentView: View { - - let item: Item - - var body: some View { - VStack(alignment: .leading) { - if item.showTitle { - TitleContentView(item: item) - .lineLimit(1, reservesSpace: true) - } - - SubtitleContentView(item: item) - .lineLimit(1, reservesSpace: true) - } - } - } - - // TODO: clean up - - // Content specific for BaseItemDto episode items - struct EpisodeContentSubtitleContent: View { - - let item: Item - - var body: some View { - if let item = item as? BaseItemDto { - // Unsure why this needs 0 spacing - // compared to other default content - VStack(alignment: .leading, spacing: 0) { - if item.showTitle, let seriesName = item.seriesName { - Text(seriesName) - .font(.footnote.weight(.regular)) - .foregroundColor(.primary) - .lineLimit(1, reservesSpace: true) - } - - Subtitle(item: item) - } - } - } - - struct Subtitle: View { - - let item: BaseItemDto - - var body: some View { - - SeparatorHStack { - Circle() - .frame(width: 2, height: 2) - .padding(.horizontal, 3) - } content: { - SeparatorHStack { - Text(item.seasonEpisodeLabel ?? .emptyDash) - - if item.showTitle { - Text(item.displayTitle) - - } else if let seriesName = item.seriesName { - Text(seriesName) - } - } - } - .font(.caption) - .foregroundColor(.secondary) - .lineLimit(1) - } - } - } - - // TODO: Find better way for these indicators, see EpisodeCard - struct DefaultOverlay: View { - - @Default(.accentColor) - private var accentColor - - @Default(.Customization.Indicators.showUnplayed) - private var showUnplayed - @Default(.Customization.Indicators.showPlayed) - private var showPlayed - - @Default(.Customization.Indicators.showFavorited) - private var showFavorited - @Default(.Customization.Indicators.showProgress) - private var showProgress - - let item: Item - - var body: some View { - ZStack { - if let item = item as? BaseItemDto { - if item.canBePlayed, !item.isLiveStream, item.userData?.isPlayed == true { - WatchedIndicator(size: 45) - .isVisible(showPlayed) - } else { - if (item.userData?.playbackPositionTicks ?? 0) > 0 { - ProgressIndicator(progress: (item.userData?.playedPercentage ?? 0) / 100, height: 10) - .isVisible(showProgress) - } else if item.canBePlayed, - !item.isLiveStream, - showUnplayed != .none - { - UnwatchedIndicator( - size: 45, - count: - showUnplayed == .count ? item.userData?.unplayedItemCount : nil - ) - .foregroundStyle(accentColor.overlayColor, accentColor) - } - } - - if item.userData?.isFavorite == true { - FavoriteIndicator(size: 45) - .isVisible(showFavorited) - } - } - } - } - } -} diff --git a/Swiftfin tvOS/Components/PosterHStack.swift b/Swiftfin tvOS/Components/PosterHStack.swift deleted file mode 100644 index d7a6643659..0000000000 --- a/Swiftfin tvOS/Components/PosterHStack.swift +++ /dev/null @@ -1,83 +0,0 @@ -// -// 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 CollectionHStack -import SwiftUI - -// TODO: trailing content refactor? - -struct PosterHStack: View where Data.Element == Element, Data.Index == Int { - - private var data: Data - private var title: String? - private var type: PosterDisplayType - private var label: (Element) -> any View - private var trailingContent: () -> any View - private var action: (Element) -> Void - - var body: some View { - VStack(alignment: .leading, spacing: 20) { - - if let title { - HStack { - Text(title) - .font(.title2) - .fontWeight(.semibold) - .accessibility(addTraits: [.isHeader]) - .padding(.leading, 50) - - Spacer() - } - } - - CollectionHStack( - uniqueElements: data, - columns: type == .landscape ? 4 : 7 - ) { item in - PosterButton( - item: item, - type: type - ) { - action(item) - } label: { - label(item).eraseToAnyView() - } - } - .clipsToBounds(false) - .dataPrefix(20) - .insets(horizontal: EdgeInsets.edgePadding, vertical: 20) - .itemSpacing(EdgeInsets.edgePadding - 20) - .scrollBehavior(.continuousLeadingEdge) - } - .focusSection() - } -} - -extension PosterHStack { - - init( - title: String? = nil, - type: PosterDisplayType, - items: Data, - action: @escaping (Element) -> Void, - @ViewBuilder label: @escaping (Element) -> any View = { PosterButton.TitleSubtitleContentView(item: $0) } - ) { - self.init( - data: items, - title: title, - type: type, - label: label, - trailingContent: { EmptyView() }, - action: action - ) - } - - func trailing(@ViewBuilder _ content: @escaping () -> any View) -> Self { - copy(modifying: \.trailingContent, with: content) - } -} diff --git a/Swiftfin tvOS/Components/SFSymbolButton.swift b/Swiftfin tvOS/Components/SFSymbolButton.swift deleted file mode 100644 index c730368394..0000000000 --- a/Swiftfin tvOS/Components/SFSymbolButton.swift +++ /dev/null @@ -1,70 +0,0 @@ -// -// 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 SwiftUI -import UIKit - -struct SFSymbolButton: UIViewRepresentable { - - private var onSelect: () -> Void - private let pointSize: CGFloat - private let systemName: String - private let systemNameFocused: String? - - private func makeButtonConfig(_ button: UIButton) { - let symbolImageConfig = UIImage.SymbolConfiguration(pointSize: pointSize) - let symbolImage = UIImage(systemName: systemName, withConfiguration: symbolImageConfig) - - button.setImage(symbolImage, for: .normal) - - if let systemNameFocused { - let focusedSymbolImage = UIImage(systemName: systemNameFocused, withConfiguration: symbolImageConfig) - - button.setImage(focusedSymbolImage, for: .focused) - } - } - - func makeUIView(context: Context) -> some UIButton { - var configuration = UIButton.Configuration.plain() - configuration.cornerStyle = .capsule - - let buttonAction = UIAction(title: "") { _ in - self.onSelect() - } - - let button = UIButton(configuration: configuration, primaryAction: buttonAction) - - makeButtonConfig(button) - - return button - } - - func updateUIView(_ uiView: UIViewType, context: Context) { - makeButtonConfig(uiView) - } -} - -extension SFSymbolButton { - - init( - systemName: String, - systemNameFocused: String? = nil, - pointSize: CGFloat = 32 - ) { - self.init( - onSelect: {}, - pointSize: pointSize, - systemName: systemName, - systemNameFocused: systemNameFocused - ) - } - - func onSelect(_ action: @escaping () -> Void) -> Self { - copy(modifying: \.onSelect, with: action) - } -} diff --git a/Swiftfin tvOS/Components/SeasonHStack.swift b/Swiftfin tvOS/Components/SeasonHStack.swift new file mode 100644 index 0000000000..9389124923 --- /dev/null +++ b/Swiftfin tvOS/Components/SeasonHStack.swift @@ -0,0 +1,110 @@ +// +// 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 SwiftUI + +// TODO: have some focus debounce + +struct SeasonsHStack: View { + +// @EnvironmentObject +// private var focusGuide: FocusGuide + + @ObservedObject + var viewModel: PagingLibraryViewModel + + @Binding + var selection: PagingSeasonViewModel.ID? + + @FocusState + private var focusedSeason: PagingSeasonViewModel.ID? + + @State + private var didScrollToPlayButtonSeason = false + + // MARK: - Body + + var body: some View { + ScrollViewReader { proxy in + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: EdgeInsets.edgePadding / 2) { + ForEach(viewModel.elements) { season in + seasonButton(season: season) + .id(season.id) + } + } + .padding(.horizontal, EdgeInsets.edgePadding) + } + .padding(.bottom, 45) + .focusSection() +// .focusGuide( +// focusGuide, +// tag: "belowHeader", +// onContentFocus: { focusedSeason = selection }, +// top: "header", +// bottom: "episodes" +// ) + .mask { + VStack(spacing: 0) { + Color.white + + LinearGradient( + stops: [ + .init(color: .white, location: 0), + .init(color: .clear, location: 1), + ], + startPoint: .top, + endPoint: .bottom + ) + .frame(height: 20) + } + } + .onChange(of: focusedSeason) { _, newValue in + if let newValue { + selection = newValue + } + } + .onFirstAppear { + guard !didScrollToPlayButtonSeason else { return } + didScrollToPlayButtonSeason = true + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + guard let selection else { return } + + proxy.scrollTo(selection) + } + } + } + .scrollClipDisabled() + } + + // MARK: - Season Button + + @ViewBuilder + private func seasonButton(season: PagingSeasonViewModel) -> some View { + Button { + selection = season.id + } label: { + Marquee(season.library.parent.displayTitle, animateWhenFocused: true) + .frame(maxWidth: 300) + .font(.headline) + .fontWeight(.semibold) + .padding(.vertical, 10) + .padding(.horizontal, 20) + .if(selection == season.id) { text in + text + .background(.white) + .foregroundColor(.black) + } + } + .focused($focusedSeason, equals: season.id) + .buttonStyle(.card) + .padding(.horizontal, 4) + .padding(.vertical) + } +} diff --git a/Swiftfin tvOS/Components/SeeAllPosterButton.swift b/Swiftfin tvOS/Components/SeeAllPosterButton.swift deleted file mode 100644 index 85e13960ed..0000000000 --- a/Swiftfin tvOS/Components/SeeAllPosterButton.swift +++ /dev/null @@ -1,50 +0,0 @@ -// -// 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 SwiftUI - -struct SeeAllPosterButton: View { - - private let type: PosterDisplayType - private var onSelect: () -> Void - - var body: some View { - Button { - onSelect() - } label: { - ZStack { - Color(UIColor.darkGray) - .opacity(0.5) - - VStack(spacing: 20) { - Image(systemName: "chevron.right") - .font(.title) - - Text(L10n.seeAll) - .font(.title3) - } - } - .posterStyle(type) - } - .buttonStyle(.card) - } -} - -extension SeeAllPosterButton { - - init(type: PosterDisplayType) { - self.init( - type: type, - onSelect: {} - ) - } - - func onSelect(_ action: @escaping () -> Void) -> Self { - copy(modifying: \.onSelect, with: action) - } -} diff --git a/Swiftfin tvOS/Extensions/View/View-tvOS.swift b/Swiftfin tvOS/Extensions/View/View-tvOS.swift index e993b4e40d..4ed95f8a2f 100644 --- a/Swiftfin tvOS/Extensions/View/View-tvOS.swift +++ b/Swiftfin tvOS/Extensions/View/View-tvOS.swift @@ -12,6 +12,12 @@ import SwiftUIIntrospect extension View { + /// - Important: This does nothing on tvOS. + @ViewBuilder + func listRowSeparator(_ visiblity: Visibility) -> some View { + self + } + @ViewBuilder func navigationBarBranding( isLoading: Bool = false @@ -23,9 +29,15 @@ extension View { ) } + // TODO: mark availability to use `toolbarTitleDisplayMode` instead + // - overload iOS for same + /// - Important: This does nothing on tvOS. + @available(*, deprecated, message: "Use `toolbarTitleDisplayMode` instead.") @ViewBuilder - func navigationBarTitleDisplayMode(_ mode: NavigationBarItem.TitleDisplayMode) -> some View { + func navigationBarTitleDisplayMode( + _ mode: NavigationBarItem.TitleDisplayMode + ) -> some View { self } @@ -40,7 +52,20 @@ extension View { /// - Important: This does nothing on tvOS. @ViewBuilder - func statusBarHidden() -> some View { + func navigationBarMenuButton( + isLoading: Bool = false, + isHidden: Bool = false, + @ViewBuilder _ content: @escaping () -> some View + ) -> some View { + self + } + + /// - Important: This does nothing on tvOS. + @ViewBuilder + func navigationBarFilterDrawer( + viewModel: FilterViewModel, + types: [ItemFilterType] + ) -> some View { self } @@ -49,6 +74,12 @@ extension View { func prefersStatusBarHidden(_ hidden: Bool = true) -> some View { self } + + /// - Important: This does nothing on tvOS. + @ViewBuilder + func statusBarHidden() -> some View { + self + } } extension EnvironmentValues { diff --git a/Swiftfin tvOS/Views/ChannelLibraryView/ChannelLibraryView.swift b/Swiftfin tvOS/Views/ChannelLibraryView/ChannelLibraryView.swift deleted file mode 100644 index c921745a4b..0000000000 --- a/Swiftfin tvOS/Views/ChannelLibraryView/ChannelLibraryView.swift +++ /dev/null @@ -1,74 +0,0 @@ -// -// 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 Foundation -import JellyfinAPI -import SwiftUI - -struct ChannelLibraryView: View { - - @Router - private var router - - @StateObject - private var viewModel = ChannelLibraryViewModel() - - @ViewBuilder - private var contentView: some View { - CollectionVGrid( - uniqueElements: viewModel.elements, - layout: .columns(3, insets: .init(0), itemSpacing: 25, lineSpacing: 25) - ) { channel in - WideChannelGridItem(channel: channel) - .onSelect { - guard let mediaSource = channel.channel.mediaSources?.first else { return } -// router.route( -// to: \.liveVideoPlayer, -// LiveVideoPlayerManager(item: channel.channel, mediaSource: mediaSource) -// ) - } - } - .onReachedBottomEdge(offset: .offset(300)) { - viewModel.send(.getNextPage) - } - } - - var body: some View { - ZStack { - switch viewModel.state { - case .content: - if viewModel.elements.isEmpty { - ContentUnavailableView(L10n.noChannels.localizedCapitalized, systemImage: "antenna.radiowaves.left.and.right") - } else { - contentView - } - case let .error(error): - ErrorView(error: error) - case .initial, .refreshing: - ProgressView() - } - } - .animation(.linear(duration: 0.1), value: viewModel.state) - .ignoresSafeArea() - .refreshable { - viewModel.send(.refresh) - } - .onFirstAppear { - if viewModel.state == .initial { - viewModel.send(.refresh) - } - } - .sinceLastDisappear { interval in - // refresh after 3 hours - if interval >= 10800 { - viewModel.send(.refresh) - } - } - } -} diff --git a/Swiftfin tvOS/Views/ChannelLibraryView/Components/WideChannelGridItem.swift b/Swiftfin tvOS/Views/ChannelLibraryView/Components/WideChannelGridItem.swift deleted file mode 100644 index 8a91f23ee3..0000000000 --- a/Swiftfin tvOS/Views/ChannelLibraryView/Components/WideChannelGridItem.swift +++ /dev/null @@ -1,154 +0,0 @@ -// -// 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 Defaults -import JellyfinAPI -import SwiftUI - -extension ChannelLibraryView { - - struct WideChannelGridItem: View { - - @Default(.accentColor) - private var accentColor - - @State - private var now: Date = .now - - let channel: ChannelProgram - - private var onSelect: () -> Void - private let timer = Timer.publish(every: 5, on: .main, in: .common).autoconnect() - - @ViewBuilder - private var channelLogo: some View { - VStack { - ZStack { - Color.clear - - ImageView(channel.portraitImageSources(maxWidth: 110, quality: 90)) - .image { - $0.aspectRatio(contentMode: .fit) - } - .failure { - SystemImageContentView(systemName: channel.systemImage, ratio: 0.66) - } - .placeholder { _ in - EmptyView() - } - } - .aspectRatio(1.0, contentMode: .fit) - - Text(channel.channel.number ?? "") - .font(.body) - .lineLimit(1) - .foregroundStyle(.primary) - } - } - - @ViewBuilder - private func programLabel(for program: BaseItemDto) -> some View { - HStack(alignment: .top, spacing: EdgeInsets.edgePadding / 2) { - AlternateLayoutView(alignment: .leading) { - // swiftlint:disable:next hard_coded_display_string - Text("00:00 AM") - .monospacedDigit() - } content: { - if let startDate = program.startDate { - Text(startDate, style: .time) - .monospacedDigit() - } else { - Text(String.emptyDash) - } - } - - Text(program.displayTitle) - } - .lineLimit(1) - } - - @ViewBuilder - private var programListView: some View { - VStack(alignment: .leading, spacing: 0) { - if let currentProgram = channel.currentProgram { - ProgressBar(progress: currentProgram.programProgress(relativeTo: now) ?? 0) - .frame(height: 8) - .padding(.bottom, 8) - .foregroundStyle(accentColor) - - programLabel(for: currentProgram) - .font(.caption.weight(.bold)) - } - - if let nextProgram = channel.programAfterCurrent(offset: 0) { - programLabel(for: nextProgram) - .font(.caption) - .foregroundStyle(.secondary) - } - - if let futureProgram = channel.programAfterCurrent(offset: 1) { - programLabel(for: futureProgram) - .font(.caption) - .foregroundStyle(.secondary) - } - } - .id(channel.currentProgram) - } - - var body: some View { - Button { - onSelect() - } label: { - HStack(alignment: .center, spacing: EdgeInsets.edgePadding / 2) { - - channelLogo - .frame(width: 110) - - HStack { - VStack(alignment: .leading, spacing: 5) { - Text(channel.displayTitle) - .font(.body) - .fontWeight(.bold) - .lineLimit(1) - .foregroundStyle(.primary) - - if channel.programs.isNotEmpty { - programListView - } - } - - Spacer() - } - .frame(maxWidth: .infinity) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .padding(.horizontal, EdgeInsets.edgePadding / 2) - } - .buttonStyle(.card) - .frame(height: 200) - .onReceive(timer) { newValue in - now = newValue - } - .animation(.linear(duration: 0.2), value: channel.currentProgram) - } - } -} - -extension ChannelLibraryView.WideChannelGridItem { - - init(channel: ChannelProgram) { - self.init( - channel: channel, - onSelect: {} - ) - } - - func onSelect(_ action: @escaping () -> Void) -> Self { - copy(modifying: \.onSelect, with: action) - } -} diff --git a/Swiftfin tvOS/Views/HomeView/Components/CinematicRecentlyAddedView.swift b/Swiftfin tvOS/Views/HomeView/Components/CinematicRecentlyAddedView.swift deleted file mode 100644 index 987408b4a2..0000000000 --- a/Swiftfin tvOS/Views/HomeView/Components/CinematicRecentlyAddedView.swift +++ /dev/null @@ -1,59 +0,0 @@ -// -// 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 JellyfinAPI -import SwiftUI - -extension HomeView { - - struct CinematicRecentlyAddedView: View { - - @Router - private var router - - @ObservedObject - var viewModel: RecentlyAddedLibraryViewModel - - private func itemSelectorImageSource(for item: BaseItemDto) -> ImageSource { - if item.type == .episode { - item.seriesImageSource( - .logo, - maxWidth: UIScreen.main.bounds.width * 0.4, - maxHeight: 200 - ) - } else { - item.imageSource( - .logo, - maxWidth: UIScreen.main.bounds.width * 0.4, - maxHeight: 200 - ) - } - } - - var body: some View { - CinematicItemSelector(items: viewModel.elements.elements) - .topContent { item in - ImageView(itemSelectorImageSource(for: item)) - .placeholder { _ in - EmptyView() - } - .failure { - Text(item.displayTitle) - .font(.largeTitle) - .fontWeight(.semibold) - } - .edgePadding(.leading) - .aspectRatio(contentMode: .fit) - .frame(height: 200, alignment: .bottomLeading) - } - .onSelect { item in - router.route(to: .item(item: item)) - } - } - } -} diff --git a/Swiftfin tvOS/Views/HomeView/Components/CinematicResumeItemView.swift b/Swiftfin tvOS/Views/HomeView/Components/CinematicResumeItemView.swift deleted file mode 100644 index e57186882f..0000000000 --- a/Swiftfin tvOS/Views/HomeView/Components/CinematicResumeItemView.swift +++ /dev/null @@ -1,74 +0,0 @@ -// -// 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 JellyfinAPI -import SwiftUI - -extension HomeView { - - struct CinematicResumeView: View { - - @Router - private var router - - @ObservedObject - var viewModel: HomeViewModel - - private func itemSelectorImageSource(for item: BaseItemDto) -> ImageSource { - if item.type == .episode { - item.seriesImageSource( - .logo, - maxWidth: UIScreen.main.bounds.width * 0.4, - maxHeight: 200 - ) - } else { - item.imageSource( - .logo, - maxWidth: UIScreen.main.bounds.width * 0.4, - maxHeight: 200 - ) - } - } - - var body: some View { - CinematicItemSelector(items: viewModel.resumeItems.elements) - .topContent { item in - ImageView(itemSelectorImageSource(for: item)) - .placeholder { _ in - EmptyView() - } - .failure { - Text(item.displayTitle) - .font(.largeTitle) - .fontWeight(.semibold) - } - .edgePadding(.leading) - .aspectRatio(contentMode: .fit) - .frame(height: 200, alignment: .bottomLeading) - } - .content { item in - // TODO: clean up - if item.type == .episode { - PosterButton.EpisodeContentSubtitleContent.Subtitle(item: item) - } else { - // swiftlint:disable:next hard_coded_display_string - Text(" ") - } - } - .onSelect { item in - router.route(to: .item(item: item)) - } - .posterOverlay(for: BaseItemDto.self) { item in - LandscapePosterProgressBar( - title: item.progressLabel ?? L10n.continue, - progress: (item.userData?.playedPercentage ?? 0) / 100 - ) - } - } - } -} diff --git a/Swiftfin tvOS/Views/HomeView/Components/LatestInLibraryView.swift b/Swiftfin tvOS/Views/HomeView/Components/LatestInLibraryView.swift deleted file mode 100644 index 9bfa22b937..0000000000 --- a/Swiftfin tvOS/Views/HomeView/Components/LatestInLibraryView.swift +++ /dev/null @@ -1,38 +0,0 @@ -// -// 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 Defaults -import JellyfinAPI -import SwiftUI - -extension HomeView { - - struct LatestInLibraryView: View { - - @Default(.Customization.latestInLibraryPosterType) - private var latestInLibraryPosterType - - @Router - private var router - - @ObservedObject - var viewModel: LatestInLibraryViewModel - - var body: some View { - if viewModel.elements.isNotEmpty { - PosterHStack( - title: L10n.latestWithString(viewModel.parent?.displayTitle ?? .emptyDash), - type: latestInLibraryPosterType, - items: viewModel.elements - ) { item in - router.route(to: .item(item: item)) - } - } - } - } -} diff --git a/Swiftfin tvOS/Views/HomeView/Components/NextUpView.swift b/Swiftfin tvOS/Views/HomeView/Components/NextUpView.swift deleted file mode 100644 index 516ec060b7..0000000000 --- a/Swiftfin tvOS/Views/HomeView/Components/NextUpView.swift +++ /dev/null @@ -1,37 +0,0 @@ -// -// 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 Defaults -import SwiftUI - -extension HomeView { - - struct NextUpView: View { - - @Default(.Customization.nextUpPosterType) - private var nextUpPosterType - - @Router - private var router - - @ObservedObject - var viewModel: NextUpLibraryViewModel - - var body: some View { - if viewModel.elements.isNotEmpty { - PosterHStack( - title: L10n.nextUp, - type: nextUpPosterType, - items: viewModel.elements - ) { item in - router.route(to: .item(item: item)) - } - } - } - } -} diff --git a/Swiftfin tvOS/Views/HomeView/Components/RecentlyAddedView.swift b/Swiftfin tvOS/Views/HomeView/Components/RecentlyAddedView.swift deleted file mode 100644 index d264fe3212..0000000000 --- a/Swiftfin tvOS/Views/HomeView/Components/RecentlyAddedView.swift +++ /dev/null @@ -1,37 +0,0 @@ -// -// 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 Defaults -import SwiftUI - -extension HomeView { - - struct RecentlyAddedView: View { - - @Default(.Customization.recentlyAddedPosterType) - private var recentlyAddedPosterType - - @Router - private var router - - @ObservedObject - var viewModel: RecentlyAddedLibraryViewModel - - var body: some View { - if viewModel.elements.isNotEmpty { - PosterHStack( - title: L10n.recentlyAdded.localizedCapitalized, - type: recentlyAddedPosterType, - items: viewModel.elements - ) { item in - router.route(to: .item(item: item)) - } - } - } - } -} diff --git a/Swiftfin tvOS/Views/HomeView/HomeView.swift b/Swiftfin tvOS/Views/HomeView/HomeView.swift deleted file mode 100644 index 6894f58052..0000000000 --- a/Swiftfin tvOS/Views/HomeView/HomeView.swift +++ /dev/null @@ -1,82 +0,0 @@ -// -// 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 Defaults -import Foundation -import JellyfinAPI -import SwiftUI - -struct HomeView: View { - - @Router - private var router - - @StateObject - private var viewModel = HomeViewModel() - - @Default(.Customization.Home.showRecentlyAdded) - private var showRecentlyAdded - - @ViewBuilder - private var contentView: some View { - ScrollView { - VStack(alignment: .leading, spacing: 0) { - - if viewModel.resumeItems.isNotEmpty { - CinematicResumeView(viewModel: viewModel) - - NextUpView(viewModel: viewModel.nextUpViewModel) - - if showRecentlyAdded { - RecentlyAddedView(viewModel: viewModel.recentlyAddedViewModel) - } - } else { - if showRecentlyAdded { - CinematicRecentlyAddedView(viewModel: viewModel.recentlyAddedViewModel) - } - - NextUpView(viewModel: viewModel.nextUpViewModel) - .safeAreaPadding(.top, 150) - } - - ForEach(viewModel.libraries) { viewModel in - LatestInLibraryView(viewModel: viewModel) - } - } - } - } - - var body: some View { - ZStack { - Color.clear - - switch viewModel.state { - case .content: - contentView - case let .error(error): - ErrorView(error: error) - case .initial, .refreshing: - ProgressView() - } - } - .animation(.linear(duration: 0.1), value: viewModel.state) - .refreshable { - viewModel.send(.refresh) - } - .onFirstAppear { - viewModel.send(.refresh) - } - .ignoresSafeArea() - .sinceLastDisappear { interval in - if interval > 60 || viewModel.notificationsReceived.contains(.itemMetadataDidChange) { - viewModel.send(.backgroundRefresh) - viewModel.notificationsReceived.remove(.itemMetadataDidChange) - } - } - } -} diff --git a/Swiftfin tvOS/Views/ItemView/CollectionItemContentView.swift b/Swiftfin tvOS/Views/ItemView/CollectionItemContentView.swift deleted file mode 100644 index fc9896253b..0000000000 --- a/Swiftfin tvOS/Views/ItemView/CollectionItemContentView.swift +++ /dev/null @@ -1,97 +0,0 @@ -// -// 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 CollectionHStack -import JellyfinAPI -import OrderedCollections -import SwiftUI - -extension ItemView { - - struct CollectionItemContentView: View { - - typealias Element = OrderedDictionary.Elements.Element - - @Router - private var router - - @ObservedObject - var viewModel: CollectionItemViewModel - - // MARK: - Episode Poster HStack - - private func episodeHStack(element: Element) -> some View { - VStack(alignment: .leading, spacing: 20) { - - HStack { - Text(L10n.episodes) - .font(.title2) - .fontWeight(.semibold) - .accessibility(addTraits: [.isHeader]) - .padding(.leading, 50) - - Spacer() - } - - CollectionHStack( - uniqueElements: element.value.elements, - id: \.unwrappedIDHashOrZero, - columns: 3.5 - ) { episode in - SeriesEpisodeSelector.EpisodeCard(episode: episode) - .padding(.horizontal, 4) - } - .scrollBehavior(.continuousLeadingEdge) - .insets(horizontal: EdgeInsets.edgePadding) - .itemSpacing(EdgeInsets.edgePadding / 2) - } - .focusSection() - } - - // MARK: - Default Poster HStack - - private func posterHStack(element: Element) -> some View { - PosterHStack( - title: element.key.pluralDisplayTitle, - type: .portrait, - items: element.value.elements - ) { item in - router.route(to: .item(item: item)) - } - .focusSection() - - // TODO: Is this possible? - /* .trailing { - SeeMoreButton() { - router.route(to: .library(viewModel: element.value)) - } - } */ - } - - var body: some View { - VStack(spacing: 0) { - ForEach( - viewModel.sections.elements, - id: \.key - ) { element in - if element.key == .episode { - episodeHStack(element: element) - } else { - posterHStack(element: element) - } - } - - if viewModel.similarItems.isNotEmpty { - ItemView.SimilarItemsHStack(items: viewModel.similarItems) - } - - ItemView.AboutView(viewModel: viewModel) - } - } - } -} diff --git a/Swiftfin tvOS/Views/ItemView/Components/AboutView/AboutView.swift b/Swiftfin tvOS/Views/ItemView/Components/AboutView/AboutView.swift deleted file mode 100644 index 0506edbbdf..0000000000 --- a/Swiftfin tvOS/Views/ItemView/Components/AboutView/AboutView.swift +++ /dev/null @@ -1,49 +0,0 @@ -// -// 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 SwiftUI - -extension ItemView { - - struct AboutView: View { - - @ObservedObject - var viewModel: ItemViewModel - - var body: some View { - VStack(alignment: .leading, spacing: 0) { - - Text(L10n.about) - .font(.title2) - .fontWeight(.semibold) - .accessibility(addTraits: [.isHeader]) - .padding(.leading, 50) - - ScrollView(.horizontal) { - HStack(alignment: .top, spacing: 30) { - ImageCard(viewModel: viewModel) - - OverviewCard(item: viewModel.item) - - if let mediaSources = viewModel.item.mediaSources { - ForEach(mediaSources) { source in - MediaSourcesCard(subtitle: mediaSources.count > 1 ? source.displayTitle : nil, source: source) - } - } - - if viewModel.item.hasRatings { - RatingsCard(item: viewModel.item) - } - } - .padding(50) - } - } - .focusSection() - } - } -} diff --git a/Swiftfin tvOS/Views/ItemView/Components/AboutView/Components/AboutViewCard.swift b/Swiftfin tvOS/Views/ItemView/Components/AboutView/Components/AboutViewCard.swift deleted file mode 100644 index 7d2fb1a19a..0000000000 --- a/Swiftfin tvOS/Views/ItemView/Components/AboutView/Components/AboutViewCard.swift +++ /dev/null @@ -1,67 +0,0 @@ -// -// 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 SwiftUI - -extension ItemView.AboutView { - - struct Card: View { - - private var content: () -> any View - private var onSelect: () -> Void - private let title: String - private let subtitle: String? - - var body: some View { - Button { - onSelect() - } label: { - VStack(alignment: .leading) { - Text(title) - .font(.title3) - .fontWeight(.semibold) - .lineLimit(2) - - if let subtitle { - Text(subtitle) - .font(.subheadline) - } - - Spacer() - .frame(maxWidth: .infinity) - - content() - .eraseToAnyView() - } - .padding() - .frame(width: 700, height: 405) - } - .buttonStyle(.card) - } - } -} - -extension ItemView.AboutView.Card { - - init(title: String, subtitle: String? = nil) { - self.init( - content: { EmptyView() }, - onSelect: {}, - title: title, - subtitle: subtitle - ) - } - - func content(@ViewBuilder _ content: @escaping () -> any View) -> Self { - copy(modifying: \.content, with: content) - } - - func onSelect(_ action: @escaping () -> Void) -> Self { - copy(modifying: \.onSelect, with: action) - } -} diff --git a/Swiftfin tvOS/Views/ItemView/Components/AboutView/Components/ImageCard.swift b/Swiftfin tvOS/Views/ItemView/Components/AboutView/Components/ImageCard.swift deleted file mode 100644 index 706f66da5a..0000000000 --- a/Swiftfin tvOS/Views/ItemView/Components/AboutView/Components/ImageCard.swift +++ /dev/null @@ -1,55 +0,0 @@ -// -// 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 Defaults -import JellyfinAPI -import SwiftUI - -extension ItemView.AboutView { - - struct ImageCard: View { - - // MARK: - Environment & Observed Objects - - @Router - private var router - - @ObservedObject - var viewModel: ItemViewModel - - // MARK: - Body - - var body: some View { - PosterButton( - item: viewModel.item, - type: .portrait, - action: onSelect - ) { - EmptyView() - } - .posterOverlay(for: BaseItemDto.self) { _ in EmptyView() } - .frame(height: 405) - } - - // MARK: - On Select - - // Switch case to allow other funcitonality if we need to expand this beyond episode > series - private func onSelect() { - switch viewModel.item.type { - case .episode: - if let episodeViewModel = viewModel as? EpisodeItemViewModel, - let seriesItem = episodeViewModel.seriesItem - { - router.route(to: .item(item: seriesItem)) - } - default: - break - } - } - } -} diff --git a/Swiftfin tvOS/Views/ItemView/Components/AboutView/Components/MediaSourcesCard.swift b/Swiftfin tvOS/Views/ItemView/Components/AboutView/Components/MediaSourcesCard.swift deleted file mode 100644 index 5082bab766..0000000000 --- a/Swiftfin tvOS/Views/ItemView/Components/AboutView/Components/MediaSourcesCard.swift +++ /dev/null @@ -1,42 +0,0 @@ -// -// 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 JellyfinAPI -import SwiftUI - -extension ItemView.AboutView { - - struct MediaSourcesCard: View { - - @Router - private var router - - let subtitle: String? - let source: MediaSourceInfo - - var body: some View { - Card(title: L10n.media, subtitle: subtitle) - .content { - if let mediaStreams = source.mediaStreams { - VStack(alignment: .leading) { - Text(mediaStreams.compactMap(\.displayTitle).prefix(4).joined(separator: "\n")) - .font(.footnote) - - if mediaStreams.count > 4 { - Text(L10n.seeMore) - .font(.footnote) - } - } - } - } - .onSelect { - router.route(to: .mediaSourceInfo(source: source)) - } - } - } -} diff --git a/Swiftfin tvOS/Views/ItemView/Components/AboutView/Components/OverviewCard.swift b/Swiftfin tvOS/Views/ItemView/Components/AboutView/Components/OverviewCard.swift deleted file mode 100644 index e5e9e5401f..0000000000 --- a/Swiftfin tvOS/Views/ItemView/Components/AboutView/Components/OverviewCard.swift +++ /dev/null @@ -1,33 +0,0 @@ -// -// 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 JellyfinAPI -import SwiftUI - -extension ItemView.AboutView { - - struct OverviewCard: View { - - @Router - private var router - - let item: BaseItemDto - - var body: some View { - Card(title: item.displayTitle) - .content { - TruncatedText(item.overview ?? L10n.noOverviewAvailable) - .font(.subheadline) - .lineLimit(4) - } - .onSelect { - router.route(to: .itemOverview(item: item)) - } - } - } -} diff --git a/Swiftfin tvOS/Views/ItemView/Components/AboutView/Components/RatingsCard.swift b/Swiftfin tvOS/Views/ItemView/Components/AboutView/Components/RatingsCard.swift deleted file mode 100644 index ac9809dbf1..0000000000 --- a/Swiftfin tvOS/Views/ItemView/Components/AboutView/Components/RatingsCard.swift +++ /dev/null @@ -1,57 +0,0 @@ -// -// 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 JellyfinAPI -import SwiftUI - -extension ItemView.AboutView { - - struct RatingsCard: View { - - let item: BaseItemDto - - var body: some View { - Card(title: L10n.ratings) - .content { - HStack(alignment: .bottom) { - if let criticRating = item.criticRating { - VStack { - Group { - if criticRating >= 60 { - Image(.tomatoFresh) - } else { - Image(.tomatoRotten) - } - } - .symbolRenderingMode(.multicolor) - .foregroundStyle(.green, .red) - .font(.largeTitle) - - // swiftlint:disable:next hard_coded_display_string - Text("\(criticRating, specifier: "%.0f")") - .font(.title3) - } - } - - if let communityRating = item.communityRating { - VStack { - Image(systemName: "star.fill") - .symbolRenderingMode(.multicolor) - .foregroundStyle(.yellow) - .font(.largeTitle) - - // swiftlint:disable:next hard_coded_display_string - Text("\(communityRating, specifier: "%.1f")") - .font(.title3) - } - } - } - } - } - } -} diff --git a/Swiftfin tvOS/Views/ItemView/Components/ActionButtonHStack/ActionButtonHStack.swift b/Swiftfin tvOS/Views/ItemView/Components/ActionButtonHStack/ActionButtonHStack.swift deleted file mode 100644 index bf44f55e90..0000000000 --- a/Swiftfin tvOS/Views/ItemView/Components/ActionButtonHStack/ActionButtonHStack.swift +++ /dev/null @@ -1,105 +0,0 @@ -// -// 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 SwiftUI - -extension ItemView { - - struct ActionButtonHStack: View { - - @StoredValue(.User.enabledTrailers) - private var enabledTrailers: TrailerSelection - - // MARK: - Observed, State, & Environment Objects - - @Router - private var router - - @ObservedObject - var viewModel: ItemViewModel - - // MARK: - Has Trailers - - private var hasTrailers: Bool { - if enabledTrailers.contains(.local), viewModel.localTrailers.isNotEmpty { - return true - } - - if enabledTrailers.contains(.external), viewModel.item.remoteTrailers?.isNotEmpty == true { - return true - } - - return false - } - - // MARK: - Initializer - - init(viewModel: ItemViewModel) { - self.viewModel = viewModel - } - - // MARK: - Body - - var body: some View { - HStack(alignment: .center, spacing: 30) { - - // MARK: Toggle Played - - if viewModel.item.canBePlayed { - let isCheckmarkSelected = viewModel.item.userData?.isPlayed == true - - Button(L10n.played, systemImage: "checkmark") { - viewModel.send(.toggleIsPlayed) - } - .buttonStyle(.tintedMaterial(tint: Color.jellyfinPurple, foregroundColor: .primary)) - .isSelected(isCheckmarkSelected) - .frame(minWidth: 100, maxWidth: .infinity) - } - - // MARK: Toggle Favorite - - let isHeartSelected = viewModel.item.userData?.isFavorite == true - - Button(L10n.favorited, systemImage: isHeartSelected ? "heart.fill" : "heart") { - viewModel.send(.toggleIsFavorite) - } - .buttonStyle(.tintedMaterial(tint: .pink, foregroundColor: .primary)) - .isSelected(isHeartSelected) - .frame(minWidth: 100, maxWidth: .infinity) - - // MARK: Watch a Trailer - - if hasTrailers { - TrailerMenu( - localTrailers: viewModel.localTrailers, - externalTrailers: viewModel.item.remoteTrailers ?? [] - ) - .buttonStyle(.tintedMaterial(tint: .pink, foregroundColor: .primary)) - .frame(minWidth: 100, maxWidth: .infinity) - } - - // MARK: Advanced Options - - if viewModel.item.showEditorMenu { - Menu { - ItemEditorMenu(item: viewModel.item) - } label: { - Label(L10n.advanced, systemImage: "ellipsis") - .rotationEffect(.degrees(90)) - } - .buttonStyle(.material) - .frame(width: 60, height: 100) - } - } - .frame(height: 100) - .labelStyle(.iconOnly) - .font(.title3) - .fontWeight(.semibold) - } - } -} diff --git a/Swiftfin tvOS/Views/ItemView/Components/ActionButtonHStack/Components/TrailerMenu.swift b/Swiftfin tvOS/Views/ItemView/Components/ActionButtonHStack/Components/TrailerMenu.swift deleted file mode 100644 index 39ae58304e..0000000000 --- a/Swiftfin tvOS/Views/ItemView/Components/ActionButtonHStack/Components/TrailerMenu.swift +++ /dev/null @@ -1,145 +0,0 @@ -// -// 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 Factory -import JellyfinAPI -import Logging -import SwiftUI - -extension ItemView { - - struct TrailerMenu: View { - - private let logger = Logger.swiftfin() - - @StoredValue(.User.enabledTrailers) - private var enabledTrailers: TrailerSelection - - @FocusState - private var isFocused: Bool - - @Router - private var router - - @State - private var error: Error? - - @State - private var selectedRemoteURL: MediaURL? - - let localTrailers: [BaseItemDto] - let externalTrailers: [MediaURL] - - private var showLocalTrailers: Bool { - enabledTrailers.contains(.local) && localTrailers.isNotEmpty - } - - private var showExternalTrailers: Bool { - enabledTrailers.contains(.external) && externalTrailers.isNotEmpty - } - - var body: some View { - Group { - switch localTrailers.count + externalTrailers.count { - case 1: - trailerButton - default: - trailerMenu - } - } - .errorMessage($error) - } - - private var trailerButton: some View { - Button( - L10n.trailers, - systemImage: "movieclapper" - ) { - if showLocalTrailers, let firstTrailer = localTrailers.first { - playLocalTrailer(firstTrailer) - } - - if showExternalTrailers, let firstTrailer = externalTrailers.first { - playExternalTrailer(firstTrailer) - } - } - } - - @ViewBuilder - private var trailerMenu: some View { - Menu(L10n.trailers, systemImage: "movieclapper") { - - if showLocalTrailers { - Section(L10n.local) { - ForEach(localTrailers) { trailer in - Button( - trailer.name ?? L10n.trailer, - systemImage: "play.fill" - ) { - playLocalTrailer(trailer) - } - } - } - } - - if showExternalTrailers { - Section(L10n.external) { - ForEach(externalTrailers, id: \.self) { mediaURL in - Button( - mediaURL.name ?? L10n.trailer, - systemImage: "arrow.up.forward" - ) { - playExternalTrailer(mediaURL) - } - } - } - } - } - } - - // MARK: Play Local Trailer - - private func playLocalTrailer(_ trailer: BaseItemDto) { - guard let selectedMediaSource = trailer.mediaSources?.first else { - logger.log(level: .error, "No media sources found") - error = ErrorMessage(L10n.unknownError) - return - } - - let manager = MediaPlayerManager(item: trailer) { item in - try await MediaPlayerItem.build(for: item, mediaSource: selectedMediaSource) - } - - router.route(to: .videoPlayer(manager: manager)) - } - - // MARK: Play External Trailer - - private func playExternalTrailer(_ trailer: MediaURL) { - guard let urlString = trailer.url else { - error = ErrorMessage(L10n.unableToOpenTrailer) - return - } - - guard let externalURL = ExternalTrailerURL(string: urlString) else { - error = ErrorMessage(L10n.unableToOpenTrailer) - return - } - - if externalURL.canBeOpened { - UIApplication.shared.open(externalURL.deepLink) { success in - if !success { - error = ErrorMessage(L10n.unableToOpenTrailerApp(externalURL.source.displayTitle)) - } - } - } else { - error = ErrorMessage(L10n.unableToOpenTrailer) - } - } - } -} diff --git a/Swiftfin tvOS/Views/ItemView/Components/AdditionalPartsHStack.swift b/Swiftfin tvOS/Views/ItemView/Components/AdditionalPartsHStack.swift deleted file mode 100644 index 30b584144f..0000000000 --- a/Swiftfin tvOS/Views/ItemView/Components/AdditionalPartsHStack.swift +++ /dev/null @@ -1,34 +0,0 @@ -// -// 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 JellyfinAPI -import SwiftUI - -// TODO: make queue for parts - -extension ItemView { - - struct AdditionalPartsHStack: View { - - @Router - private var router - - let items: [BaseItemDto] - - var body: some View { - PosterHStack( - title: L10n.additionalParts, - type: .landscape, - items: items - ) { item in - guard let mediaSource = item.mediaSources?.first else { return } - router.route(to: .videoPlayer(item: item, mediaSource: mediaSource)) - } - } - } -} diff --git a/Swiftfin tvOS/Views/ItemView/Components/AttributeHStack.swift b/Swiftfin tvOS/Views/ItemView/Components/AttributeHStack.swift deleted file mode 100644 index cf9842bc3f..0000000000 --- a/Swiftfin tvOS/Views/ItemView/Components/AttributeHStack.swift +++ /dev/null @@ -1,156 +0,0 @@ -// -// 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 SwiftUI - -extension ItemView { - - struct AttributesHStack: View { - - @ObservedObject - private var viewModel: ItemViewModel - - private let alignment: HorizontalAlignment - private let attributes: [ItemViewAttribute] - private let flowDirection: FlowLayout.Direction - - init( - attributes: [ItemViewAttribute], - viewModel: ItemViewModel, - alignment: HorizontalAlignment = .center, - flowDirection: FlowLayout.Direction = .up - ) { - self.viewModel = viewModel - self.alignment = alignment - self.attributes = attributes - self.flowDirection = flowDirection - } - - var body: some View { - if attributes.isNotEmpty { - FlowLayout( - alignment: alignment, - direction: flowDirection, - spacing: 20 - ) { - ForEach(attributes, id: \.self) { attribute in - switch attribute { - case .ratingCritics: CriticRating() - case .ratingCommunity: CommunityRating() - case .ratingOfficial: OfficialRating() - case .videoQuality: VideoQuality() - case .audioChannels: AudioChannels() - case .subtitles: Subtitles() - } - } - } - .foregroundStyle(Color(UIColor.darkGray)) - .lineLimit(1) - } - } - - @ViewBuilder - private func CriticRating() -> some View { - if let criticRating = viewModel.item.criticRating { - AttributeBadge( - style: .outline, - // swiftlint:disable:next hard_coded_display_string - title: Text("\(criticRating, specifier: "%.0f")") - ) { - if criticRating >= 60 { - Image(.tomatoFresh) - .symbolRenderingMode(.hierarchical) - } else { - Image(.tomatoRotten) - } - } - } - } - - @ViewBuilder - private func CommunityRating() -> some View { - if let communityRating = viewModel.item.communityRating { - AttributeBadge( - style: .outline, - // swiftlint:disable:next hard_coded_display_string - title: Text("\(communityRating, specifier: "%.01f")"), - systemName: "star.fill" - ) - } - } - - @ViewBuilder - private func OfficialRating() -> some View { - if let officialRating = viewModel.item.officialRating { - AttributeBadge( - style: .outline, - title: officialRating - ) - } - } - - @ViewBuilder - private func VideoQuality() -> some View { - if let mediaStreams = viewModel.selectedMediaSource?.mediaStreams { - if mediaStreams.has4KVideo { - AttributeBadge( - style: .fill, - title: "4K" - ) - } else if mediaStreams.hasHDVideo { - AttributeBadge( - style: .fill, - title: "HD" - ) - } - if mediaStreams.hasDolbyVision { - AttributeBadge( - style: .fill, - title: "DV" - ) - } - if mediaStreams.hasHDRVideo { - AttributeBadge( - style: .fill, - title: "HDR" - ) - } - } - } - - @ViewBuilder - private func AudioChannels() -> some View { - if let mediaStreams = viewModel.selectedMediaSource?.mediaStreams { - if mediaStreams.has51AudioChannelLayout { - AttributeBadge( - style: .fill, - title: "5.1" - ) - } - if mediaStreams.has71AudioChannelLayout { - AttributeBadge( - style: .fill, - title: "7.1" - ) - } - } - } - - @ViewBuilder - private func Subtitles() -> some View { - if let mediaStreams = viewModel.selectedMediaSource?.mediaStreams, - mediaStreams.hasSubtitles - { - AttributeBadge( - style: .outline, - title: "CC" - ) - } - } - } -} diff --git a/Swiftfin tvOS/Views/ItemView/Components/CastAndCrewHStack.swift b/Swiftfin tvOS/Views/ItemView/Components/CastAndCrewHStack.swift deleted file mode 100644 index df0e60ab1d..0000000000 --- a/Swiftfin tvOS/Views/ItemView/Components/CastAndCrewHStack.swift +++ /dev/null @@ -1,33 +0,0 @@ -// -// 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 JellyfinAPI -import SwiftUI - -extension ItemView { - - struct CastAndCrewHStack: View { - - @Router - private var router - - let people: [BaseItemPerson] - - var body: some View { - PosterHStack( - title: L10n.castAndCrew.localizedCapitalized, - type: .portrait, - items: people.filter { person in - person.type?.isSupported ?? false - } - ) { person in - router.route(to: .item(item: .init(person: person))) - } - } - } -} diff --git a/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/EmptyCard.swift b/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/EmptyCard.swift deleted file mode 100644 index 756a4bac19..0000000000 --- a/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/EmptyCard.swift +++ /dev/null @@ -1,49 +0,0 @@ -// -// 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 SwiftUI - -extension SeriesEpisodeSelector { - - struct EmptyCard: View { - - private var onSelect: () -> Void - - init() { - self.onSelect = {} - } - - func onSelect(perform action: @escaping () -> Void) -> Self { - copy(modifying: \.onSelect, with: action) - } - - var body: some View { - VStack(alignment: .leading) { - Button { - onSelect() - } label: { - Color.secondarySystemFill - .opacity(0.75) - .posterStyle(.landscape) - .overlay { - Image(systemName: "questionmark") - .font(.system(size: 40)) - } - } - .buttonStyle(.card) - .posterShadow() - - SeriesEpisodeSelector.EpisodeContent( - subHeader: .emptyDash, - header: L10n.noResults, - content: L10n.noEpisodesAvailable - ) - } - } - } -} diff --git a/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/EpisodeCard.swift b/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/EpisodeCard.swift deleted file mode 100644 index f66e7c7a06..0000000000 --- a/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/EpisodeCard.swift +++ /dev/null @@ -1,99 +0,0 @@ -// -// 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 Defaults -import JellyfinAPI -import SwiftUI - -extension SeriesEpisodeSelector { - - struct EpisodeCard: View { - - @Default(.accentColor) - private var accentColor - @Default(.Customization.Indicators.showUnplayed) - private var showUnplayed - @Default(.Customization.Indicators.showPlayed) - private var showPlayed - - @Router - private var router - - let episode: BaseItemDto - - @FocusState - private var isFocused: Bool - - @ViewBuilder - private var overlayView: some View { - ZStack { - if let progressLabel = episode.progressLabel { - LandscapePosterProgressBar( - title: progressLabel, - progress: (episode.userData?.playedPercentage ?? 0) / 100 - ) - } else if episode.userData?.isPlayed ?? false { - WatchedIndicator(size: 45) - .isVisible(showPlayed) - } - - if isFocused { - Image(systemName: "play.fill") - .resizable() - .frame(width: 50, height: 50) - .foregroundStyle(.secondary) - } - } - } - - private var episodeContent: String { - if episode.isUnaired { - episode.airDateLabel ?? L10n.noOverviewAvailable - } else { - episode.overview ?? L10n.noOverviewAvailable - } - } - - var body: some View { - VStack(alignment: .leading) { - Button { - router.route( - to: .videoPlayer( - item: episode, - queue: EpisodeMediaPlayerQueue(episode: episode) - ) - ) - } label: { - ZStack { - Color.clear - - ImageView(episode.imageSource(.primary, maxWidth: 500)) - .failure { - SystemImageContentView(systemName: episode.systemImage) - } - - overlayView - } - .posterStyle(.landscape) - } - .buttonStyle(.card) - .posterShadow() - .focused($isFocused) - - SeriesEpisodeSelector.EpisodeContent( - subHeader: episode.episodeLocator ?? .emptyDash, - header: episode.displayTitle, - content: episodeContent - ) - .onSelect { - router.route(to: .item(item: episode)) - } - } - } - } -} diff --git a/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/EpisodeContent.swift b/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/EpisodeContent.swift deleted file mode 100644 index de9a507ff4..0000000000 --- a/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/EpisodeContent.swift +++ /dev/null @@ -1,92 +0,0 @@ -// -// 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 Defaults -import JellyfinAPI -import SwiftUI - -extension SeriesEpisodeSelector { - - struct EpisodeContent: View { - - @Default(.accentColor) - private var accentColor - - private var onSelect: () -> Void - - let subHeader: String - let header: String - let content: String - - @ViewBuilder - private var subHeaderView: some View { - Text(subHeader) - .font(.caption) - .foregroundColor(.secondary) - .lineLimit(1) - } - - @ViewBuilder - private var headerView: some View { - Text(header) - .font(.footnote) - .foregroundColor(.primary) - .lineLimit(1) - .multilineTextAlignment(.leading) - .padding(.bottom, 1) - } - - @ViewBuilder - private var contentView: some View { - Text(content) - .font(.caption.weight(.light)) - .foregroundColor(.secondary) - .multilineTextAlignment(.leading) - .lineLimit(3, reservesSpace: true) - .font(.caption.weight(.light)) - } - - var body: some View { - Button { - onSelect() - } label: { - VStack(alignment: .leading, spacing: 8) { - subHeaderView - - headerView - - contentView - .frame(maxWidth: .infinity, alignment: .leading) - - Text(L10n.seeMore) - .font(.caption.weight(.light)) - .foregroundStyle(accentColor) - } - .padding() - } - .buttonStyle(.card) - } - } -} - -extension SeriesEpisodeSelector.EpisodeContent { - init( - subHeader: String, - header: String, - content: String - ) { - self.subHeader = subHeader - self.header = header - self.content = content - self.onSelect = {} - } - - func onSelect(perform action: @escaping () -> Void) -> Self { - copy(modifying: \.onSelect, with: action) - } -} diff --git a/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/ErrorCard.swift b/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/ErrorCard.swift deleted file mode 100644 index da2d5d177b..0000000000 --- a/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/ErrorCard.swift +++ /dev/null @@ -1,51 +0,0 @@ -// -// 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 SwiftUI - -extension SeriesEpisodeSelector { - - struct ErrorCard: View { - - let error: ErrorMessage - private var onSelect: () -> Void - - init(error: ErrorMessage) { - self.error = error - self.onSelect = {} - } - - func onSelect(perform action: @escaping () -> Void) -> Self { - copy(modifying: \.onSelect, with: action) - } - - var body: some View { - VStack(alignment: .leading) { - Button { - onSelect() - } label: { - Color.secondarySystemFill - .opacity(0.75) - .posterStyle(.landscape) - .overlay { - Image(systemName: "arrow.clockwise") - .font(.system(size: 40)) - } - } - .buttonStyle(.card) - .posterShadow() - - SeriesEpisodeSelector.EpisodeContent( - subHeader: .emptyDash, - header: L10n.error, - content: error.localizedDescription - ) - } - } - } -} diff --git a/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/HStacks/EpisodeHStack.swift b/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/HStacks/EpisodeHStack.swift deleted file mode 100644 index 9e31683a65..0000000000 --- a/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/HStacks/EpisodeHStack.swift +++ /dev/null @@ -1,229 +0,0 @@ -// -// 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 CollectionHStack -import Foundation -import JellyfinAPI -import SwiftUI - -extension SeriesEpisodeSelector { - - struct EpisodeHStack: View { - - @EnvironmentObject - private var focusGuide: FocusGuide - - @FocusState - private var focusedEpisodeID: String? - - @ObservedObject - var viewModel: SeasonItemViewModel - - @State - private var didScrollToPlayButtonItem = false - @State - private var lastFocusedEpisodeID: String? - - @StateObject - private var proxy = CollectionHStackProxy() - - let playButtonItem: BaseItemDto? - - // MARK: - Content View - - private func contentView(viewModel: SeasonItemViewModel) -> some View { - CollectionHStack( - uniqueElements: viewModel.elements, - id: \.unwrappedIDHashOrZero, - columns: 3.5 - ) { episode in - SeriesEpisodeSelector.EpisodeCard(episode: episode) - .focused($focusedEpisodeID, equals: episode.id) - .padding(.horizontal, 4) - } - .scrollBehavior(.continuousLeadingEdge) - .insets(horizontal: EdgeInsets.edgePadding) - .itemSpacing(EdgeInsets.edgePadding / 2) - .proxy(proxy) - .onFirstAppear { - guard !didScrollToPlayButtonItem else { return } - didScrollToPlayButtonItem = true - - lastFocusedEpisodeID = playButtonItem?.id - - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - guard let playButtonItem else { return } - proxy.scrollTo(id: playButtonItem.unwrappedIDHashOrZero, animated: false) - } - } - } - - // MARK: - Determine Which Episode should be Focused - - private func getContentFocus() { - switch viewModel.state { - case .content: - if viewModel.elements.isEmpty { - /// Focus the EmptyCard if the Season has no elements - focusedEpisodeID = "emptyCard" - } else { - if let lastFocusedEpisodeID, - viewModel.elements.contains(where: { $0.id == lastFocusedEpisodeID }) - { - /// Return focus to the Last Focused Episode if it exists in the current Season - focusedEpisodeID = lastFocusedEpisodeID - } else { - /// Focus the First Episode in the season as a last resort - focusedEpisodeID = viewModel.elements.first?.id - } - } - case .error: - /// Focus the ErrorCard if the Season failed to load - focusedEpisodeID = "errorCard" - case .initial, .refreshing: - /// Focus the LoadingCard if the Season is currently loading - focusedEpisodeID = "loadingCard" - } - } - - // MARK: - Body - - var body: some View { - ZStack { - PlaceholderHStack() - - Group { - switch viewModel.state { - case .content: - if viewModel.elements.isEmpty { - EmptyHStack(focusedEpisodeID: $focusedEpisodeID) - } else { - contentView(viewModel: viewModel) - } - case let .error(error): - ErrorHStack(viewModel: viewModel, error: error, focusedEpisodeID: $focusedEpisodeID) - case .initial, .refreshing: - LoadingHStack(focusedEpisodeID: $focusedEpisodeID) - } - }.transition(.opacity.animation(.linear(duration: 0.1))) - } - .padding(.bottom, 45) - .focusSection() - .focusGuide( - focusGuide, - tag: "episodes", - onContentFocus: { - getContentFocus() - }, - top: "belowHeader" - ) - .onChange(of: viewModel.id) { - lastFocusedEpisodeID = viewModel.elements.first?.id - } - .onChange(of: focusedEpisodeID) { _, newValue in - guard let newValue else { return } - lastFocusedEpisodeID = newValue - } - .onChange(of: viewModel.state) { _, newValue in - if newValue == .content { - lastFocusedEpisodeID = viewModel.elements.first?.id - } - } - } - } - - // MARK: - Empty HStack - - struct EmptyHStack: View { - - let focusedEpisodeID: FocusState.Binding - - var body: some View { - CollectionHStack( - count: 1, - columns: 3.5 - ) { _ in - SeriesEpisodeSelector.EmptyCard() - .focused(focusedEpisodeID, equals: "emptyCard") - .padding(.horizontal, 4) - } - .insets(horizontal: EdgeInsets.edgePadding) - .itemSpacing(EdgeInsets.edgePadding / 2) - .scrollDisabled(true) - } - } - - // MARK: - Error HStack - - struct ErrorHStack: View { - - @ObservedObject - var viewModel: SeasonItemViewModel - - let error: ErrorMessage - let focusedEpisodeID: FocusState.Binding - - var body: some View { - CollectionHStack( - count: 1, - columns: 3.5 - ) { _ in - SeriesEpisodeSelector.ErrorCard(error: error) - .onSelect { - viewModel.send(.refresh) - } - .focused(focusedEpisodeID, equals: "errorCard") - .padding(.horizontal, 4) - } - .insets(horizontal: EdgeInsets.edgePadding) - .itemSpacing(EdgeInsets.edgePadding / 2) - .scrollDisabled(true) - } - } - - // MARK: - Loading HStack - - struct LoadingHStack: View { - - let focusedEpisodeID: FocusState.Binding - - var body: some View { - CollectionHStack( - count: 1, - columns: 3.5 - ) { _ in - SeriesEpisodeSelector.LoadingCard() - .focused(focusedEpisodeID, equals: "loadingCard") - .padding(.horizontal, 4) - } - .insets(horizontal: EdgeInsets.edgePadding) - .itemSpacing(EdgeInsets.edgePadding / 2) - .scrollDisabled(true) - } - } - - // MARK: - Placeholder HStack - - struct PlaceholderHStack: View { - - var body: some View { - CollectionHStack( - count: 1, - columns: 3.5 - ) { _ in - SeriesEpisodeSelector.EmptyCard() - .padding(.horizontal, 4) - } - .insets(horizontal: EdgeInsets.edgePadding) - .itemSpacing(EdgeInsets.edgePadding / 2) - .opacity(0) - .allowsHitTesting(false) - .scrollDisabled(true) - } - } -} diff --git a/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/HStacks/SeasonHStack.swift b/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/HStacks/SeasonHStack.swift deleted file mode 100644 index 7039930c6a..0000000000 --- a/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/HStacks/SeasonHStack.swift +++ /dev/null @@ -1,117 +0,0 @@ -// -// 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 SwiftUI - -extension SeriesEpisodeSelector { - - struct SeasonsHStack: View { - - // MARK: - Environment & Observed Objects - - @EnvironmentObject - private var focusGuide: FocusGuide - - @ObservedObject - var viewModel: SeriesItemViewModel - - // MARK: - Selection Binding - - @Binding - var selection: SeasonItemViewModel.ID? - - // MARK: - Focus Variables - - @FocusState - private var focusedSeason: SeasonItemViewModel.ID? - - @State - private var didScrollToPlayButtonSeason = false - - // MARK: - Body - - var body: some View { - ScrollViewReader { proxy in - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: EdgeInsets.edgePadding / 2) { - ForEach(viewModel.seasons) { season in - seasonButton(season: season) - .id(season.id) - } - } - .padding(.horizontal, EdgeInsets.edgePadding) - } - .padding(.bottom, 45) - .focusSection() - .focusGuide( - focusGuide, - tag: "belowHeader", - onContentFocus: { focusedSeason = selection }, - top: "header", - bottom: "episodes" - ) - .mask { - VStack(spacing: 0) { - Color.white - - LinearGradient( - stops: [ - .init(color: .white, location: 0), - .init(color: .clear, location: 1), - ], - startPoint: .top, - endPoint: .bottom - ) - .frame(height: 20) - } - } - .onChange(of: focusedSeason) { _, newValue in - if let newValue { - selection = newValue - } - } - .onFirstAppear { - guard !didScrollToPlayButtonSeason else { return } - didScrollToPlayButtonSeason = true - - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - guard let selection else { return } - - proxy.scrollTo(selection) - } - } - } - .scrollClipDisabled() - } - - // MARK: - Season Button - - @ViewBuilder - private func seasonButton(season: SeasonItemViewModel) -> some View { - Button { - selection = season.id - } label: { - Marquee(season.season.displayTitle, animateWhenFocused: true) - .frame(maxWidth: 300) - .font(.headline) - .fontWeight(.semibold) - .padding(.vertical, 10) - .padding(.horizontal, 20) - .if(selection == season.id) { text in - text - .background(.white) - .foregroundColor(.black) - } - } - .focused($focusedSeason, equals: season.id) - .buttonStyle(.card) - .padding(.horizontal, 4) - .padding(.vertical) - } - } -} diff --git a/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/LoadingCard.swift b/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/LoadingCard.swift deleted file mode 100644 index f3f650ddb3..0000000000 --- a/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/Components/LoadingCard.swift +++ /dev/null @@ -1,49 +0,0 @@ -// -// 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 SwiftUI - -extension SeriesEpisodeSelector { - - struct LoadingCard: View { - - private var onSelect: () -> Void - - init() { - self.onSelect = {} - } - - func onSelect(perform action: @escaping () -> Void) -> Self { - copy(modifying: \.onSelect, with: action) - } - - var body: some View { - VStack(alignment: .leading) { - Button { - onSelect() - } label: { - Color.secondarySystemFill - .opacity(0.75) - .posterStyle(.landscape) - .overlay { - ProgressView() - } - } - .buttonStyle(.card) - .posterShadow() - - SeriesEpisodeSelector.EpisodeContent( - subHeader: String.random(count: 7 ..< 12), - header: String.random(count: 10 ..< 20), - content: String.random(count: 20 ..< 80) - ) - .redacted(reason: .placeholder) - } - } - } -} diff --git a/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/EpisodeSelector.swift b/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/EpisodeSelector.swift deleted file mode 100644 index 3c26363c31..0000000000 --- a/Swiftfin tvOS/Views/ItemView/Components/EpisodeSelector/EpisodeSelector.swift +++ /dev/null @@ -1,67 +0,0 @@ -// -// 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 CollectionHStack -import JellyfinAPI -import SwiftUI - -struct SeriesEpisodeSelector: View { - - // MARK: - Observed & Environment Objects - - @ObservedObject - var viewModel: SeriesItemViewModel - - @EnvironmentObject - private var parentFocusGuide: FocusGuide - - // MARK: - State Variables - - @State - private var didSelectPlayButtonSeason = false - @State - private var selection: SeasonItemViewModel.ID? - - // MARK: - Calculated Variables - - private var selectionViewModel: SeasonItemViewModel? { - viewModel.seasons.first(where: { $0.id == selection }) - } - - // MARK: - Body - - var body: some View { - VStack(spacing: 0) { - SeasonsHStack(viewModel: viewModel, selection: $selection) - .environmentObject(parentFocusGuide) - - if let selectionViewModel { - EpisodeHStack(viewModel: selectionViewModel, playButtonItem: viewModel.playButtonItem) - .environmentObject(parentFocusGuide) - } - } - .onReceive(viewModel.playButtonItem.publisher) { newValue in - - guard !didSelectPlayButtonSeason else { return } - didSelectPlayButtonSeason = true - - if let playButtonSeason = viewModel.seasons.first(where: { $0.id == newValue.seasonID }) { - selection = playButtonSeason.id - } else { - selection = viewModel.seasons.first?.id - } - } - .onChange(of: selection) { _, _ in - guard let selectionViewModel else { return } - - if selectionViewModel.state == .initial { - selectionViewModel.send(.refresh) - } - } - } -} diff --git a/Swiftfin tvOS/Views/ItemView/Components/OverviewView.swift b/Swiftfin tvOS/Views/ItemView/Components/OverviewView.swift deleted file mode 100644 index 4b707525a9..0000000000 --- a/Swiftfin tvOS/Views/ItemView/Components/OverviewView.swift +++ /dev/null @@ -1,84 +0,0 @@ -// -// 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 JellyfinAPI -import SwiftUI - -// TODO: have items provide labeled attributes -// TODO: don't layout `VStack` if no data - -extension ItemView { - - struct OverviewView: View { - - let item: BaseItemDto - private var overviewLineLimit: Int? - private var taglineLineLimit: Int? - - var body: some View { - VStack(alignment: .leading, spacing: 10) { - - if let birthday = item.birthday?.formatted(date: .long, time: .omitted) { - LabeledContent( - L10n.born, - value: birthday - ) - } - - if let deathday = item.deathday?.formatted(date: .long, time: .omitted) { - LabeledContent( - L10n.died, - value: deathday - ) - } - - if let birthplace = item.birthplace { - LabeledContent( - L10n.birthplace, - value: birthplace - ) - } - - if let firstTagline = item.taglines?.first { - Text(firstTagline) - .font(.subheadline) - .fontWeight(.bold) - .multilineTextAlignment(.leading) - .lineLimit(taglineLineLimit) - } - - if let itemOverview = item.overview { - Text(itemOverview) - .font(.subheadline) - .lineLimit(overviewLineLimit) - } - } - .font(.footnote) - .labeledContentStyle(.itemAttribute) - } - } -} - -extension ItemView.OverviewView { - - init(item: BaseItemDto) { - self.init( - item: item, - overviewLineLimit: nil, - taglineLineLimit: nil - ) - } - - func overviewLineLimit(_ limit: Int) -> Self { - copy(modifying: \.overviewLineLimit, with: limit) - } - - func taglineLineLimit(_ limit: Int) -> Self { - copy(modifying: \.taglineLineLimit, with: limit) - } -} diff --git a/Swiftfin tvOS/Views/ItemView/Components/PlayButton/Components/VersionMenu.swift b/Swiftfin tvOS/Views/ItemView/Components/PlayButton/Components/VersionMenu.swift deleted file mode 100644 index 7a0edd5116..0000000000 --- a/Swiftfin tvOS/Views/ItemView/Components/PlayButton/Components/VersionMenu.swift +++ /dev/null @@ -1,54 +0,0 @@ -// -// 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 JellyfinAPI -import SwiftUI - -extension ItemView { - - struct VersionMenu: View { - - // MARK: - Focus State - - @FocusState - private var isFocused: Bool - - @ObservedObject - var viewModel: ItemViewModel - - let mediaSources: [MediaSourceInfo] - - // MARK: - Selected Media Source Binding - - private var selectedMediaSource: Binding { - Binding( - get: { viewModel.selectedMediaSource }, - set: { newSource in - if let newSource { - viewModel.send(.selectMediaSource(newSource)) - } - } - ) - } - - // MARK: - Body - - var body: some View { - Menu(L10n.version, systemImage: "list.dash") { - Picker(L10n.version, selection: selectedMediaSource) { - ForEach(mediaSources, id: \.hashValue) { mediaSource in - Text(mediaSource.displayTitle) - .tag(mediaSource as MediaSourceInfo?) - } - } - } - .labelStyle(.iconOnly) - .buttonStyle(.material) - } - } -} diff --git a/Swiftfin tvOS/Views/ItemView/Components/PlayButton/PlayButton.swift b/Swiftfin tvOS/Views/ItemView/Components/PlayButton/PlayButton.swift deleted file mode 100644 index 673b0145f7..0000000000 --- a/Swiftfin tvOS/Views/ItemView/Components/PlayButton/PlayButton.swift +++ /dev/null @@ -1,162 +0,0 @@ -// -// 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 JellyfinAPI -import Logging -import SwiftUI - -extension ItemView { - - struct PlayButton: View { - - @Router - private var router - - @ObservedObject - var viewModel: ItemViewModel - - private let logger = Logger.swiftfin() - - // MARK: - Media Sources - - private var mediaSources: [MediaSourceInfo] { - viewModel.playButtonItem?.mediaSources ?? [] - } - - // MARK: - Multiple Media Sources - - private var multipleVersions: Bool { - mediaSources.count > 1 - } - - // MARK: - Validation - - private var isEnabled: Bool { - viewModel.selectedMediaSource != nil - } - - // MARK: - Title - - private var title: String { - /// Use the Season/Episode label for the Series ItemView - if let seriesViewModel = viewModel as? SeriesItemViewModel, - let seasonEpisodeLabel = seriesViewModel.playButtonItem?.seasonEpisodeLabel - { - seasonEpisodeLabel - - /// Use a Play/Resume label for single Media Source items that are not Series - } else if let playButtonLabel = viewModel.playButtonItem?.playButtonLabel { - playButtonLabel - - /// Fallback to a generic `Play` label - } else { - L10n.play - } - } - - // MARK: - Media Source - - private var source: String? { - guard let sourceLabel = viewModel.selectedMediaSource?.displayTitle, - viewModel.item.mediaSources?.count ?? 0 > 1 - else { - return nil - } - - return sourceLabel - } - - // MARK: - Body - - var body: some View { - HStack(spacing: 30) { - playButton - - if multipleVersions { - VersionMenu(viewModel: viewModel, mediaSources: mediaSources) - .frame(width: 100, height: 100) - } - } - .fontWeight(.semibold) - } - - // MARK: - Play Button - - private var playButton: some View { - Button { - play() - } label: { - HStack(spacing: 15) { - Image(systemName: "play.fill") - - VStack { - Text(title) - - if let source { - Marquee(source, animateWhenFocused: true) - .font(.caption) - .fontWeight(.medium) - } - } - } - .padding(.horizontal, 20) - } - .buttonStyle( - .tintedMaterial( - tint: .white, - foregroundColor: .black - ) - ) - .contextMenu { - if viewModel.playButtonItem?.userData?.playbackPositionTicks != 0 { - Button(L10n.playFromBeginning, systemImage: "gobackward") { - play(fromBeginning: true) - } - } - } - .isSelected(true) - .enabled(isEnabled) - } - - // MARK: - Play Content - - private func play(fromBeginning: Bool = false) { - guard let playButtonItem = viewModel.playButtonItem, - let selectedMediaSource = viewModel.selectedMediaSource - else { - logger.error("Play selected with no item or media source") - return - } - - let queue: (any MediaPlayerQueue)? = { - if playButtonItem.type == .episode { - return EpisodeMediaPlayerQueue(episode: playButtonItem) - } - return nil - }() - - let provider = MediaPlayerItemProvider(item: playButtonItem) { item in - try await MediaPlayerItem.build( - for: item, - mediaSource: selectedMediaSource - ) { - if fromBeginning { - $0.userData?.playbackPositionTicks = 0 - } - } - } - - router.route( - to: .videoPlayer( - provider: provider, - queue: queue - ) - ) - } - } -} diff --git a/Swiftfin tvOS/Views/ItemView/Components/SimilarItemsHStack.swift b/Swiftfin tvOS/Views/ItemView/Components/SimilarItemsHStack.swift deleted file mode 100644 index a110f12f9b..0000000000 --- a/Swiftfin tvOS/Views/ItemView/Components/SimilarItemsHStack.swift +++ /dev/null @@ -1,40 +0,0 @@ -// -// 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 Defaults -import JellyfinAPI -import SwiftUI - -extension ItemView { - - struct SimilarItemsHStack: View { - - @Default(.Customization.similarPosterType) - private var similarPosterType - - @Router - private var router - - @StateObject - private var viewModel: PagingLibraryViewModel - - init(items: [BaseItemDto]) { - self._viewModel = StateObject(wrappedValue: PagingLibraryViewModel(items, parent: BaseItemDto(name: L10n.recommended))) - } - - var body: some View { - PosterHStack( - title: L10n.recommended, - type: similarPosterType, - items: viewModel.elements - ) { item in - router.route(to: .item(item: item)) - } - } - } -} diff --git a/Swiftfin tvOS/Views/ItemView/Components/SpecialFeaturesHStack.swift b/Swiftfin tvOS/Views/ItemView/Components/SpecialFeaturesHStack.swift deleted file mode 100644 index 488494345d..0000000000 --- a/Swiftfin tvOS/Views/ItemView/Components/SpecialFeaturesHStack.swift +++ /dev/null @@ -1,35 +0,0 @@ -// -// 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 JellyfinAPI -import SwiftUI - -extension ItemView { - - struct SpecialFeaturesHStack: View { - - @Router - private var router - - let items: [BaseItemDto] - - var body: some View { - PosterHStack( - title: L10n.specialFeatures, - type: .landscape, - items: items - ) { item in - guard let mediaSource = item.mediaSources?.first else { return } -// router.route( -// to: .videoPlayer(manager: OnlineVideoPlayerManager(item: item, mediaSource: mediaSource)) -// ) - } - .posterOverlay(for: BaseItemDto.self) { _ in EmptyView() } - } - } -} diff --git a/Swiftfin tvOS/Views/ItemView/ItemView.swift b/Swiftfin tvOS/Views/ItemView/ItemView.swift deleted file mode 100644 index 2ebd709f1b..0000000000 --- a/Swiftfin tvOS/Views/ItemView/ItemView.swift +++ /dev/null @@ -1,100 +0,0 @@ -// -// 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 JellyfinAPI -import SwiftUI - -struct ItemView: View { - - protocol ScrollContainerView: View { - - associatedtype Content: View - - init(viewModel: ItemViewModel, content: @escaping () -> Content) - } - - @StateObject - private var viewModel: ItemViewModel - - // MARK: typeViewModel - - private static func typeViewModel(for item: BaseItemDto) -> ItemViewModel { - switch item.type { - case .boxSet, .person, .musicArtist: - return CollectionItemViewModel(item: item) - case .episode: - return EpisodeItemViewModel(item: item) - case .movie: - return MovieItemViewModel(item: item) - case .musicVideo, .video: - return ItemViewModel(item: item) - case .series: - return SeriesItemViewModel(item: item) - default: - assertionFailure("Unsupported item") - return ItemViewModel(item: item) - } - } - - init(item: BaseItemDto) { - self._viewModel = StateObject(wrappedValue: Self.typeViewModel(for: item)) - } - - @ViewBuilder - private var scrollContentView: some View { - switch viewModel.item.type { - case .boxSet, .person, .musicArtist: - CollectionItemContentView(viewModel: viewModel as! CollectionItemViewModel) - case .episode, .musicVideo, .video: - SimpleItemContentView(viewModel: viewModel) - case .movie: - MovieItemContentView(viewModel: viewModel as! MovieItemViewModel) - case .series: - SeriesItemContentView(viewModel: viewModel as! SeriesItemViewModel) - default: - Text(L10n.notImplementedYetWithType(viewModel.item.type ?? "--")) - } - } - - // MARK: scrollContainerView - - private func scrollContainerView( - viewModel: ItemViewModel, - content: @escaping () -> some View - ) -> any ScrollContainerView { - CinematicScrollView(viewModel: viewModel, content: content) - } - - @ViewBuilder - private var innerBody: some View { - scrollContainerView(viewModel: viewModel) { - scrollContentView - } - .eraseToAnyView() - } - - var body: some View { - ZStack { - switch viewModel.state { - case .content: - innerBody - case let .error(error): - ErrorView(error: error) - case .initial, .refreshing: - ProgressView() - } - } - .animation(.linear(duration: 0.1), value: viewModel.state) - .onFirstAppear { - viewModel.send(.refresh) - } - .refreshable { - viewModel.send(.refresh) - } - } -} diff --git a/Swiftfin tvOS/Views/ItemView/MovieItemContentView.swift b/Swiftfin tvOS/Views/ItemView/MovieItemContentView.swift deleted file mode 100644 index 332570f239..0000000000 --- a/Swiftfin tvOS/Views/ItemView/MovieItemContentView.swift +++ /dev/null @@ -1,38 +0,0 @@ -// -// 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 SwiftUI - -extension ItemView { - struct MovieItemContentView: View { - @ObservedObject - var viewModel: MovieItemViewModel - - var body: some View { - VStack(spacing: 0) { - if let castAndCrew = viewModel.item.people, castAndCrew.isNotEmpty { - ItemView.CastAndCrewHStack(people: castAndCrew) - } - - if viewModel.additionalParts.isNotEmpty { - AdditionalPartsHStack(items: viewModel.additionalParts) - } - - if viewModel.specialFeatures.isNotEmpty { - ItemView.SpecialFeaturesHStack(items: viewModel.specialFeatures) - } - - if viewModel.similarItems.isNotEmpty { - ItemView.SimilarItemsHStack(items: viewModel.similarItems) - } - - ItemView.AboutView(viewModel: viewModel) - } - } - } -} diff --git a/Swiftfin tvOS/Views/ItemView/ScrollViews/CinematicScrollView.swift b/Swiftfin tvOS/Views/ItemView/ScrollViews/CinematicScrollView.swift deleted file mode 100644 index 1bcaf89677..0000000000 --- a/Swiftfin tvOS/Views/ItemView/ScrollViews/CinematicScrollView.swift +++ /dev/null @@ -1,222 +0,0 @@ -// -// 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 JellyfinAPI -import SwiftUI - -extension ItemView { - - struct CinematicScrollView: ScrollContainerView { - - @ObservedObject - private var viewModel: ItemViewModel - - @StateObject - private var focusGuide = FocusGuide() - - private let content: Content - - init( - viewModel: ItemViewModel, - content: @escaping () -> Content - ) { - self.viewModel = viewModel - self.content = content() - } - - private func withBackgroundImageSource( - @ViewBuilder content: @escaping (ImageSource) -> some View - ) -> some View { - let item: BaseItemDto = if viewModel.item.type == .person || viewModel.item.type == .musicArtist, - let typeViewModel = viewModel as? CollectionItemViewModel, - let randomItem = typeViewModel.randomItem() - { - randomItem - } else { - viewModel.item - } - - let imageType: ImageType = { - switch item.type { - case .episode, .musicVideo, .video: - .primary - default: - .backdrop - } - }() - - let imageSource = item.imageSource(imageType, maxWidth: 1920) - - return content(imageSource) - .id(imageSource.url?.hashValue) - .animation(.linear(duration: 0.1), value: imageSource.url?.hashValue) - } - - var body: some View { - GeometryReader { proxy in - ZStack { - withBackgroundImageSource { imageSource in - ImageView(imageSource) - } - - ScrollView(.vertical, showsIndicators: false) { - VStack(spacing: 0) { - CinematicHeaderView(viewModel: viewModel) - .ifLet(viewModel as? SeriesItemViewModel) { view, _ in - view - .focusGuide( - focusGuide, - tag: "header", - bottom: "belowHeader" - ) - } - .frame(height: proxy.size.height - 150) - .padding(.bottom, 50) - - content - } - .background { - BlurView(style: .dark) - .mask { - VStack(spacing: 0) { - LinearGradient(gradient: Gradient(stops: [ - .init(color: .white, location: 0), - .init(color: .white.opacity(0.7), location: 0.4), - .init(color: .white.opacity(0), location: 1), - ]), startPoint: .bottom, endPoint: .top) - .frame(height: proxy.size.height - 150) - - Color.white - } - } - } - .environmentObject(focusGuide) - } - } - } - .ignoresSafeArea() - } - } -} - -extension ItemView { - - struct CinematicHeaderView: View { - - enum CinematicHeaderFocusLayer: Hashable { - case top - case playButton - case actionButtons - } - - @StoredValue(.User.itemViewAttributes) - private var attributes - - @Router - private var router - @ObservedObject - var viewModel: ItemViewModel - @FocusState - private var focusedLayer: CinematicHeaderFocusLayer? - - var body: some View { - VStack(alignment: .leading) { - - Color.clear - .focusable() - .focused($focusedLayer, equals: .top) - - HStack(alignment: .bottom) { - - VStack(alignment: .leading, spacing: 20) { - - ImageView(viewModel.item.imageSource( - .logo, - maxHeight: 250 - )) - .placeholder { _ in - EmptyView() - } - .failure { - Marquee(viewModel.item.displayTitle) - .font(.largeTitle) - .fontWeight(.semibold) - .lineLimit(1) - .foregroundStyle(.white) - } - .aspectRatio(contentMode: .fit) - .padding(.bottom) - - OverviewView(item: viewModel.item) - .taglineLineLimit(1) - .overviewLineLimit(3) - - if viewModel.item.type != .person { - HStack { - - DotHStack { - if let firstGenre = viewModel.item.genres?.first { - Text(firstGenre) - } - - if let premiereYear = viewModel.item.premiereDateYear { - Text(premiereYear) - } - - if let playButtonitem = viewModel.playButtonItem, let runtime = playButtonitem.runTimeLabel { - Text(runtime) - } - } - .font(.caption) - .foregroundColor(Color(UIColor.lightGray)) - - ItemView.AttributesHStack( - attributes: attributes, - viewModel: viewModel - ) - } - } - } - - Spacer() - - VStack(spacing: 30) { - if viewModel.item.type == .person || viewModel.item.type == .musicArtist { - ImageView(viewModel.item.imageSource(.primary, maxWidth: 450)) - .failure { - SystemImageContentView(systemName: viewModel.item.systemImage) - } - .posterStyle(.portrait, contentMode: .fill) - .cornerRadius(10) - .accessibilityIgnoresInvertColors() - } else if viewModel.item.presentPlayButton { - ItemView.PlayButton(viewModel: viewModel) - .focused($focusedLayer, equals: .playButton) - .frame(height: 100) - } - ItemView.ActionButtonHStack(viewModel: viewModel) - .focused($focusedLayer, equals: .actionButtons) - .frame(height: 100) - } - .frame(width: 450) - .padding(.leading, 150) - } - } - .padding(.horizontal, 50) - .onChange(of: focusedLayer) { _, layer in - if layer == .top { - if viewModel.item.presentPlayButton { - focusedLayer = .playButton - } else { - focusedLayer = .actionButtons - } - } - } - } - } -} diff --git a/Swiftfin tvOS/Views/ItemView/SeriesItemContentView.swift b/Swiftfin tvOS/Views/ItemView/SeriesItemContentView.swift deleted file mode 100644 index d99cf8271b..0000000000 --- a/Swiftfin tvOS/Views/ItemView/SeriesItemContentView.swift +++ /dev/null @@ -1,40 +0,0 @@ -// -// 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 SwiftUI - -extension ItemView { - - struct SeriesItemContentView: View { - - @ObservedObject - var viewModel: SeriesItemViewModel - - var body: some View { - VStack(spacing: 0) { - if viewModel.seasons.isNotEmpty { - SeriesEpisodeSelector(viewModel: viewModel) - } - - if let castAndCrew = viewModel.item.people, castAndCrew.isNotEmpty { - ItemView.CastAndCrewHStack(people: castAndCrew) - } - - if viewModel.specialFeatures.isNotEmpty { - ItemView.SpecialFeaturesHStack(items: viewModel.specialFeatures) - } - - if viewModel.similarItems.isNotEmpty { - ItemView.SimilarItemsHStack(items: viewModel.similarItems) - } - - ItemView.AboutView(viewModel: viewModel) - } - } - } -} diff --git a/Swiftfin tvOS/Views/ItemView/SimpleItemContentView.swift b/Swiftfin tvOS/Views/ItemView/SimpleItemContentView.swift deleted file mode 100644 index 44554b3301..0000000000 --- a/Swiftfin tvOS/Views/ItemView/SimpleItemContentView.swift +++ /dev/null @@ -1,28 +0,0 @@ -// -// 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 SwiftUI - -extension ItemView { - - struct SimpleItemContentView: View { - - @ObservedObject - var viewModel: ItemViewModel - - var body: some View { - VStack(spacing: 0) { - if let castAndCrew = viewModel.item.people, castAndCrew.isNotEmpty { - ItemView.CastAndCrewHStack(people: castAndCrew) - } - - ItemView.AboutView(viewModel: viewModel) - } - } - } -} diff --git a/Swiftfin tvOS/Views/LearnMoreModal.swift b/Swiftfin tvOS/Views/LearnMoreModal.swift index db046c99df..3dd2de4f46 100644 --- a/Swiftfin tvOS/Views/LearnMoreModal.swift +++ b/Swiftfin tvOS/Views/LearnMoreModal.swift @@ -9,13 +9,13 @@ import SwiftUI @available(*, deprecated, message: "Use `Section(:content:learnMore:)` and a `Form` with an image instead") -struct LearnMoreModal: View { +struct LearnMoreModal: View { - private let content: AnyView + private let content: Content // MARK: - Initializer - init(@LabeledContentBuilder content: () -> AnyView) { + init(@LabeledContentBuilder content: () -> Content) { self.content = content() } diff --git a/Swiftfin tvOS/Views/PagingLibraryView/Components/LibraryRow.swift b/Swiftfin tvOS/Views/PagingLibraryView/Components/LibraryRow.swift deleted file mode 100644 index 9619f9f9e2..0000000000 --- a/Swiftfin tvOS/Views/PagingLibraryView/Components/LibraryRow.swift +++ /dev/null @@ -1,130 +0,0 @@ -// -// 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 Defaults -import JellyfinAPI -import SwiftUI - -private let landscapeMaxWidth: CGFloat = 110 -private let portraitMaxWidth: CGFloat = 60 - -extension PagingLibraryView { - - struct LibraryRow: View { - - private let item: Element - private var action: () -> Void - private let posterType: PosterDisplayType - - init( - item: Element, - posterType: PosterDisplayType, - action: @escaping () -> Void - ) { - self.item = item - self.action = action - self.posterType = posterType - } - - private func imageSources(from element: Element) -> [ImageSource] { - switch posterType { - case .landscape: - element.landscapeImageSources(maxWidth: landscapeMaxWidth, quality: 90) - case .portrait: - element.portraitImageSources(maxWidth: portraitMaxWidth, quality: 90) - case .square: - element.squareImageSources(maxWidth: portraitMaxWidth, quality: 90) - } - } - - @ViewBuilder - private func itemAccessoryView(item: BaseItemDto) -> some View { - DotHStack { - if item.type == .episode, let seasonEpisodeLocator = item.seasonEpisodeLabel { - Text(seasonEpisodeLocator) - } else if let premiereYear = item.premiereDateYear { - Text(premiereYear) - } - - if let runtime = item.runTimeLabel { - Text(runtime) - } - - if let officialRating = item.officialRating { - Text(officialRating) - } - } - } - - @ViewBuilder - private func personAccessoryView(person: BaseItemPerson) -> some View { - if let subtitle = person.subtitle { - Text(subtitle) - } - } - - @ViewBuilder - private var accessoryView: some View { - switch item { - case let element as BaseItemDto: - itemAccessoryView(item: element) - case let element as BaseItemPerson: - personAccessoryView(person: element) - default: - AssertionFailureView("Used an unexpected type within a `PagingLibaryView`?") - } - } - - @ViewBuilder - private var rowContent: some View { - HStack { - VStack(alignment: .leading, spacing: 5) { - Text(item.displayTitle) - .font(posterType == .landscape ? .subheadline : .callout) - .fontWeight(.semibold) - .foregroundColor(.primary) - .lineLimit(2) - .multilineTextAlignment(.leading) - - accessoryView - .font(.caption) - .foregroundColor(Color(UIColor.lightGray)) - } - Spacer() - } - } - - @ViewBuilder - private var rowLeading: some View { - ZStack { - Color.clear - - ImageView(imageSources(from: item)) - .failure { - SystemImageContentView(systemName: item.systemImage) - } - } - .posterStyle(posterType) - .frame(width: posterType == .landscape ? 110 : 60) - .posterShadow() - .padding(.vertical, 8) - } - - // MARK: body - - var body: some View { - ListRow(insets: .init(horizontal: EdgeInsets.edgePadding)) { - rowLeading - } content: { - rowContent - } - .onSelect(perform: action) - .focusedValue(\.focusedPoster, AnyPoster(item)) - } - } -} diff --git a/Swiftfin tvOS/Views/PagingLibraryView/Components/ListRow.swift b/Swiftfin tvOS/Views/PagingLibraryView/Components/ListRow.swift deleted file mode 100644 index 15fc205070..0000000000 --- a/Swiftfin tvOS/Views/PagingLibraryView/Components/ListRow.swift +++ /dev/null @@ -1,78 +0,0 @@ -// -// 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 SwiftUI - -// TODO: come up with better name along with `ListRowButton` - -// Meant to be used when making a custom list without `List` or `Form` -struct ListRow: View { - - @State - private var contentSize: CGSize = .zero - - private let leading: Leading - private let content: Content - private var action: () -> Void - private var insets: EdgeInsets - private var isSeparatorVisible: Bool - - var body: some View { - ZStack(alignment: .bottomTrailing) { - - Button { - action() - } label: { - HStack(alignment: .center, spacing: EdgeInsets.edgePadding) { - - leading - - content - .frame(maxHeight: .infinity) - .trackingSize($contentSize) - } - .padding(.top, insets.top) - .padding(.bottom, insets.bottom) - .padding(.leading, insets.leading) - .padding(.trailing, insets.trailing) - } - .foregroundStyle(.primary, .secondary) - .buttonStyle(.plain) - - Color.secondarySystemFill - .frame(width: contentSize.width, height: 1) - .padding(.trailing, insets.trailing) - .isVisible(isSeparatorVisible) - } - } -} - -extension ListRow { - - init( - insets: EdgeInsets = .zero, - @ViewBuilder leading: @escaping () -> Leading, - @ViewBuilder content: @escaping () -> Content - ) { - self.init( - leading: leading(), - content: content(), - action: {}, - insets: insets, - isSeparatorVisible: true - ) - } - - func isSeparatorVisible(_ isVisible: Bool) -> Self { - copy(modifying: \.isSeparatorVisible, with: isVisible) - } - - func onSelect(perform action: @escaping () -> Void) -> Self { - copy(modifying: \.action, with: action) - } -} diff --git a/Swiftfin tvOS/Views/PagingLibraryView/PagingLibraryView.swift b/Swiftfin tvOS/Views/PagingLibraryView/PagingLibraryView.swift deleted file mode 100644 index feb05d7679..0000000000 --- a/Swiftfin tvOS/Views/PagingLibraryView/PagingLibraryView.swift +++ /dev/null @@ -1,366 +0,0 @@ -// -// 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 Defaults -import JellyfinAPI -import SwiftUI - -// TODO: Figure out proper tab bar handling with the collection offset -// TODO: fix paging for next item focusing the tab - -struct PagingLibraryView: View { - - @Default(.Customization.Library.cinematicBackground) - private var cinematicBackground - @Default(.Customization.Library.enabledDrawerFilters) - private var enabledDrawerFilters - @Default(.Customization.Library.rememberLayout) - private var rememberLayout - - @Default(.Customization.Library.displayType) - private var defaultDisplayType: LibraryDisplayType - @Default(.Customization.Library.listColumnCount) - private var defaultListColumnCount: Int - @Default(.Customization.Library.posterType) - private var defaultPosterType: PosterDisplayType - - @FocusedValue(\.focusedPoster) - private var focusedPoster - - @Router - private var router - - @State - private var presentBackground = false - @State - private var layout: CollectionVGridLayout - @State - private var safeArea: EdgeInsets = .zero - - @StoredValue - private var displayType: LibraryDisplayType - @StoredValue - private var listColumnCount: Int - @StoredValue - private var posterType: PosterDisplayType - - @StateObject - private var collectionVGridProxy: CollectionVGridProxy = .init() - @StateObject - private var viewModel: PagingLibraryViewModel - - @StateObject - private var cinematicBackgroundProxy: CinematicBackgroundView.Proxy = .init() - - init(viewModel: PagingLibraryViewModel) { - - self._displayType = StoredValue(.User.libraryDisplayType(parentID: viewModel.parent?.id)) - self._listColumnCount = StoredValue(.User.libraryListColumnCount(parentID: viewModel.parent?.id)) - self._posterType = StoredValue(.User.libraryPosterType(parentID: viewModel.parent?.id)) - - self._viewModel = StateObject(wrappedValue: viewModel) - - let defaultDisplayType = Defaults[.Customization.Library.displayType] - let defaultListColumnCount = Defaults[.Customization.Library.listColumnCount] - let defaultPosterType = Defaults[.Customization.Library.posterType] - - let displayType = StoredValues[.User.libraryDisplayType(parentID: viewModel.parent?.id)] - let listColumnCount = StoredValues[.User.libraryListColumnCount(parentID: viewModel.parent?.id)] - let posterType = StoredValues[.User.libraryPosterType(parentID: viewModel.parent?.id)] - - let initialDisplayType = Defaults[.Customization.Library.rememberLayout] ? displayType : defaultDisplayType - let initialListColumnCount = Defaults[.Customization.Library.rememberLayout] ? listColumnCount : defaultListColumnCount - let initialPosterType = Defaults[.Customization.Library.rememberLayout] ? posterType : defaultPosterType - - self._layout = State( - initialValue: Self.makeLayout( - posterType: initialPosterType, - viewType: initialDisplayType, - listColumnCount: initialListColumnCount - ) - ) - } - - // MARK: On Select - - private func onSelect(_ element: Element) { - switch element { - case let element as BaseItemDto: - select(item: element) - case let element as BaseItemPerson: - select(item: BaseItemDto(person: element)) - default: - assertionFailure("Used an unexpected type within a `PagingLibaryView`?") - } - } - - private func select(item: BaseItemDto) { - switch item.type { - case .collectionFolder, .folder: - let viewModel = ItemLibraryViewModel(parent: item, filters: .default) - router.route(to: .library(viewModel: viewModel)) - default: - router.route(to: .item(item: item)) - } - } - - // MARK: Select Person - - private func select(person: BaseItemPerson) { - let viewModel = ItemLibraryViewModel(parent: person) - router.route(to: .library(viewModel: viewModel)) - } - - // MARK: Make Layout - - private static func makeLayout( - posterType: PosterDisplayType, - viewType: LibraryDisplayType, - listColumnCount: Int - ) -> CollectionVGridLayout { - switch (posterType, viewType) { - case (.landscape, .grid): - .columns(5, insets: .init(50), itemSpacing: 50, lineSpacing: 50) - case (.portrait, .grid), (.square, .grid): - .columns(7, insets: .init(50), itemSpacing: 50, lineSpacing: 50) - case (_, .list): - .columns(listColumnCount, insets: .init(50), itemSpacing: 50, lineSpacing: 50) - } - } - - // MARK: Set Default Layout - - private func setDefaultLayout() { - layout = Self.makeLayout( - posterType: defaultPosterType, - viewType: defaultDisplayType, - listColumnCount: defaultListColumnCount - ) - } - - // MARK: Set Custom Layout - - private func setCustomLayout() { - layout = Self.makeLayout( - posterType: posterType, - viewType: displayType, - listColumnCount: listColumnCount - ) - } - - // MARK: Set Cinematic Background - - private func setCinematicBackground() { - guard let focusedPoster else { - withAnimation { - presentBackground = false - } - return - } - - cinematicBackgroundProxy.select(item: focusedPoster) - - if !presentBackground { - withAnimation { - presentBackground = true - } - } - } - - // MARK: Landscape Grid Item View - - private func landscapeGridItemView(item: Element) -> some View { - PosterButton( - item: item, - type: .landscape - ) { - onSelect(item) - } label: { - if item.showTitle { - PosterButton.TitleContentView(item: item) - .lineLimit(1, reservesSpace: true) - } else if viewModel.parent?.libraryType == .folder { - PosterButton.TitleContentView(item: item) - .lineLimit(1, reservesSpace: true) - .hidden() - } - } - } - - // MARK: Portrait Grid Item View - - @ViewBuilder - private func portraitGridItemView(item: Element) -> some View { - PosterButton( - item: item, - type: .portrait - ) { - onSelect(item) - } label: { - if item.showTitle { - PosterButton.TitleContentView(item: item) - .lineLimit(1, reservesSpace: true) - } else if viewModel.parent?.libraryType == .folder { - PosterButton.TitleContentView(item: item) - .lineLimit(1, reservesSpace: true) - .hidden() - } - } - } - - // MARK: List Item View - - @ViewBuilder - private func listItemView(item: Element, posterType: PosterDisplayType) -> some View { - LibraryRow( - item: item, - posterType: posterType - ) { - onSelect(item) - } - } - - // MARK: Grid View - - @ViewBuilder - private var gridView: some View { - CollectionVGrid( - uniqueElements: viewModel.elements, - layout: layout - ) { item in - - let displayType = Defaults[.Customization.Library.rememberLayout] ? _displayType.wrappedValue : _defaultDisplayType - .wrappedValue - let posterType = Defaults[.Customization.Library.rememberLayout] ? _posterType.wrappedValue : _defaultPosterType.wrappedValue - - switch (posterType, displayType) { - case (.landscape, .grid): - landscapeGridItemView(item: item) - case (.portrait, .grid), (.square, .grid): - portraitGridItemView(item: item) - case (_, .list): - listItemView(item: item, posterType: posterType) - } - } - .onReachedBottomEdge(offset: .rows(3)) { - viewModel.send(.getNextPage) - } - .proxy(collectionVGridProxy) - .scrollIndicators(.hidden) - } - - // MARK: Content View - - @ViewBuilder - private var contentView: some View { - switch viewModel.state { - case .content: - if viewModel.elements.isEmpty { - ContentUnavailableView(L10n.noItems.localizedCapitalized, systemImage: "rectangle.on.rectangle.slash") - } else { - gridView - .onChange(of: posterType) { - setCustomLayout() - } - .onChange(of: displayType) { - setCustomLayout() - } - .onChange(of: listColumnCount) { - setCustomLayout() - } - } - case .initial, .refreshing: - ProgressView() - default: - AssertionFailureView("Expected view for unexpected state") - } - } - - // MARK: Body - - var body: some View { - ZStack { - Color.clear - .ignoresSafeArea() - - if cinematicBackground { - CinematicBackgroundView(viewModel: cinematicBackgroundProxy) - .isVisible(presentBackground) - .blurred() - .ignoresSafeArea() - } - - switch viewModel.state { - case .content, .initial, .refreshing: - contentView - case let .error(error): - ErrorView(error: error) - } - } - .frame(maxWidth: .infinity) - .animation(.linear(duration: 0.1), value: viewModel.state) - .navigationTitle(viewModel.parent?.displayTitle ?? "") - .ignoresSafeArea(.all, edges: .vertical) - .letterPickerBar(filterViewModel: viewModel.filterViewModel) - .refreshable { - viewModel.send(.refresh) - } - .onChange(of: focusedPoster) { - setCinematicBackground() - } - .onChange(of: rememberLayout) { - if rememberLayout { - setCustomLayout() - } else { - setDefaultLayout() - } - } - .onChange(of: defaultPosterType) { - guard !Defaults[.Customization.Library.rememberLayout] else { return } - setDefaultLayout() - } - .onChange(of: defaultDisplayType) { - guard !Defaults[.Customization.Library.rememberLayout] else { return } - setDefaultLayout() - } - .onChange(of: defaultListColumnCount) { - guard !Defaults[.Customization.Library.rememberLayout] else { return } - setDefaultLayout() - } - .onChange(of: viewModel.filterViewModel?.currentFilters) { _, newValue in - guard let newValue, let id = viewModel.parent?.id else { return } - - if Defaults[.Customization.Library.rememberSort] { - let newStoredFilters = StoredValues[.User.libraryFilters(parentID: id)] - .mutating(\.sortBy, with: newValue.sortBy) - .mutating(\.sortOrder, with: newValue.sortOrder) - - StoredValues[.User.libraryFilters(parentID: id)] = newStoredFilters - } - } - .onReceive(viewModel.events) { event in - switch event { - case let .gotRandomItem(item): - switch item { - case let item as BaseItemDto: - select(item: item) - case let item as BaseItemPerson: - select(item: BaseItemDto(person: item)) - default: - assertionFailure("Used an unexpected type within a `PagingLibaryView`?") - } - } - } - .onFirstAppear { - if viewModel.state == .initial { - viewModel.send(.refresh) - } - } - } -} diff --git a/Swiftfin tvOS/Views/ProgramsView/Components/ProgramButtonContent.swift b/Swiftfin tvOS/Views/ProgramsView/Components/ProgramButtonContent.swift deleted file mode 100644 index 4b09ee0fb1..0000000000 --- a/Swiftfin tvOS/Views/ProgramsView/Components/ProgramButtonContent.swift +++ /dev/null @@ -1,51 +0,0 @@ -// -// 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 JellyfinAPI -import SwiftUI - -extension ProgramsView { - - struct ProgramButtonContent: View { - - let program: BaseItemDto - - var body: some View { - VStack(alignment: .leading) { - - Text(program.channelName ?? .emptyDash) - .font(.footnote.weight(.semibold)) - .foregroundColor(.primary) - .lineLimit(1, reservesSpace: true) - - Text(program.displayTitle) - .font(.footnote.weight(.regular)) - .foregroundColor(.primary) - .lineLimit(1, reservesSpace: true) - - HStack(spacing: 2) { - if let startDate = program.startDate { - Text(startDate, style: .time) - } else { - Text(String.emptyDash) - } - - Text(String.hyphen) - - if let endDate = program.endDate { - Text(endDate, style: .time) - } else { - Text(String.emptyDash) - } - } - .font(.footnote) - .foregroundStyle(.secondary) - } - } - } -} diff --git a/Swiftfin tvOS/Views/ProgramsView/Components/ProgramProgressOverlay.swift b/Swiftfin tvOS/Views/ProgramsView/Components/ProgramProgressOverlay.swift deleted file mode 100644 index 6c32869fa5..0000000000 --- a/Swiftfin tvOS/Views/ProgramsView/Components/ProgramProgressOverlay.swift +++ /dev/null @@ -1,37 +0,0 @@ -// -// 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 JellyfinAPI -import SwiftUI - -extension ProgramsView { - - struct ProgramProgressOverlay: View { - - @State - private var programProgress: Double = 0.0 - - let program: BaseItemDto - private let timer = Timer.publish(every: 5, on: .main, in: .common).autoconnect() - - var body: some View { - WrappedView { - if let startDate = program.startDate, startDate < Date.now { - LandscapePosterProgressBar( - progress: program.programProgress ?? 0 - ) - } - } - .onReceive(timer) { newValue in - if let startDate = program.startDate, startDate < newValue, let duration = program.programDuration { - programProgress = newValue.timeIntervalSince(startDate) / duration - } - } - } - } -} diff --git a/Swiftfin tvOS/Views/ProgramsView/ProgramsView.swift b/Swiftfin tvOS/Views/ProgramsView/ProgramsView.swift deleted file mode 100644 index ea36df6f9f..0000000000 --- a/Swiftfin tvOS/Views/ProgramsView/ProgramsView.swift +++ /dev/null @@ -1,104 +0,0 @@ -// -// 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 JellyfinAPI -import SwiftUI - -// TODO: background refresh for programs with timer? - -// Note: there are some unsafe first element accesses, but `ChannelProgram` data should always have a single program - -struct ProgramsView: View { - - @Router - private var router - - @StateObject - private var programsViewModel = ProgramsViewModel() - - @ViewBuilder - private var contentView: some View { - ScrollView(showsIndicators: false) { - VStack(spacing: 20) { - if programsViewModel.recommended.isNotEmpty { - programsSection(title: L10n.onNow, keyPath: \.recommended) - } - - if programsViewModel.series.isNotEmpty { - programsSection(title: L10n.series, keyPath: \.series) - } - - if programsViewModel.movies.isNotEmpty { - programsSection(title: L10n.movies, keyPath: \.movies) - } - - if programsViewModel.kids.isNotEmpty { - programsSection(title: L10n.kids, keyPath: \.kids) - } - - if programsViewModel.sports.isNotEmpty { - programsSection(title: L10n.sports, keyPath: \.sports) - } - - if programsViewModel.news.isNotEmpty { - programsSection(title: L10n.news, keyPath: \.news) - } - } - } - } - - @ViewBuilder - private func programsSection( - title: String, - keyPath: KeyPath - ) -> some View { - PosterHStack( - title: title, - type: .landscape, - items: programsViewModel[keyPath: keyPath] - ) { _ in -// guard let mediaSource = channelProgram.channel.mediaSources?.first else { return } -// router.route( -// to: \.liveVideoPlayer, -// LiveVideoPlayerManager(item: channelProgram.channel, mediaSource: mediaSource) -// ) - } label: { - ProgramButtonContent(program: $0) - } - .posterOverlay(for: BaseItemDto.self) { - ProgramProgressOverlay(program: $0) - } - } - - var body: some View { - ZStack { - switch programsViewModel.state { - case .content: - if programsViewModel.hasNoResults { - ContentUnavailableView(L10n.noPrograms.localizedCapitalized, systemImage: "tv") - } else { - contentView - } - case let .error(error): - ErrorView(error: error) - case .initial, .refreshing: - ProgressView() - } - } - .animation(.linear(duration: 0.1), value: programsViewModel.state) - .ignoresSafeArea(edges: [.bottom, .horizontal]) - .refreshable { - programsViewModel.send(.refresh) - } - .onFirstAppear { - if programsViewModel.state == .initial { - programsViewModel.send(.refresh) - } - } - } -} diff --git a/Swiftfin tvOS/Views/SearchView.swift b/Swiftfin tvOS/Views/SearchView.swift deleted file mode 100644 index 8934364ade..0000000000 --- a/Swiftfin tvOS/Views/SearchView.swift +++ /dev/null @@ -1,197 +0,0 @@ -// -// 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 Defaults -import JellyfinAPI -import SwiftUI - -struct SearchView: View { - - @Default(.Customization.searchPosterType) - private var searchPosterType - - @Router - private var router - - @State - private var searchQuery = "" - - @StateObject - private var viewModel = SearchViewModel(filterViewModel: .init()) - - @ViewBuilder - private var suggestionsView: some View { - VStack(spacing: 20) { - ForEach(viewModel.suggestions) { item in - Button(item.displayTitle) { - searchQuery = item.displayTitle - } - .buttonStyle(.plain) - .foregroundStyle(.secondary) - } - } - } - - @ViewBuilder - private var resultsView: some View { - ScrollView(showsIndicators: false) { - VStack(spacing: 20) { - if let movies = viewModel.items[.movie], movies.isNotEmpty { - itemsSection( - title: L10n.movies, - type: .movie, - items: movies, - posterType: searchPosterType - ) - } - - if let series = viewModel.items[.series], series.isNotEmpty { - itemsSection( - title: L10n.tvShows, - type: .series, - items: series, - posterType: searchPosterType - ) - } - - if let collections = viewModel.items[.boxSet], collections.isNotEmpty { - itemsSection( - title: L10n.collections, - type: .boxSet, - items: collections, - posterType: searchPosterType - ) - } - - if let episodes = viewModel.items[.episode], episodes.isNotEmpty { - itemsSection( - title: L10n.episodes, - type: .episode, - items: episodes, - posterType: searchPosterType - ) - } - - if let musicVideos = viewModel.items[.musicVideo], musicVideos.isNotEmpty { - itemsSection( - title: L10n.musicVideos, - type: .musicVideo, - items: musicVideos, - posterType: .landscape - ) - } - - if let videos = viewModel.items[.video], videos.isNotEmpty { - itemsSection( - title: L10n.videos, - type: .video, - items: videos, - posterType: .landscape - ) - } - - if let programs = viewModel.items[.program], programs.isNotEmpty { - itemsSection( - title: L10n.programs, - type: .program, - items: programs, - posterType: .landscape - ) - } - - if let channels = viewModel.items[.tvChannel], channels.isNotEmpty { - itemsSection( - title: L10n.channels, - type: .tvChannel, - items: channels, - posterType: .square - ) - } - - if let musicArtists = viewModel.items[.musicArtist], musicArtists.isNotEmpty { - itemsSection( - title: L10n.artists, - type: .musicArtist, - items: musicArtists, - posterType: .portrait - ) - } - - if let people = viewModel.items[.person], people.isNotEmpty { - itemsSection( - title: L10n.people, - type: .person, - items: people, - posterType: .portrait - ) - } - } - .edgePadding(.vertical) - } - } - - private func select(_ item: BaseItemDto) { - switch item.type { - case .program, .tvChannel: - let provider = item.getPlaybackItemProvider(userSession: viewModel.userSession) - router.route(to: .videoPlayer(provider: provider)) - default: - router.route(to: .item(item: item)) - } - } - - @ViewBuilder - private func itemsSection( - title: String, - type: BaseItemKind, - items: [BaseItemDto], - posterType: PosterDisplayType - ) -> some View { - PosterHStack( - title: title, - type: posterType, - items: items, - action: select - ) - } - - var body: some View { - ZStack { - switch viewModel.state { - case .error: - viewModel.error.map { - ErrorView(error: $0) - } - case .initial: - if viewModel.hasNoResults { - if viewModel.canSearch { - ContentUnavailableView.search - } else { - suggestionsView - } - } else { - resultsView - } - case .searching: - ProgressView() - } - } - .animation(.linear(duration: 0.1), value: viewModel.state) - .ignoresSafeArea(edges: [.bottom, .horizontal]) - .refreshable { - viewModel.search(query: searchQuery) - } - .onFirstAppear { - viewModel.getSuggestions() - } - .onChange(of: searchQuery) { _, newValue in - viewModel.search(query: newValue) - } - .searchable(text: $searchQuery, prompt: L10n.search) - } -} diff --git a/Swiftfin tvOS/Views/SelectUserView/SelectUserView.swift b/Swiftfin tvOS/Views/SelectUserView/SelectUserView.swift index df163eaaae..79eba40ded 100644 --- a/Swiftfin tvOS/Views/SelectUserView/SelectUserView.swift +++ b/Swiftfin tvOS/Views/SelectUserView/SelectUserView.swift @@ -221,7 +221,7 @@ struct SelectUserView: View { .focusSection() } .animation(.linear(duration: 0.1), value: scrollViewOffset) - .environment(\.isOverComplexContent, true) + .withViewContext(.isOverComplexContent) .background { if selectUserUseSplashscreen, splashScreenImageSources.isNotEmpty { ZStack { diff --git a/Swiftfin tvOS/Views/VideoPlayerContainerState/PlaybackControls/Components/ActionButtons/ActionButtons.swift b/Swiftfin tvOS/Views/VideoPlayerContainerState/PlaybackControls/Components/ActionButtons/ActionButtons.swift index 1aa8557102..00ad46b6fa 100644 --- a/Swiftfin tvOS/Views/VideoPlayerContainerState/PlaybackControls/Components/ActionButtons/ActionButtons.swift +++ b/Swiftfin tvOS/Views/VideoPlayerContainerState/PlaybackControls/Components/ActionButtons/ActionButtons.swift @@ -113,7 +113,7 @@ extension VideoPlayer.PlaybackControls.NavigationBar { menuActionButtons, content: view(for:) ) - .environment(\.isInMenu, true) + .withViewContext(.isInMenu) } } } diff --git a/Swiftfin tvOS/Views/VideoPlayerContainerState/PlaybackControls/Components/ActionButtons/AudioActionButton.swift b/Swiftfin tvOS/Views/VideoPlayerContainerState/PlaybackControls/Components/ActionButtons/AudioActionButton.swift index 9827d9d575..6c0b2c9377 100644 --- a/Swiftfin tvOS/Views/VideoPlayerContainerState/PlaybackControls/Components/ActionButtons/AudioActionButton.swift +++ b/Swiftfin tvOS/Views/VideoPlayerContainerState/PlaybackControls/Components/ActionButtons/AudioActionButton.swift @@ -12,7 +12,7 @@ extension VideoPlayer.PlaybackControls.NavigationBar.ActionButtons { struct Audio: View { - @Environment(\.isInMenu) + @ViewContextContains(.isInMenu) private var isInMenu @EnvironmentObject diff --git a/Swiftfin tvOS/Views/VideoPlayerContainerState/PlaybackControls/Components/ActionButtons/AutoPlayActionButton.swift b/Swiftfin tvOS/Views/VideoPlayerContainerState/PlaybackControls/Components/ActionButtons/AutoPlayActionButton.swift index f9fdc46f32..42f5e336e5 100644 --- a/Swiftfin tvOS/Views/VideoPlayerContainerState/PlaybackControls/Components/ActionButtons/AutoPlayActionButton.swift +++ b/Swiftfin tvOS/Views/VideoPlayerContainerState/PlaybackControls/Components/ActionButtons/AutoPlayActionButton.swift @@ -16,7 +16,7 @@ extension VideoPlayer.PlaybackControls.NavigationBar.ActionButtons { @Default(.VideoPlayer.autoPlayEnabled) private var isAutoPlayEnabled - @Environment(\.isInMenu) + @ViewContextContains(.isInMenu) private var isInMenu @EnvironmentObject diff --git a/Swiftfin tvOS/Views/VideoPlayerContainerState/PlaybackControls/Components/ActionButtons/SubtitleActionButton.swift b/Swiftfin tvOS/Views/VideoPlayerContainerState/PlaybackControls/Components/ActionButtons/SubtitleActionButton.swift index 34ff338418..5eab8cdb3f 100644 --- a/Swiftfin tvOS/Views/VideoPlayerContainerState/PlaybackControls/Components/ActionButtons/SubtitleActionButton.swift +++ b/Swiftfin tvOS/Views/VideoPlayerContainerState/PlaybackControls/Components/ActionButtons/SubtitleActionButton.swift @@ -12,7 +12,7 @@ extension VideoPlayer.PlaybackControls.NavigationBar.ActionButtons { struct Subtitles: View { - @Environment(\.isInMenu) + @ViewContextContains(.isInMenu) private var isInMenu @EnvironmentObject diff --git a/Swiftfin tvOS/Views/VideoPlayerContainerState/PlaybackControls/VideoPlayerContainerView.swift b/Swiftfin tvOS/Views/VideoPlayerContainerState/PlaybackControls/VideoPlayerContainerView.swift index 82f0bd9b3b..9b4ef9da51 100644 --- a/Swiftfin tvOS/Views/VideoPlayerContainerState/PlaybackControls/VideoPlayerContainerView.swift +++ b/Swiftfin tvOS/Views/VideoPlayerContainerState/PlaybackControls/VideoPlayerContainerView.swift @@ -264,7 +264,7 @@ extension VideoPlayer { extension VideoPlayer.UIVideoPlayerContainerViewController { typealias PressEvent = (type: UIPress.PressType, phase: UIPress.Phase) - typealias OnPressEvent = LegacyEventPublisher + typealias OnPressEvent = PassthroughSubject } @propertyWrapper diff --git a/Swiftfin.xcodeproj/project.pbxproj b/Swiftfin.xcodeproj/project.pbxproj index 998b6a5994..ce53c737af 100644 --- a/Swiftfin.xcodeproj/project.pbxproj +++ b/Swiftfin.xcodeproj/project.pbxproj @@ -45,6 +45,9 @@ BD88CB422D77E6A0006BB5E3 /* TVOSPicker in Frameworks */ = {isa = PBXBuildFile; productRef = BD88CB412D77E6A0006BB5E3 /* TVOSPicker */; }; E1002B682793CFBA00E47059 /* Algorithms in Frameworks */ = {isa = PBXBuildFile; productRef = E1002B672793CFBA00E47059 /* Algorithms */; }; E1002B6B2793E36600E47059 /* Algorithms in Frameworks */ = {isa = PBXBuildFile; productRef = E1002B6A2793E36600E47059 /* Algorithms */; }; + E10477722E9D84600059AB5A /* CollectionHStack in Frameworks */ = {isa = PBXBuildFile; productRef = E10477712E9D84600059AB5A /* CollectionHStack */; }; + E10477742E9D84C00059AB5A /* CollectionVGrid in Frameworks */ = {isa = PBXBuildFile; productRef = E10477732E9D84C00059AB5A /* CollectionVGrid */; }; + E10477762E9D85390059AB5A /* StatefulMacros in Frameworks */ = {isa = PBXBuildFile; productRef = E10477752E9D85390059AB5A /* StatefulMacros */; }; E10706102942F57D00646DAF /* Pulse in Frameworks */ = {isa = PBXBuildFile; productRef = E107060F2942F57D00646DAF /* Pulse */; }; E10706122942F57D00646DAF /* PulseLogHandler in Frameworks */ = {isa = PBXBuildFile; productRef = E10706112942F57D00646DAF /* PulseLogHandler */; }; E10706142942F57D00646DAF /* PulseUI in Frameworks */ = {isa = PBXBuildFile; productRef = E10706132942F57D00646DAF /* PulseUI */; }; @@ -72,7 +75,6 @@ E13DD3C62716499E009D4DAF /* CoreStore in Frameworks */ = {isa = PBXBuildFile; productRef = E13DD3C52716499E009D4DAF /* CoreStore */; }; E13DD3CD27164CA7009D4DAF /* CoreStore in Frameworks */ = {isa = PBXBuildFile; productRef = E13DD3CC27164CA7009D4DAF /* CoreStore */; }; E13DD3D327168E65009D4DAF /* Defaults in Frameworks */ = {isa = PBXBuildFile; productRef = E13DD3D227168E65009D4DAF /* Defaults */; }; - E14000BD2EF622AC00354A3C /* StatefulMacros in Frameworks */ = {isa = PBXBuildFile; productRef = E14000BC2EF622AC00354A3C /* StatefulMacros */; }; E145EB4B2BE16849003BF6F3 /* KeychainSwift in Frameworks */ = {isa = PBXBuildFile; productRef = E145EB4A2BE16849003BF6F3 /* KeychainSwift */; }; E14EA1652BF70A8E00DE757A /* Mantis in Frameworks */ = {isa = PBXBuildFile; productRef = E14EA1642BF70A8E00DE757A /* Mantis */; }; E150C0C12BFD62FD00944FFA /* JellyfinAPI in Frameworks */ = {isa = PBXBuildFile; productRef = E150C0C02BFD62FD00944FFA /* JellyfinAPI */; }; @@ -80,6 +82,7 @@ E15210562946DF1B00375CC2 /* PulseLogHandler in Frameworks */ = {isa = PBXBuildFile; productRef = E15210552946DF1B00375CC2 /* PulseLogHandler */; }; E15210582946DF1B00375CC2 /* PulseUI in Frameworks */ = {isa = PBXBuildFile; productRef = E15210572946DF1B00375CC2 /* PulseUI */; }; E1523F822B132C350062821A /* CollectionHStack in Frameworks */ = {isa = PBXBuildFile; productRef = E1523F812B132C350062821A /* CollectionHStack */; }; + E156B9882E9877C700EE7984 /* StatefulMacros in Frameworks */ = {isa = PBXBuildFile; productRef = E156B9872E9877C700EE7984 /* StatefulMacros */; }; E1575E3C293C6B15001665B1 /* Files in Frameworks */ = {isa = PBXBuildFile; productRef = E1575E3B293C6B15001665B1 /* Files */; }; E1575E58293E7685001665B1 /* Files in Frameworks */ = {isa = PBXBuildFile; productRef = E1575E57293E7685001665B1 /* Files */; }; E15D4F052B1B0C3C00442DB8 /* PreferencesView in Frameworks */ = {isa = PBXBuildFile; productRef = E15D4F042B1B0C3C00442DB8 /* PreferencesView */; }; @@ -102,10 +105,6 @@ E19E6E0728A0B958005C10C8 /* NukeUI in Frameworks */ = {isa = PBXBuildFile; productRef = E19E6E0628A0B958005C10C8 /* NukeUI */; }; E19E6E0A28A0BEFF005C10C8 /* BlurHashKit in Frameworks */ = {isa = PBXBuildFile; productRef = E19E6E0928A0BEFF005C10C8 /* BlurHashKit */; }; E19FA1A02E84F0A800F5A60D /* StatefulMacros in Frameworks */ = {isa = PBXBuildFile; productRef = E19FA19F2E84F0A800F5A60D /* StatefulMacros */; }; - E1A09F722D05933D00835265 /* CollectionVGrid in Frameworks */ = {isa = PBXBuildFile; productRef = E1A09F712D05933D00835265 /* CollectionVGrid */; }; - E1A09F752D05935100835265 /* CollectionHStack in Frameworks */ = {isa = PBXBuildFile; productRef = E1A09F742D05935100835265 /* CollectionHStack */; }; - E1A09F772D05935A00835265 /* CollectionVGrid in Frameworks */ = {isa = PBXBuildFile; productRef = E1A09F762D05935A00835265 /* CollectionVGrid */; }; - E1A09F792D05935A00835265 /* CollectionHStack in Frameworks */ = {isa = PBXBuildFile; productRef = E1A09F782D05935A00835265 /* CollectionHStack */; }; E1A76F1A2E8369A500A5F2C1 /* StatefulMacros in Frameworks */ = {isa = PBXBuildFile; productRef = E1A76F192E8369A500A5F2C1 /* StatefulMacros */; }; E1A7B1652B9A9F7800152546 /* PreferencesView in Frameworks */ = {isa = PBXBuildFile; productRef = E1A7B1642B9A9F7800152546 /* PreferencesView */; }; E1B5F7A729577BCE004B26CF /* Pulse in Frameworks */ = {isa = PBXBuildFile; productRef = E1B5F7A629577BCE004B26CF /* Pulse */; }; @@ -113,7 +112,8 @@ E1B5F7AB29577BCE004B26CF /* PulseUI in Frameworks */ = {isa = PBXBuildFile; productRef = E1B5F7AA29577BCE004B26CF /* PulseUI */; }; E1B5F7AD29577BDD004B26CF /* OrderedCollections in Frameworks */ = {isa = PBXBuildFile; productRef = E1B5F7AC29577BDD004B26CF /* OrderedCollections */; }; E1B9743B2E86F51D008CED48 /* StatefulMacros in Frameworks */ = {isa = PBXBuildFile; productRef = E1B9743A2E86F51D008CED48 /* StatefulMacros */; }; - E1C5CB822EF61A650047C249 /* StatefulMacros in Frameworks */ = {isa = PBXBuildFile; productRef = E1C5CB812EF61A650047C249 /* StatefulMacros */; }; + E1CBA43F2E8B5E8200967419 /* CollectionHStack in Frameworks */ = {isa = PBXBuildFile; productRef = E1CBA43E2E8B5E8200967419 /* CollectionHStack */; }; + E1CBA4422E8B88D800967419 /* CollectionVGrid in Frameworks */ = {isa = PBXBuildFile; productRef = E1CBA4412E8B88D800967419 /* CollectionVGrid */; }; E1DC9814296DC06200982F06 /* PulseLogHandler in Frameworks */ = {isa = PBXBuildFile; productRef = E1DC9813296DC06200982F06 /* PulseLogHandler */; }; E1E2D7BF2E3FD936004E2E5F /* Transmission in Frameworks */ = {isa = PBXBuildFile; productRef = E1E2D7BE2E3FD936004E2E5F /* Transmission */; }; E1FAD1C62A0375BA007F5521 /* UDPBroadcast in Frameworks */ = {isa = PBXBuildFile; productRef = E1FAD1C52A0375BA007F5521 /* UDPBroadcast */; }; @@ -219,6 +219,7 @@ E14565D92DFCAE6E008FF700 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( + "Components/OffsetNavigationBar/OffsetNavigationBar-iOS.swift", Extensions/JellyfinAPI/TaskTriggerInfoType.swift, Objects/ItemArrayElements.swift, ViewModels/AdminDashboard/ActiveSessionsViewModel.swift, @@ -232,7 +233,6 @@ ViewModels/AdminDashboard/ServerTasksViewModel.swift, ViewModels/AdminDashboard/ServerUserAdminViewModel.swift, ViewModels/AdminDashboard/ServerUsersViewModel.swift, - ViewModels/DownloadListViewModel.swift, ViewModels/ItemAdministration/IdentifyItemViewModel.swift, ViewModels/ItemAdministration/ItemEditorViewModel/GenreEditorViewModel.swift, ViewModels/ItemAdministration/ItemEditorViewModel/PeopleEditorViewModel.swift, @@ -252,15 +252,45 @@ ); target = 5358705F2669D21600D05A09 /* Swiftfin tvOS */; }; + E1A3B1752F05046000CF38AF /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Components/LargePosterGroup.swift, + ); + target = 5377CBF0263B596A003A4E83 /* Swiftfin iOS */; + }; + E1A3B17B2F05290D00CF38AF /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + DevelopmentTeam.xcconfig, + Shared.xcconfig, + ); + target = 5377CBF0263B596A003A4E83 /* Swiftfin iOS */; + }; + E1A3B17C2F05290D00CF38AF /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + DevelopmentTeam.xcconfig, + Shared.xcconfig, + ); + target = 5358705F2669D21600D05A09 /* Swiftfin tvOS */; + }; + E1ACD2A12F01E10700950424 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + "Components/OffsetNavigationBar/OffsetNavigationBar-tvOS.swift", + ); + target = 5377CBF0263B596A003A4E83 /* Swiftfin iOS */; + }; /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ E14560852DFCAE51008FF700 /* Swiftfin */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (E14561A22DFCAE51008FF700 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, E14561A32DFCAE51008FF700 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = Swiftfin; sourceTree = ""; }; - E14563272DFCAE6E008FF700 /* Shared */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (E14565D92DFCAE6E008FF700 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = Shared; sourceTree = ""; }; + E14563272DFCAE6E008FF700 /* Shared */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (E1ACD2A12F01E10700950424 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, E14565D92DFCAE6E008FF700 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = Shared; sourceTree = ""; }; E14565DD2DFCAE78008FF700 /* Scripts */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Scripts; sourceTree = ""; }; - E145669F2DFCAEFD008FF700 /* Swiftfin tvOS */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (E14567272DFCAEFD008FF700 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = "Swiftfin tvOS"; sourceTree = ""; }; + E145669F2DFCAEFD008FF700 /* Swiftfin tvOS */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (E1A3B1752F05046000CF38AF /* PBXFileSystemSynchronizedBuildFileExceptionSet */, E14567272DFCAEFD008FF700 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = "Swiftfin tvOS"; sourceTree = ""; }; E1456FC82DFCB323008FF700 /* Translations */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Translations; sourceTree = ""; }; - E150B7D12DFF2E7C00DC7CF4 /* XcodeConfig */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = XcodeConfig; sourceTree = ""; }; + E150B7D12DFF2E7C00DC7CF4 /* XcodeConfig */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (E1A3B17B2F05290D00CF38AF /* PBXFileSystemSynchronizedBuildFileExceptionSet */, E1A3B17C2F05290D00CF38AF /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = XcodeConfig; sourceTree = ""; }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -269,6 +299,7 @@ buildActionMask = 2147483647; files = ( E1137D312E090C230091EB60 /* VLCUI in Frameworks */, + E10477742E9D84C00059AB5A /* CollectionVGrid in Frameworks */, 62666E1727E501CC00EC0ECD /* CFNetwork.framework in Frameworks */, 62666DFA27E5013700EC0ECD /* TVVLCKit.xcframework in Frameworks */, 62666E3227E5021E00EC0ECD /* UIKit.framework in Frameworks */, @@ -283,13 +314,14 @@ E13AF3B828A0C598009093AB /* NukeExtensions in Frameworks */, E1575E58293E7685001665B1 /* Files in Frameworks */, E1B5F7A729577BCE004B26CF /* Pulse in Frameworks */, - E1A09F792D05935A00835265 /* CollectionHStack in Frameworks */, + E10477722E9D84600059AB5A /* CollectionHStack in Frameworks */, E13AF3BA28A0C598009093AB /* NukeUI in Frameworks */, E1B5F7AB29577BCE004B26CF /* PulseUI in Frameworks */, E1B5F7A929577BCE004B26CF /* PulseLogHandler in Frameworks */, 62666E1B27E501D400EC0ECD /* CoreGraphics.framework in Frameworks */, + E1388A46293F0ABA009721B1 /* SwizzleSwift in Frameworks */, + E10477762E9D85390059AB5A /* StatefulMacros in Frameworks */, 62666E2C27E5021000EC0ECD /* QuartzCore.framework in Frameworks */, - E14000BD2EF622AC00354A3C /* StatefulMacros in Frameworks */, 62666E1927E501D000EC0ECD /* CoreFoundation.framework in Frameworks */, E19D41B22BF2BFA50082B8B2 /* KeychainSwift in Frameworks */, E18443CB2A037773002DDDC8 /* UDPBroadcast in Frameworks */, @@ -300,7 +332,6 @@ 62666E1F27E501DF00EC0ECD /* CoreText.framework in Frameworks */, E13DD3CD27164CA7009D4DAF /* CoreStore in Frameworks */, E1A7B1652B9A9F7800152546 /* PreferencesView in Frameworks */, - E1A09F772D05935A00835265 /* CollectionVGrid in Frameworks */, E1153DD22BBB649C00424D36 /* SVGKit in Frameworks */, 62666E1527E501C800EC0ECD /* AVFoundation.framework in Frameworks */, E13AF3BC28A0C59E009093AB /* BlurHashKit in Frameworks */, @@ -335,8 +366,9 @@ E10706102942F57D00646DAF /* Pulse in Frameworks */, E176EBE92D050925009F4CF1 /* CollectionVGrid in Frameworks */, E192608328D2D0DB002314B4 /* Factory in Frameworks */, + E1CBA43F2E8B5E8200967419 /* CollectionHStack in Frameworks */, + E156B9882E9877C700EE7984 /* StatefulMacros in Frameworks */, E150C0C12BFD62FD00944FFA /* JellyfinAPI in Frameworks */, - E1C5CB822EF61A650047C249 /* StatefulMacros in Frameworks */, E113A2A72B5A178D009CAAAA /* CollectionHStack in Frameworks */, E19138CA2E7E7FA20061E464 /* StatefulMacros in Frameworks */, E1523F822B132C350062821A /* CollectionHStack in Frameworks */, @@ -355,6 +387,7 @@ 62666E0C27E501A500EC0ECD /* OpenGLES.framework in Frameworks */, E19E6E0A28A0BEFF005C10C8 /* BlurHashKit in Frameworks */, E1FAD1C62A0375BA007F5521 /* UDPBroadcast in Frameworks */, + E1CBA4422E8B88D800967419 /* CollectionVGrid in Frameworks */, E1153DD02BBB634F00424D36 /* SVGKit in Frameworks */, E19FA1A02E84F0A800F5A60D /* StatefulMacros in Frameworks */, E18D6AA62BAA96F000A0D167 /* CollectionHStack in Frameworks */, @@ -373,7 +406,6 @@ E15EFA862BA1685F0080E926 /* SwiftUIIntrospect in Frameworks */, 62666DFE27E5015700EC0ECD /* AVFoundation.framework in Frameworks */, 62666DFD27E5014F00EC0ECD /* AudioToolbox.framework in Frameworks */, - E1A09F722D05933D00835265 /* CollectionVGrid in Frameworks */, E19E6E0528A0B958005C10C8 /* Nuke in Frameworks */, E1153DAC2BBA6AD200424D36 /* CollectionHStack in Frameworks */, 62666E0D27E501AA00EC0ECD /* QuartzCore.framework in Frameworks */, @@ -382,7 +414,6 @@ E134DD2C2E7F4DC300AED027 /* StatefulMacros in Frameworks */, E1A76F1A2E8369A500A5F2C1 /* StatefulMacros in Frameworks */, 62666E3F27E5040300EC0ECD /* SystemConfiguration.framework in Frameworks */, - E1A09F752D05935100835265 /* CollectionHStack in Frameworks */, E164308C2E3AA9710028D4E8 /* Transmission in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -519,12 +550,12 @@ E19D41B12BF2BFA50082B8B2 /* KeychainSwift */, E150C0C22BFD6DA200944FFA /* JellyfinAPI */, E1155ACA2D0584A90021557A /* IdentifiedCollections */, - E1A09F762D05935A00835265 /* CollectionVGrid */, - E1A09F782D05935A00835265 /* CollectionHStack */, BD88CB412D77E6A0006BB5E3 /* TVOSPicker */, E1137D302E090C230091EB60 /* VLCUI */, E1E2D7BE2E3FD936004E2E5F /* Transmission */, - E14000BC2EF622AC00354A3C /* StatefulMacros */, + E10477712E9D84600059AB5A /* CollectionHStack */, + E10477732E9D84C00059AB5A /* CollectionVGrid */, + E10477752E9D85390059AB5A /* StatefulMacros */, ); productName = "JellyfinPlayer tvOS"; productReference = 535870602669D21600D05A09 /* Swiftfin tvOS.app */; @@ -588,8 +619,6 @@ E176EBDF2D0502A6009F4CF1 /* CollectionHStack */, E176EBE22D0502C6009F4CF1 /* CollectionHStack */, E176EBE82D050925009F4CF1 /* CollectionVGrid */, - E1A09F712D05933D00835265 /* CollectionVGrid */, - E1A09F742D05935100835265 /* CollectionHStack */, E17567992E0375F300B90F41 /* VLCUI */, E1137D2E2E090C1A0091EB60 /* VLCUI */, E164308B2E3AA9710028D4E8 /* Transmission */, @@ -601,7 +630,9 @@ E1FADDF02E84B63600FB310E /* StatefulMacros */, E19FA19F2E84F0A800F5A60D /* StatefulMacros */, E1B9743A2E86F51D008CED48 /* StatefulMacros */, - E1C5CB812EF61A650047C249 /* StatefulMacros */, + E1CBA43E2E8B5E8200967419 /* CollectionHStack */, + E1CBA4412E8B88D800967419 /* CollectionVGrid */, + E156B9872E9877C700EE7984 /* StatefulMacros */, ); productName = JellyfinPlayer; productReference = 5377CBF1263B596A003A4E83 /* Swiftfin iOS.app */; @@ -618,7 +649,7 @@ New, ); LastSwiftUpdateCheck = 1250; - LastUpgradeCheck = 1610; + LastUpgradeCheck = 2620; TargetAttributes = { 5358705F2669D21600D05A09 = { CreatedOnToolsVersion = 12.5; @@ -707,13 +738,13 @@ E14EA1632BF70A8E00DE757A /* XCRemoteSwiftPackageReference "Mantis" */, E150C0BF2BFD62FD00944FFA /* XCRemoteSwiftPackageReference "jellyfin-sdk-swift" */, E176EBDC2D050067009F4CF1 /* XCRemoteSwiftPackageReference "swift-identified-collections" */, - E1A09F702D05933D00835265 /* XCRemoteSwiftPackageReference "CollectionVGrid" */, - E1A09F732D05935100835265 /* XCRemoteSwiftPackageReference "CollectionHStack" */, BD88CB402D77E6A0006BB5E3 /* XCRemoteSwiftPackageReference "TVOSPicker" */, E1137D2D2E090C1A0091EB60 /* XCRemoteSwiftPackageReference "VLCUI" */, E164308A2E3AA9710028D4E8 /* XCRemoteSwiftPackageReference "Transmission" */, E13CCE482E6C077D0070965F /* XCRemoteSwiftPackageReference "LNPopupUI" */, - E1C404CB2EF61D4C00959C4F /* XCRemoteSwiftPackageReference "StatefulMacro" */, + E1CBA43D2E8B5E8200967419 /* XCLocalSwiftPackageReference "../CollectionHStack" */, + E1CBA4402E8B88D800967419 /* XCLocalSwiftPackageReference "../CollectionVGrid" */, + E156B9862E9877C700EE7984 /* XCLocalSwiftPackageReference "../../XcodeProjects/StatefulMacros" */, ); productRefGroup = 5377CBF2263B596A003A4E83 /* Products */; projectDirPath = ""; @@ -926,7 +957,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 70; DEVELOPMENT_ASSET_PATHS = ""; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = TY84JMYEFE; ENABLE_PREVIEWS = YES; ENABLE_USER_SCRIPT_SANDBOXING = NO; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; @@ -956,7 +987,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 70; DEVELOPMENT_ASSET_PATHS = ""; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = TY84JMYEFE; ENABLE_PREVIEWS = YES; ENABLE_USER_SCRIPT_SANDBOXING = NO; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; @@ -1040,6 +1071,7 @@ ONLY_ACTIVE_ARCH = YES; OTHER_SWIFT_FLAGS = "-Xfrontend -warn-long-expression-type-checking=200"; SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; }; @@ -1099,6 +1131,7 @@ MTL_FAST_MATH = YES; OTHER_SWIFT_FLAGS = "-Xfrontend -warn-long-expression-type-checking=200"; SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; VALIDATE_PRODUCT = YES; @@ -1116,7 +1149,9 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = ""; + ENABLE_APP_SANDBOX = YES; ENABLE_BITCODE = NO; + ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; ENABLE_PREVIEWS = YES; ENABLE_USER_SCRIPT_SANDBOXING = NO; EXCLUDED_ARCHS = ""; @@ -1154,7 +1189,9 @@ CURRENT_PROJECT_VERSION = 1; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_ASSET_PATHS = ""; + ENABLE_APP_SANDBOX = YES; ENABLE_BITCODE = NO; + ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; ENABLE_PREVIEWS = YES; ENABLE_USER_SCRIPT_SANDBOXING = NO; EXCLUDED_ARCHS = ""; @@ -1213,10 +1250,22 @@ /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ + E156B9862E9877C700EE7984 /* XCLocalSwiftPackageReference "../../XcodeProjects/StatefulMacros" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = ../../XcodeProjects/StatefulMacros; + }; E15D4F032B1B0C3C00442DB8 /* XCLocalSwiftPackageReference "PreferencesView" */ = { isa = XCLocalSwiftPackageReference; relativePath = PreferencesView; }; + E1CBA43D2E8B5E8200967419 /* XCLocalSwiftPackageReference "../CollectionHStack" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = ../CollectionHStack; + }; + E1CBA4402E8B88D800967419 /* XCLocalSwiftPackageReference "../CollectionVGrid" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = ../CollectionVGrid; + }; /* End XCLocalSwiftPackageReference section */ /* Begin XCRemoteSwiftPackageReference section */ @@ -1380,30 +1429,6 @@ minimumVersion = 1.0.0; }; }; - E1A09F702D05933D00835265 /* XCRemoteSwiftPackageReference "CollectionVGrid" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/LePips/CollectionVGrid"; - requirement = { - branch = main; - kind = branch; - }; - }; - E1A09F732D05935100835265 /* XCRemoteSwiftPackageReference "CollectionHStack" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/LePips/CollectionHStack"; - requirement = { - branch = main; - kind = branch; - }; - }; - E1C404CB2EF61D4C00959C4F /* XCRemoteSwiftPackageReference "StatefulMacro" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/LePips/StatefulMacro"; - requirement = { - kind = exactVersion; - version = 0.0.2; - }; - }; E1DC9812296DC06200982F06 /* XCRemoteSwiftPackageReference "PulseLogHandler" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/kean/PulseLogHandler"; @@ -1438,6 +1463,21 @@ package = E1002B662793CFBA00E47059 /* XCRemoteSwiftPackageReference "swift-algorithms" */; productName = Algorithms; }; + E10477712E9D84600059AB5A /* CollectionHStack */ = { + isa = XCSwiftPackageProductDependency; + package = E1CBA43D2E8B5E8200967419 /* XCLocalSwiftPackageReference "../CollectionHStack" */; + productName = CollectionHStack; + }; + E10477732E9D84C00059AB5A /* CollectionVGrid */ = { + isa = XCSwiftPackageProductDependency; + package = E1CBA4402E8B88D800967419 /* XCLocalSwiftPackageReference "../CollectionVGrid" */; + productName = CollectionVGrid; + }; + E10477752E9D85390059AB5A /* StatefulMacros */ = { + isa = XCSwiftPackageProductDependency; + package = E156B9862E9877C700EE7984 /* XCLocalSwiftPackageReference "../../XcodeProjects/StatefulMacros" */; + productName = StatefulMacros; + }; E107060F2942F57D00646DAF /* Pulse */ = { isa = XCSwiftPackageProductDependency; package = E107060E2942F57D00646DAF /* XCRemoteSwiftPackageReference "Pulse" */; @@ -1560,11 +1600,6 @@ package = E13DD3D127168E65009D4DAF /* XCRemoteSwiftPackageReference "Defaults" */; productName = Defaults; }; - E14000BC2EF622AC00354A3C /* StatefulMacros */ = { - isa = XCSwiftPackageProductDependency; - package = E1C404CB2EF61D4C00959C4F /* XCRemoteSwiftPackageReference "StatefulMacro" */; - productName = StatefulMacros; - }; E145EB4A2BE16849003BF6F3 /* KeychainSwift */ = { isa = XCSwiftPackageProductDependency; package = E145EB492BE16849003BF6F3 /* XCRemoteSwiftPackageReference "keychain-swift" */; @@ -1604,6 +1639,10 @@ isa = XCSwiftPackageProductDependency; productName = CollectionHStack; }; + E156B9872E9877C700EE7984 /* StatefulMacros */ = { + isa = XCSwiftPackageProductDependency; + productName = StatefulMacros; + }; E1575E3B293C6B15001665B1 /* Files */ = { isa = XCSwiftPackageProductDependency; package = E1575E3A293C6B15001665B1 /* XCRemoteSwiftPackageReference "Files" */; @@ -1706,26 +1745,6 @@ isa = XCSwiftPackageProductDependency; productName = StatefulMacros; }; - E1A09F712D05933D00835265 /* CollectionVGrid */ = { - isa = XCSwiftPackageProductDependency; - package = E1A09F702D05933D00835265 /* XCRemoteSwiftPackageReference "CollectionVGrid" */; - productName = CollectionVGrid; - }; - E1A09F742D05935100835265 /* CollectionHStack */ = { - isa = XCSwiftPackageProductDependency; - package = E1A09F732D05935100835265 /* XCRemoteSwiftPackageReference "CollectionHStack" */; - productName = CollectionHStack; - }; - E1A09F762D05935A00835265 /* CollectionVGrid */ = { - isa = XCSwiftPackageProductDependency; - package = E1A09F702D05933D00835265 /* XCRemoteSwiftPackageReference "CollectionVGrid" */; - productName = CollectionVGrid; - }; - E1A09F782D05935A00835265 /* CollectionHStack */ = { - isa = XCSwiftPackageProductDependency; - package = E1A09F732D05935100835265 /* XCRemoteSwiftPackageReference "CollectionHStack" */; - productName = CollectionHStack; - }; E1A76F192E8369A500A5F2C1 /* StatefulMacros */ = { isa = XCSwiftPackageProductDependency; productName = StatefulMacros; @@ -1759,9 +1778,13 @@ isa = XCSwiftPackageProductDependency; productName = StatefulMacros; }; - E1C5CB812EF61A650047C249 /* StatefulMacros */ = { + E1CBA43E2E8B5E8200967419 /* CollectionHStack */ = { isa = XCSwiftPackageProductDependency; - productName = StatefulMacros; + productName = CollectionHStack; + }; + E1CBA4412E8B88D800967419 /* CollectionVGrid */ = { + isa = XCSwiftPackageProductDependency; + productName = CollectionVGrid; }; E1DC9813296DC06200982F06 /* PulseLogHandler */ = { isa = XCSwiftPackageProductDependency; diff --git a/Swiftfin.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Swiftfin.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 74a803e4a0..ba75777dbd 100644 --- a/Swiftfin.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Swiftfin.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "4117aea6c512a3e43a7bb340fecc0d1cd1f5561733cee28088160bdbb312f092", + "originHash" : "55151a9a3d6123b69efed8b810826ec877ea36b287a3047e9f8809974db5778d", "pins" : [ { "identity" : "blurhashkit", @@ -19,24 +19,6 @@ "version" : "3.9.1" } }, - { - "identity" : "collectionhstack", - "kind" : "remoteSourceControl", - "location" : "https://github.com/LePips/CollectionHStack", - "state" : { - "branch" : "main", - "revision" : "ce86c82ae46ba958d6e7f8459d592a77e1e299c5" - } - }, - { - "identity" : "collectionvgrid", - "kind" : "remoteSourceControl", - "location" : "https://github.com/LePips/CollectionVGrid", - "state" : { - "branch" : "main", - "revision" : "e5b869ca215504ed24fc2c39f284ebce4cad5ce8" - } - }, { "identity" : "corestore", "kind" : "remoteSourceControl", @@ -181,15 +163,6 @@ "version" : "5.1.0" } }, - { - "identity" : "statefulmacro", - "kind" : "remoteSourceControl", - "location" : "https://github.com/LePips/StatefulMacro", - "state" : { - "revision" : "705981b6886c4e9c450d079b468fadb5715a1e9a", - "version" : "0.0.2" - } - }, { "identity" : "svgkit", "kind" : "remoteSourceControl", @@ -208,6 +181,15 @@ "version" : "1.2.1" } }, + { + "identity" : "swift-argument-parser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-argument-parser", + "state" : { + "revision" : "626b5b7b2f45e1b0b1c6f4a309296d1d21d7311b", + "version" : "1.7.1" + } + }, { "identity" : "swift-case-paths", "kind" : "remoteSourceControl", diff --git a/Swiftfin.xcodeproj/xcshareddata/xcschemes/Swiftfin tvOS.xcscheme b/Swiftfin.xcodeproj/xcshareddata/xcschemes/Swiftfin tvOS.xcscheme index 7449a0c32b..3908316e1b 100644 --- a/Swiftfin.xcodeproj/xcshareddata/xcschemes/Swiftfin tvOS.xcscheme +++ b/Swiftfin.xcodeproj/xcshareddata/xcschemes/Swiftfin tvOS.xcscheme @@ -1,6 +1,6 @@ + + + + : View { var body: some View { ProgressView(value: value, total: total) - .progressViewStyle(.playback) + .progressViewStyle(.playback.square) .overlay { Color.clear .frame(height: contentSize.height + gesturePadding) diff --git a/Swiftfin/Components/LandscapePosterProgressBar.swift b/Swiftfin/Components/LandscapePosterProgressBar.swift deleted file mode 100644 index 2165d77821..0000000000 --- a/Swiftfin/Components/LandscapePosterProgressBar.swift +++ /dev/null @@ -1,99 +0,0 @@ -// -// 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 Defaults -import SwiftUI - -// TODO: fix relative padding, or remove? -// TODO: gradient should grow/shrink with content, not relative to container - -struct LandscapePosterProgressBar: View { - - @Default(.accentColor) - private var accentColor - - // Scale padding depending on view width - @State - private var paddingScale: CGFloat = 1.0 - @State - private var width: CGFloat = 0 - - private let content: () -> Content - private let progress: Double - - var body: some View { - ZStack(alignment: .bottom) { - - Color.clear - - LinearGradient( - stops: [ - .init(color: .clear, location: 0), - .init(color: .black.opacity(0.7), location: 1), - ], - startPoint: .top, - endPoint: .bottom - ) - .frame(height: 40) - - VStack(alignment: .leading, spacing: 3 * paddingScale) { - - content() - - ProgressBar(progress: progress) - .foregroundColor(accentColor) - .frame(height: 3) - } - .padding(.horizontal, 5 * paddingScale) - .padding(.bottom, 7 * paddingScale) - } - .onSizeChanged { newSize, _ in - width = newSize.width - } - } -} - -extension LandscapePosterProgressBar where Content == Text { - - init( - title: String, - progress: Double - ) { - self.init( - content: { - Text(title) - .font(.subheadline) - .foregroundColor(.white) - }, - progress: progress - ) - } -} - -extension LandscapePosterProgressBar where Content == EmptyView { - - init(progress: Double) { - self.init( - content: { EmptyView() }, - progress: progress - ) - } -} - -extension LandscapePosterProgressBar { - - init( - progress: Double, - @ViewBuilder content: @escaping () -> Content - ) { - self.init( - content: content, - progress: progress - ) - } -} diff --git a/Swiftfin/Components/LearnMoreButton.swift b/Swiftfin/Components/LearnMoreButton.swift index 76e55e7b41..9040d06f11 100644 --- a/Swiftfin/Components/LearnMoreButton.swift +++ b/Swiftfin/Components/LearnMoreButton.swift @@ -9,19 +9,19 @@ import SwiftUI @available(*, deprecated, message: "Use `Section(:content:learnMore:)` instead") -struct LearnMoreButton: View { +struct LearnMoreButton: View { @State private var isPresented: Bool = false private let title: String - private let content: AnyView + private let content: Content // MARK: - Initializer init( _ title: String, - @LabeledContentBuilder content: () -> AnyView + @LabeledContentBuilder content: () -> Content ) { self.title = title self.content = content() diff --git a/Swiftfin/Components/ListRow.swift b/Swiftfin/Components/ListRow.swift deleted file mode 100644 index db8a6eea7c..0000000000 --- a/Swiftfin/Components/ListRow.swift +++ /dev/null @@ -1,73 +0,0 @@ -// -// 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 SwiftUI - -// TODO: come up with better name along with `ListRowButton` - -// Meant to be used when making a custom list without `List` or `Form` -struct ListRow: View { - - @State - private var contentSize: CGSize = .zero - - private let leading: Leading - private let content: Content - private var action: () -> Void - private var insets: EdgeInsets - private var isSeparatorVisible: Bool - - var body: some View { - ZStack(alignment: .bottomTrailing) { - - Button(action: action) { - HStack(alignment: .center, spacing: EdgeInsets.edgePadding) { - - leading - - content - .frame(maxHeight: .infinity) - .trackingSize($contentSize) - } - .padding(insets) - } - .foregroundStyle(.primary, .secondary) - .contentShape(.contextMenuPreview, Rectangle()) - - Color.secondarySystemFill - .frame(width: contentSize.width, height: 1) - .padding(.trailing, insets.trailing) - .isVisible(isSeparatorVisible) - } - } -} - -extension ListRow { - - init( - insets: EdgeInsets = .zero, - @ViewBuilder leading: @escaping () -> Leading, - @ViewBuilder content: @escaping () -> Content - ) { - self.init( - leading: leading(), - content: content(), - action: {}, - insets: insets, - isSeparatorVisible: true - ) - } - - func isSeparatorVisible(_ isVisible: Bool) -> Self { - copy(modifying: \.isSeparatorVisible, with: isVisible) - } - - func onSelect(perform action: @escaping () -> Void) -> Self { - copy(modifying: \.action, with: action) - } -} diff --git a/Swiftfin/Components/ListTitleSection.swift b/Swiftfin/Components/ListTitleSection.swift index bc244759ce..7b3f415721 100644 --- a/Swiftfin/Components/ListTitleSection.swift +++ b/Swiftfin/Components/ListTitleSection.swift @@ -116,7 +116,8 @@ struct InsetGroupedListHeader: View { .fill(Color.secondarySystemBackground) SeparatorVStack { - RowDivider() + Divider() + .edgePadding(.horizontal) } content: { if title != nil || description != nil { header diff --git a/Swiftfin/Components/PillHStack.swift b/Swiftfin/Components/PillHStack.swift index aee81819ea..d5282c9638 100644 --- a/Swiftfin/Components/PillHStack.swift +++ b/Swiftfin/Components/PillHStack.swift @@ -8,55 +8,37 @@ import SwiftUI -struct PillHStack: View { +struct PillHStack: View where Data.Element: Displayable, Data.Index == Int { - private var title: String - private var items: [Item] - private var onSelect: (Item) -> Void + let title: String + let data: Data + let action: (Data.Element) -> Void var body: some View { - VStack(alignment: .leading, spacing: 10) { - Text(title) - .font(.title2) - .fontWeight(.semibold) - .accessibility(addTraits: [.isHeader]) - .edgePadding(.leading) - - ScrollView(.horizontal, showsIndicators: false) { + Section { + ScrollView(.horizontal) { HStack { - ForEach(items, id: \.displayTitle) { item in + ForEach(data, id: \.displayTitle) { item in Button { - onSelect(item) + action(item) } label: { Text(item.displayTitle) .font(.caption) .fontWeight(.semibold) - .foregroundColor(.primary) .padding(10) .background { Color.systemFill - .cornerRadius(10) } + .cornerRadius(10) } + .foregroundStyle(.primary, .secondary) } } .edgePadding(.horizontal) } + .scrollIndicators(.hidden) + } header: { + DefaultHeader(title: title) } } } - -extension PillHStack { - - init(title: String, items: [Item]) { - self.init( - title: title, - items: items, - onSelect: { _ in } - ) - } - - func onSelect(_ action: @escaping (Item) -> Void) -> Self { - copy(modifying: \.onSelect, with: action) - } -} diff --git a/Swiftfin/Components/PosterButton.swift b/Swiftfin/Components/PosterButton.swift deleted file mode 100644 index 49359d60fc..0000000000 --- a/Swiftfin/Components/PosterButton.swift +++ /dev/null @@ -1,227 +0,0 @@ -// -// 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 Defaults -import JellyfinAPI -import SwiftUI - -// TODO: expose `ImageView.image` modifier for image aspect fill/fit - -struct PosterButton: View { - - @EnvironmentTypeValue(\.posterOverlayRegistry) - private var posterOverlayRegistry - - @Namespace - private var namespace - - @State - private var posterSize: CGSize = .zero - - private let item: Item - private let type: PosterDisplayType - private let label: any View - private let action: (Namespace.ID) -> Void - - @ViewBuilder - private func posterView(overlay: some View = EmptyView()) -> some View { - VStack(alignment: .leading) { - PosterImage(item: item, type: type) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .overlay { overlay } - .contentShape(.contextMenuPreview, Rectangle()) - .posterCornerRadius(type) - .backport - .matchedTransitionSource(id: "item", in: namespace) - .posterShadow() - - label - .eraseToAnyView() - .allowsHitTesting(false) - } - } - - var body: some View { - Button { - action(namespace) - } label: { - let overlay = posterOverlayRegistry?(item) ?? - PosterButton.DefaultOverlay(item: item) - .eraseToAnyView() - - posterView(overlay: overlay) - .trackingSize($posterSize) - } - .foregroundStyle(.primary, .secondary) - .buttonStyle(.plain) - .matchedContextMenu(for: item) { - let frameScale = 1.3 - - posterView() - .frame( - width: posterSize.width * frameScale, - height: posterSize.height * frameScale - ) - .padding(20) - .background { - RoundedRectangle(cornerRadius: 10) - .fill(Color(uiColor: UIColor.secondarySystemGroupedBackground)) - } - } - } -} - -extension PosterButton { - - init( - item: Item, - type: PosterDisplayType, - action: @escaping (Namespace.ID) -> Void, - @ViewBuilder label: @escaping () -> any View - ) { - self.item = item - self.type = type - self.action = action - self.label = label() - } -} - -// TODO: remove these and replace with `TextStyle` - -extension PosterButton { - - // MARK: Default Content - - struct TitleContentView: View { - - let title: String - - var body: some View { - Text(title) - .font(.footnote) - .fontWeight(.regular) - .foregroundStyle(.primary) - } - } - - struct SubtitleContentView: View { - - let subtitle: String? - - var body: some View { - Text(subtitle ?? " ") - .font(.caption) - .fontWeight(.medium) - .foregroundStyle(.secondary) - } - } - - struct TitleSubtitleContentView: View { - - let item: Item - - var body: some View { - VStack(alignment: .leading, spacing: 0) { - if item.showTitle { - TitleContentView(title: item.displayTitle) - .lineLimit(1, reservesSpace: true) - } - - SubtitleContentView(subtitle: item.subtitle) - .lineLimit(1, reservesSpace: true) - } - } - } - - // Content specific for BaseItemDto episode items - struct EpisodeContentSubtitleContent: View { - - @Default(.Customization.Episodes.useSeriesLandscapeBackdrop) - private var useSeriesLandscapeBackdrop - - let item: Item - - var body: some View { - if let item = item as? BaseItemDto { - // Unsure why this needs 0 spacing - // compared to other default content - VStack(alignment: .leading, spacing: 0) { - if item.showTitle, let seriesName = item.seriesName { - Text(seriesName) - .font(.footnote) - .fontWeight(.regular) - .foregroundColor(.primary) - .lineLimit(1, reservesSpace: true) - } - - DotHStack(padding: 3) { - Text(item.seasonEpisodeLabel ?? .emptyDash) - - if item.showTitle || useSeriesLandscapeBackdrop { - Text(item.displayTitle) - } else if let seriesName = item.seriesName { - Text(seriesName) - } - } - .font(.caption) - .foregroundColor(.secondary) - .lineLimit(1) - } - } - } - } - - // MARK: Default Overlay - - struct DefaultOverlay: View { - - @Default(.accentColor) - private var accentColor - @Default(.Customization.Indicators.showFavorited) - private var showFavorited - @Default(.Customization.Indicators.showProgress) - private var showProgress - @Default(.Customization.Indicators.showUnplayed) - private var showUnplayed - @Default(.Customization.Indicators.showPlayed) - private var showPlayed - - let item: Item - - var body: some View { - ZStack { - if let item = item as? BaseItemDto { - if item.canBePlayed, !item.isLiveStream, item.userData?.isPlayed == true { - WatchedIndicator(size: 25) - .isVisible(showPlayed) - } else { - if (item.userData?.playbackPositionTicks ?? 0) > 0 { - ProgressIndicator(progress: (item.userData?.playedPercentage ?? 0) / 100, height: 5) - .isVisible(showProgress) - } else if item.canBePlayed, - !item.isLiveStream, - showUnplayed != .none - { - UnwatchedIndicator( - size: 25, - count: - showUnplayed == .count ? item.userData?.unplayedItemCount : nil - ) - .foregroundStyle(accentColor.overlayColor, accentColor) - } - } - - if item.userData?.isFavorite == true { - FavoriteIndicator(size: 25) - .isVisible(showFavorited) - } - } - } - } - } -} diff --git a/Swiftfin/Components/PosterHStack.swift b/Swiftfin/Components/PosterHStack.swift deleted file mode 100644 index 4d0d37588d..0000000000 --- a/Swiftfin/Components/PosterHStack.swift +++ /dev/null @@ -1,122 +0,0 @@ -// -// 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 CollectionHStack -import SwiftUI - -// TODO: Migrate to single `header: View` - -struct PosterHStack: View where Data.Element == Element, Data.Index == Int { - - private var data: Data - private var header: () -> any View - private var title: String? - private var type: PosterDisplayType - private var label: (Element) -> any View - private var trailingContent: () -> any View - private var action: (Element, Namespace.ID) -> Void - - private var layout: CollectionHStackLayout { - if UIDevice.isPhone { - .grid( - columns: type == .landscape ? 2 : 3, - rows: 1, - columnTrailingInset: 0 - ) - } else { - .minimumWidth( - columnWidth: type == .landscape ? 220 : 140, - rows: 1 - ) - } - } - - @ViewBuilder - private var stack: some View { - CollectionHStack( - uniqueElements: data, - layout: layout - ) { item in - PosterButton( - item: item, - type: type - ) { namespace in - action(item, namespace) - } label: { - label(item).eraseToAnyView() - } - } - .clipsToBounds(false) - .dataPrefix(20) - .insets(horizontal: EdgeInsets.edgePadding) - .itemSpacing(EdgeInsets.edgePadding / 2) - .scrollBehavior(.continuousLeadingEdge) - } - - var body: some View { - VStack(alignment: .leading) { - - HStack { - header() - .eraseToAnyView() - - Spacer() - - trailingContent() - .eraseToAnyView() - } - .edgePadding(.horizontal) - - stack - } - } -} - -extension PosterHStack { - - init( - title: String? = nil, - type: PosterDisplayType, - items: Data, - action: @escaping (Element, Namespace.ID) -> Void, - @ViewBuilder label: @escaping (Element) -> any View = { PosterButton.TitleSubtitleContentView(item: $0) } - ) { - self.init( - data: items, - header: { DefaultHeader(title: title) }, - title: title, - type: type, - label: label, - trailingContent: { EmptyView() }, - action: action - ) - } - - func trailing(@ViewBuilder _ content: @escaping () -> any View) -> Self { - copy(modifying: \.trailingContent, with: content) - } -} - -// MARK: Default Header - -extension PosterHStack { - - struct DefaultHeader: View { - - let title: String? - - var body: some View { - if let title { - Text(title) - .font(.title2) - .fontWeight(.semibold) - .accessibility(addTraits: [.isHeader]) - } - } - } -} diff --git a/Swiftfin/Components/SeeAllButton.swift b/Swiftfin/Components/SeeAllButton.swift deleted file mode 100644 index 220133f8cc..0000000000 --- a/Swiftfin/Components/SeeAllButton.swift +++ /dev/null @@ -1,37 +0,0 @@ -// -// 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 SwiftUI - -struct SeeAllButton: View { - - private var action: () -> Void - - var body: some View { - Button( - L10n.seeAll, - systemImage: "chevron.right", - action: action - ) - .font(.subheadline.weight(.bold)) - .labelStyle(.titleAndIcon.trailingIcon) - } -} - -extension SeeAllButton { - - init() { - self.init( - action: {} - ) - } - - func onSelect(perform action: @escaping () -> Void) -> Self { - copy(modifying: \.action, with: action) - } -} diff --git a/Swiftfin/Components/SettingsBarButton.swift b/Swiftfin/Components/SettingsBarButton.swift index 49e0164562..1dc74cbbdd 100644 --- a/Swiftfin/Components/SettingsBarButton.swift +++ b/Swiftfin/Components/SettingsBarButton.swift @@ -11,21 +11,34 @@ import SwiftUI struct SettingsBarButton: View { - let server: ServerState - let user: UserState - let action: () -> Void + @Injected(\.currentUserSession) + private var userSession + + @Router + private var router var body: some View { - Button(action: action) { + Button { + router.route(to: .settings) + } label: { AlternateLayoutView { // Seems necessary for button layout Image(systemName: "gearshape.fill") } content: { - UserProfileImage( - userID: user.id, - source: user.profileImageSource( + + let imageSource: ImageSource = { + guard let user = userSession?.user, let server = userSession?.server else { + return .init() + } + + return user.profileImageSource( client: server.client - ), + ) + }() + + UserProfileImage( + userID: userSession?.user.id, + source: imageSource, pipeline: .Swiftfin.local ) } diff --git a/Swiftfin/Components/Slider/ThumbSlider.swift b/Swiftfin/Components/Slider/ThumbSlider.swift deleted file mode 100644 index b1a44befcf..0000000000 --- a/Swiftfin/Components/Slider/ThumbSlider.swift +++ /dev/null @@ -1,103 +0,0 @@ -// -// 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 SwiftUI - -// TODO: gesture padding - -struct ThumbSlider: View { - - @Binding - private var value: V - - @State - private var contentSize: CGSize = .zero - @State - private var isEditing: Bool = false - @State - private var translationStartLocation: CGPoint = .zero - @State - private var translationStartValue: V = 0 - @State - private var currentTranslation: CGFloat = 0 - - private var onEditingChanged: (Bool) -> Void - private let total: V - private var trackMask: () -> any View - - private var trackDrag: some Gesture { - DragGesture(coordinateSpace: .global) - .onChanged { newValue in - if !isEditing { - isEditing = true - onEditingChanged(true) - translationStartValue = value - translationStartLocation = newValue.location - currentTranslation = 0 - } - - currentTranslation = translationStartLocation.x - newValue.location.x - - let newProgress = translationStartValue - V(currentTranslation / contentSize.width) * total - value = clamp(newProgress, min: 0, max: total) - } - .onEnded { _ in - isEditing = false - onEditingChanged(false) - } - } - - var body: some View { - ProgressView(value: value, total: total) - .progressViewStyle(.playback.square) - .overlay(alignment: .leading) { - Circle() - .foregroundStyle(.primary) - .frame(height: 20) - .gesture(trackDrag) - .offset(x: Double(value / total) * contentSize.width - 10) - } - .trackingSize($contentSize) - } -} - -extension ThumbSlider { - - init(value: Binding, total: V = 1.0) { - self.init( - value: value, - onEditingChanged: { _ in }, - total: total, - trackMask: { Color.white } - ) - } - - func onEditingChanged(_ action: @escaping (Bool) -> Void) -> Self { - copy(modifying: \.onEditingChanged, with: action) - } - - func trackMask(@ViewBuilder _ content: @escaping () -> any View) -> Self { - copy(modifying: \.trackMask, with: content) - } -} - -struct ThumbSliderTests: View { - - @State - private var value: Double = 0.3 - - var body: some View { - ThumbSlider(value: $value, total: 1.0) - .frame(height: 5) - .padding(.horizontal, 10) - } -} - -#Preview { - ThumbSliderTests() -} diff --git a/Swiftfin/Components/UnmaskSecureField.swift b/Swiftfin/Components/UnmaskSecureField.swift index 5cd723ca9c..7c5146a843 100644 --- a/Swiftfin/Components/UnmaskSecureField.swift +++ b/Swiftfin/Components/UnmaskSecureField.swift @@ -6,13 +6,14 @@ // Copyright (c) 2026 Jellyfin & Jellyfin Contributors // +import Engine import SwiftUI // TODO: use _UIHostingView for button animation workaround? // - have a nice animation for toggle /// - Note: Do not use this view directly. -/// Use `SecureField.init(_:text:maskToggle)` instead +/// Use `SecureField.init(_:text:maskToggle:)` instead struct _UnmaskSecureField: UIViewRepresentable { private var submitAction: () -> Void @@ -31,7 +32,7 @@ struct _UnmaskSecureField: UIViewRepresentable { func makeUIView(context: Context) -> UITextField { let textField = UITextField() - textField.font = context.environment.font?.uiFont ?? UIFont.preferredFont(forTextStyle: .body) + textField.font = context.environment.font?.toUIFont() textField.adjustsFontForContentSizeCategory = true textField.isSecureTextEntry = true textField.keyboardType = .asciiCapable diff --git a/Swiftfin/Components/Video3DFormatPicker.swift b/Swiftfin/Components/Video3DFormatPicker.swift deleted file mode 100644 index 1256a8f91a..0000000000 --- a/Swiftfin/Components/Video3DFormatPicker.swift +++ /dev/null @@ -1,25 +0,0 @@ -// -// 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 JellyfinAPI -import SwiftUI - -struct Video3DFormatPicker: View { - let title: String - @Binding - var selectedFormat: Video3DFormat? - - var body: some View { - Picker(title, selection: $selectedFormat) { - Text(L10n.none).tag(nil as Video3DFormat?) - ForEach(Video3DFormat.allCases, id: \.self) { format in - Text(format.displayTitle).tag(format as Video3DFormat?) - } - } - } -} diff --git a/Swiftfin/Extensions/Label-iOS.swift b/Swiftfin/Extensions/View/Label-iOS.swift similarity index 64% rename from Swiftfin/Extensions/Label-iOS.swift rename to Swiftfin/Extensions/View/Label-iOS.swift index cc8481664b..dce3fc4ce5 100644 --- a/Swiftfin/Extensions/Label-iOS.swift +++ b/Swiftfin/Extensions/View/Label-iOS.swift @@ -31,31 +31,10 @@ struct EpisodeSelectorLabelStyle: LabelStyle { .padding(.vertical, 5) .padding(.horizontal, 10) .background { - Color.tertiarySystemFill - .cornerRadius(10) + RoundedRectangle(cornerRadius: 10) + .fill(Color.tertiarySystemFill) } .compositingGroup() .shadow(radius: 1) - .font(.caption) - } -} - -// MARK: SectionFooterWithImageLabelStyle - -extension TitleAndIconLabelStyle { - - var trailingIcon: TrailingIconReversedButtonStyle { - TrailingIconReversedButtonStyle() - } -} - -struct TrailingIconReversedButtonStyle: LabelStyle { - - func makeBody(configuration: Configuration) -> some View { - HStack { - configuration.title - - configuration.icon - } } } diff --git a/Swiftfin/Extensions/View/Modifiers/DetectOrientationModifier.swift b/Swiftfin/Extensions/View/Modifiers/DetectOrientationModifier.swift deleted file mode 100644 index d138cc9ebc..0000000000 --- a/Swiftfin/Extensions/View/Modifiers/DetectOrientationModifier.swift +++ /dev/null @@ -1,22 +0,0 @@ -// -// 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 SwiftUI - -struct DetectOrientation: ViewModifier { - - @Binding - var orientation: UIDeviceOrientation - - func body(content: Content) -> some View { - content - .onReceive(NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)) { _ in - orientation = UIDevice.current.orientation - } - } -} diff --git a/Swiftfin/Extensions/View/Modifiers/NavigationBarDrawerButtons/NavigationBarDrawerModifier.swift b/Swiftfin/Extensions/View/Modifiers/NavigationBarDrawerButtons/NavigationBarDrawerModifier.swift index db870a4367..afcc4930e0 100644 --- a/Swiftfin/Extensions/View/Modifiers/NavigationBarDrawerButtons/NavigationBarDrawerModifier.swift +++ b/Swiftfin/Extensions/View/Modifiers/NavigationBarDrawerButtons/NavigationBarDrawerModifier.swift @@ -10,15 +10,15 @@ import SwiftUI struct NavigationBarDrawerModifier: ViewModifier { - private let drawer: () -> Drawer + private let drawer: Drawer init(@ViewBuilder drawer: @escaping () -> Drawer) { - self.drawer = drawer + self.drawer = drawer() } func body(content: Content) -> some View { NavigationBarDrawerView { - drawer() + drawer .ignoresSafeArea() } content: { content diff --git a/Swiftfin/Extensions/View/Modifiers/NavigationBarDrawerButtons/NavigationBarDrawerView.swift b/Swiftfin/Extensions/View/Modifiers/NavigationBarDrawerButtons/NavigationBarDrawerView.swift index 7e5a219aa7..62f37b161b 100644 --- a/Swiftfin/Extensions/View/Modifiers/NavigationBarDrawerButtons/NavigationBarDrawerView.swift +++ b/Swiftfin/Extensions/View/Modifiers/NavigationBarDrawerButtons/NavigationBarDrawerView.swift @@ -10,31 +10,28 @@ import SwiftUI struct NavigationBarDrawerView: UIViewControllerRepresentable { - private let buttons: () -> Drawer - private let content: () -> Content + private let content: Content + private let drawer: Drawer init( - @ViewBuilder buttons: @escaping () -> Drawer, + @ViewBuilder drawer: @escaping () -> Drawer, @ViewBuilder content: @escaping () -> Content ) { - self.buttons = buttons - self.content = content + self.content = content() + self.drawer = drawer() } - func makeUIViewController(context: Context) -> UINavigationBarDrawerHostingController { - UINavigationBarDrawerHostingController(buttons: buttons, content: content) + func makeUIViewController(context: Context) -> _UINavigationBarDrawerHostingController { + _UINavigationBarDrawerHostingController(content: content, drawer: drawer) } - func updateUIViewController(_ uiViewController: UINavigationBarDrawerHostingController, context: Context) {} + func updateUIViewController(_ uiViewController: _UINavigationBarDrawerHostingController, context: Context) {} } -class UINavigationBarDrawerHostingController: UIHostingController { +class _UINavigationBarDrawerHostingController: UIHostingController { - private let drawer: () -> Drawer - private let content: () -> Content - - // TODO: see if we can get the height instead from the view passed in - private let drawerHeight: CGFloat = 36 + private let drawer: Drawer + private var drawerHeight: CGFloat = 0 private lazy var blurView: UIVisualEffectView = { let blurView = UIVisualEffectView(effect: UIBlurEffect(style: .systemThinMaterial)) @@ -42,21 +39,19 @@ class UINavigationBarDrawerHostingController: UIHos return blurView }() - private lazy var drawerButtonsView: UIHostingController = { - let drawerButtonsView = UIHostingController(rootView: drawer()) + private lazy var drawerView: UIHostingController = { + let drawerButtonsView = UIHostingController(rootView: drawer) drawerButtonsView.view.translatesAutoresizingMaskIntoConstraints = false drawerButtonsView.view.backgroundColor = nil return drawerButtonsView }() init( - buttons: @escaping () -> Drawer, - content: @escaping () -> Content + content: Content, + drawer: Drawer ) { - self.drawer = buttons - self.content = content - - super.init(rootView: content()) + self.drawer = drawer + super.init(rootView: content) } @available(*, unavailable) @@ -68,23 +63,30 @@ class UINavigationBarDrawerHostingController: UIHos super.viewDidLoad() view.backgroundColor = nil - view.addSubview(blurView) - addChild(drawerButtonsView) - view.addSubview(drawerButtonsView.view) - drawerButtonsView.didMove(toParent: self) + addChild(drawerView) + view.addSubview(drawerView.view) + drawerView.didMove(toParent: self) + + drawerHeight = drawerView.sizeThatFits( + in: .init( + width: view.frame.width, + height: CGFloat.greatestFiniteMagnitude + ) + ) + .height NSLayoutConstraint.activate([ - drawerButtonsView.view.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: -drawerHeight), - drawerButtonsView.view.heightAnchor.constraint(equalToConstant: drawerHeight), - drawerButtonsView.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), - drawerButtonsView.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + drawerView.view.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: -drawerHeight), + drawerView.view.heightAnchor.constraint(equalToConstant: drawerHeight), + drawerView.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + drawerView.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), ]) NSLayoutConstraint.activate([ blurView.topAnchor.constraint(equalTo: view.topAnchor), - blurView.bottomAnchor.constraint(equalTo: drawerButtonsView.view.bottomAnchor), + blurView.bottomAnchor.constraint(equalTo: drawerView.view.bottomAnchor), blurView.leadingAnchor.constraint(equalTo: view.leadingAnchor), blurView.trailingAnchor.constraint(equalTo: view.trailingAnchor), ]) @@ -104,10 +106,20 @@ class UINavigationBarDrawerHostingController: UIHos override var additionalSafeAreaInsets: UIEdgeInsets { get { - .init(top: drawerHeight, left: 0, bottom: 0, right: 0) + .init( + top: drawerHeight, + left: 0, + bottom: 0, + right: 0 + ) } set { - super.additionalSafeAreaInsets = .init(top: drawerHeight, left: 0, bottom: 0, right: 0) + super.additionalSafeAreaInsets = .init( + top: drawerHeight, + left: 0, + bottom: 0, + right: 0 + ) } } } diff --git a/Swiftfin/Extensions/View/Modifiers/NavigationBarMenuButton.swift b/Swiftfin/Extensions/View/Modifiers/NavigationBarMenuButton.swift index 431f20c9b9..b21499d87d 100644 --- a/Swiftfin/Extensions/View/Modifiers/NavigationBarMenuButton.swift +++ b/Swiftfin/Extensions/View/Modifiers/NavigationBarMenuButton.swift @@ -9,32 +9,53 @@ import Defaults import SwiftUI -struct NavigationBarMenuButtonModifier: ViewModifier { +struct NavigationBarMenuButtonModifier: ViewModifier { @Default(.accentColor) private var accentColor - let isLoading: Bool - let isHidden: Bool - let items: () -> Content + @State + private var collectedMenuGroups: [MenuContentGroup] = [] + + private let menuContent: MenuContent + private let isLoading: Bool + private let isHidden: Bool + + init( + isLoading: Bool = false, + isHidden: Bool = false, + @ViewBuilder menuContent: () -> MenuContent + ) { + self.isLoading = isLoading + self.isHidden = isHidden + self.menuContent = menuContent() + } func body(content: Self.Content) -> some View { - content.toolbar { - ToolbarItemGroup(placement: .topBarTrailing) { + content + .toolbar { + ToolbarItemGroup(placement: .topBarTrailing) { - if isLoading { - ProgressView() - } + if isLoading { + ProgressView() + } + + if !isHidden, collectedMenuGroups.isNotEmpty { + Menu(L10n.options, systemImage: "ellipsis.circle") { + menuContent - if !isHidden { - Menu(L10n.options, systemImage: "ellipsis.circle") { - items() + ForEach(collectedMenuGroups) { group in + group.content + } + } + .labelStyle(.iconOnly) + .fontWeight(.semibold) + .foregroundStyle(accentColor) } - .labelStyle(.iconOnly) - .fontWeight(.semibold) - .foregroundStyle(accentColor) } } - } + .onPreferenceChange(MenuContentKey.self) { newGroups in + self.collectedMenuGroups = newGroups + } } } diff --git a/Swiftfin/Extensions/View/Modifiers/NavigationBarOffset/NavigationBarOffsetModifier.swift b/Swiftfin/Extensions/View/Modifiers/NavigationBarOffset/NavigationBarOffsetModifier.swift deleted file mode 100644 index 7c8c57b3dd..0000000000 --- a/Swiftfin/Extensions/View/Modifiers/NavigationBarOffset/NavigationBarOffsetModifier.swift +++ /dev/null @@ -1,29 +0,0 @@ -// -// 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 SwiftUI - -struct NavigationBarOffsetModifier: ViewModifier { - - @Binding - var scrollViewOffset: CGFloat - - let start: CGFloat - let end: CGFloat - - func body(content: Content) -> some View { - NavigationBarOffsetView( - scrollViewOffset: $scrollViewOffset, - start: start, - end: end - ) { - content - } - .ignoresSafeArea() - } -} diff --git a/Swiftfin/Extensions/View/View-iOS.swift b/Swiftfin/Extensions/View/View-iOS.swift index 8003d7190b..4f84012b93 100644 --- a/Swiftfin/Extensions/View/View-iOS.swift +++ b/Swiftfin/Extensions/View/View-iOS.swift @@ -6,29 +6,48 @@ // Copyright (c) 2026 Jellyfin & Jellyfin Contributors // -import Defaults import SwiftUI @_spi(Advanced) import SwiftUIIntrospect extension View { - func detectOrientation(_ orientation: Binding) -> some View { - modifier(DetectOrientation(orientation: orientation)) - } - /// - Important: This does nothing on iOS. + @ViewBuilder func focusSection() -> some View { self } - func navigationBarOffset(_ scrollViewOffset: Binding, start: CGFloat, end: CGFloat) -> some View { - modifier(NavigationBarOffsetModifier(scrollViewOffset: scrollViewOffset, start: start, end: end)) + @ViewBuilder + func listRowCornerRadius(_ radius: CGFloat) -> some View { + introspect(.listCell, on: .iOS(.v16...)) { cell in + if #available(iOS 26, *) { + cell.cornerConfiguration = .uniformCorners(radius: .fixed(radius)) + } else { + cell.layer.cornerRadius = radius + } + } } - func navigationBarDrawer(@ViewBuilder _ drawer: @escaping () -> some View) -> some View { + @ViewBuilder + func navigationBarDrawer( + @ViewBuilder _ drawer: @escaping () -> some View + ) -> some View { modifier(NavigationBarDrawerModifier(drawer: drawer)) } + @ViewBuilder + func navigationBarCloseButton( + disabled: Bool = false, + _ action: @escaping () -> Void + ) -> some View { + modifier( + NavigationBarCloseButtonModifier( + disabled: disabled, + action: action + ) + ) + } + @ViewBuilder func navigationBarFilterDrawer( viewModel: FilterViewModel, @@ -46,43 +65,18 @@ extension View { } } - @ViewBuilder - func navigationBarCloseButton( - disabled: Bool = false, - _ action: @escaping () -> Void - ) -> some View { - modifier( - NavigationBarCloseButtonModifier( - disabled: disabled, - action: action - ) - ) - } - @ViewBuilder func navigationBarMenuButton( isLoading: Bool = false, isHidden: Bool = false, - @ViewBuilder - _ items: @escaping () -> some View + @ViewBuilder _ content: @escaping () -> some View ) -> some View { modifier( NavigationBarMenuButtonModifier( isLoading: isLoading, isHidden: isHidden, - items: items + menuContent: content ) ) } - - @ViewBuilder - func listRowCornerRadius(_ radius: CGFloat) -> some View { - introspect(.listCell, on: .iOS(.v16...)) { cell in - if #available(iOS 26, *) { - cell.cornerConfiguration = .uniformCorners(radius: .fixed(radius)) - } else { - cell.layer.cornerRadius = radius - } - } - } } diff --git a/Swiftfin/Objects/AppURLHandler.swift b/Swiftfin/Objects/AppURLHandler.swift deleted file mode 100644 index 9c4b7c3f50..0000000000 --- a/Swiftfin/Objects/AppURLHandler.swift +++ /dev/null @@ -1,109 +0,0 @@ -// -// 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 Combine -import Foundation -import JellyfinAPI - -final class AppURLHandler { - static let deepLinkScheme = "jellyfin" - - enum AppURLState { - case launched - case allowedInLogin - case allowed - - func allowedScheme(with url: URL) -> Bool { - switch self { - case .launched: - false - case .allowed: - true - case .allowedInLogin: - false - } - } - } - - static let shared = AppURLHandler() - - var cancellables = Set() - - var appURLState: AppURLState = .launched - var launchURL: URL? -} - -extension AppURLHandler { - @discardableResult - func processDeepLink(url: URL) -> Bool { - guard url.scheme == Self.deepLinkScheme || url.scheme == "widget-extension" else { - return false - } - if AppURLHandler.shared.appURLState.allowedScheme(with: url) { - return processURL(url) - } else { - launchURL = url - } - return true - } - - func processLaunchedURLIfNeeded() { - guard let launchURL, - launchURL.absoluteString.isNotEmpty else { return } - if processDeepLink(url: launchURL) { - self.launchURL = nil - } - } - - private func processURL(_ url: URL) -> Bool { - if processURLForUser(url: url) { - return true - } - - return false - } - - private func processURLForUser(url: URL) -> Bool { - guard url.host?.lowercased() == "users", - url.pathComponents[safe: 1]?.isEmpty == false else { return false } - - // /Users/{UserID}/Items/{ItemID} - if url.pathComponents[safe: 2]?.lowercased() == "items", - let userID = url.pathComponents[safe: 1], - let itemID = url.pathComponents[safe: 3] - { - // It would be nice if the ItemViewModel could be initialized to id later. - getItem(userID: userID, itemID: itemID) { _ in -// guard let item else { return } - // TODO: reimplement URL handling -// Notifications[.processDeepLink].post(DeepLink.item(item)) - } - - return true - } - - return false - } -} - -extension AppURLHandler { - func getItem(userID: String, itemID: String, completion: @escaping (BaseItemDto?) -> Void) { -// UserLibraryAPI.getItem(userId: userID, itemId: itemID) -// .sink(receiveCompletion: { innerCompletion in -// switch innerCompletion { -// case .failure: -// completion(nil) -// default: -// break -// } -// }, receiveValue: { item in -// completion(item) -// }) -// .store(in: &cancellables) - } -} diff --git a/Swiftfin/Resources/Swiftfin.entitlements b/Swiftfin/Resources/Swiftfin.entitlements index ee95ab7e58..0c67376eba 100644 --- a/Swiftfin/Resources/Swiftfin.entitlements +++ b/Swiftfin/Resources/Swiftfin.entitlements @@ -1,10 +1,5 @@ - - com.apple.security.app-sandbox - - com.apple.security.network.client - - + diff --git a/Swiftfin/Views/AdminDashboardView/ActiveSessions/ActiveSessionDetailView/ServerSessionDetailView.swift b/Swiftfin/Views/AdminDashboardView/ActiveSessions/ActiveSessionDetailView/ServerSessionDetailView.swift index 5b15bce7ad..9ce9624612 100644 --- a/Swiftfin/Views/AdminDashboardView/ActiveSessions/ActiveSessionDetailView/ServerSessionDetailView.swift +++ b/Swiftfin/Views/AdminDashboardView/ActiveSessions/ActiveSessionDetailView/ServerSessionDetailView.swift @@ -17,7 +17,7 @@ struct ActiveSessionDetailView: View { private var router @ObservedObject - var box: BindingBox + var box: PublishedBox // MARK: Create Idle Content View diff --git a/Swiftfin/Views/AdminDashboardView/ActiveSessions/ActiveSessionsView/Components/ActiveSessionRow.swift b/Swiftfin/Views/AdminDashboardView/ActiveSessions/ActiveSessionsView/Components/ActiveSessionRow.swift index 678955a491..6977710109 100644 --- a/Swiftfin/Views/AdminDashboardView/ActiveSessions/ActiveSessionsView/Components/ActiveSessionRow.swift +++ b/Swiftfin/Views/AdminDashboardView/ActiveSessions/ActiveSessionsView/Components/ActiveSessionRow.swift @@ -18,7 +18,7 @@ extension ActiveSessionsView { private var currentDate: Date @ObservedObject - private var box: BindingBox + private var box: PublishedBox private let onSelect: () -> Void @@ -26,44 +26,42 @@ extension ActiveSessionsView { box.value ?? .init() } - private var isPlaying: Bool { - session.nowPlayingItem != nil && session.playState != nil - } - - init(box: BindingBox, onSelect action: @escaping () -> Void) { + init(box: PublishedBox, onSelect action: @escaping () -> Void) { self.box = box self.onSelect = action } @ViewBuilder private var rowLeading: some View { - if let nowPlayingItem = session.nowPlayingItem { - PosterImage( - item: nowPlayingItem, - type: nowPlayingItem.preferredPosterDisplayType, - contentMode: .fit - ) - .frame(width: 60) - .frame(minHeight: 90) - .posterShadow() - } else { - ZStack { - session.device.clientColor - - Image(session.device.image) - .resizable() - .aspectRatio(contentMode: .fit) - .padding(8) + Group { + if let nowPlayingItem = session.nowPlayingItem { + PosterImage( + item: nowPlayingItem, + type: nowPlayingItem.preferredPosterDisplayType, + contentMode: .fit + ) + .frame(width: 60) + } else { + ZStack { + session.device.clientColor + + Image(session.device.image) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 40) + } + .posterStyle(.square) + .frame(width: 60, height: 60) } - .posterStyle(.square) - .frame(width: 60, height: 60) - .posterShadow() } + .frame(width: 60, height: 90) + .posterShadow() + .padding(.vertical, 8) } @ViewBuilder private func activeSessionDetails(_ nowPlayingItem: BaseItemDto, playState: PlayerStateInfo) -> some View { - VStack(alignment: .leading, spacing: 4) { + VStack(alignment: .leading) { Text(session.userName ?? L10n.unknown) .multilineTextAlignment(.leading) .font(.headline) @@ -84,7 +82,7 @@ extension ActiveSessionsView { @ViewBuilder private var idleSessionDetails: some View { - VStack(alignment: .leading, spacing: 4) { + VStack(alignment: .leading) { Text(session.userName ?? L10n.unknown) .font(.headline) @@ -117,7 +115,10 @@ extension ActiveSessionsView { } var body: some View { - ListRow(insets: .init(vertical: isPlaying ? 8 : 12, horizontal: EdgeInsets.edgePadding)) { + ListRow( + insets: .init(vertical: 8, horizontal: EdgeInsets.edgePadding), + action: onSelect + ) { rowLeading } content: { if let nowPlayingItem = session.nowPlayingItem, let playState = session.playState { @@ -126,7 +127,6 @@ extension ActiveSessionsView { idleSessionDetails } } - .onSelect(perform: onSelect) } } } diff --git a/Swiftfin/Views/AdminDashboardView/ServerActivity/ServerActivityFilterView/ServerActivityFilterView.swift b/Swiftfin/Views/AdminDashboardView/ServerActivity/ServerActivityFilterView/ServerActivityFilterView.swift index e1eb4b0d73..e4545356ca 100644 --- a/Swiftfin/Views/AdminDashboardView/ServerActivity/ServerActivityFilterView/ServerActivityFilterView.swift +++ b/Swiftfin/Views/AdminDashboardView/ServerActivity/ServerActivityFilterView/ServerActivityFilterView.swift @@ -12,42 +12,25 @@ import SwiftUI struct ServerActivityFilterView: View { - // MARK: - Environment Objects - @Router private var router - // MARK: - State Objects - - @ObservedObject - private var viewModel: ServerActivityViewModel - - // MARK: - Dialog States - @State - private var tempDate: Date? - - // MARK: - Initializer + private var activeDate: Date - init(viewModel: ServerActivityViewModel) { + private let environmentBinding: Binding - self.viewModel = viewModel - - if let minDate = viewModel.minDate { - tempDate = minDate - } else { - tempDate = .now - } + init(environment: Binding) { + self.environmentBinding = environment + self.activeDate = environment.wrappedValue.minDate ?? .now } - // MARK: - Body - var body: some View { List { Section { DatePicker( L10n.date, - selection: $tempDate.coalesce(.now), + selection: $activeDate, in: ...Date.now, displayedComponents: .date ) @@ -56,17 +39,17 @@ struct ServerActivityFilterView: View { } /// Reset button to remove the filter - if viewModel.minDate != nil { - Section { - Button(L10n.reset, role: .destructive) { - viewModel.minDate = nil - router.dismiss() - } - .buttonStyle(.primary) - } footer: { - Text(L10n.resetFilterFooter) - } - } +// if viewModel.minDate != nil { +// Section { +// Button(L10n.reset, role: .destructive) { +// viewModel.minDate = nil +// router.dismiss() +// } +// .buttonStyle(.primary) +// } footer: { +// Text(L10n.resetFilterFooter) +// } +// } } .navigationTitle(L10n.startDate.localizedCapitalized) .navigationBarTitleDisplayMode(.inline) @@ -75,14 +58,15 @@ struct ServerActivityFilterView: View { } .topBarTrailing { let startOfDay = Calendar.current - .startOfDay(for: tempDate ?? .now) + .startOfDay(for: activeDate) Button(L10n.save) { - viewModel.minDate = startOfDay + environmentBinding.wrappedValue.minDate = startOfDay router.dismiss() } .buttonStyle(.toolbarPill) - .disabled(viewModel.minDate != nil && startOfDay == viewModel.minDate) + .disabled(activeDate == environmentBinding.wrappedValue.minDate) +// .disabled(viewModel.minDate != nil && startOfDay == viewModel.minDate) } } } diff --git a/Swiftfin/Views/AdminDashboardView/ServerActivity/ServerActivityView/Components/ServerActivityEntry.swift b/Swiftfin/Views/AdminDashboardView/ServerActivity/ServerActivityView/Components/ServerActivityEntry.swift index 7514d2874b..bd5ad3bd83 100644 --- a/Swiftfin/Views/AdminDashboardView/ServerActivity/ServerActivityView/Components/ServerActivityEntry.swift +++ b/Swiftfin/Views/AdminDashboardView/ServerActivity/ServerActivityView/Components/ServerActivityEntry.swift @@ -25,14 +25,13 @@ extension ServerActivityView { // MARK: - Body var body: some View { - ListRow { + ListRow(action: action) { userImage .frame(width: 60, height: 60) } content: { rowContent .padding(.bottom, 8) } - .onSelect(perform: action) } // MARK: - User Image diff --git a/Swiftfin/Views/AdminDashboardView/ServerActivity/ServerActivityView/ServerActivityView.swift b/Swiftfin/Views/AdminDashboardView/ServerActivity/ServerActivityView/ServerActivityView.swift index 9a038c6914..7b865dc541 100644 --- a/Swiftfin/Views/AdminDashboardView/ServerActivity/ServerActivityView/ServerActivityView.swift +++ b/Swiftfin/Views/AdminDashboardView/ServerActivity/ServerActivityView/ServerActivityView.swift @@ -13,25 +13,22 @@ import SwiftUI // TODO: WebSocket struct ServerActivityView: View { - // MARK: - Router - @Router private var router - // MARK: - State Objects + @State + private var libraryEnvironment: ServerActivityLibrary.Environment = .default @StateObject - private var viewModel = ServerActivityViewModel() - - // MARK: - Body + private var viewModel: ServerActivityViewModel = .init() var body: some View { ZStack { switch viewModel.state { case .content: contentView - case let .error(error): - ErrorView(error: error) + case .error: + viewModel.error.map(ErrorView.init) case .initial, .refreshing: ProgressView() } @@ -40,20 +37,23 @@ struct ServerActivityView: View { .navigationTitle(L10n.activity) .navigationBarTitleDisplayMode(.inline) .refreshable { - viewModel.send(.refresh) + await viewModel.refresh() } .topBarTrailing { - if viewModel.backgroundStates.contains(.gettingNextPage) { + if viewModel.background.is(.retrievingNextPage) { ProgressView() } - Menu(L10n.filters, systemImage: "line.3.horizontal.decrease.circle") { + Menu( + L10n.filters, + systemImage: "line.3.horizontal.decrease.circle" + ) { startDateButton userFilterButton } } .onFirstAppear { - viewModel.send(.refresh) + viewModel.refresh() } } @@ -69,7 +69,6 @@ struct ServerActivityView: View { } else { CollectionVGrid( uniqueElements: viewModel.elements, - id: \.unwrappedIDHashOrZero, layout: .columns(1) ) { log in @@ -88,9 +87,8 @@ struct ServerActivityView: View { } } .onReachedBottomEdge(offset: .offset(300)) { - viewModel.send(.getNextPage) + viewModel.retrieveNextPage() } - .frame(maxWidth: .infinity) } } @@ -98,7 +96,7 @@ struct ServerActivityView: View { @ViewBuilder private var userFilterButton: some View { - Picker(selection: $viewModel.hasUserId) { + Picker(selection: $libraryEnvironment.hasUserID) { Label( L10n.all, systemImage: "line.3.horizontal" @@ -119,7 +117,7 @@ struct ServerActivityView: View { } label: { Text(L10n.type) - if let hasUserID = viewModel.hasUserId { + if let hasUserID = libraryEnvironment.hasUserID { Text(hasUserID ? L10n.users : L10n.system) Image(systemName: hasUserID ? "person" : "gearshape") @@ -140,7 +138,7 @@ struct ServerActivityView: View { } label: { Text(L10n.startDate) - if let startDate = viewModel.minDate { + if let startDate = libraryEnvironment.minDate { Text(startDate.formatted(date: .numeric, time: .omitted)) } else { Text(verbatim: .emptyDash) diff --git a/Swiftfin/Views/AdminDashboardView/ServerDevices/DevicesView/Components/DeviceRow.swift b/Swiftfin/Views/AdminDashboardView/ServerDevices/DevicesView/Components/DeviceRow.swift index 48ff864945..06ec5ebd3d 100644 --- a/Swiftfin/Views/AdminDashboardView/ServerDevices/DevicesView/Components/DeviceRow.swift +++ b/Swiftfin/Views/AdminDashboardView/ServerDevices/DevicesView/Components/DeviceRow.swift @@ -117,13 +117,11 @@ extension DevicesView { // MARK: - Body var body: some View { - ListRow { + ListRow(action: onSelect) { deviceImage } content: { rowContent } - .onSelect(perform: onSelect) - .isSeparatorVisible(false) .swipeActions { if let onDelete { Button( diff --git a/Swiftfin/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserParentalRatingView/ServerUserParentalRatingView.swift b/Swiftfin/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserParentalRatingView/ServerUserParentalRatingView.swift index a5ac798483..8ee5348188 100644 --- a/Swiftfin/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserParentalRatingView/ServerUserParentalRatingView.swift +++ b/Swiftfin/Views/AdminDashboardView/ServerUsers/ServerUserSettings/ServerUserParentalRatingView/ServerUserParentalRatingView.swift @@ -17,10 +17,10 @@ struct ServerUserParentalRatingView: View { @Router private var router - @StateObject + @ObservedObject private var viewModel: ServerUserAdminViewModel @StateObject - private var parentalRatingsViewModel: ParentalRatingsViewModel + private var parentalRatingsViewModel: PagingLibraryViewModel // MARK: - Policy Variable @@ -35,8 +35,8 @@ struct ServerUserParentalRatingView: View { // MARK: - Initializer init(viewModel: ServerUserAdminViewModel) { - self._viewModel = StateObject(wrappedValue: viewModel) - self._parentalRatingsViewModel = StateObject(wrappedValue: ParentalRatingsViewModel(initialValue: [])) + self.viewModel = viewModel + self._parentalRatingsViewModel = .init(wrappedValue: .init(library: .init())) guard let policy = viewModel.user.policy else { preconditionFailure("User policy cannot be empty.") @@ -131,7 +131,7 @@ struct ServerUserParentalRatingView: View { private func reducedParentalRatings() -> [ParentalRating] { [ParentalRating(name: L10n.none, value: nil)] + - parentalRatingsViewModel.value.grouped { $0.value ?? 0 } + parentalRatingsViewModel.elements.grouped { $0.value ?? 0 } .map { key, group in if key < 100 { if key == 0 { @@ -154,9 +154,9 @@ struct ServerUserParentalRatingView: View { // MARK: - Parental Rating Learn More @LabeledContentBuilder - private func parentalRatingLabeledContent() -> AnyView { + private func parentalRatingLabeledContent() -> some View { let reducedRatings = reducedParentalRatings() - let groupedRatings = parentalRatingsViewModel.value.grouped { $0.value ?? 0 } + let groupedRatings = parentalRatingsViewModel.elements.grouped { $0.value ?? 0 } ForEach(groupedRatings.keys.sorted(), id: \.self) { key in if let matchingRating = reducedRatings.first(where: { $0.value == key }) { diff --git a/Swiftfin/Views/AdminDashboardView/ServerUsers/ServerUsersView/Components/ServerUsersRow.swift b/Swiftfin/Views/AdminDashboardView/ServerUsers/ServerUsersView/Components/ServerUsersRow.swift index 676ca92699..99ee0a5c62 100644 --- a/Swiftfin/Views/AdminDashboardView/ServerUsers/ServerUsersView/Components/ServerUsersRow.swift +++ b/Swiftfin/Views/AdminDashboardView/ServerUsers/ServerUsersView/Components/ServerUsersRow.swift @@ -131,13 +131,11 @@ extension ServerUsersView { // MARK: - Body var body: some View { - ListRow { + ListRow(action: onSelect) { userImage } content: { rowContent } - .onSelect(perform: onSelect) - .isSeparatorVisible(false) .swipeActions { Button( L10n.delete, diff --git a/Swiftfin/Views/ChannelLibraryView/ChannelLibraryView.swift b/Swiftfin/Views/ChannelLibraryView/ChannelLibraryView.swift deleted file mode 100644 index 7425fa6b56..0000000000 --- a/Swiftfin/Views/ChannelLibraryView/ChannelLibraryView.swift +++ /dev/null @@ -1,184 +0,0 @@ -// -// 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 Defaults -import Foundation -import JellyfinAPI -import SwiftUI - -// TODO: remove and flatten to `PagingLibraryView` - -// TODO: sorting by number/filtering -// - see if can use normal filter view model? -// - how to add custom filters for data context? -// TODO: saving item display type/detailed column count -// - wait until after user refactor - -// Note: Repurposes `LibraryDisplayType` to save from creating a new type. -// If there are other places where detailed/compact contextually differ -// from the library types, then create a new type and use it here. -// - list: detailed -// - grid: compact - -struct ChannelLibraryView: View { - - @Router - private var router - - @State - private var channelDisplayType: LibraryDisplayType = .list - @State - private var layout: CollectionVGridLayout - - @StateObject - private var viewModel = ChannelLibraryViewModel() - - // MARK: init - - init() { - if UIDevice.isPhone { - layout = Self.padlayout(channelDisplayType: .list) - } else { - layout = Self.phonelayout(channelDisplayType: .list) - } - } - - // MARK: layout - - private static func padlayout( - channelDisplayType: LibraryDisplayType - ) -> CollectionVGridLayout { - switch channelDisplayType { - case .grid: - .minWidth(150) - case .list: - .minWidth(250) - } - } - - private static func phonelayout( - channelDisplayType: LibraryDisplayType - ) -> CollectionVGridLayout { - switch channelDisplayType { - case .grid: - .columns(3) - case .list: - .columns(1) - } - } - - // MARK: item view - - private func compactChannelView(channel: ChannelProgram) -> some View { - CompactChannelView(channel: channel.channel) { - router.route( - to: .videoPlayer( - provider: channel.channel.getPlaybackItemProvider( - userSession: viewModel.userSession - ) - ) - ) - } - } - - private func detailedChannelView(channel: ChannelProgram) -> some View { - DetailedChannelView(channel: channel) { - router.route( - to: .videoPlayer( - provider: channel.channel.getPlaybackItemProvider( - userSession: viewModel.userSession - ) - ) - ) - } - } - - @ViewBuilder - private var contentView: some View { - CollectionVGrid( - uniqueElements: viewModel.elements, - layout: layout - ) { channel in - switch channelDisplayType { - case .grid: - compactChannelView(channel: channel) - case .list: - detailedChannelView(channel: channel) - } - } - .onReachedBottomEdge(offset: .offset(300)) { - viewModel.send(.getNextPage) - } - } - - var body: some View { - ZStack { - Color.clear - - switch viewModel.state { - case .content: - if viewModel.elements.isEmpty { - ContentUnavailableView(L10n.noChannels.localizedCapitalized, systemImage: "antenna.radiowaves.left.and.right") - } else { - contentView - } - case let .error(error): - ErrorView(error: error) - case .initial, .refreshing: - ProgressView() - } - } - .navigationTitle(L10n.channels) - .navigationBarTitleDisplayMode(.inline) - .refreshable { - viewModel.send(.refresh) - } - .onChange(of: channelDisplayType) { newValue in - if UIDevice.isPhone { - layout = Self.phonelayout(channelDisplayType: newValue) - } else { - layout = Self.padlayout(channelDisplayType: newValue) - } - } - .onFirstAppear { - if viewModel.state == .initial { - viewModel.send(.refresh) - } - } - .sinceLastDisappear { interval in - // refresh after 3 hours - if interval >= 10800 { - viewModel.send(.refresh) - } - } - .topBarTrailing { - - if viewModel.backgroundStates.contains(.gettingNextPage) { - ProgressView() - } - - Menu { - // We repurposed `LibraryDisplayType` but want different labels - Picker(L10n.channelDisplay, selection: $channelDisplayType) { - - Label(L10n.compact, systemImage: LibraryDisplayType.grid.systemImage) - .tag(LibraryDisplayType.grid) - - Label(L10n.detailed, systemImage: LibraryDisplayType.list.systemImage) - .tag(LibraryDisplayType.list) - } - } label: { - Label( - channelDisplayType.displayTitle, - systemImage: channelDisplayType.systemImage - ) - } - } - } -} diff --git a/Swiftfin/Views/ChannelLibraryView/Components/CompactChannelView.swift b/Swiftfin/Views/ChannelLibraryView/Components/CompactChannelView.swift deleted file mode 100644 index 1cdd65e92b..0000000000 --- a/Swiftfin/Views/ChannelLibraryView/Components/CompactChannelView.swift +++ /dev/null @@ -1,37 +0,0 @@ -// -// 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 JellyfinAPI -import SwiftUI - -extension ChannelLibraryView { - - struct CompactChannelView: View { - - let channel: BaseItemDto - let action: () -> Void - - var body: some View { - Button(action: action) { - VStack(alignment: .leading) { - PosterImage( - item: channel, - type: .square - ) - - Text(channel.displayTitle) - .font(.footnote.weight(.regular)) - .foregroundColor(.primary) - .lineLimit(1, reservesSpace: true) - .font(.footnote.weight(.regular)) - } - } - .buttonStyle(.plain) - } - } -} diff --git a/Swiftfin/Views/ChannelLibraryView/Components/DetailedChannelView.swift b/Swiftfin/Views/ChannelLibraryView/Components/DetailedChannelView.swift deleted file mode 100644 index 9c4880398e..0000000000 --- a/Swiftfin/Views/ChannelLibraryView/Components/DetailedChannelView.swift +++ /dev/null @@ -1,135 +0,0 @@ -// -// 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 Defaults -import JellyfinAPI -import SwiftUI - -// TODO: can look busy with 3 programs, probably just do 2? - -extension ChannelLibraryView { - - struct DetailedChannelView: View { - - @Default(.accentColor) - private var accentColor - - @State - private var contentSize: CGSize = .zero - @State - private var now: Date = .now - - let channel: ChannelProgram - let action: () -> Void - - private let timer = Timer.publish(every: 5, on: .main, in: .common).autoconnect() - - @ViewBuilder - private var channelLogo: some View { - VStack { - PosterImage( - item: channel.channel, - type: .square - ) - - Text(channel.channel.number ?? "") - .font(.body) - .lineLimit(1) - .foregroundColor(Color.jellyfinPurple) - } - } - - @ViewBuilder - private func programLabel(for program: BaseItemDto) -> some View { - HStack(alignment: .top) { - AlternateLayoutView(alignment: .leading) { - // swiftlint:disable:next hard_coded_display_string - Text("00:00 AAA") - .monospacedDigit() - } content: { - if let startDate = program.startDate { - Text(startDate, style: .time) - .monospacedDigit() - } else { - Text(String.emptyRuntime) - } - } - - Text(program.displayTitle) - } - .lineLimit(1) - } - - @ViewBuilder - private var programListView: some View { - VStack(alignment: .leading, spacing: 0) { - if let currentProgram = channel.currentProgram { - ProgressBar(progress: currentProgram.programProgress(relativeTo: now) ?? 0) - .frame(height: 5) - .padding(.bottom, 5) - .foregroundStyle(accentColor) - - programLabel(for: currentProgram) - .font(.footnote.weight(.bold)) - } - - if let nextProgram = channel.programAfterCurrent(offset: 0) { - programLabel(for: nextProgram) - .font(.footnote) - .foregroundStyle(.secondary) - } - - if let futureProgram = channel.programAfterCurrent(offset: 1) { - programLabel(for: futureProgram) - .font(.footnote) - .foregroundStyle(.secondary) - } - } - .id(channel.currentProgram) - } - - var body: some View { - ZStack(alignment: .bottomTrailing) { - Button(action: action) { - HStack(alignment: .center, spacing: EdgeInsets.edgePadding) { - - channelLogo - .frame(width: 80) - .padding(.vertical, 8) - - HStack { - VStack(alignment: .leading, spacing: 5) { - Text(channel.displayTitle) - .font(.body) - .fontWeight(.semibold) - .lineLimit(1) - .foregroundStyle(.primary) - - if channel.programs.isNotEmpty { - programListView - } - } - - Spacer() - } - .frame(maxWidth: .infinity) - .trackingSize($contentSize) - } - } - .buttonStyle(.plain) - - Color.secondarySystemFill - .frame(width: contentSize.width, height: 1) - } - .onReceive(timer) { newValue in - now = newValue - } - .animation(.linear(duration: 0.2), value: channel.currentProgram) - } - } -} diff --git a/Swiftfin/Views/DownloadListView.swift b/Swiftfin/Views/DownloadListView.swift deleted file mode 100644 index 103a5120a2..0000000000 --- a/Swiftfin/Views/DownloadListView.swift +++ /dev/null @@ -1,64 +0,0 @@ -// -// 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 SwiftUI - -struct DownloadListView: View { - - @ObservedObject - var viewModel: DownloadListViewModel - - var body: some View { - ScrollView(showsIndicators: false) { - ForEach(viewModel.items) { item in - DownloadTaskRow(downloadTask: item) - } - } - .navigationTitle(L10n.downloads) - .navigationBarTitleDisplayMode(.inline) - } -} - -extension DownloadListView { - - struct DownloadTaskRow: View { - - @Router - private var router - - let downloadTask: DownloadTask - - var body: some View { - Button { - router.route(to: .downloadTask(downloadTask: downloadTask)) - } label: { - HStack(alignment: .bottom) { - ImageView(downloadTask.getImageURL(name: "Primary")) - .failure { - Color.secondary - .opacity(0.8) - } -// .posterStyle(type: .portrait, width: 60) - .posterShadow() - - VStack(alignment: .leading) { - Text(downloadTask.item.displayTitle) - .foregroundColor(.primary) - .fontWeight(.semibold) - .lineLimit(2) - .multilineTextAlignment(.leading) - .fixedSize(horizontal: false, vertical: true) - } - .padding(.vertical) - - Spacer() - } - } - } - } -} diff --git a/Swiftfin/Views/DownloadTaskView/DownloadTaskContentView.swift b/Swiftfin/Views/DownloadTaskView/DownloadTaskContentView.swift deleted file mode 100644 index 18f14a0851..0000000000 --- a/Swiftfin/Views/DownloadTaskView/DownloadTaskContentView.swift +++ /dev/null @@ -1,167 +0,0 @@ -// -// 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 Defaults -import Factory -import JellyfinAPI -import SwiftUI - -extension DownloadTaskView { - - struct ContentView: View { - - @Default(.accentColor) - private var accentColor - - @Injected(\.downloadManager) - private var downloadManager - - @Router - private var router - - @ObservedObject - var downloadTask: DownloadTask - - @State - private var isPresentingVideoPlayerTypeError: Bool = false - - var body: some View { - VStack(alignment: .leading, spacing: 10) { - - VStack(alignment: .center) { - ImageView(downloadTask.item.landscapeImageSources(maxWidth: 600)) - .frame(maxHeight: 300) - .aspectRatio(1.77, contentMode: .fill) - .cornerRadius(10) - .padding(.horizontal) - .posterShadow() - - ShelfView(downloadTask: downloadTask) - - // TODO: Break into subview - switch downloadTask.state { - case .ready, .cancelled: - Button(L10n.download) { - downloadManager.download(task: downloadTask) - } - .frame(maxWidth: 300) - .frame(height: 50) - case let .downloading(progress): - HStack { -// CircularProgressView(progress: progress) -// .buttonStyle(.plain) -// .frame(width: 30, height: 30) - - // swiftlint:disable:next hard_coded_display_string - Text("\(Int(progress * 100))%") - .foregroundColor(.secondary) - - Spacer() - - Button { - downloadManager.cancel(task: downloadTask) - } label: { - Image(systemName: "stop.circle") - .foregroundColor(.red) - } - } - .padding(.horizontal) - case let .error(error): - VStack { - Button(L10n.retry) { - downloadManager.download(task: downloadTask) - } - .frame(maxWidth: 300) - .frame(height: 50) - - Text(error.localizedDescription) - .padding(.horizontal) - } - case .complete: - Button(L10n.play) { - if Defaults[.VideoPlayer.videoPlayerType] == .swiftfin { - router.dismiss() -// router.route(to: .videoPlayer(manager: DownloadVideoPlayerManager(downloadTask: downloadTask))) - } else { - isPresentingVideoPlayerTypeError = true - } - } - .frame(maxWidth: 300) - .frame(height: 50) - } - } - } - .alert( - L10n.error, - isPresented: $isPresentingVideoPlayerTypeError - ) { - Button { - isPresentingVideoPlayerTypeError = false - } label: { - Text(L10n.dismiss) - } - } message: { - Text(L10n.downloadedPlayerWarning) - } - } - } -} - -extension DownloadTaskView.ContentView { - - struct ShelfView: View { - - @ObservedObject - var downloadTask: DownloadTask - - var body: some View { - VStack(alignment: .center, spacing: 10) { - - if let seriesName = downloadTask.item.seriesName { - Text(seriesName) - .font(.headline) - .fontWeight(.semibold) - .multilineTextAlignment(.center) - .lineLimit(2) - .padding(.horizontal) - .foregroundColor(.secondary) - } - - Text(downloadTask.item.displayTitle) - .font(.title2) - .fontWeight(.bold) - .multilineTextAlignment(.center) - .lineLimit(2) - .padding(.horizontal) - - DotHStack { - if downloadTask.item.type == .episode { - if let episodeLocation = downloadTask.item.episodeLocator { - Text(episodeLocation) - } - } else { - if let firstGenre = downloadTask.item.genres?.first { - Text(firstGenre) - } - } - - if let productionYear = downloadTask.item.premiereDateYear { - Text(productionYear) - } - - if let runtime = downloadTask.item.runTimeLabel { - Text(runtime) - } - } - .font(.caption) - .foregroundColor(.secondary) - .padding(.horizontal) - } - } - } -} diff --git a/Swiftfin/Views/DownloadTaskView/DownloadTaskView.swift b/Swiftfin/Views/DownloadTaskView/DownloadTaskView.swift deleted file mode 100644 index 196ed654ec..0000000000 --- a/Swiftfin/Views/DownloadTaskView/DownloadTaskView.swift +++ /dev/null @@ -1,28 +0,0 @@ -// -// 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 Defaults -import SwiftUI - -struct DownloadTaskView: View { - - @Router - private var router - - @ObservedObject - var downloadTask: DownloadTask - - var body: some View { - ScrollView(showsIndicators: false) { - ContentView(downloadTask: downloadTask) - } - .navigationBarCloseButton { - router.dismiss() - } - } -} diff --git a/Swiftfin/Views/HomeView/Components/ContinueWatchingView.swift b/Swiftfin/Views/HomeView/Components/ContinueWatchingView.swift deleted file mode 100644 index c3b1f35291..0000000000 --- a/Swiftfin/Views/HomeView/Components/ContinueWatchingView.swift +++ /dev/null @@ -1,75 +0,0 @@ -// -// 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 CollectionHStack -import JellyfinAPI -import SwiftUI - -extension HomeView { - - struct ContinueWatchingView: View { - - @Router - private var router - - @ObservedObject - var viewModel: HomeViewModel - - // TODO: see how this looks across multiple screen sizes - // alongside PosterHStack + landscape - // TODO: need better handling for iPadOS + portrait orientation - private var columnCount: CGFloat { - if UIDevice.isPhone { - 1.5 - } else { - 3.5 - } - } - - var body: some View { - CollectionHStack( - uniqueElements: viewModel.resumeItems, - columns: columnCount - ) { item in - PosterButton( - item: item, - type: .landscape - ) { namespace in - router.route(to: .item(item: item), in: namespace) - } label: { - if item.type == .episode { - PosterButton.EpisodeContentSubtitleContent(item: item) - } else { - PosterButton.TitleSubtitleContentView(item: item) - } - } - } - .clipsToBounds(false) - .scrollBehavior(.continuousLeadingEdge) - .contextMenu(for: BaseItemDto.self) { item in - Button { - viewModel.send(.setIsPlayed(true, item)) - } label: { - Label(L10n.played, systemImage: "checkmark.circle") - } - - Button(role: .destructive) { - viewModel.send(.setIsPlayed(false, item)) - } label: { - Label(L10n.unplayed, systemImage: "minus.circle") - } - } - .posterOverlay(for: BaseItemDto.self) { item in - LandscapePosterProgressBar( - title: item.progressLabel ?? L10n.continue, - progress: (item.userData?.playedPercentage ?? 0) / 100 - ) - } - } - } -} diff --git a/Swiftfin/Views/HomeView/Components/LatestInLibraryView.swift b/Swiftfin/Views/HomeView/Components/LatestInLibraryView.swift deleted file mode 100644 index 5af22d5786..0000000000 --- a/Swiftfin/Views/HomeView/Components/LatestInLibraryView.swift +++ /dev/null @@ -1,43 +0,0 @@ -// -// 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 Defaults -import SwiftUI - -extension HomeView { - - struct LatestInLibraryView: View { - - @Default(.Customization.latestInLibraryPosterType) - private var latestInLibraryPosterType - - @Router - private var router - - @ObservedObject - var viewModel: LatestInLibraryViewModel - - var body: some View { - if viewModel.elements.isNotEmpty { - PosterHStack( - title: L10n.latestWithString(viewModel.parent?.displayTitle ?? .emptyDash), - type: latestInLibraryPosterType, - items: viewModel.elements - ) { item, namespace in - router.route(to: .item(item: item), in: namespace) - } - .trailing { - SeeAllButton() - .onSelect { - router.route(to: .library(viewModel: viewModel)) - } - } - } - } - } -} diff --git a/Swiftfin/Views/HomeView/Components/NextUpView.swift b/Swiftfin/Views/HomeView/Components/NextUpView.swift deleted file mode 100644 index 4d064ada64..0000000000 --- a/Swiftfin/Views/HomeView/Components/NextUpView.swift +++ /dev/null @@ -1,73 +0,0 @@ -// -// 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 Defaults -import JellyfinAPI -import SwiftUI - -extension HomeView { - - struct NextUpView: View { - - @Default(.Customization.nextUpPosterType) - private var nextUpPosterType - - @Router - private var router - - @ObservedObject - var viewModel: NextUpLibraryViewModel - - private var onSetPlayed: (BaseItemDto) -> Void - - var body: some View { - if viewModel.elements.isNotEmpty { - PosterHStack( - title: L10n.nextUp, - type: nextUpPosterType, - items: viewModel.elements - ) { item, namespace in - router.route(to: .item(item: item), in: namespace) - } label: { item in - if item.type == .episode { - PosterButton.EpisodeContentSubtitleContent(item: item) - } else { - PosterButton.TitleSubtitleContentView(item: item) - } - } - .trailing { - SeeAllButton() - .onSelect { - router.route(to: .library(viewModel: viewModel)) - } - } - .contextMenu(for: BaseItemDto.self) { item in - Button { - onSetPlayed(item) - } label: { - Label(L10n.played, systemImage: "checkmark.circle") - } - } - } - } - } -} - -extension HomeView.NextUpView { - - init(viewModel: NextUpLibraryViewModel) { - self.init( - viewModel: viewModel, - onSetPlayed: { _ in } - ) - } - - func onSetPlayed(perform action: @escaping (BaseItemDto) -> Void) -> Self { - copy(modifying: \.onSetPlayed, with: action) - } -} diff --git a/Swiftfin/Views/HomeView/Components/RecentlyAddedView.swift b/Swiftfin/Views/HomeView/Components/RecentlyAddedView.swift deleted file mode 100644 index 8eb03c1799..0000000000 --- a/Swiftfin/Views/HomeView/Components/RecentlyAddedView.swift +++ /dev/null @@ -1,47 +0,0 @@ -// -// 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 Defaults -import JellyfinAPI -import SwiftUI - -extension HomeView { - - struct RecentlyAddedView: View { - - @Default(.Customization.recentlyAddedPosterType) - private var recentlyAddedPosterType - - @Router - private var router - - @ObservedObject - var viewModel: RecentlyAddedLibraryViewModel - - var body: some View { - if viewModel.elements.isNotEmpty { - PosterHStack( - title: L10n.recentlyAdded.localizedCapitalized, - type: recentlyAddedPosterType, - items: viewModel.elements - ) { item, namespace in - router.route(to: .item(item: item), in: namespace) - } - .trailing { - SeeAllButton() - .onSelect { - // Give a new view model becaues we don't want to - // keep paginated items on the home view model - let viewModel = RecentlyAddedLibraryViewModel() - router.route(to: .library(viewModel: viewModel)) - } - } - } - } - } -} diff --git a/Swiftfin/Views/HomeView/HomeView.swift b/Swiftfin/Views/HomeView/HomeView.swift deleted file mode 100644 index 18144a19d7..0000000000 --- a/Swiftfin/Views/HomeView/HomeView.swift +++ /dev/null @@ -1,98 +0,0 @@ -// -// 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 Defaults -import Factory -import Foundation -import SwiftUI - -// TODO: seems to redraw view when popped to sometimes? -// - similar to MediaView TODO bug? -// - indicated by snapping to the top -struct HomeView: View { - - @Default(.Customization.nextUpPosterType) - private var nextUpPosterType - @Default(.Customization.Home.showRecentlyAdded) - private var showRecentlyAdded - @Default(.Customization.recentlyAddedPosterType) - private var recentlyAddedPosterType - - @Router - private var router - - @StateObject - private var viewModel = HomeViewModel() - - @ViewBuilder - private var contentView: some View { - ScrollView { - VStack(alignment: .leading, spacing: 10) { - - ContinueWatchingView(viewModel: viewModel) - - NextUpView(viewModel: viewModel.nextUpViewModel) - .onSetPlayed { item in - viewModel.send(.setIsPlayed(true, item)) - } - - if showRecentlyAdded { - RecentlyAddedView(viewModel: viewModel.recentlyAddedViewModel) - } - - ForEach(viewModel.libraries) { viewModel in - LatestInLibraryView(viewModel: viewModel) - } - } - .edgePadding(.vertical) - } - .refreshable { - viewModel.send(.refresh) - } - } - - var body: some View { - ZStack { - switch viewModel.state { - case .content: - contentView - case let .error(error): - ErrorView(error: error) - case .initial, .refreshing: - ProgressView() - } - } - .animation(.linear(duration: 0.1), value: viewModel.state) - .onFirstAppear { - viewModel.send(.refresh) - } - .navigationTitle(L10n.home) - .refreshable { - viewModel.send(.refresh) - } - .topBarTrailing { - - if viewModel.backgroundStates.contains(.refresh) { - ProgressView() - } - - SettingsBarButton( - server: viewModel.userSession.server, - user: viewModel.userSession.user - ) { - router.route(to: .settings) - } - } - .sinceLastDisappear { interval in - if interval > 60 || viewModel.notificationsReceived.contains(.itemMetadataDidChange) { - viewModel.send(.backgroundRefresh) - viewModel.notificationsReceived.remove(.itemMetadataDidChange) - } - } - } -} diff --git a/Swiftfin/Views/ItemEditorView/ItemEditorView.swift b/Swiftfin/Views/ItemEditorView/ItemEditorView.swift index 32bcebab18..10df0624a0 100644 --- a/Swiftfin/Views/ItemEditorView/ItemEditorView.swift +++ b/Swiftfin/Views/ItemEditorView/ItemEditorView.swift @@ -36,7 +36,6 @@ struct ItemEditorView: View { router.dismiss() } .onFirstAppear { - // Ensure we have a full `BaseItemDto` or some non-required metadata may be missing viewModel.refreshItem(sendNotification: false) } .refreshable { @@ -72,6 +71,12 @@ struct ItemEditorView: View { ChevronButton(L10n.metadata) { router.route(to: .editMetadata(viewModel: viewModel)) } + + if viewModel.item.canEditSubtitles { + ChevronButton(L10n.subtitles) { + router.route(to: .editSubtitles(item: viewModel.item)) + } + } } if viewModel.item.hasComponents { diff --git a/Swiftfin/Views/ItemEditorView/ItemImages/AddItemImageView.swift b/Swiftfin/Views/ItemEditorView/ItemImages/AddItemImageView.swift index acfde44939..c9b6fa3781 100644 --- a/Swiftfin/Views/ItemEditorView/ItemImages/AddItemImageView.swift +++ b/Swiftfin/Views/ItemEditorView/ItemImages/AddItemImageView.swift @@ -16,104 +16,51 @@ import SwiftUI struct AddItemImageView: View { - // MARK: - Observed, & Environment Objects + @ObservedObject + private var itemImagesViewModel: ItemImagesViewModel @Router private var router - @ObservedObject - private var viewModel: ItemImagesViewModel - - @StateObject - private var remoteImageInfoViewModel: RemoteImageInfoViewModel - - // MARK: - Dialog State - @State private var error: Error? - // MARK: - Collection Layout - - @State - private var layout: CollectionVGridLayout = .minWidth(150) - - // MARK: - Initializer + @StateObject + private var remoteImageInfoViewModel: RemoteImageInfoViewModel init(viewModel: ItemImagesViewModel, imageType: ImageType) { - self.viewModel = viewModel + self.itemImagesViewModel = viewModel self._remoteImageInfoViewModel = StateObject( - wrappedValue: RemoteImageInfoViewModel( - imageType: imageType, - parent: viewModel.item + wrappedValue: .init( + itemID: viewModel.item.id ?? "unknown", + imageType: imageType ) ) } - // MARK: - Body - var body: some View { ZStack { - switch remoteImageInfoViewModel.state { - case .initial, .refreshing: - ProgressView() - case .content: - gridView - case let .error(error): - ErrorView(error: error) - } + ImageElementsView( + viewModel: remoteImageInfoViewModel.remoteImageLibrary, + itemImagesViewModel: itemImagesViewModel, + remoteImageInfoViewModel: remoteImageInfoViewModel + ) } - .animation(.linear(duration: 0.1), value: remoteImageInfoViewModel.state) - .navigationTitle(remoteImageInfoViewModel.imageType.displayTitle) + .navigationTitle(remoteImageInfoViewModel.remoteImageLibrary.library.imageType.displayTitle) .navigationBarTitleDisplayMode(.inline) .refreshable { - remoteImageInfoViewModel.send(.refresh) + remoteImageInfoViewModel.refresh() } - .navigationBarBackButtonHidden(viewModel.backgroundStates.contains(.updating)) - .navigationBarMenuButton(isLoading: viewModel.backgroundStates.contains(.updating)) { - Button { - remoteImageInfoViewModel.includeAllLanguages.toggle() - } label: { - if remoteImageInfoViewModel.includeAllLanguages { - Label(L10n.allLanguages, systemImage: "checkmark") - } else { - Text(L10n.allLanguages) - } - } - - if remoteImageInfoViewModel.providers.isNotEmpty { - Menu { - Button { - remoteImageInfoViewModel.provider = nil - } label: { - if remoteImageInfoViewModel.provider == nil { - Label(L10n.all, systemImage: "checkmark") - } else { - Text(L10n.all) - } - } - - ForEach(remoteImageInfoViewModel.providers, id: \.self) { provider in - Button { - remoteImageInfoViewModel.provider = provider - } label: { - if remoteImageInfoViewModel.provider == provider { - Label(provider, systemImage: "checkmark") - } else { - Text(provider) - } - } - } - } label: { - Text(L10n.provider) - - Text(remoteImageInfoViewModel.provider ?? L10n.all) - } - } + .navigationBarBackButtonHidden(itemImagesViewModel.backgroundStates.contains(.updating)) + .navigationBarMenuButton(isLoading: itemImagesViewModel.backgroundStates.contains(.updating)) { + ImageProvidersMenuContent( + viewModel: remoteImageInfoViewModel + ) } .onFirstAppear { - remoteImageInfoViewModel.send(.refresh) + remoteImageInfoViewModel.refresh() } - .onReceive(viewModel.events) { event in + .onReceive(itemImagesViewModel.events) { event in switch event { case .updated: UIDevice.feedback(.success) @@ -125,72 +72,123 @@ struct AddItemImageView: View { } .errorMessage($error) } +} + +extension AddItemImageView { + + struct ImageElementsView: View { - // MARK: - Content Grid View - - @ViewBuilder - private var gridView: some View { - if remoteImageInfoViewModel.elements.isEmpty { - ContentUnavailableView(L10n.noResults.localizedCapitalized, systemImage: "photo") - } else { - CollectionVGrid( - uniqueElements: remoteImageInfoViewModel.elements, - layout: layout - ) { image in - imageButton(image) + @ObservedObject + private var viewModel: PagingLibraryViewModel + + @Router + private var router + + private let itemImagesViewModel: ItemImagesViewModel + private let layout: CollectionVGridLayout = .minWidth(150) + private let remoteImageInfoViewModel: RemoteImageInfoViewModel + + init( + viewModel: PagingLibraryViewModel, + itemImagesViewModel: ItemImagesViewModel, + remoteImageInfoViewModel: RemoteImageInfoViewModel + ) { + self.viewModel = viewModel + self.itemImagesViewModel = itemImagesViewModel + self.remoteImageInfoViewModel = remoteImageInfoViewModel + } + + @ViewBuilder + private var gridView: some View { + if viewModel.elements.isEmpty { + Text(L10n.none) + } else { + CollectionVGrid( + uniqueElements: viewModel.elements, + layout: layout, + viewProvider: imageButton + ) + .onReachedBottomEdge(offset: .offset(300)) { + viewModel.retrieveNextPage() + } } - .onReachedBottomEdge(offset: .offset(300)) { - remoteImageInfoViewModel.send(.getNextPage) + } + + @ViewBuilder + private func imageButton(_ image: RemoteImageInfo) -> some View { + Button { + router.route( + to: .itemSearchImageDetails( + viewModel: itemImagesViewModel, + remoteImageInfo: image + ) + ) + } label: { + PosterImage( + item: image, + type: image.preferredPosterDisplayType + ) + .pipeline(.Swiftfin.other) } } + + var body: some View { + ZStack { + switch viewModel.state { + case .content: + gridView + case .initial, .refreshing: + ProgressView() + case .error: + viewModel.error.map(ErrorView.init) + } + } + .animation(.linear(duration: 0.1), value: viewModel.state) + } } - // MARK: - Poster Image Button + struct ImageProvidersMenuContent: View { - @ViewBuilder - private func imageButton(_ image: RemoteImageInfo) -> some View { - Button { - router.route( - to: .itemSearchImageDetails( - viewModel: viewModel, - remoteImageInfo: image - ) - ) - } label: { - posterImage( - image, - posterStyle: (image.height ?? 0) > (image.width ?? 0) ? .portrait : .landscape - ) + @ObservedObject + private var remoteImagesViewModel: PagingLibraryViewModel + @ObservedObject + private var providersViewModel: PagingLibraryViewModel + + init(viewModel: RemoteImageInfoViewModel) { + self.remoteImagesViewModel = viewModel.remoteImageLibrary + self.providersViewModel = viewModel.remoteImageProvidersLibrary } - } - // MARK: - Poster Image + var body: some View { + Group { + Toggle( + L10n.allLanguages, + isOn: $remoteImagesViewModel.environment.includeAllLanguages + ) - @ViewBuilder - private func posterImage( - _ posterImageInfo: RemoteImageInfo?, - posterStyle: PosterDisplayType - ) -> some View { - ZStack { - Color.secondarySystemFill - .frame(maxWidth: .infinity, maxHeight: .infinity) - - ImageView(posterImageInfo?.url?.url) - .placeholder { source in - if let blurHash = source.blurHash { - BlurHashView(blurHash: blurHash) - .scaledToFit() - } else { - Image(systemName: "photo") + if providersViewModel.elements.isNotEmpty { + Picker(selection: $remoteImagesViewModel.environment.provider) { + Text(L10n.all) + .tag(nil as String?) + + ForEach( + providersViewModel.elements + ) { provider in + Text(provider.name ?? L10n.unknown) + .tag(provider.name) + } + } label: { + Text(L10n.provider) + + Text(remoteImagesViewModel.environment.provider ?? L10n.all) } + .pickerStyle(.menu) } - .failure { - Image(systemName: "photo") - } - .pipeline(.Swiftfin.other) - .foregroundStyle(.secondary) - .font(.headline) + } + .backport + .onChange(of: remoteImagesViewModel.environment) { _, _ in + remoteImagesViewModel.refresh() + } } - .posterStyle(posterStyle) } } diff --git a/Swiftfin/Views/ItemEditorView/ItemImages/ItemImageDetailsView/Components/ItemImageDetailsHeaderSection.swift b/Swiftfin/Views/ItemEditorView/ItemImages/ItemImageDetailsView/Components/ItemImageDetailsHeaderSection.swift index d119cfed45..d7c801dfbc 100644 --- a/Swiftfin/Views/ItemEditorView/ItemImages/ItemImageDetailsView/Components/ItemImageDetailsHeaderSection.swift +++ b/Swiftfin/Views/ItemEditorView/ItemImages/ItemImageDetailsView/Components/ItemImageDetailsHeaderSection.swift @@ -16,28 +16,66 @@ extension ItemImageDetailsView { // MARK: - Image Info let imageSource: ImageSource + let imageType: ImageType? let posterType: PosterDisplayType // MARK: - Body var body: some View { Section { - ImageView(imageSource) - .placeholder { _ in - Image(systemName: "photo") - } - .failure { - Image(systemName: "photo") - } - .pipeline(.Swiftfin.other) + PosterImage( + item: BasicImagePosterItem( + displayTitle: L10n.image, + id: 0, + imageSource: imageSource, + preferredPosterDisplayType: posterType, + systemImage: "photo", + type: imageType + ), + type: posterType, + contentMode: .fit + ) + .pipeline(.Swiftfin.other) + .frame(maxWidth: .infinity) } - .scaledToFit() .frame(maxHeight: 300) - .posterStyle(posterType) - .frame(maxWidth: .infinity) .listRowBackground(Color.clear) .listRowCornerRadius(0) .listRowInsets(.zero) } } } + +// TODO: have ImageInfo and RemoteImageInfo conform to a shared protocol + +private struct BasicImagePosterItem: Poster { + + let displayTitle: String + let id: Int + let imageSource: ImageSource + let preferredPosterDisplayType: PosterDisplayType + let systemImage: String + let type: ImageType? + + func imageSources( + for displayType: PosterDisplayType, + size: PosterDisplayType.Size, + environment: Empty + ) -> [ImageSource] { + [imageSource] + } + + @ViewBuilder + func transform(image: Image, displayType: PosterDisplayType) -> some View { + switch type { + case .logo: + ContainerRelativeView(ratio: 0.95) { + image + .aspectRatio(contentMode: .fit) + } + default: + image + .aspectRatio(contentMode: .fill) + } + } +} diff --git a/Swiftfin/Views/ItemEditorView/ItemImages/ItemImageDetailsView/ItemImageDetailsView.swift b/Swiftfin/Views/ItemEditorView/ItemImages/ItemImageDetailsView/ItemImageDetailsView.swift index 6eed3e2bcc..51e96280bf 100644 --- a/Swiftfin/Views/ItemEditorView/ItemImages/ItemImageDetailsView/ItemImageDetailsView.swift +++ b/Swiftfin/Views/ItemEditorView/ItemImages/ItemImageDetailsView/ItemImageDetailsView.swift @@ -37,6 +37,7 @@ struct ItemImageDetailsView: View { private let provider: String? private let rating: Double? private let ratingVotes: Int? + private let type: ImageType? // MARK: - Image Actions @@ -89,7 +90,8 @@ struct ItemImageDetailsView: View { List { HeaderSection( imageSource: imageSource, - posterType: height ?? 0 > width ?? 0 ? .portrait : .landscape + imageType: type, + posterType: width ?? 0 > height ?? 0 ? .landscape : .portrait ) DetailsSection( @@ -121,10 +123,14 @@ extension ItemImageDetailsView { imageInfo: ImageInfo ) { self.viewModel = viewModel - self.imageSource = imageInfo.itemImageSource( - itemID: viewModel.item.id!, - client: viewModel.userSession.client - ) + self.imageSource = { + guard let itemID = viewModel.item.id, let imageSource = imageInfo.itemImageSource( + itemID: itemID, + client: viewModel.userSession.client + ) else { return .init() } + + return imageSource + }() self.index = imageInfo.imageIndex self.width = imageInfo.width self.height = imageInfo.height @@ -132,6 +138,7 @@ extension ItemImageDetailsView { self.provider = nil self.rating = nil self.ratingVotes = nil + self.type = imageInfo.imageType self.onSave = nil self.onDelete = { viewModel.send(.deleteImage(imageInfo)) @@ -153,6 +160,7 @@ extension ItemImageDetailsView { self.provider = remoteImageInfo.providerName self.rating = remoteImageInfo.communityRating self.ratingVotes = remoteImageInfo.voteCount + self.type = remoteImageInfo.type self.onSave = { viewModel.send(.setImage(remoteImageInfo)) } diff --git a/Swiftfin/Views/ItemEditorView/ItemImages/ItemImagesView.swift b/Swiftfin/Views/ItemEditorView/ItemImages/ItemImagesView.swift index 2607771c0a..a9c68e8412 100644 --- a/Swiftfin/Views/ItemEditorView/ItemImages/ItemImagesView.swift +++ b/Swiftfin/Views/ItemEditorView/ItemImages/ItemImagesView.swift @@ -92,14 +92,16 @@ struct ItemImagesView: View { @ViewBuilder private var imageView: some View { ScrollView { - ForEach(ImageType.allCases.sorted(using: \.rawValue), id: \.self) { imageType in - Section { - imageScrollView(for: imageType) - - RowDivider() - .padding(.vertical, 16) - } header: { - sectionHeader(for: imageType) + SeparatorVStack(alignment: .leading) { + Divider() + .edgePadding(.horizontal) + .padding(.vertical, 10) + } content: { + ForEach( + ImageType.allCases.sorted(using: \.rawValue), + id: \.self + ) { imageType in + imageSection(for: imageType) } } } @@ -108,26 +110,29 @@ struct ItemImagesView: View { // MARK: - Image Scroll View @ViewBuilder - private func imageScrollView(for imageType: ImageType) -> some View { + private func imageSection(for imageType: ImageType) -> some View { let images = viewModel.images[imageType] ?? [] - if images.isNotEmpty { - ScrollView(.horizontal, showsIndicators: false) { - HStack { - ForEach(images, id: \.self) { imageInfo in - imageButton(imageInfo: imageInfo) { - router.route( - to: .itemImageDetails( - viewModel: viewModel, - imageInfo: imageInfo - ) - ) - } - } - } - .edgePadding(.horizontal) - } + PosterHStack( + elements: images, + type: images.first?.preferredPosterDisplayType ?? .portrait + ) { imageInfo, _ in + router.route( + to: .itemImageDetails( + viewModel: viewModel, + imageInfo: imageInfo + ) + ) + } header: { + sectionHeader(for: imageType) } + .customEnvironment( + for: ImageInfo.self, + value: .init( + itemID: viewModel.item.id!, + client: viewModel.userSession.client + ) + ) } // MARK: - Section Header @@ -136,7 +141,7 @@ struct ItemImagesView: View { private func sectionHeader(for imageType: ImageType) -> some View { HStack { Text(imageType.displayTitle) - .font(.headline) + .accessibilityAddTraits(.isHeader) Spacer() @@ -156,44 +161,32 @@ struct ItemImagesView: View { router.route(to: .itemImageSelector(viewModel: viewModel, imageType: imageType)) } } - .font(.body) .labelStyle(.iconOnly) - .fontWeight(.semibold) .foregroundStyle(accentColor) } + .font(.title2) + .fontWeight(.semibold) .edgePadding(.horizontal) } // MARK: - Image Button - // TODO: instead of using `posterStyle`, should be sized based on - // the image type and just ignore and poster styling @ViewBuilder private func imageButton( imageInfo: ImageInfo, onSelect: @escaping () -> Void ) -> some View { Button(action: onSelect) { - ZStack { - Color.secondarySystemFill - - ImageView( - imageInfo.itemImageSource( - itemID: viewModel.item.id!, - client: viewModel.userSession.client - ) - ) - .placeholder { _ in - Image(systemName: "photo") - } - .failure { - Image(systemName: "photo") - } - .pipeline(.Swiftfin.other) - } - .posterStyle(imageInfo.height ?? 0 > imageInfo.width ?? 0 ? .portrait : .landscape) - .frame(maxHeight: 150) + PosterImage( + item: imageInfo, + type: imageInfo.preferredPosterDisplayType + ) + .pipeline(.Swiftfin.other) .posterShadow() + .customEnvironment( + for: ImageInfo.self, + value: .init(itemID: viewModel.item.id!, client: viewModel.userSession.client) + ) } } } diff --git a/Swiftfin/Views/ItemEditorView/ItemMetadata/AddItemElementView/Components/SearchResultsSection.swift b/Swiftfin/Views/ItemEditorView/ItemMetadata/AddItemElementView/Components/SearchResultsSection.swift new file mode 100644 index 0000000000..f3e4d0808d --- /dev/null +++ b/Swiftfin/Views/ItemEditorView/ItemMetadata/AddItemElementView/Components/SearchResultsSection.swift @@ -0,0 +1,111 @@ +// +// 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 JellyfinAPI +import SwiftUI + +extension AddItemElementView { + + struct SearchResultsSection: View { + + // MARK: - Element Variables + + @Binding + var name: String + @Binding + var id: String? + + // MARK: - Element Search Variables + + let type: ItemArrayElements + let population: [Element] + let isSearching: Bool + + // MARK: - Body + + var body: some View { + if name.isNotEmpty { + Section { + if population.isNotEmpty { + resultsView + .animation(.easeInOut, value: population.count) + } else if !isSearching { + noResultsView + .transition(.opacity) + .animation(.easeInOut, value: population.count) + } + } header: { + HStack { + Text(L10n.existingItems) + if isSearching { + ProgressView() + } else { + Text("-") + Text(population.count.description) + } + } + .animation(.easeInOut, value: isSearching) + } + } + } + + // MARK: - No Results View + + private var noResultsView: some View { + Text(L10n.none) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .center) + } + + // MARK: - Results View + + private var resultsView: some View { + ForEach(population, id: \.self) { result in + Button { + name = type.getName(for: result) + id = type.getId(for: result) + } label: { + labelView(result) + } + .foregroundStyle(.primary) + .disabled(name == type.getName(for: result)) + .transition(.opacity.combined(with: .move(edge: .top))) + .animation(.easeInOut, value: population.count) + } + } + + // MARK: - Label View + + @ViewBuilder + private func labelView(_ match: Element) -> some View { + switch type { + case .people: + let person = match as! BaseItemPerson + HStack { + ZStack { + Color.clear + + ImageView(person.imageSources(for: .portrait, size: .small)) + .failure { + SystemImageContentView(systemName: "person.fill") + } + } + .posterStyle(.portrait) + .frame(width: 30, height: 90) + .padding(.horizontal) + + Text(type.getName(for: match)) + .frame(maxWidth: .infinity, alignment: .leading) + } + default: + Text(type.getName(for: match)) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } +} diff --git a/Swiftfin/Views/ItemEditorView/ItemMetadata/EditItemElementView/Components/EditItemElementRow.swift b/Swiftfin/Views/ItemEditorView/ItemMetadata/EditItemElementView/Components/EditItemElementRow.swift index 7b39b74e4b..830783de14 100644 --- a/Swiftfin/Views/ItemEditorView/ItemMetadata/EditItemElementView/Components/EditItemElementRow.swift +++ b/Swiftfin/Views/ItemEditorView/ItemMetadata/EditItemElementView/Components/EditItemElementRow.swift @@ -34,15 +34,16 @@ extension EditItemElementView { // MARK: - Body var body: some View { - ListRow { + ListRow( + insets: .init(horizontal: EdgeInsets.edgePadding), + action: onSelect + ) { if type == .people { personImage } } content: { rowContent } - .onSelect(perform: onSelect) - .isSeparatorVisible(false) .swipeActions { Button( L10n.delete, @@ -102,6 +103,7 @@ extension EditItemElementView { ) .frame(width: 60) .posterShadow() + .padding(.vertical, 8) } } } diff --git a/Swiftfin/Views/ItemEditorView/ItemMetadata/EditMetadataView/Components/Sections/MediaFormatSection.swift b/Swiftfin/Views/ItemEditorView/ItemMetadata/EditMetadataView/Components/Sections/MediaFormatSection.swift index 09a45ef61d..9dfb7b7bd6 100644 --- a/Swiftfin/Views/ItemEditorView/ItemMetadata/EditMetadataView/Components/Sections/MediaFormatSection.swift +++ b/Swiftfin/Views/ItemEditorView/ItemMetadata/EditMetadataView/Components/Sections/MediaFormatSection.swift @@ -25,10 +25,7 @@ extension EditMetadataView { format: .nilIfEmptyString ) - Video3DFormatPicker( - title: L10n.format3D, - selectedFormat: $item.video3DFormat - ) + Picker(L10n.format3D, selection: $item.video3DFormat) } } } diff --git a/Swiftfin/Views/ItemView/CollectionItemContentView.swift b/Swiftfin/Views/ItemView/CollectionItemContentView.swift deleted file mode 100644 index 075261c6a8..0000000000 --- a/Swiftfin/Views/ItemView/CollectionItemContentView.swift +++ /dev/null @@ -1,107 +0,0 @@ -// -// 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 CollectionHStack -import JellyfinAPI -import OrderedCollections -import SwiftUI - -// TODO: Show show name in episode subheader - -extension ItemView { - - struct CollectionItemContentView: View { - - typealias Element = OrderedDictionary.Elements.Element - - @Router - private var router - - @ObservedObject - var viewModel: CollectionItemViewModel - - private func episodeHStack(element: Element) -> some View { - VStack(alignment: .leading) { - - Text(L10n.episodes) - .font(.title2) - .fontWeight(.semibold) - .accessibility(addTraits: [.isHeader]) - .edgePadding(.horizontal) - - CollectionHStack( - uniqueElements: element.value.elements, - id: \.unwrappedIDHashOrZero, - columns: UIDevice.isPhone ? 1.5 : 3.5 - ) { episode in - SeriesEpisodeSelector.EpisodeCard(episode: episode) - } - .scrollBehavior(.continuousLeadingEdge) - .insets(horizontal: EdgeInsets.edgePadding) - .itemSpacing(EdgeInsets.edgePadding / 2) - } - } - - private func posterHStack(element: Element) -> some View { - PosterHStack( - title: element.key.pluralDisplayTitle, - type: element.key.preferredPosterDisplayType, - items: element.value.elements - ) { item, namespace in - router.route(to: .item(item: item), in: namespace) - } - .trailing { - SeeAllButton() - .onSelect { - router.route(to: .library(viewModel: element.value)) - } - } - } - - var body: some View { - SeparatorVStack(alignment: .leading) { - RowDivider() - .padding(.vertical, 10) - } content: { - - // MARK: - Items - - ForEach( - viewModel.sections.elements, - id: \.key - ) { element in - if element.key == .episode { - episodeHStack(element: element) - } else { - posterHStack(element: element) - } - } - - // MARK: Genres - - if let genres = viewModel.item.itemGenres, genres.isNotEmpty { - ItemView.GenresHStack(genres: genres) - } - - // MARK: Studios - - if let studios = viewModel.item.studios, studios.isNotEmpty { - ItemView.StudiosHStack(studios: studios) - } - - // MARK: Similar - - if viewModel.similarItems.isNotEmpty { - ItemView.SimilarItemsHStack(items: viewModel.similarItems) - } - - ItemView.AboutView(viewModel: viewModel) - } - } - } -} diff --git a/Swiftfin/Views/ItemView/Components/AboutView/AboutView.swift b/Swiftfin/Views/ItemView/Components/AboutView/AboutView.swift deleted file mode 100644 index 32fc534521..0000000000 --- a/Swiftfin/Views/ItemView/Components/AboutView/AboutView.swift +++ /dev/null @@ -1,148 +0,0 @@ -// -// 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 CollectionHStack -import Defaults -import IdentifiedCollections -import JellyfinAPI -import SwiftUI - -// TODO: rename `AboutItemView` -// TODO: see what to do about bottom padding -// - don't like it adds more than the edge -// - just have this determine bottom padding -// instead of scrollviews? - -extension ItemView { - - struct AboutView: View { - - private enum AboutViewItem: Identifiable { - case image - case overview - case mediaSource(MediaSourceInfo) - case ratings - - var id: String? { - switch self { - case .image: - "image" - case .overview: - "overview" - case let .mediaSource(source): - source.id - case .ratings: - "ratings" - } - } - } - - @ObservedObject - var viewModel: ItemViewModel - - @State - private var contentSize: CGSize = .zero - - private var items: [AboutViewItem] { - var items: [AboutViewItem] = [ - .image, - .overview, - ] - - if let mediaSources = viewModel.item.mediaSources { - items.append(contentsOf: mediaSources.map { AboutViewItem.mediaSource($0) }) - } - - if viewModel.item.hasRatings { - items.append(.ratings) - } - - return items - } - - init(viewModel: ItemViewModel) { - self.viewModel = viewModel - } - - // TODO: break out into a general solution for general use? - // use similar math from CollectionHStack - private var padImageWidth: CGFloat { - let portraitMinWidth: CGFloat = 140 - let contentWidth = contentSize.width - let usableWidth = contentWidth - EdgeInsets.edgePadding * 2 - var columns = CGFloat(Int(usableWidth / portraitMinWidth)) - let preItemSpacing = (columns - 1) * (EdgeInsets.edgePadding / 2) - let preTotalNegative = EdgeInsets.edgePadding * 2 + preItemSpacing - - if columns * portraitMinWidth + preTotalNegative > contentWidth { - columns -= 1 - } - - let itemSpacing = (columns - 1) * (EdgeInsets.edgePadding / 2) - let totalNegative = EdgeInsets.edgePadding * 2 + itemSpacing - let itemWidth = (contentWidth - totalNegative) / columns - - return max(0, itemWidth) - } - - private var phoneImageWidth: CGFloat { - let contentWidth = contentSize.width - let usableWidth = contentWidth - EdgeInsets.edgePadding * 2 - let itemSpacing = (EdgeInsets.edgePadding / 2) * 2 - let itemWidth = (usableWidth - itemSpacing) / 3 - - return max(0, itemWidth) - } - - private var cardSize: CGSize { - let height = UIDevice.isPad ? padImageWidth * 3 / 2 : phoneImageWidth * 3 / 2 - let width = height * 1.65 - - return CGSize(width: width, height: height) - } - - var body: some View { - VStack(alignment: .leading) { - Text(L10n.about) - .font(.title2) - .fontWeight(.bold) - .accessibility(addTraits: [.isHeader]) - .edgePadding(.horizontal) - - CollectionHStack( - uniqueElements: items, - variadicWidths: true - ) { item in - switch item { - case .image: - ImageCard(viewModel: viewModel) - .frame(width: UIDevice.isPad ? padImageWidth : phoneImageWidth) - case .overview: - OverviewCard(item: viewModel.item) - .frame(width: cardSize.width, height: cardSize.height) - case let .mediaSource(source): - MediaSourcesCard( - subtitle: (viewModel.item.mediaSources ?? []).count > 1 ? source.displayTitle : nil, - source: source - ) - .frame(width: cardSize.width, height: cardSize.height) - case .ratings: - RatingsCard(item: viewModel.item) - .frame(width: cardSize.width, height: cardSize.height) - } - } - .clipsToBounds(false) - .insets(horizontal: EdgeInsets.edgePadding) - .itemSpacing(EdgeInsets.edgePadding / 2) - .scrollBehavior(.continuousLeadingEdge) - } - .trackingSize($contentSize) - .id(viewModel.item.hashValue) - } - } -} diff --git a/Swiftfin/Views/ItemView/Components/AboutView/Components/AboutView+Card.swift b/Swiftfin/Views/ItemView/Components/AboutView/Components/AboutView+Card.swift deleted file mode 100644 index 261ec235bc..0000000000 --- a/Swiftfin/Views/ItemView/Components/AboutView/Components/AboutView+Card.swift +++ /dev/null @@ -1,64 +0,0 @@ -// -// 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 SwiftUI - -extension ItemView.AboutView { - - struct Card: View { - - private var action: () -> Void - private var content: Content - private let title: String - private let subtitle: String? - - init( - title: String, - subtitle: String? = nil, - action: @escaping () -> Void, - @ViewBuilder content: @escaping () -> Content - ) { - self.title = title - self.subtitle = subtitle - self.action = action - self.content = content() - } - - var body: some View { - Button(action: action) { - ZStack(alignment: .leading) { - - Rectangle() - .fill(Color.systemFill) - .cornerRadius(ratio: 1 / 45, of: \.height) - - VStack(alignment: .leading, spacing: 5) { - Text(title) - .font(.title2) - .fontWeight(.semibold) - .lineLimit(2) - .fixedSize(horizontal: false, vertical: true) - - if let subtitle { - Text(subtitle) - .font(.subheadline) - .foregroundStyle(.secondary) - .lineLimit(2) - .fixedSize(horizontal: false, vertical: true) - } - - content - .frame(maxHeight: .infinity, alignment: .bottomLeading) - } - .padding() - } - } - .buttonStyle(.plain) - } - } -} diff --git a/Swiftfin/Views/ItemView/Components/AboutView/Components/ImageCard.swift b/Swiftfin/Views/ItemView/Components/AboutView/Components/ImageCard.swift deleted file mode 100644 index acbfaefe7b..0000000000 --- a/Swiftfin/Views/ItemView/Components/AboutView/Components/ImageCard.swift +++ /dev/null @@ -1,54 +0,0 @@ -// -// 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 Defaults -import JellyfinAPI -import SwiftUI - -extension ItemView.AboutView { - - struct ImageCard: View { - - // MARK: - Environment & Observed Objects - - @Router - private var router - - @ObservedObject - var viewModel: ItemViewModel - - // MARK: - Body - - var body: some View { - PosterButton( - item: viewModel.item, - type: .portrait, - action: action - ) { - EmptyView() - } - .posterOverlay(for: BaseItemDto.self) { _ in - EmptyView() - } - } - - // Switch case to allow other funcitonality if we need to expand this beyond episode > series - private func action(namespace: Namespace.ID) { - switch viewModel.item.type { - case .episode: - if let episodeViewModel = viewModel as? EpisodeItemViewModel, - let seriesItem = episodeViewModel.seriesItem - { - router.route(to: .item(item: seriesItem), in: namespace) - } - default: - break - } - } - } -} diff --git a/Swiftfin/Views/ItemView/Components/AboutView/Components/MediaSourcesCard.swift b/Swiftfin/Views/ItemView/Components/AboutView/Components/MediaSourcesCard.swift deleted file mode 100644 index 76bfaae4d2..0000000000 --- a/Swiftfin/Views/ItemView/Components/AboutView/Components/MediaSourcesCard.swift +++ /dev/null @@ -1,45 +0,0 @@ -// -// 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 Defaults -import JellyfinAPI -import SwiftUI - -extension ItemView.AboutView { - - struct MediaSourcesCard: View { - - @Default(.accentColor) - private var accentColor - - @Router - private var router - - let subtitle: String? - let source: MediaSourceInfo - - var body: some View { - Card(title: L10n.media, subtitle: subtitle) { - router.route(to: .mediaSourceInfo(source: source)) - } content: { - if let mediaStreams = source.mediaStreams { - VStack(alignment: .leading) { - Text(mediaStreams.compactMap(\.displayTitle).prefix(4).joined(separator: "\n")) - .font(.footnote) - - if mediaStreams.count > 4 { - Text(L10n.seeMore) - .font(.footnote) - .foregroundColor(accentColor) - } - } - } - } - } - } -} diff --git a/Swiftfin/Views/ItemView/Components/AboutView/Components/OverviewCard.swift b/Swiftfin/Views/ItemView/Components/AboutView/Components/OverviewCard.swift deleted file mode 100644 index fd2631966f..0000000000 --- a/Swiftfin/Views/ItemView/Components/AboutView/Components/OverviewCard.swift +++ /dev/null @@ -1,38 +0,0 @@ -// -// 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 JellyfinAPI -import SwiftUI - -extension ItemView.AboutView { - - struct OverviewCard: View { - - @Router - private var router - - let item: BaseItemDto - - var body: some View { - Card(title: item.displayTitle, subtitle: item.alternateTitle) { - router.route(to: .itemOverview(item: item)) - } content: { - if let overview = item.overview { - TruncatedText(overview) - .lineLimit(4) - .font(.footnote) - .allowsHitTesting(false) - } else { - Text(L10n.noOverviewAvailable) - .font(.footnote) - .foregroundColor(.secondary) - } - } - } - } -} diff --git a/Swiftfin/Views/ItemView/Components/AboutView/Components/RatingsCard.swift b/Swiftfin/Views/ItemView/Components/AboutView/Components/RatingsCard.swift deleted file mode 100644 index ac86e815fa..0000000000 --- a/Swiftfin/Views/ItemView/Components/AboutView/Components/RatingsCard.swift +++ /dev/null @@ -1,56 +0,0 @@ -// -// 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 JellyfinAPI -import SwiftUI - -extension ItemView.AboutView { - - struct RatingsCard: View { - - let item: BaseItemDto - - var body: some View { - Card(title: L10n.ratings, action: {}) { - HStack(alignment: .bottom, spacing: 20) { - if let criticRating = item.criticRating { - VStack { - Group { - if criticRating >= 60 { - Image(.tomatoFresh) - .symbolRenderingMode(.multicolor) - .foregroundStyle(.green, .red) - } else { - Image(.tomatoRotten) - .symbolRenderingMode(.monochrome) - .foregroundColor(.green) - } - } - .font(.largeTitle) - - // swiftlint:disable:next hard_coded_display_string - Text("\(criticRating, specifier: "%.0f")") - } - } - - if let communityRating = item.communityRating { - VStack { - Image(systemName: "star.fill") - .symbolRenderingMode(.multicolor) - .foregroundStyle(.yellow) - .font(.largeTitle) - - // swiftlint:disable:next hard_coded_display_string - Text("\(communityRating, specifier: "%.1f")") - } - } - } - } - } - } -} diff --git a/Swiftfin/Views/ItemView/Components/ActionButtonHStack/ActionButtonHStack.swift b/Swiftfin/Views/ItemView/Components/ActionButtonHStack/ActionButtonHStack.swift deleted file mode 100644 index a1cdb2630b..0000000000 --- a/Swiftfin/Views/ItemView/Components/ActionButtonHStack/ActionButtonHStack.swift +++ /dev/null @@ -1,122 +0,0 @@ -// -// 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 Defaults -import Factory -import JellyfinAPI -import SwiftUI - -extension ItemView { - - struct ActionButtonHStack: View { - - @Default(.accentColor) - private var accentColor - - @StoredValue(.User.enabledTrailers) - private var enabledTrailers: TrailerSelection - - @ObservedObject - private var viewModel: ItemViewModel - - private let equalSpacing: Bool - - // MARK: - Has Trailers - - private var hasTrailers: Bool { - if enabledTrailers.contains(.local), viewModel.localTrailers.isNotEmpty { - return true - } - - if enabledTrailers.contains(.external), viewModel.item.remoteTrailers?.isNotEmpty == true { - return true - } - - return false - } - - // MARK: - Initializer - - init(viewModel: ItemViewModel, equalSpacing: Bool = true) { - self.viewModel = viewModel - self.equalSpacing = equalSpacing - } - - // MARK: - Body - - var body: some View { - HStack(alignment: .center, spacing: 10) { - - if viewModel.item.canBePlayed { - - // MARK: - Toggle Played - - let isCheckmarkSelected = viewModel.item.userData?.isPlayed == true - - Button(L10n.played, systemImage: "checkmark") { - viewModel.send(.toggleIsPlayed) - } - .buttonStyle(.tintedMaterial(tint: .jellyfinPurple, foregroundColor: .white)) - .isSelected(isCheckmarkSelected) - .frame(maxWidth: .infinity) - .if(!equalSpacing) { view in - view.aspectRatio(1, contentMode: .fit) - } - } - - // MARK: - Toggle Favorite - - let isHeartSelected = viewModel.item.userData?.isFavorite == true - - Button(L10n.favorite, systemImage: isHeartSelected ? "heart.fill" : "heart") { - viewModel.send(.toggleIsFavorite) - } - .buttonStyle(.tintedMaterial(tint: .red, foregroundColor: .white)) - .isSelected(isHeartSelected) - .frame(maxWidth: .infinity) - .if(!equalSpacing) { view in - view.aspectRatio(1, contentMode: .fit) - } - - // MARK: - Select a Version - - if let mediaSources = viewModel.playButtonItem?.mediaSources, - mediaSources.count > 1 - { - VersionMenu( - viewModel: viewModel, - mediaSources: mediaSources - ) - .menuStyle(.button) - .frame(maxWidth: .infinity) - .if(!equalSpacing) { view in - view.aspectRatio(1, contentMode: .fit) - } - } - - // MARK: - Watch a Trailer - - if hasTrailers { - TrailerMenu( - localTrailers: viewModel.localTrailers, - externalTrailers: viewModel.item.remoteTrailers ?? [] - ) - .menuStyle(.button) - .frame(maxWidth: .infinity) - .if(!equalSpacing) { view in - view.aspectRatio(1, contentMode: .fit) - } - } - } - .font(.title3) - .fontWeight(.semibold) - .buttonStyle(.material) - .labelStyle(.iconOnly) - } - } -} diff --git a/Swiftfin/Views/ItemView/Components/ActionButtonHStack/Components/TrailerMenu.swift b/Swiftfin/Views/ItemView/Components/ActionButtonHStack/Components/TrailerMenu.swift deleted file mode 100644 index ebe3d4c49c..0000000000 --- a/Swiftfin/Views/ItemView/Components/ActionButtonHStack/Components/TrailerMenu.swift +++ /dev/null @@ -1,136 +0,0 @@ -// -// 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 Factory -import JellyfinAPI -import Logging -import SwiftUI - -extension ItemView { - - struct TrailerMenu: View { - - // MARK: - Stored Value - - @StoredValue(.User.enabledTrailers) - private var enabledTrailers: TrailerSelection - - // MARK: - Observed & Envirnoment Objects - - @Router - private var router - - // MARK: - Error State - - @State - private var error: Error? - - let localTrailers: [BaseItemDto] - let externalTrailers: [MediaURL] - private let logger = Logger.swiftfin() - - private var showLocalTrailers: Bool { - enabledTrailers.contains(.local) && localTrailers.isNotEmpty - } - - private var showExternalTrailers: Bool { - enabledTrailers.contains(.external) && externalTrailers.isNotEmpty - } - - // MARK: - Body - - var body: some View { - Group { - switch localTrailers.count + externalTrailers.count { - case 1: - trailerButton - default: - trailerMenu - } - } - .errorMessage($error) - } - - // MARK: - Single Trailer Button - - @ViewBuilder - private var trailerButton: some View { - Button( - L10n.trailers, - systemImage: "movieclapper" - ) { - if showLocalTrailers, let firstTrailer = localTrailers.first { - playLocalTrailer(firstTrailer) - } - - if showExternalTrailers, let firstTrailer = externalTrailers.first { - playExternalTrailer(firstTrailer) - } - } - } - - // MARK: - Multiple Trailers Menu Button - - @ViewBuilder - private var trailerMenu: some View { - Menu(L10n.trailers, systemImage: "movieclapper") { - - if showLocalTrailers { - Section(L10n.local) { - ForEach(localTrailers) { trailer in - Button( - trailer.name ?? L10n.trailer, - systemImage: "play.fill" - ) { - playLocalTrailer(trailer) - } - } - } - } - - if showExternalTrailers { - Section(L10n.external) { - ForEach(externalTrailers, id: \.self) { mediaURL in - Button( - mediaURL.name ?? L10n.trailer, - systemImage: "arrow.up.forward" - ) { - playExternalTrailer(mediaURL) - } - } - } - } - } - } - - // MARK: - Play: Local Trailer - - private func playLocalTrailer(_ trailer: BaseItemDto) { - if let mediaSource = trailer.mediaSources?.first { - router.route(to: .videoPlayer(item: trailer, mediaSource: mediaSource)) - } else { - logger.log(level: .error, "No media sources found") - error = ErrorMessage(L10n.unknownError) - } - } - - // MARK: - Play: External Trailer - - private func playExternalTrailer(_ trailer: MediaURL) { - if let url = URL(string: trailer.url), UIApplication.shared.canOpenURL(url) { - UIApplication.shared.open(url) { success in - guard !success else { return } - - error = ErrorMessage(L10n.unableToOpenTrailer) - } - } else { - error = ErrorMessage(L10n.unableToOpenTrailer) - } - } - } -} diff --git a/Swiftfin/Views/ItemView/Components/ActionButtonHStack/Components/VersionMenu.swift b/Swiftfin/Views/ItemView/Components/ActionButtonHStack/Components/VersionMenu.swift deleted file mode 100644 index 08b177ccb2..0000000000 --- a/Swiftfin/Views/ItemView/Components/ActionButtonHStack/Components/VersionMenu.swift +++ /dev/null @@ -1,46 +0,0 @@ -// -// 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 JellyfinAPI -import SwiftUI - -extension ItemView { - - // TODO: take in binding instead of view model - struct VersionMenu: View { - - @ObservedObject - var viewModel: ItemViewModel - - let mediaSources: [MediaSourceInfo] - - private var selectedMediaSourceBinding: Binding { - Binding( - get: { viewModel.selectedMediaSource }, - set: { newSource in - if let newSource { - viewModel.send(.selectMediaSource(newSource)) - } - } - ) - } - - // MARK: - Body - - var body: some View { - Menu(L10n.version, systemImage: "list.dash") { - Picker(L10n.version, selection: selectedMediaSourceBinding) { - ForEach(mediaSources, id: \.hashValue) { mediaSource in - Text(mediaSource.displayTitle) - .tag(mediaSource as MediaSourceInfo?) - } - } - } - } - } -} diff --git a/Swiftfin/Views/ItemView/Components/AdditionalPartsHStack.swift b/Swiftfin/Views/ItemView/Components/AdditionalPartsHStack.swift deleted file mode 100644 index b3e446b39c..0000000000 --- a/Swiftfin/Views/ItemView/Components/AdditionalPartsHStack.swift +++ /dev/null @@ -1,34 +0,0 @@ -// -// 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 JellyfinAPI -import SwiftUI - -// TODO: make queue for parts - -extension ItemView { - - struct AdditionalPartsHStack: View { - - @Router - private var router - - let items: [BaseItemDto] - - var body: some View { - PosterHStack( - title: L10n.additionalParts.localizedCapitalized, - type: .landscape, - items: items - ) { item, _ in - guard let mediaSource = item.mediaSources?.first else { return } - router.route(to: .videoPlayer(item: item, mediaSource: mediaSource)) - } - } - } -} diff --git a/Swiftfin/Views/ItemView/Components/AttributeHStack.swift b/Swiftfin/Views/ItemView/Components/AttributeHStack.swift deleted file mode 100644 index 1147c18e3f..0000000000 --- a/Swiftfin/Views/ItemView/Components/AttributeHStack.swift +++ /dev/null @@ -1,155 +0,0 @@ -// -// 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 SwiftUI - -extension ItemView { - - struct AttributesHStack: View { - - @ObservedObject - private var viewModel: ItemViewModel - - private let alignment: HorizontalAlignment - private let attributes: [ItemViewAttribute] - private let flowDirection: FlowLayout.Direction - - init( - attributes: [ItemViewAttribute], - viewModel: ItemViewModel, - alignment: HorizontalAlignment = .center, - flowDirection: FlowLayout.Direction = .up - ) { - self.viewModel = viewModel - self.alignment = alignment - self.attributes = attributes - self.flowDirection = flowDirection - } - - var body: some View { - if attributes.isNotEmpty { - FlowLayout( - alignment: alignment, - direction: flowDirection - ) { - ForEach(attributes, id: \.self) { attribute in - switch attribute { - case .ratingCritics: CriticRating() - case .ratingCommunity: CommunityRating() - case .ratingOfficial: OfficialRating() - case .videoQuality: VideoQuality() - case .audioChannels: AudioChannels() - case .subtitles: Subtitles() - } - } - } - .foregroundStyle(Color(UIColor.darkGray)) - .lineLimit(1) - } - } - - @ViewBuilder - private func CriticRating() -> some View { - if let criticRating = viewModel.item.criticRating { - AttributeBadge( - style: .outline, - // swiftlint:disable:next hard_coded_display_string - title: Text("\(criticRating, specifier: "%.0f")") - ) { - if criticRating >= 60 { - Image(.tomatoFresh) - .symbolRenderingMode(.hierarchical) - } else { - Image(.tomatoRotten) - } - } - } - } - - @ViewBuilder - private func CommunityRating() -> some View { - if let communityRating = viewModel.item.communityRating { - AttributeBadge( - style: .outline, - // swiftlint:disable:next hard_coded_display_string - title: Text("\(communityRating, specifier: "%.01f")"), - systemName: "star.fill" - ) - } - } - - @ViewBuilder - private func OfficialRating() -> some View { - if let officialRating = viewModel.item.officialRating { - AttributeBadge( - style: .outline, - title: officialRating - ) - } - } - - @ViewBuilder - private func VideoQuality() -> some View { - if let mediaStreams = viewModel.selectedMediaSource?.mediaStreams { - if mediaStreams.has4KVideo { - AttributeBadge( - style: .fill, - title: "4K" - ) - } else if mediaStreams.hasHDVideo { - AttributeBadge( - style: .fill, - title: "HD" - ) - } - if mediaStreams.hasDolbyVision { - AttributeBadge( - style: .fill, - title: "DV" - ) - } - if mediaStreams.hasHDRVideo { - AttributeBadge( - style: .fill, - title: "HDR" - ) - } - } - } - - @ViewBuilder - private func AudioChannels() -> some View { - if let mediaStreams = viewModel.selectedMediaSource?.mediaStreams { - if mediaStreams.has51AudioChannelLayout { - AttributeBadge( - style: .fill, - title: "5.1" - ) - } - if mediaStreams.has71AudioChannelLayout { - AttributeBadge( - style: .fill, - title: "7.1" - ) - } - } - } - - @ViewBuilder - private func Subtitles() -> some View { - if let mediaStreams = viewModel.selectedMediaSource?.mediaStreams, - mediaStreams.hasSubtitles - { - AttributeBadge( - style: .outline, - title: "CC" - ) - } - } - } -} diff --git a/Swiftfin/Views/ItemView/Components/CastAndCrewHStack.swift b/Swiftfin/Views/ItemView/Components/CastAndCrewHStack.swift deleted file mode 100644 index 69bb0f78cb..0000000000 --- a/Swiftfin/Views/ItemView/Components/CastAndCrewHStack.swift +++ /dev/null @@ -1,39 +0,0 @@ -// -// 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 JellyfinAPI -import SwiftUI - -extension ItemView { - - struct CastAndCrewHStack: View { - - @Router - private var router - - let people: [BaseItemPerson] - - var body: some View { - PosterHStack( - title: L10n.castAndCrew.localizedCapitalized, - type: .portrait, - items: people.filter { person in - person.type?.isSupported ?? false - } - ) { person, namespace in - router.route(to: .item(item: .init(person: person)), in: namespace) - } - .trailing { - SeeAllButton() - .onSelect { - router.route(to: .castAndCrew(people: people, itemID: nil)) - } - } - } - } -} diff --git a/Swiftfin/Views/ItemView/Components/DownloadTaskButton.swift b/Swiftfin/Views/ItemView/Components/DownloadTaskButton.swift deleted file mode 100644 index 187b68db1f..0000000000 --- a/Swiftfin/Views/ItemView/Components/DownloadTaskButton.swift +++ /dev/null @@ -1,59 +0,0 @@ -// -// 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 Factory -import JellyfinAPI -import SwiftUI - -struct DownloadTaskButton: View { - - @ObservedObject - private var downloadManager: DownloadManager - @ObservedObject - private var downloadTask: DownloadTask - - private var onSelect: (DownloadTask) -> Void - - var body: some View { - Button { - onSelect(downloadTask) - } label: { - switch downloadTask.state { - case .cancelled: - Image(systemName: "exclamationmark.circle.fill") - .foregroundColor(.red) - case .complete: - Image(systemName: "checkmark.circle.fill") - .foregroundColor(.green) - case .downloading: - EmptyView() -// CircularProgressView(progress: progress) - case .error: - Image(systemName: "exclamationmark.circle.fill") - .foregroundColor(.red) - case .ready: - Image(systemName: "arrow.down.circle") - } - } - } -} - -extension DownloadTaskButton { - - init(item: BaseItemDto) { - let downloadManager = Container.shared.downloadManager() - - self.downloadTask = downloadManager.task(for: item) ?? .init(item: item) - self.onSelect = { _ in } - self.downloadManager = downloadManager - } - - func onSelect(_ action: @escaping (DownloadTask) -> Void) -> Self { - copy(modifying: \.onSelect, with: action) - } -} diff --git a/Swiftfin/Views/ItemView/Components/EpisodeSelector/Components/EmptyCard.swift b/Swiftfin/Views/ItemView/Components/EpisodeSelector/Components/EmptyCard.swift deleted file mode 100644 index 4a2147883b..0000000000 --- a/Swiftfin/Views/ItemView/Components/EpisodeSelector/Components/EmptyCard.swift +++ /dev/null @@ -1,31 +0,0 @@ -// -// 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 SwiftUI - -extension SeriesEpisodeSelector { - - struct EmptyCard: View { - - var body: some View { - VStack(alignment: .leading) { - Color.secondarySystemFill - .opacity(0.75) - .posterStyle(.landscape) - - SeriesEpisodeSelector.EpisodeContent( - header: L10n.noResults, - subHeader: .emptyDash, - content: L10n.noEpisodesAvailable, - action: {} - ) - .disabled(true) - } - } - } -} diff --git a/Swiftfin/Views/ItemView/Components/EpisodeSelector/Components/EpisodeCard.swift b/Swiftfin/Views/ItemView/Components/EpisodeSelector/Components/EpisodeCard.swift deleted file mode 100644 index f3c0bd71f6..0000000000 --- a/Swiftfin/Views/ItemView/Components/EpisodeSelector/Components/EpisodeCard.swift +++ /dev/null @@ -1,85 +0,0 @@ -// -// 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 Defaults -import JellyfinAPI -import SwiftUI - -extension SeriesEpisodeSelector { - - struct EpisodeCard: View { - - @Default(.accentColor) - private var accentColor - @Default(.Customization.Indicators.showPlayed) - private var showPlayed - - @Namespace - private var namespace - - @Router - private var router - - let episode: BaseItemDto - - @ViewBuilder - private var overlayView: some View { - if let progressLabel = episode.progressLabel { - LandscapePosterProgressBar( - title: progressLabel, - progress: (episode.userData?.playedPercentage ?? 0) / 100 - ) - } else if episode.userData?.isPlayed ?? false, showPlayed { - WatchedIndicator(size: 25) - } - } - - private var episodeContent: String { - if episode.isUnaired { - episode.airDateLabel ?? L10n.noOverviewAvailable - } else { - episode.overview ?? L10n.noOverviewAvailable - } - } - - var body: some View { - VStack(alignment: .leading) { - Button { - router.route( - to: .videoPlayer( - item: episode, - queue: EpisodeMediaPlayerQueue(episode: episode) - ) - ) - } label: { - ImageView(episode.imageSource(.primary, maxWidth: 250)) - .failure { - SystemImageContentView(systemName: episode.systemImage) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .overlay { - overlayView - } - .contentShape(.contextMenuPreview, Rectangle()) - .backport - .matchedTransitionSource(id: "item", in: namespace) - .posterStyle(.landscape) - .posterShadow() - } - - SeriesEpisodeSelector.EpisodeContent( - header: episode.displayTitle, - subHeader: episode.episodeLocator ?? .emptyDash, - content: episodeContent - ) { - router.route(to: .item(item: episode), in: namespace) - } - } - } - } -} diff --git a/Swiftfin/Views/ItemView/Components/EpisodeSelector/Components/EpisodeContent.swift b/Swiftfin/Views/ItemView/Components/EpisodeSelector/Components/EpisodeContent.swift deleted file mode 100644 index 026c0854b7..0000000000 --- a/Swiftfin/Views/ItemView/Components/EpisodeSelector/Components/EpisodeContent.swift +++ /dev/null @@ -1,69 +0,0 @@ -// -// 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 Defaults -import SwiftUI - -extension SeriesEpisodeSelector { - - struct EpisodeContent: View { - - @Default(.accentColor) - private var accentColor - - let header: String - let subHeader: String - let content: String - let action: () -> Void - - @ViewBuilder - private var subHeaderView: some View { - Text(subHeader) - .font(.footnote) - .foregroundColor(.secondary) - .lineLimit(1) - } - - @ViewBuilder - private var headerView: some View { - Text(header) - .font(.body) - .foregroundColor(.primary) - .lineLimit(1) - .multilineTextAlignment(.leading) - .padding(.bottom, 1) - } - - @ViewBuilder - private var contentView: some View { - Text(content) - .font(.caption) - .fontWeight(.light) - .foregroundColor(.secondary) - .multilineTextAlignment(.leading) - .lineLimit(3, reservesSpace: true) - } - - var body: some View { - Button(action: action) { - VStack(alignment: .leading) { - subHeaderView - - headerView - - contentView - - Text(L10n.seeMore) - .font(.caption) - .fontWeight(.light) - .foregroundStyle(accentColor) - } - } - } - } -} diff --git a/Swiftfin/Views/ItemView/Components/EpisodeSelector/Components/EpisodeHStack.swift b/Swiftfin/Views/ItemView/Components/EpisodeSelector/Components/EpisodeHStack.swift deleted file mode 100644 index f74bd895a0..0000000000 --- a/Swiftfin/Views/ItemView/Components/EpisodeSelector/Components/EpisodeHStack.swift +++ /dev/null @@ -1,123 +0,0 @@ -// -// 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 CollectionHStack -import JellyfinAPI -import SwiftUI - -// TODO: The content/loading/error states are implemented as different CollectionHStacks because it was just easy. -// A theoretically better implementation would be a single CollectionHStack with cards that represent the state instead. -extension SeriesEpisodeSelector { - - struct EpisodeHStack: View { - - @ObservedObject - var viewModel: SeasonItemViewModel - - @State - private var didScrollToPlayButtonItem = false - - @StateObject - private var proxy = CollectionHStackProxy() - - let playButtonItem: BaseItemDto? - - private func contentView(viewModel: SeasonItemViewModel) -> some View { - CollectionHStack( - uniqueElements: viewModel.elements, - id: \.unwrappedIDHashOrZero, - columns: UIDevice.isPhone ? 1.5 : 3.5 - ) { episode in - SeriesEpisodeSelector.EpisodeCard(episode: episode) - } - .clipsToBounds(false) - .scrollBehavior(.continuousLeadingEdge) - .insets(horizontal: EdgeInsets.edgePadding) - .itemSpacing(EdgeInsets.edgePadding / 2) - .proxy(proxy) - .onFirstAppear { - guard !didScrollToPlayButtonItem else { return } - didScrollToPlayButtonItem = true - - // good enough? - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - guard let playButtonItem else { return } - proxy.scrollTo(id: playButtonItem.unwrappedIDHashOrZero, animated: false) - } - } - } - - var body: some View { - switch viewModel.state { - case .content: - if viewModel.elements.isEmpty { - EmptyHStack() - } else { - contentView(viewModel: viewModel) - } - case let .error(error): - ErrorHStack(viewModel: viewModel, error: error) - case .initial, .refreshing: - LoadingHStack() - } - } - } - - struct EmptyHStack: View { - - var body: some View { - CollectionHStack( - count: 1, - columns: UIDevice.isPhone ? 1.5 : 3.5 - ) { _ in - SeriesEpisodeSelector.EmptyCard() - } - .insets(horizontal: EdgeInsets.edgePadding) - .itemSpacing(EdgeInsets.edgePadding / 2) - .scrollDisabled(true) - } - } - - // TODO: better refresh design - struct ErrorHStack: View { - - @ObservedObject - var viewModel: SeasonItemViewModel - - let error: ErrorMessage - - var body: some View { - CollectionHStack( - count: 1, - columns: UIDevice.isPhone ? 1.5 : 3.5 - ) { _ in - SeriesEpisodeSelector.ErrorCard(error: error) { - viewModel.send(.refresh) - } - } - .insets(horizontal: EdgeInsets.edgePadding) - .itemSpacing(EdgeInsets.edgePadding / 2) - .scrollDisabled(true) - } - } - - struct LoadingHStack: View { - - var body: some View { - CollectionHStack( - count: Int.random(in: 2 ..< 5), - columns: UIDevice.isPhone ? 1.5 : 3.5 - ) { _ in - SeriesEpisodeSelector.LoadingCard() - } - .insets(horizontal: EdgeInsets.edgePadding) - .itemSpacing(EdgeInsets.edgePadding / 2) - .scrollDisabled(true) - } - } -} diff --git a/Swiftfin/Views/ItemView/Components/EpisodeSelector/Components/ErrorCard.swift b/Swiftfin/Views/ItemView/Components/EpisodeSelector/Components/ErrorCard.swift deleted file mode 100644 index 09b7778f02..0000000000 --- a/Swiftfin/Views/ItemView/Components/EpisodeSelector/Components/ErrorCard.swift +++ /dev/null @@ -1,39 +0,0 @@ -// -// 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 SwiftUI - -extension SeriesEpisodeSelector { - - struct ErrorCard: View { - - let error: ErrorMessage - let action: () -> Void - - var body: some View { - Button(action: action) { - VStack(alignment: .leading) { - Color.secondarySystemFill - .opacity(0.75) - .posterStyle(.landscape) - .overlay { - Image(systemName: "arrow.clockwise.circle.fill") - .font(.system(size: 40)) - } - - SeriesEpisodeSelector.EpisodeContent( - header: L10n.error, - subHeader: .emptyDash, - content: error.localizedDescription, - action: action - ) - } - } - } - } -} diff --git a/Swiftfin/Views/ItemView/Components/EpisodeSelector/Components/LoadingCard.swift b/Swiftfin/Views/ItemView/Components/EpisodeSelector/Components/LoadingCard.swift deleted file mode 100644 index 8650b8e380..0000000000 --- a/Swiftfin/Views/ItemView/Components/EpisodeSelector/Components/LoadingCard.swift +++ /dev/null @@ -1,31 +0,0 @@ -// -// 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 SwiftUI - -extension SeriesEpisodeSelector { - - struct LoadingCard: View { - - var body: some View { - VStack(alignment: .leading) { - Color.secondarySystemFill - .opacity(0.75) - .posterStyle(.landscape) - - SeriesEpisodeSelector.EpisodeContent( - header: String.random(count: 10 ..< 20), - subHeader: String.random(count: 7 ..< 12), - content: String.random(count: 20 ..< 80), - action: {} - ) - .redacted(reason: .placeholder) - } - } - } -} diff --git a/Swiftfin/Views/ItemView/Components/EpisodeSelector/EpisodeSelector.swift b/Swiftfin/Views/ItemView/Components/EpisodeSelector/EpisodeSelector.swift deleted file mode 100644 index 4917766edc..0000000000 --- a/Swiftfin/Views/ItemView/Components/EpisodeSelector/EpisodeSelector.swift +++ /dev/null @@ -1,91 +0,0 @@ -// -// 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 JellyfinAPI -import SwiftUI - -struct SeriesEpisodeSelector: View { - - @ObservedObject - var viewModel: SeriesItemViewModel - - @State - private var didSelectPlayButtonSeason = false - @State - private var selection: SeasonItemViewModel.ID? - - private var selectionViewModel: SeasonItemViewModel? { - viewModel.seasons.first(where: { $0.id == selection }) - } - - @ViewBuilder - private var seasonSelectorMenu: some View { - if let seasonDisplayName = selectionViewModel?.season.displayTitle, - viewModel.seasons.count <= 1 - { - Text(seasonDisplayName) - .font(.title2) - .fontWeight(.semibold) - } else { - Menu { - ForEach(viewModel.seasons, id: \.season.id) { seasonViewModel in - Button { - selection = seasonViewModel.id - } label: { - if seasonViewModel.id == selection { - Label(seasonViewModel.season.displayTitle, systemImage: "checkmark") - } else { - Text(seasonViewModel.season.displayTitle) - } - } - } - } label: { - Label( - selectionViewModel?.season.displayTitle ?? .emptyDash, - systemImage: "chevron.down" - ) - .labelStyle(.episodeSelector) - } - } - } - - var body: some View { - VStack(alignment: .leading, spacing: 10) { - - seasonSelectorMenu - .edgePadding(.horizontal) - - Group { - if let selectionViewModel { - EpisodeHStack(viewModel: selectionViewModel, playButtonItem: viewModel.playButtonItem) - } else { - LoadingHStack() - } - } - .transition(.opacity.animation(.linear(duration: 0.1))) - } - .onReceive(viewModel.playButtonItem.publisher) { newValue in - - guard !didSelectPlayButtonSeason else { return } - didSelectPlayButtonSeason = true - - if let playButtonSeason = viewModel.seasons.first(where: { $0.id == newValue.seasonID }) { - selection = playButtonSeason.id - } else { - selection = viewModel.seasons.first?.id - } - } - .onChange(of: selection) { _ in - guard let selectionViewModel else { return } - - if selectionViewModel.state == .initial { - selectionViewModel.send(.refresh) - } - } - } -} diff --git a/Swiftfin/Views/ItemView/Components/GenresHStack.swift b/Swiftfin/Views/ItemView/Components/GenresHStack.swift deleted file mode 100644 index ad5c78df4b..0000000000 --- a/Swiftfin/Views/ItemView/Components/GenresHStack.swift +++ /dev/null @@ -1,35 +0,0 @@ -// -// 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 JellyfinAPI -import SwiftUI - -extension ItemView { - - struct GenresHStack: View { - - @Router - private var router - - let genres: [ItemGenre] - - var body: some View { - PillHStack( - title: L10n.genres, - items: genres - ).onSelect { genre in - let viewModel = ItemLibraryViewModel( - title: genre.displayTitle, - id: genre.value, - filters: .init(genres: [genre]) - ) - router.route(to: .library(viewModel: viewModel)) - } - } - } -} diff --git a/Swiftfin/Views/ItemView/Components/OffsetScrollView.swift b/Swiftfin/Views/ItemView/Components/OffsetScrollView.swift deleted file mode 100644 index ca23356183..0000000000 --- a/Swiftfin/Views/ItemView/Components/OffsetScrollView.swift +++ /dev/null @@ -1,92 +0,0 @@ -// -// 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 SwiftUI - -// TODO: given height or height ratio options - -// The fading values just "feel right" and is the same for iOS and iPadOS. -// Adjust if necessary or if a more concrete design comes along. - -extension ItemView { - - struct OffsetScrollView: View { - - @State - private var scrollViewOffset: CGFloat = 0 - @State - private var size: CGSize = .zero - @State - private var safeAreaInsets: EdgeInsets = .zero - - private let header: Header - private let overlay: Overlay - private let content: Content - private let heightRatio: CGFloat - - init( - heightRatio: CGFloat = 0, - @ViewBuilder header: @escaping () -> Header, - @ViewBuilder overlay: @escaping () -> Overlay, - @ViewBuilder content: @escaping () -> Content - ) { - self.header = header() - self.overlay = overlay() - self.content = content() - self.heightRatio = clamp(heightRatio, min: 0, max: 1) - } - - private var headerOpacity: CGFloat { - let headerHeight = headerHeight - let start = headerHeight - safeAreaInsets.top - 90 - let end = headerHeight - safeAreaInsets.top - 40 - let diff = end - start - return clamp((scrollViewOffset - start) / diff, min: 0, max: 1) - } - - private var headerHeight: CGFloat { - (size.height + safeAreaInsets.vertical) * heightRatio - } - - var body: some View { - ScrollView(showsIndicators: false) { - VStack(spacing: 0) { - AlternateLayoutView { - Color.clear - .frame(height: headerHeight, alignment: .bottom) - } content: { - overlay - .frame(height: headerHeight, alignment: .bottom) - } - .overlay { - Color.systemBackground - .opacity(headerOpacity) - } - - content - } - } - .edgesIgnoringSafeArea(.top) - .trackingSize($size, $safeAreaInsets) - .scrollViewOffset($scrollViewOffset) - .navigationBarOffset( - $scrollViewOffset, - start: headerHeight - safeAreaInsets.top - 45, - end: headerHeight - safeAreaInsets.top - 5 - ) - .backgroundParallaxHeader( - $scrollViewOffset, - height: headerHeight, - multiplier: 0.3 - ) { - header - .frame(height: headerHeight) - } - } - } -} diff --git a/Swiftfin/Views/ItemView/Components/OverviewView.swift b/Swiftfin/Views/ItemView/Components/OverviewView.swift deleted file mode 100644 index 2ae88e5d05..0000000000 --- a/Swiftfin/Views/ItemView/Components/OverviewView.swift +++ /dev/null @@ -1,90 +0,0 @@ -// -// 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 JellyfinAPI -import SwiftUI - -// TODO: have items provide labeled attributes -// TODO: don't layout `VStack` if no data - -extension ItemView { - - struct OverviewView: View { - - @Router - private var router - - let item: BaseItemDto - private var overviewLineLimit: Int? - private var taglineLineLimit: Int? - - var body: some View { - VStack(alignment: .leading, spacing: 10) { - - if let firstTagline = item.taglines?.first { - Text(firstTagline) - .font(.body) - .fontWeight(.semibold) - .multilineTextAlignment(.leading) - .lineLimit(taglineLineLimit) - } - - if let itemOverview = item.overview { - TruncatedText(itemOverview) - .onSeeMore { - router.route(to: .itemOverview(item: item)) - } - .seeMoreType(.view) - .lineLimit(overviewLineLimit) - } - - if let birthday = item.birthday?.formatted(date: .long, time: .omitted) { - LabeledContent( - L10n.born, - value: birthday - ) - } - - if let deathday = item.deathday?.formatted(date: .long, time: .omitted) { - LabeledContent( - L10n.died, - value: deathday - ) - } - - if let birthplace = item.birthplace { - LabeledContent( - L10n.birthplace, - value: birthplace - ) - } - } - .font(.footnote) - .labeledContentStyle(.itemAttribute) - } - } -} - -extension ItemView.OverviewView { - - init(item: BaseItemDto) { - self.init( - item: item, - overviewLineLimit: nil, - taglineLineLimit: nil - ) - } - - func overviewLineLimit(_ limit: Int) -> Self { - copy(modifying: \.overviewLineLimit, with: limit) - } - - func taglineLineLimit(_ limit: Int) -> Self { - copy(modifying: \.taglineLineLimit, with: limit) - } -} diff --git a/Swiftfin/Views/ItemView/Components/PlayButton.swift b/Swiftfin/Views/ItemView/Components/PlayButton.swift deleted file mode 100644 index 68e3641c70..0000000000 --- a/Swiftfin/Views/ItemView/Components/PlayButton.swift +++ /dev/null @@ -1,142 +0,0 @@ -// -// 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 Defaults -import JellyfinAPI -import Logging -import SwiftUI - -extension ItemView { - - struct PlayButton: View { - - @Default(.accentColor) - private var accentColor - - @Router - private var router - - @ObservedObject - var viewModel: ItemViewModel - - private let logger = Logger.swiftfin() - - // MARK: - Validation - - private var isEnabled: Bool { - viewModel.selectedMediaSource != nil - } - - // MARK: - Title - - private var title: String { - /// Use the Season/Episode label for the Series ItemView - if let seriesViewModel = viewModel as? SeriesItemViewModel, - let seasonEpisodeLabel = seriesViewModel.playButtonItem?.seasonEpisodeLabel - { - seasonEpisodeLabel - - /// Use a Play/Resume label for single Media Source items that are not Series - } else if let playButtonLabel = viewModel.playButtonItem?.playButtonLabel { - playButtonLabel - - /// Fallback to a generic `Play` label - } else { - L10n.play - } - } - - // MARK: - Media Source - - private var source: String? { - guard let sourceLabel = viewModel.selectedMediaSource?.displayTitle, - viewModel.item.mediaSources?.count ?? 0 > 1 - else { - return nil - } - - return sourceLabel - } - - // MARK: - Body - - var body: some View { - Button { - play() - } label: { - HStack { - Image(systemName: "play.fill") - - VStack { - Text(title) - - if let source { - Marquee(source, speed: 40, delay: 3, fade: 5) - .font(.caption) - .fontWeight(.medium) - } - } - } - .padding(.horizontal, 20) - .font(.callout) - .fontWeight(.semibold) - } - .buttonStyle( - .tintedMaterial( - tint: accentColor, - foregroundColor: accentColor.overlayColor - ) - ) - .contextMenu { - if viewModel.playButtonItem?.userData?.playbackPositionTicks != 0 { - Button(L10n.playFromBeginning, systemImage: "gobackward") { - play(fromBeginning: true) - } - } - } - .isSelected(true) - .enabled(isEnabled) - } - - // MARK: - Play Content - - private func play(fromBeginning: Bool = false) { - guard let playButtonItem = viewModel.playButtonItem, - let selectedMediaSource = viewModel.selectedMediaSource - else { - logger.error("Play selected with no item or media source") - return - } - - let queue: (any MediaPlayerQueue)? = { - if playButtonItem.type == .episode { - return EpisodeMediaPlayerQueue(episode: playButtonItem) - } - return nil - }() - - let provider = MediaPlayerItemProvider(item: playButtonItem) { item in - try await MediaPlayerItem.build( - for: item, - mediaSource: selectedMediaSource - ) { - if fromBeginning { - $0.userData?.playbackPositionTicks = 0 - } - } - } - - router.route( - to: .videoPlayer( - provider: provider, - queue: queue - ) - ) - } - } -} diff --git a/Swiftfin/Views/ItemView/Components/SimilarItemsHStack.swift b/Swiftfin/Views/ItemView/Components/SimilarItemsHStack.swift deleted file mode 100644 index 03758e1643..0000000000 --- a/Swiftfin/Views/ItemView/Components/SimilarItemsHStack.swift +++ /dev/null @@ -1,47 +0,0 @@ -// -// 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 Defaults -import JellyfinAPI -import OrderedCollections -import SwiftUI - -extension ItemView { - - struct SimilarItemsHStack: View { - - @Default(.Customization.similarPosterType) - private var similarPosterType - - @Router - private var router - - @StateObject - private var viewModel: PagingLibraryViewModel - - init(items: [BaseItemDto]) { - self._viewModel = StateObject(wrappedValue: PagingLibraryViewModel(items, parent: BaseItemDto(name: L10n.recommended))) - } - - var body: some View { - PosterHStack( - title: L10n.recommended, - type: similarPosterType, - items: viewModel.elements - ) { item, namespace in - router.route(to: .item(item: item), in: namespace) - } - .trailing { - SeeAllButton() - .onSelect { - router.route(to: .library(viewModel: viewModel)) - } - } - } - } -} diff --git a/Swiftfin/Views/ItemView/Components/SpecialFeatureHStack.swift b/Swiftfin/Views/ItemView/Components/SpecialFeatureHStack.swift deleted file mode 100644 index 9c47a23385..0000000000 --- a/Swiftfin/Views/ItemView/Components/SpecialFeatureHStack.swift +++ /dev/null @@ -1,33 +0,0 @@ -// -// 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 JellyfinAPI -import OrderedCollections -import SwiftUI - -extension ItemView { - - struct SpecialFeaturesHStack: View { - - @Router - private var router - - let items: [BaseItemDto] - - var body: some View { - PosterHStack( - title: L10n.specialFeatures, - type: .landscape, - items: items - ) { item, _ in - guard let mediaSource = item.mediaSources?.first else { return } - router.route(to: .videoPlayer(item: item, mediaSource: mediaSource)) - } - } - } -} diff --git a/Swiftfin/Views/ItemView/Components/StudiosHStack.swift b/Swiftfin/Views/ItemView/Components/StudiosHStack.swift deleted file mode 100644 index 273f2ce2fb..0000000000 --- a/Swiftfin/Views/ItemView/Components/StudiosHStack.swift +++ /dev/null @@ -1,31 +0,0 @@ -// -// 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 JellyfinAPI -import SwiftUI - -extension ItemView { - - struct StudiosHStack: View { - - @Router - private var router - - let studios: [NameGuidPair] - - var body: some View { - PillHStack( - title: L10n.studios, - items: studios - ).onSelect { studio in - let viewModel = ItemLibraryViewModel(parent: studio) - router.route(to: .library(viewModel: viewModel)) - } - } - } -} diff --git a/Swiftfin/Views/ItemView/ItemView.swift b/Swiftfin/Views/ItemView/ItemView.swift deleted file mode 100644 index 75d76000cc..0000000000 --- a/Swiftfin/Views/ItemView/ItemView.swift +++ /dev/null @@ -1,131 +0,0 @@ -// -// 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 Defaults -import JellyfinAPI -import SwiftUI - -struct ItemView: View { - - protocol ScrollContainerView: View { - - associatedtype Content: View - - init(viewModel: ItemViewModel, content: @escaping () -> Content) - } - - @Default(.Customization.itemViewType) - private var itemViewType - - @Router - private var router - - @StateObject - private var viewModel: ItemViewModel - - private static func typeViewModel(for item: BaseItemDto) -> ItemViewModel { - switch item.type { - case .boxSet, .person, .musicArtist: - return CollectionItemViewModel(item: item) - case .episode: - return EpisodeItemViewModel(item: item) - case .movie: - return MovieItemViewModel(item: item) - case .musicVideo, .video: - return ItemViewModel(item: item) - case .series: - return SeriesItemViewModel(item: item) - default: - assertionFailure("Unsupported item") - return ItemViewModel(item: item) - } - } - - init(item: BaseItemDto) { - self._viewModel = StateObject(wrappedValue: Self.typeViewModel(for: item)) - } - - @ViewBuilder - private var scrollContentView: some View { - switch viewModel.item.type { - case .boxSet, .person, .musicArtist: - CollectionItemContentView(viewModel: viewModel as! CollectionItemViewModel) - case .episode, .musicVideo, .video: - SimpleItemContentView(viewModel: viewModel) - case .movie: - MovieItemContentView(viewModel: viewModel as! MovieItemViewModel) - case .series: - SeriesItemContentView(viewModel: viewModel as! SeriesItemViewModel) - default: - Text(L10n.notImplementedYetWithType(viewModel.item.type ?? "--")) - } - } - - // TODO: break out into pad vs phone views based on item type - private func scrollContainerView( - viewModel: ItemViewModel, - content: @escaping () -> some View - ) -> any ScrollContainerView { - - if UIDevice.isPad { - return iPadOSCinematicScrollView(viewModel: viewModel, content: content) - } - - switch viewModel.item.type { - case .movie, .series: - switch itemViewType { - case .compactPoster: - return CompactPosterScrollView(viewModel: viewModel, content: content) - case .compactLogo: - return CompactLogoScrollView(viewModel: viewModel, content: content) - case .cinematic: - return CinematicScrollView(viewModel: viewModel, content: content) - } - case .person, .musicArtist: - return CompactPosterScrollView(viewModel: viewModel, content: content) - default: - return SimpleScrollView(viewModel: viewModel, content: content) - } - } - - @ViewBuilder - private var innerBody: some View { - scrollContainerView(viewModel: viewModel) { - scrollContentView - } - .eraseToAnyView() - } - - var body: some View { - ZStack { - switch viewModel.state { - case .content: - innerBody - .navigationTitle(viewModel.item.displayTitle) - case let .error(error): - ErrorView(error: error) - case .initial, .refreshing: - ProgressView() - } - } - .animation(.linear(duration: 0.1), value: viewModel.state) - .navigationBarTitleDisplayMode(.inline) - .refreshable { - viewModel.send(.refresh) - } - .onFirstAppear { - viewModel.send(.refresh) - } - .navigationBarMenuButton( - isLoading: viewModel.backgroundStates.contains(.refresh), - isHidden: !viewModel.item.showEditorMenu - ) { - ItemEditorMenu(item: viewModel.item) - } - } -} diff --git a/Swiftfin/Views/ItemView/MovieItemContentView.swift b/Swiftfin/Views/ItemView/MovieItemContentView.swift deleted file mode 100644 index 21af14c6fe..0000000000 --- a/Swiftfin/Views/ItemView/MovieItemContentView.swift +++ /dev/null @@ -1,68 +0,0 @@ -// -// 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 JellyfinAPI -import SwiftUI - -extension ItemView { - - struct MovieItemContentView: View { - - @ObservedObject - var viewModel: MovieItemViewModel - - var body: some View { - SeparatorVStack(alignment: .leading) { - RowDivider() - .padding(.vertical, 10) - } content: { - - // MARK: Genres - - if let genres = viewModel.item.itemGenres, genres.isNotEmpty { - ItemView.GenresHStack(genres: genres) - } - - // MARK: Studios - - if let studios = viewModel.item.studios, studios.isNotEmpty { - ItemView.StudiosHStack(studios: studios) - } - - // MARK: - Parts - - // TODO: Implement after part queue made - if viewModel.additionalParts.isNotEmpty { - AdditionalPartsHStack(items: viewModel.additionalParts) - } - - // MARK: Cast and Crew - - if let castAndCrew = viewModel.item.people, - castAndCrew.isNotEmpty - { - ItemView.CastAndCrewHStack(people: castAndCrew) - } - - // MARK: Special Features - - if viewModel.specialFeatures.isNotEmpty { - ItemView.SpecialFeaturesHStack(items: viewModel.specialFeatures) - } - - // MARK: Similar - - if viewModel.similarItems.isNotEmpty { - ItemView.SimilarItemsHStack(items: viewModel.similarItems) - } - - ItemView.AboutView(viewModel: viewModel) - } - } - } -} diff --git a/Swiftfin/Views/ItemView/ScrollViews/CinematicScrollView.swift b/Swiftfin/Views/ItemView/ScrollViews/CinematicScrollView.swift deleted file mode 100644 index c80e0f33a2..0000000000 --- a/Swiftfin/Views/ItemView/ScrollViews/CinematicScrollView.swift +++ /dev/null @@ -1,163 +0,0 @@ -// -// 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 Defaults -import JellyfinAPI -import SwiftUI - -extension ItemView { - - struct CinematicScrollView: ScrollContainerView { - - @Default(.Customization.CinematicItemViewType.usePrimaryImage) - private var usePrimaryImage - - @Router - private var router - - @ObservedObject - private var viewModel: ItemViewModel - - private let content: Content - - init( - viewModel: ItemViewModel, - content: @escaping () -> Content - ) { - self.content = content() - self.viewModel = viewModel - } - - private var imageType: ImageType { - usePrimaryImage ? .primary : .backdrop - } - - @ViewBuilder - private var headerView: some View { - - let bottomColor = viewModel.item.blurHash(for: imageType)?.averageLinearColor ?? Color.secondarySystemFill - - GeometryReader { proxy in - if proxy.size.height.isZero { EmptyView() } - else { - ImageView(viewModel.item.imageSource( - imageType, - maxWidth: usePrimaryImage ? proxy.size.width : 0, - maxHeight: usePrimaryImage ? 0 : proxy.size.height * 0.6 - )) - .aspectRatio(usePrimaryImage ? (2 / 3) : 1.77, contentMode: .fill) - .frame(width: proxy.size.width, height: proxy.size.height * 0.6) - .bottomEdgeGradient(bottomColor: bottomColor) - } - } - } - - var body: some View { - OffsetScrollView(heightRatio: 0.75) { - headerView - } overlay: { - OverlayView(viewModel: viewModel) - .edgePadding(.horizontal) - .edgePadding(.bottom) - .frame(maxWidth: .infinity) - .background { - BlurView(style: .systemThinMaterialDark) - .maskLinearGradient { - (location: 0, opacity: 0) - (location: 0.3, opacity: 1) - (location: 1, opacity: 1) - } - } - } content: { - content - .padding(.top, 10) - .edgePadding(.bottom) - } - } - } -} - -extension ItemView.CinematicScrollView { - - struct OverlayView: View { - - @Default(.Customization.CinematicItemViewType.usePrimaryImage) - private var usePrimaryImage - - @StoredValue(.User.itemViewAttributes) - private var attributes - - @Router - private var router - @ObservedObject - var viewModel: ItemViewModel - - var body: some View { - VStack(alignment: .leading, spacing: 10) { - VStack(alignment: .center, spacing: 10) { - if !usePrimaryImage { - ImageView(viewModel.item.imageURL(.logo, maxHeight: 100)) - .placeholder { _ in - EmptyView() - } - .failure { - MaxHeightText(text: viewModel.item.displayTitle, maxHeight: 100) - .font(.largeTitle.weight(.semibold)) - .lineLimit(2) - .multilineTextAlignment(.center) - .foregroundColor(.white) - } - .aspectRatio(contentMode: .fit) - .frame(height: 100, alignment: .bottom) - } - - DotHStack { - if let firstGenre = viewModel.item.genres?.first { - Text(firstGenre) - } - - if let premiereYear = viewModel.item.premiereDateYear { - Text(premiereYear) - } - - if let playButtonitem = viewModel.playButtonItem, let runtime = playButtonitem.runTimeLabel { - Text(runtime) - } - } - .font(.caption) - .foregroundColor(Color(UIColor.lightGray)) - .padding(.horizontal) - - Group { - if viewModel.item.presentPlayButton { - ItemView.PlayButton(viewModel: viewModel) - .frame(height: 50) - } - - ItemView.ActionButtonHStack(viewModel: viewModel) - .foregroundStyle(.white) - .frame(height: 50) - } - .frame(maxWidth: 300) - } - .frame(maxWidth: .infinity) - - ItemView.OverviewView(item: viewModel.item) - .overviewLineLimit(3) - .taglineLineLimit(2) - .foregroundColor(.white) - - ItemView.AttributesHStack( - attributes: attributes, - viewModel: viewModel, - alignment: .leading - ) - } - } - } -} diff --git a/Swiftfin/Views/ItemView/ScrollViews/CompactLogoScrollView.swift b/Swiftfin/Views/ItemView/ScrollViews/CompactLogoScrollView.swift deleted file mode 100644 index f512cfe8d5..0000000000 --- a/Swiftfin/Views/ItemView/ScrollViews/CompactLogoScrollView.swift +++ /dev/null @@ -1,143 +0,0 @@ -// -// 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 SwiftUI - -extension ItemView { - - struct CompactLogoScrollView: ScrollContainerView { - - @Router - private var router - - @ObservedObject - private var viewModel: ItemViewModel - - private let content: Content - - init( - viewModel: ItemViewModel, - content: @escaping () -> Content - ) { - self.content = content() - self.viewModel = viewModel - } - - @ViewBuilder - private var headerView: some View { - - let bottomColor = viewModel.item.blurHash(for: .backdrop)?.averageLinearColor ?? Color.secondarySystemFill - - GeometryReader { proxy in - ImageView(viewModel.item.imageSource(.backdrop, maxWidth: 1320)) - .aspectRatio(1.77, contentMode: .fill) - .frame(width: proxy.size.width, height: proxy.size.height * 0.70, alignment: .top) - .bottomEdgeGradient(bottomColor: bottomColor) - } - } - - var body: some View { - OffsetScrollView(heightRatio: 0.5) { - headerView - } overlay: { - OverlayView(viewModel: viewModel) - .edgePadding(.horizontal) - .edgePadding(.bottom) - .frame(maxWidth: .infinity) - .background { - BlurView(style: .systemThinMaterialDark) - .maskLinearGradient { - (location: 0, opacity: 0) - (location: 0.3, opacity: 1) - } - } - } content: { - SeparatorVStack(alignment: .leading) { - RowDivider() - .padding(.vertical, 10) - } content: { - ItemView.OverviewView(item: viewModel.item) - .overviewLineLimit(4) - .taglineLineLimit(2) - .edgePadding(.horizontal) - .frame(maxWidth: .infinity, alignment: .leading) - - content - } - .edgePadding(.vertical) - } - } - } -} - -extension ItemView.CompactLogoScrollView { - - struct OverlayView: View { - - @StoredValue(.User.itemViewAttributes) - private var attributes - - @Router - private var router - - @ObservedObject - var viewModel: ItemViewModel - - var body: some View { - VStack(alignment: .center, spacing: 10) { - ImageView(viewModel.item.imageURL(.logo, maxHeight: 70)) - .placeholder { _ in - EmptyView() - } - .failure { - MaxHeightText(text: viewModel.item.displayTitle, maxHeight: 70) - .font(.largeTitle.weight(.semibold)) - .lineLimit(2) - .multilineTextAlignment(.center) - .foregroundColor(.white) - } - .aspectRatio(contentMode: .fit) - .frame(height: 70, alignment: .bottom) - - DotHStack { - if let firstGenre = viewModel.item.genres?.first { - Text(firstGenre) - } - - if let premiereYear = viewModel.item.premiereDateYear { - Text(premiereYear) - } - - if let playButtonitem = viewModel.playButtonItem, let runtime = playButtonitem.runTimeLabel { - Text(runtime) - } - } - .font(.caption) - .foregroundColor(Color(UIColor.lightGray)) - .padding(.horizontal) - - Group { - ItemView.AttributesHStack( - attributes: attributes, - viewModel: viewModel - ) - - if viewModel.item.presentPlayButton { - ItemView.PlayButton(viewModel: viewModel) - .frame(height: 50) - } - - ItemView.ActionButtonHStack(viewModel: viewModel) - .foregroundStyle(.white) - .frame(height: 50) - } - .frame(maxWidth: 300) - } - } - } -} diff --git a/Swiftfin/Views/ItemView/ScrollViews/CompactPortraitScrollView.swift b/Swiftfin/Views/ItemView/ScrollViews/CompactPortraitScrollView.swift deleted file mode 100644 index 72ff88c3c5..0000000000 --- a/Swiftfin/Views/ItemView/ScrollViews/CompactPortraitScrollView.swift +++ /dev/null @@ -1,196 +0,0 @@ -// -// 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 JellyfinAPI -import SwiftUI - -extension ItemView { - - struct CompactPosterScrollView: ScrollContainerView { - - @Router - private var router - - @ObservedObject - private var viewModel: ItemViewModel - - private let content: Content - - init( - viewModel: ItemViewModel, - @ViewBuilder content: @escaping () -> Content - ) { - self.content = content() - self.viewModel = viewModel - } - - private func withHeaderImageItem( - @ViewBuilder content: @escaping (ImageSource, Color) -> some View - ) -> some View { - - let item: BaseItemDto = if viewModel.item.type == .person || viewModel.item.type == .musicArtist, - let typeViewModel = viewModel as? CollectionItemViewModel, - let randomItem = typeViewModel.randomItem() - { - randomItem - } else { - viewModel.item - } - - let imageType: ImageType = item.type == .episode ? .primary : .backdrop - let bottomColor = item.blurHash(for: imageType)?.averageLinearColor ?? Color.secondarySystemFill - let imageSource = item.imageSource(imageType, maxWidth: 1320) - - return content(imageSource, bottomColor) - .id(imageSource.url?.hashValue) - .animation(.linear(duration: 0.1), value: imageSource.url?.hashValue) - } - - @ViewBuilder - private var headerView: some View { - GeometryReader { proxy in - withHeaderImageItem { imageSource, bottomColor in - ImageView(imageSource) - .aspectRatio(1.77, contentMode: .fill) - .frame(width: proxy.size.width, height: proxy.size.height * 0.78, alignment: .top) - .bottomEdgeGradient(bottomColor: bottomColor) - } - } - } - - var body: some View { - OffsetScrollView(heightRatio: 0.45) { - headerView - } overlay: { - OverlayView(viewModel: viewModel) - .edgePadding(.horizontal) - .edgePadding(.bottom) - .frame(maxWidth: .infinity) - .background { - BlurView(style: .systemThinMaterialDark) - .maskLinearGradient { - (location: 0.2, opacity: 0) - (location: 0.3, opacity: 0.5) - (location: 0.55, opacity: 1) - } - } - } content: { - SeparatorVStack(alignment: .leading) { - RowDivider() - .padding(.vertical, 10) - } content: { - ItemView.OverviewView(item: viewModel.item) - .overviewLineLimit(4) - .taglineLineLimit(2) - .edgePadding(.horizontal) - .frame(maxWidth: .infinity, alignment: .leading) - - content - } - .edgePadding(.vertical) - } - } - } -} - -// TODO: have action buttons part of the right shelf view -// - possible on leading edge instead - -extension ItemView.CompactPosterScrollView { - - struct OverlayView: View { - - @StoredValue(.User.itemViewAttributes) - private var attributes - - @Router - private var router - - @ObservedObject - var viewModel: ItemViewModel - - @ViewBuilder - private var rightShelfView: some View { - VStack(alignment: .leading) { - - Text(viewModel.item.displayTitle) - .font(.title2) - .lineLimit(2) - .fontWeight(.semibold) - .foregroundColor(.white) - - DotHStack { - if viewModel.item.type == .person { - if let birthday = viewModel.item.birthday { - Text( - birthday, - format: .age.death(viewModel.item.deathday) - ) - } - } else { - if viewModel.item.isUnaired { - if let premiereDateLabel = viewModel.item.airDateLabel { - Text(premiereDateLabel) - } - } else { - if let productionYear = viewModel.item.premiereDateYear { - Text(String(productionYear)) - } - } - - if let playButtonitem = viewModel.playButtonItem, let runtime = playButtonitem.runTimeLabel { - Text(runtime) - } - } - } - .lineLimit(1) - .font(.subheadline.weight(.medium)) - .foregroundColor(Color(UIColor.lightGray)) - - ItemView.AttributesHStack( - attributes: attributes, - viewModel: viewModel, - alignment: .leading - ) - } - } - - var body: some View { - VStack(alignment: .leading, spacing: 10) { - HStack(alignment: .bottom, spacing: 12) { - - PosterImage( - item: viewModel.item, - type: .portrait, - contentMode: .fit - ) - .environment(\.isOverComplexContent, true) - .frame(width: 130) - .accessibilityIgnoresInvertColors() - - rightShelfView - .padding(.bottom) - } - - HStack(alignment: .center) { - - if viewModel.item.presentPlayButton { - ItemView.PlayButton(viewModel: viewModel) - .frame(width: 130) - } - - Spacer() - - ItemView.ActionButtonHStack(viewModel: viewModel, equalSpacing: false) - .foregroundStyle(.white) - } - .frame(height: 45) - } - } - } -} diff --git a/Swiftfin/Views/ItemView/ScrollViews/SimpleScrollView.swift b/Swiftfin/Views/ItemView/ScrollViews/SimpleScrollView.swift deleted file mode 100644 index 36584ac967..0000000000 --- a/Swiftfin/Views/ItemView/ScrollViews/SimpleScrollView.swift +++ /dev/null @@ -1,146 +0,0 @@ -// -// 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 BlurHashKit -import JellyfinAPI -import SwiftUI - -extension ItemView { - - struct SimpleScrollView: ScrollContainerView { - - @StoredValue(.User.itemViewAttributes) - private var attributes - - @Router - private var router - - @ObservedObject - private var viewModel: ItemViewModel - - private let content: Content - - init( - viewModel: ItemViewModel, - @ViewBuilder content: () -> Content - ) { - self.content = content() - self.viewModel = viewModel - } - - @ViewBuilder - private var shelfView: some View { - VStack(alignment: .center, spacing: 10) { - if let parentTitle = viewModel.item.parentTitle { - Text(parentTitle) - .font(.headline) - .fontWeight(.semibold) - .multilineTextAlignment(.center) - .lineLimit(2) - .padding(.horizontal) - .foregroundColor(.secondary) - } - - Text(viewModel.item.displayTitle) - .font(.title2) - .fontWeight(.bold) - .multilineTextAlignment(.center) - .lineLimit(2) - .padding(.horizontal) - - DotHStack { - if let seasonEpisodeLabel = viewModel.item.seasonEpisodeLabel { - Text(seasonEpisodeLabel) - } - - if let productionYear = viewModel.item.premiereDateYear { - Text(productionYear) - } - - if let runtime = viewModel.item.runTimeLabel { - Text(runtime) - } - } - .font(.caption) - .foregroundColor(.secondary) - .padding(.horizontal) - - Group { - ItemView.AttributesHStack( - attributes: attributes, - viewModel: viewModel, - alignment: .center - ) - - if viewModel.item.presentPlayButton { - ItemView.PlayButton(viewModel: viewModel) - .frame(height: 50) - } - - ItemView.ActionButtonHStack(viewModel: viewModel) - .frame(height: 50) - } - .frame(maxWidth: 300) - } - } - - // TODO: remove and just use `PosterImage` with landscape - // after poster environment implemented - private var imageType: ImageType { - switch viewModel.item.type { - case .episode, .musicVideo, .video: - .primary - default: - .backdrop - } - } - - @ViewBuilder - private var header: some View { - VStack(alignment: .center) { - ZStack { - Rectangle() - .fill(.complexSecondary) - - ImageView(viewModel.item.imageSource(imageType, maxWidth: 600)) - .failure { - SystemImageContentView(systemName: viewModel.item.systemImage) - } - } - .frame(maxHeight: 300) - .posterStyle(.landscape) - .posterShadow() - .padding(.horizontal) - - shelfView - } - } - - var body: some View { - ScrollView(showsIndicators: false) { - VStack(alignment: .leading, spacing: 10) { - - header - - // MARK: Overview - - ItemView.OverviewView(item: viewModel.item) - .overviewLineLimit(4) - .padding(.horizontal) - - RowDivider() - - // MARK: Genres - - content - .edgePadding(.bottom) - } - } - } - } -} diff --git a/Swiftfin/Views/ItemView/ScrollViews/iPadOSCinematicScrollView.swift b/Swiftfin/Views/ItemView/ScrollViews/iPadOSCinematicScrollView.swift deleted file mode 100644 index 97b2be1110..0000000000 --- a/Swiftfin/Views/ItemView/ScrollViews/iPadOSCinematicScrollView.swift +++ /dev/null @@ -1,196 +0,0 @@ -// -// 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 JellyfinAPI -import SwiftUI - -extension ItemView { - - struct iPadOSCinematicScrollView: ScrollContainerView { - - @ObservedObject - private var viewModel: ItemViewModel - - @State - private var globalSize: CGSize = .zero - - private let content: Content - - init( - viewModel: ItemViewModel, - @ViewBuilder content: () -> Content - ) { - self.content = content() - self.viewModel = viewModel - } - - private var imageType: ImageType { - switch viewModel.item.type { - case .episode, .musicVideo, .video: - .primary - default: - .backdrop - } - } - - private func withHeaderImageItem( - @ViewBuilder content: @escaping (ImageSource, Color) -> some View - ) -> some View { - - let item: BaseItemDto = if viewModel.item.type == .person || viewModel.item.type == .musicArtist, - let typeViewModel = viewModel as? CollectionItemViewModel, - let randomItem = typeViewModel.randomItem() - { - randomItem - } else { - viewModel.item - } - - let bottomColor = item.blurHash(for: imageType)?.averageLinearColor ?? Color.secondarySystemFill - let imageSource = item.imageSource(imageType, maxWidth: 1920) - - return content(imageSource, bottomColor) - .id(imageSource.url?.hashValue) - .animation(.linear(duration: 0.1), value: imageSource.url?.hashValue) - } - - @ViewBuilder - private var headerView: some View { - withHeaderImageItem { imageSource, bottomColor in - ImageView(imageSource) - .aspectRatio(1.77, contentMode: .fill) - .bottomEdgeGradient(bottomColor: bottomColor) - } - } - - var body: some View { - OffsetScrollView( - heightRatio: globalSize.isLandscape ? 0.75 : 0.5 - ) { - headerView - } overlay: { - OverlayView(viewModel: viewModel) - .edgePadding() - .frame(maxWidth: .infinity) - .background { - BlurView(style: .systemThinMaterialDark) - .maskLinearGradient { - (location: 0.4, opacity: 0) - (location: 0.8, opacity: 1) - } - } - } content: { - content - .padding(.top, 10) - .edgePadding(.bottom) - } - .trackingSize($globalSize) - } - } -} - -extension ItemView.iPadOSCinematicScrollView { - - struct OverlayView: View { - - @StoredValue(.User.itemViewAttributes) - private var attributes - - @ObservedObject - var viewModel: ItemViewModel - - var body: some View { - GeometryReader { geometry in - HStack(alignment: .bottom) { - - VStack(alignment: .leading, spacing: 20) { - - ImageView(viewModel.item.imageSource( - .logo, - maxHeight: 130 - )) - .placeholder { _ in - EmptyView() - } - .failure { - Text(viewModel.item.displayTitle) - .font(.largeTitle) - .fontWeight(.semibold) - .lineLimit(2) - .multilineTextAlignment(.leading) - .foregroundStyle(.white) - } - .aspectRatio(contentMode: .fit) - .frame(maxWidth: geometry.size.width * 0.4, maxHeight: 130, alignment: .bottomLeading) - - ItemView.OverviewView(item: viewModel.item) - .overviewLineLimit(3) - .taglineLineLimit(2) - .foregroundStyle(.white) - - if viewModel.item.type != .person { - FlowLayout( - alignment: .leading, - direction: .down, - spacing: 30, - minRowLength: 1 - ) { - DotHStack { - if let firstGenre = viewModel.item.genres?.first { - Text(firstGenre) - } - - if let premiereYear = viewModel.item.premiereDateYear { - Text(premiereYear) - } - - if let playButtonitem = viewModel.playButtonItem, let runtime = playButtonitem.runTimeLabel { - Text(runtime) - } - } - .font(.footnote) - .foregroundStyle(Color(UIColor.lightGray)) - .fixedSize(horizontal: true, vertical: false) - - ItemView.AttributesHStack( - attributes: attributes, - viewModel: viewModel, - alignment: .leading - ) - } - } - } - .padding(.trailing, geometry.size.width * 0.05) - - Spacer() - - VStack(spacing: 10) { - if viewModel.item.type == .person || viewModel.item.type == .musicArtist { - ImageView(viewModel.item.imageSource(.primary, maxWidth: 200)) - .failure { - SystemImageContentView(systemName: viewModel.item.systemImage) - } - .posterStyle(.portrait, contentMode: .fit) - .frame(width: 200) - .accessibilityIgnoresInvertColors() - } else if viewModel.item.presentPlayButton { - ItemView.PlayButton(viewModel: viewModel) - .frame(height: 50) - } - - ItemView.ActionButtonHStack(viewModel: viewModel) - .foregroundStyle(.white) - .frame(height: 50) - } - .frame(width: 250) - } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) - } - } - } -} diff --git a/Swiftfin/Views/ItemView/SeriesItemContentView.swift b/Swiftfin/Views/ItemView/SeriesItemContentView.swift deleted file mode 100644 index 6cc30e25fa..0000000000 --- a/Swiftfin/Views/ItemView/SeriesItemContentView.swift +++ /dev/null @@ -1,67 +0,0 @@ -// -// 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 JellyfinAPI -import SwiftUI - -extension ItemView { - - struct SeriesItemContentView: View { - - @ObservedObject - var viewModel: SeriesItemViewModel - - var body: some View { - SeparatorVStack(alignment: .leading) { - RowDivider() - .padding(.vertical, 10) - } content: { - - // MARK: Episodes - - if viewModel.seasons.isNotEmpty { - SeriesEpisodeSelector(viewModel: viewModel) - } - - // MARK: Genres - - if let genres = viewModel.item.itemGenres, genres.isNotEmpty { - ItemView.GenresHStack(genres: genres) - } - - // MARK: Studios - - if let studios = viewModel.item.studios, studios.isNotEmpty { - ItemView.StudiosHStack(studios: studios) - } - - // MARK: Cast and Crew - - if let castAndCrew = viewModel.item.people, - castAndCrew.isNotEmpty - { - ItemView.CastAndCrewHStack(people: castAndCrew) - } - - // MARK: Special Features - - if viewModel.specialFeatures.isNotEmpty { - ItemView.SpecialFeaturesHStack(items: viewModel.specialFeatures) - } - - // MARK: Similar - - if viewModel.similarItems.isNotEmpty { - ItemView.SimilarItemsHStack(items: viewModel.similarItems) - } - - ItemView.AboutView(viewModel: viewModel) - } - } - } -} diff --git a/Swiftfin/Views/ItemView/SimpleItemContentView.swift b/Swiftfin/Views/ItemView/SimpleItemContentView.swift deleted file mode 100644 index 499fd9aa0f..0000000000 --- a/Swiftfin/Views/ItemView/SimpleItemContentView.swift +++ /dev/null @@ -1,49 +0,0 @@ -// -// 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 JellyfinAPI -import SwiftUI - -extension ItemView { - - struct SimpleItemContentView: View { - - @ObservedObject - var viewModel: ItemViewModel - - var body: some View { - SeparatorVStack(alignment: .leading) { - RowDivider() - .padding(.vertical, 10) - } content: { - - // MARK: Genres - - if let genres = viewModel.item.itemGenres, genres.isNotEmpty { - ItemView.GenresHStack(genres: genres) - } - - // MARK: Studios - - if let studios = viewModel.item.studios, studios.isNotEmpty { - ItemView.StudiosHStack(studios: studios) - } - - // MARK: Cast and Crew - - if let castAndCrew = viewModel.item.people, - castAndCrew.isNotEmpty - { - ItemView.CastAndCrewHStack(people: castAndCrew) - } - - ItemView.AboutView(viewModel: viewModel) - } - } - } -} diff --git a/Swiftfin/Views/PagingLibraryView/Components/LibraryRow.swift b/Swiftfin/Views/PagingLibraryView/Components/LibraryRow.swift deleted file mode 100644 index 770bca9f29..0000000000 --- a/Swiftfin/Views/PagingLibraryView/Components/LibraryRow.swift +++ /dev/null @@ -1,121 +0,0 @@ -// -// 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 JellyfinAPI -import SwiftUI - -private let landscapeMaxWidth: CGFloat = 110 -private let portraitMaxWidth: CGFloat = 60 - -extension PagingLibraryView { - - struct LibraryRow: View { - - @Namespace - private var namespace - - private let item: Element - private var action: (Namespace.ID) -> Void - private let posterType: PosterDisplayType - - init( - item: Element, - posterType: PosterDisplayType, - action: @escaping (Namespace.ID) -> Void - ) { - self.item = item - self.action = action - self.posterType = posterType - } - - @ViewBuilder - private func itemAccessoryView(item: BaseItemDto) -> some View { - DotHStack { - if item.type == .episode, let seasonEpisodeLocator = item.seasonEpisodeLabel { - Text(seasonEpisodeLocator) - } else if let premiereYear = item.premiereDateYear { - Text(premiereYear) - } - - if let runtime = item.runTimeLabel { - Text(runtime) - } - - if let officialRating = item.officialRating { - Text(officialRating) - } - } - } - - @ViewBuilder - private func personAccessoryView(person: BaseItemPerson) -> some View { - if let subtitle = person.subtitle { - Text(subtitle) - } - } - - @ViewBuilder - private var accessoryView: some View { - switch item { - case let element as BaseItemDto: - itemAccessoryView(item: element) - case let element as BaseItemPerson: - personAccessoryView(person: element) - default: - AssertionFailureView("Used an unexpected type within a `PagingLibaryView`?") - } - } - - @ViewBuilder - private var rowContent: some View { - HStack { - VStack(alignment: .leading, spacing: 5) { - Text(item.displayTitle) - .font(posterType == .landscape ? .subheadline : .callout) - .fontWeight(.semibold) - .foregroundColor(.primary) - .lineLimit(2) - .multilineTextAlignment(.leading) - - accessoryView - .font(.caption) - .foregroundColor(Color(UIColor.lightGray)) - } - - Spacer() - } - } - - @ViewBuilder - private var rowLeading: some View { - PosterImage( - item: item, - type: posterType, - contentMode: .fill - ) - .posterShadow() - .frame(width: posterType == .landscape ? landscapeMaxWidth : portraitMaxWidth) - .padding(.vertical, 8) - } - - // MARK: body - - var body: some View { - ListRow(insets: .init(horizontal: EdgeInsets.edgePadding)) { - rowLeading - } content: { - rowContent - } - .onSelect { - action(namespace) - } - .backport - .matchedTransitionSource(id: "item", in: namespace) - } - } -} diff --git a/Swiftfin/Views/PagingLibraryView/Components/LibraryViewTypeToggle.swift b/Swiftfin/Views/PagingLibraryView/Components/LibraryViewTypeToggle.swift deleted file mode 100644 index 030666a18a..0000000000 --- a/Swiftfin/Views/PagingLibraryView/Components/LibraryViewTypeToggle.swift +++ /dev/null @@ -1,97 +0,0 @@ -// -// 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 Defaults -import SwiftUI - -// TODO: rename `LibraryDisplayTypeToggle`/Section -// - change to 2 Menu's in a section with subtitle -// like on `SelectUserView`? - -extension PagingLibraryView { - - struct LibraryViewTypeToggle: View { - - @Binding - private var listColumnCount: Int - @Binding - private var posterType: PosterDisplayType - @Binding - private var viewType: LibraryDisplayType - - init( - posterType: Binding, - viewType: Binding, - listColumnCount: Binding - ) { - self._listColumnCount = listColumnCount - self._posterType = posterType - self._viewType = viewType - } - - var body: some View { - Menu { - - Section(L10n.posters) { - Button { - posterType = .landscape - } label: { - if posterType == .landscape { - Label(L10n.landscape, systemImage: "checkmark") - } else { - Label(L10n.landscape, systemImage: "rectangle") - } - } - - Button { - posterType = .portrait - } label: { - if posterType == .portrait { - Label(L10n.portrait, systemImage: "checkmark") - } else { - Label(L10n.portrait, systemImage: "rectangle.portrait") - } - } - } - - Section(L10n.layout) { - Button { - viewType = .grid - } label: { - if viewType == .grid { - Label(L10n.grid, systemImage: "checkmark") - } else { - Label(L10n.grid, systemImage: "square.grid.2x2.fill") - } - } - - Button { - viewType = .list - } label: { - if viewType == .list { - Label(L10n.list, systemImage: "checkmark") - } else { - Label(L10n.list, systemImage: "square.fill.text.grid.1x2") - } - } - } - - if viewType == .list, UIDevice.isPad { - Stepper(L10n.columnsWithCount(listColumnCount), value: $listColumnCount, in: 1 ... 3) - } - } label: { - switch viewType { - case .grid: - Label(L10n.layout, systemImage: "square.grid.2x2.fill") - case .list: - Label(L10n.layout, systemImage: "square.fill.text.grid.1x2") - } - } - } - } -} diff --git a/Swiftfin/Views/PagingLibraryView/PagingLibraryView.swift b/Swiftfin/Views/PagingLibraryView/PagingLibraryView.swift deleted file mode 100644 index d36f854289..0000000000 --- a/Swiftfin/Views/PagingLibraryView/PagingLibraryView.swift +++ /dev/null @@ -1,445 +0,0 @@ -// -// 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 Defaults -import JellyfinAPI -import Nuke -import SwiftUI - -// TODO: need to think about better design for views that may not support current library display type -// - ex: channels/albums when in portrait/landscape -// - just have the supported view embedded in a container view? -// TODO: could bottom (defaults + stored) `onChange` copies be cleaned up? -// - more could be cleaned up if there was a "switcher" property wrapper that takes two -// sources and a switch and holds the current expected value -// - or if Defaults values were moved to StoredValues and each key would return/respond to -// what values they should have -// TODO: when there are no filters sometimes navigation bar will be clear until popped back to - -/* - Note: Currently, it is a conscious decision to not have grid posters have subtitle content. - This is due to episodes, which have their `S_E_` subtitles, and these can be alongside - other items that don't have a subtitle which requires the entire library to implement - subtitle content but that doesn't look appealing. Until a solution arrives grid posters - will not have subtitle content. - There should be a solution since there are contexts where subtitles are desirable and/or - we can have subtitle content for other items. - - Note: For `rememberLayout` and `rememberSort`, there are quirks for observing changes while a - library is open and the setting has been changed. For simplicity, do not enforce observing - changes and doing proper updates since there is complexity with what "actual" settings - should be applied. - */ - -struct PagingLibraryView: View { - - @Default(.Customization.Library.enabledDrawerFilters) - private var enabledDrawerFilters - @Default(.Customization.Library.rememberLayout) - private var rememberLayout - - @Default(.Customization.Library.displayType) - private var defaultDisplayType: LibraryDisplayType - @Default(.Customization.Library.listColumnCount) - private var defaultListColumnCount: Int - @Default(.Customization.Library.posterType) - private var defaultPosterType: PosterDisplayType - - @Namespace - private var namespace - - @Router - private var router - - @State - private var layout: CollectionVGridLayout - @State - private var safeArea: EdgeInsets = .zero - - @StoredValue - private var displayType: LibraryDisplayType - @StoredValue - private var listColumnCount: Int - @StoredValue - private var posterType: PosterDisplayType - - @StateObject - private var collectionVGridProxy: CollectionVGridProxy = .init() - @StateObject - private var viewModel: PagingLibraryViewModel - - // MARK: init - - init(viewModel: PagingLibraryViewModel) { - - // have to set these properties manually to get proper initial layout - - self._displayType = StoredValue(.User.libraryDisplayType(parentID: viewModel.parent?.id)) - self._listColumnCount = StoredValue(.User.libraryListColumnCount(parentID: viewModel.parent?.id)) - self._posterType = StoredValue(.User.libraryPosterType(parentID: viewModel.parent?.id)) - - self._viewModel = StateObject(wrappedValue: viewModel) - - let defaultDisplayType = Defaults[.Customization.Library.displayType] - let defaultListColumnCount = Defaults[.Customization.Library.listColumnCount] - let defaultPosterType = Defaults[.Customization.Library.posterType] - - let displayType = StoredValues[.User.libraryDisplayType(parentID: viewModel.parent?.id)] - let listColumnCount = StoredValues[.User.libraryListColumnCount(parentID: viewModel.parent?.id)] - let posterType = StoredValues[.User.libraryPosterType(parentID: viewModel.parent?.id)] - - let initialDisplayType = Defaults[.Customization.Library.rememberLayout] ? displayType : defaultDisplayType - let initialListColumnCount = Defaults[.Customization.Library.rememberLayout] ? listColumnCount : defaultListColumnCount - let initialPosterType = Defaults[.Customization.Library.rememberLayout] ? posterType : defaultPosterType - - if UIDevice.isPhone { - layout = Self.phoneLayout( - posterType: initialPosterType, - viewType: initialDisplayType - ) - } else { - layout = Self.padLayout( - posterType: initialPosterType, - viewType: initialDisplayType, - listColumnCount: initialListColumnCount - ) - } - } - - // MARK: onSelect - - private func onSelect(_ element: Element, in namespace: Namespace.ID) { - switch element { - case let element as BaseItemDto: - select(item: element, in: namespace) - case let element as BaseItemPerson: - select(item: BaseItemDto(person: element), in: namespace) - default: - assertionFailure("Used an unexpected type within a `PagingLibaryView`?") - } - } - - private func select(item: BaseItemDto, in namespace: Namespace.ID) { - switch item.type { - case .collectionFolder, .folder: - let viewModel = ItemLibraryViewModel(parent: item, filters: .default) - router.route(to: .library(viewModel: viewModel), in: namespace) - default: - router.route(to: .item(item: item), in: namespace) - } - } - - // MARK: layout - - // TODO: rename old "viewType" paramter to "displayType" and sort - - private static func padLayout( - posterType: PosterDisplayType, - viewType: LibraryDisplayType, - listColumnCount: Int - ) -> CollectionVGridLayout { - switch (posterType, viewType) { - case (.landscape, .grid): - .minWidth(200) - case (.portrait, .grid), (.square, .grid): - .minWidth(150) - case (_, .list): - .columns(listColumnCount, insets: .zero, itemSpacing: 0, lineSpacing: 0) - } - } - - private static func phoneLayout( - posterType: PosterDisplayType, - viewType: LibraryDisplayType - ) -> CollectionVGridLayout { - switch (posterType, viewType) { - case (.landscape, .grid): - .columns(2) - case (.portrait, .grid): - .columns(3) - case (.square, .grid): - .columns(3) - case (_, .list): - .columns(1, insets: .zero, itemSpacing: 0, lineSpacing: 0) - } - } - - // MARK: item view - - // Note: if parent is a folders then other items will have labels, - // so an empty content view is necessary - - @ViewBuilder - private func gridItemView(item: Element, posterType: PosterDisplayType) -> some View { - PosterButton( - item: item, - type: posterType - ) { namespace in - onSelect(item, in: namespace) - } label: { - if item.showTitle { - PosterButton.TitleContentView(title: item.displayTitle) - .lineLimit(1, reservesSpace: true) - } else if viewModel.parent?.libraryType == .folder { - PosterButton.TitleContentView(title: item.displayTitle) - .lineLimit(1, reservesSpace: true) - .hidden() - } - } - } - - @ViewBuilder - private func listItemView(item: Element, posterType: PosterDisplayType) -> some View { - LibraryRow( - item: item, - posterType: posterType - ) { namespace in - onSelect(item, in: namespace) - } - } - - @ViewBuilder - private var elementsView: some View { - CollectionVGrid( - uniqueElements: viewModel.elements, - id: \.unwrappedIDHashOrZero, - layout: layout - ) { item in - let displayType = Defaults[.Customization.Library.rememberLayout] ? displayType : defaultDisplayType - let posterType = Defaults[.Customization.Library.rememberLayout] ? posterType : defaultPosterType - - switch displayType { - case .grid: - gridItemView(item: item, posterType: posterType) - case .list: - listItemView(item: item, posterType: posterType) - } - } - .onReachedBottomEdge(offset: .offset(300)) { - viewModel.send(.getNextPage) - } - .proxy(collectionVGridProxy) - .scrollIndicators(.hidden) - } - - @ViewBuilder - private var contentView: some View { - switch viewModel.state { - case .content: - if viewModel.elements.isEmpty { - ContentUnavailableView(L10n.noItems.localizedCapitalized, systemImage: "rectangle.on.rectangle.slash") - } else { - elementsView - } - case .initial, .refreshing: - ProgressView() - default: - AssertionFailureView("Expected view for unexpected state") - } - } - - // MARK: body - - // TODO: becoming too large for typechecker during development, should break up somehow - - var body: some View { - ZStack { - Color.clear - - switch viewModel.state { - case .content, .initial, .refreshing: - contentView - case let .error(error): - ErrorView(error: error) - } - } - .animation(.linear(duration: 0.1), value: viewModel.state) - .ignoresSafeArea(.all, edges: .vertical) - .letterPickerBar(filterViewModel: viewModel.filterViewModel) - .onSizeChanged { _, safeArea in - self.safeArea = safeArea - } - .navigationTitle(viewModel.parent?.displayTitle ?? "") - .navigationBarTitleDisplayMode(.inline) - .refreshable { - viewModel.send(.refresh) - } - .ifLet(viewModel.filterViewModel) { view, filterViewModel in - view.navigationBarFilterDrawer( - viewModel: filterViewModel, - types: enabledDrawerFilters - ) - } - .onChange(of: defaultDisplayType) { newValue in - guard !Defaults[.Customization.Library.rememberLayout] else { return } - - if UIDevice.isPhone { - layout = Self.phoneLayout( - posterType: defaultPosterType, - viewType: newValue - ) - } else { - layout = Self.padLayout( - posterType: defaultPosterType, - viewType: newValue, - listColumnCount: defaultListColumnCount - ) - } - } - .onChange(of: defaultListColumnCount) { newValue in - guard !Defaults[.Customization.Library.rememberLayout] else { return } - - if UIDevice.isPad { - layout = Self.padLayout( - posterType: defaultPosterType, - viewType: defaultDisplayType, - listColumnCount: newValue - ) - } - } - .onChange(of: defaultPosterType) { newValue in - guard !Defaults[.Customization.Library.rememberLayout] else { return } - - if UIDevice.isPhone { - if defaultDisplayType == .list { - collectionVGridProxy.layout() - } else { - layout = Self.phoneLayout( - posterType: newValue, - viewType: defaultDisplayType - ) - } - } else { - if defaultDisplayType == .list { - collectionVGridProxy.layout() - } else { - layout = Self.padLayout( - posterType: newValue, - viewType: defaultDisplayType, - listColumnCount: defaultListColumnCount - ) - } - } - } - .onChange(of: displayType) { newValue in - if UIDevice.isPhone { - layout = Self.phoneLayout( - posterType: posterType, - viewType: newValue - ) - } else { - layout = Self.padLayout( - posterType: posterType, - viewType: newValue, - listColumnCount: listColumnCount - ) - } - } - .onChange(of: listColumnCount) { newValue in - if UIDevice.isPad { - layout = Self.padLayout( - posterType: posterType, - viewType: displayType, - listColumnCount: newValue - ) - } - } - .onChange(of: posterType) { newValue in - if UIDevice.isPhone { - if displayType == .list { - collectionVGridProxy.layout() - } else { - layout = Self.phoneLayout( - posterType: newValue, - viewType: displayType - ) - } - } else { - if displayType == .list { - collectionVGridProxy.layout() - } else { - layout = Self.padLayout( - posterType: newValue, - viewType: displayType, - listColumnCount: listColumnCount - ) - } - } - } - .onChange(of: rememberLayout) { newValue in - let newDisplayType = newValue ? displayType : defaultDisplayType - let newListColumnCount = newValue ? listColumnCount : defaultListColumnCount - let newPosterType = newValue ? posterType : defaultPosterType - - if UIDevice.isPhone { - layout = Self.phoneLayout( - posterType: newPosterType, - viewType: newDisplayType - ) - } else { - layout = Self.padLayout( - posterType: newPosterType, - viewType: newDisplayType, - listColumnCount: newListColumnCount - ) - } - } - .onChange(of: viewModel.filterViewModel?.currentFilters) { newValue in - guard let newValue, let id = viewModel.parent?.id else { return } - - if Defaults[.Customization.Library.rememberSort] { - let newStoredFilters = StoredValues[.User.libraryFilters(parentID: id)] - .mutating(\.sortBy, with: newValue.sortBy) - .mutating(\.sortOrder, with: newValue.sortOrder) - - StoredValues[.User.libraryFilters(parentID: id)] = newStoredFilters - } - } - .onReceive(viewModel.events) { event in - switch event { - case let .gotRandomItem(item): - switch item { - case let item as BaseItemDto: - select(item: item, in: namespace) - case let item as BaseItemPerson: - select(item: BaseItemDto(person: item), in: namespace) - default: - assertionFailure("Used an unexpected type within a `PagingLibaryView`?") - } - } - } - .onFirstAppear { - if viewModel.state == .initial { - viewModel.send(.refresh) - } - } - .navigationBarMenuButton( - isLoading: viewModel.backgroundStates.contains(.gettingNextPage) - ) { - if Defaults[.Customization.Library.rememberLayout] { - LibraryViewTypeToggle( - posterType: $posterType, - viewType: $displayType, - listColumnCount: $listColumnCount - ) - } else { - LibraryViewTypeToggle( - posterType: $defaultPosterType, - viewType: $defaultDisplayType, - listColumnCount: $defaultListColumnCount - ) - } - - Button(L10n.random, systemImage: "dice.fill") { - viewModel.send(.getRandomItem) - } - .disabled(viewModel.elements.isEmpty) - } - } -} diff --git a/Swiftfin/Views/ProgramsView/Components/ProgramButtonContent.swift b/Swiftfin/Views/ProgramsView/Components/ProgramButtonContent.swift deleted file mode 100644 index 4b09ee0fb1..0000000000 --- a/Swiftfin/Views/ProgramsView/Components/ProgramButtonContent.swift +++ /dev/null @@ -1,51 +0,0 @@ -// -// 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 JellyfinAPI -import SwiftUI - -extension ProgramsView { - - struct ProgramButtonContent: View { - - let program: BaseItemDto - - var body: some View { - VStack(alignment: .leading) { - - Text(program.channelName ?? .emptyDash) - .font(.footnote.weight(.semibold)) - .foregroundColor(.primary) - .lineLimit(1, reservesSpace: true) - - Text(program.displayTitle) - .font(.footnote.weight(.regular)) - .foregroundColor(.primary) - .lineLimit(1, reservesSpace: true) - - HStack(spacing: 2) { - if let startDate = program.startDate { - Text(startDate, style: .time) - } else { - Text(String.emptyDash) - } - - Text(String.hyphen) - - if let endDate = program.endDate { - Text(endDate, style: .time) - } else { - Text(String.emptyDash) - } - } - .font(.footnote) - .foregroundStyle(.secondary) - } - } - } -} diff --git a/Swiftfin/Views/ProgramsView/Components/ProgramProgressOverlay.swift b/Swiftfin/Views/ProgramsView/Components/ProgramProgressOverlay.swift deleted file mode 100644 index f0e0a1f38d..0000000000 --- a/Swiftfin/Views/ProgramsView/Components/ProgramProgressOverlay.swift +++ /dev/null @@ -1,39 +0,0 @@ -// -// 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 JellyfinAPI -import SwiftUI - -// TODO: item-type dependent views may be more appropriate near/on -// the `PosterButton` object instead of on these larger views -extension ProgramsView { - - struct ProgramProgressOverlay: View { - - @State - private var programProgress: Double = 0.0 - - let program: BaseItemDto - private let timer = Timer.publish(every: 5, on: .main, in: .common).autoconnect() - - var body: some View { - WrappedView { - if let startDate = program.startDate, startDate < Date.now { - LandscapePosterProgressBar( - progress: program.programProgress ?? 0 - ) - } - } - .onReceive(timer) { newValue in - if let startDate = program.startDate, startDate < newValue, let duration = program.programDuration { - programProgress = newValue.timeIntervalSince(startDate) / duration - } - } - } - } -} diff --git a/Swiftfin/Views/ProgramsView/ProgramsView.swift b/Swiftfin/Views/ProgramsView/ProgramsView.swift deleted file mode 100644 index 4e36490fd7..0000000000 --- a/Swiftfin/Views/ProgramsView/ProgramsView.swift +++ /dev/null @@ -1,139 +0,0 @@ -// -// 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 JellyfinAPI -import SwiftUI - -// TODO: background refresh for programs with timer? -// TODO: find other another way to handle channels/other views? - -// Note: there are some unsafe first element accesses, but `ChannelProgram` data should always have a single program - -struct ProgramsView: View { - - @Router - private var router - - @StateObject - private var programsViewModel = ProgramsViewModel() - - @ViewBuilder - private var liveTVSectionScrollView: some View { - ScrollView(.horizontal, showsIndicators: false) { - HStack { - liveTVSectionPill( - title: L10n.channels, - systemImage: "play.square.stack" - ) { - router.route(to: .channels) - } - } - .edgePadding(.horizontal) - } - } - - // TODO: probably make own pill view - // - see if could merge with item view pills - @ViewBuilder - private func liveTVSectionPill(title: String, systemImage: String, onSelect: @escaping () -> Void) -> some View { - Button { - onSelect() - } label: { - Label(title, systemImage: systemImage) - .font(.callout.weight(.semibold)) - .foregroundColor(.primary) - .padding(8) - .background { - Color.systemFill - .cornerRadius(10) - } - } - } - - @ViewBuilder - private var contentView: some View { - ScrollView(showsIndicators: false) { - VStack(spacing: 20) { - - liveTVSectionScrollView - - if programsViewModel.hasNoResults { - ContentUnavailableView(L10n.noPrograms.localizedCapitalized, systemImage: "tv") - } - - if programsViewModel.recommended.isNotEmpty { - programsSection(title: L10n.onNow, keyPath: \.recommended) - } - - if programsViewModel.series.isNotEmpty { - programsSection(title: L10n.series, keyPath: \.series) - } - - if programsViewModel.movies.isNotEmpty { - programsSection(title: L10n.movies, keyPath: \.movies) - } - - if programsViewModel.kids.isNotEmpty { - programsSection(title: L10n.kids, keyPath: \.kids) - } - - if programsViewModel.sports.isNotEmpty { - programsSection(title: L10n.sports, keyPath: \.sports) - } - - if programsViewModel.news.isNotEmpty { - programsSection(title: L10n.news, keyPath: \.news) - } - } - } - } - - @ViewBuilder - private func programsSection( - title: String, - keyPath: KeyPath - ) -> some View { - PosterHStack( - title: title, - type: .landscape, - items: programsViewModel[keyPath: keyPath] - ) { _, _ in - // router.route( - // to: .liveVideoPlayer(manager: LiveVideoPlayerManager(program: item)) - // ) - } label: { - ProgramButtonContent(program: $0) - } - .posterOverlay(for: BaseItemDto.self) { - ProgramProgressOverlay(program: $0) - } - } - - var body: some View { - WrappedView { - switch programsViewModel.state { - case .content: - contentView - case let .error(error): - ErrorView(error: error) - case .initial, .refreshing: - ProgressView() - } - } - .navigationTitle(L10n.liveTV) - .navigationBarTitleDisplayMode(.inline) - .refreshable { - programsViewModel.send(.refresh) - } - .onFirstAppear { - if programsViewModel.state == .initial { - programsViewModel.send(.refresh) - } - } - } -} diff --git a/Swiftfin/Views/SearchView.swift b/Swiftfin/Views/SearchView.swift deleted file mode 100644 index e361c9ef5a..0000000000 --- a/Swiftfin/Views/SearchView.swift +++ /dev/null @@ -1,237 +0,0 @@ -// -// 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 Defaults -import JellyfinAPI -import SwiftUI - -// TODO: have a `SearchLibraryViewModel` that allows paging on searched items? -// TODO: implement search view result type between `PosterHStack` -// and `ListHStack` (3 row list columns)? (iOS only) -// TODO: have programs only pull recommended/current? -// - have progress overlay -struct SearchView: View { - - @Default(.Customization.Search.enabledDrawerFilters) - private var enabledDrawerFilters - @Default(.Customization.searchPosterType) - private var searchPosterType - - @FocusState - private var isSearchFocused: Bool - - @Router - private var router - - @State - private var searchQuery = "" - - @TabItemSelected - private var tabItemSelected - - @StateObject - private var viewModel = SearchViewModel(filterViewModel: .init()) - - @ViewBuilder - private var suggestionsView: some View { - VStack(spacing: 20) { - ForEach(viewModel.suggestions) { item in - Button(item.displayTitle) { - searchQuery = item.displayTitle - } - } - } - } - - @ViewBuilder - private var resultsView: some View { - ScrollView(showsIndicators: false) { - VStack(spacing: 20) { - if let movies = viewModel.items[.movie], movies.isNotEmpty { - itemsSection( - title: L10n.movies, - type: .movie, - items: movies, - posterType: searchPosterType - ) - } - - if let series = viewModel.items[.series], series.isNotEmpty { - itemsSection( - title: L10n.tvShows, - type: .series, - items: series, - posterType: searchPosterType - ) - } - - if let collections = viewModel.items[.boxSet], collections.isNotEmpty { - itemsSection( - title: L10n.collections, - type: .boxSet, - items: collections, - posterType: searchPosterType - ) - } - - if let episodes = viewModel.items[.episode], episodes.isNotEmpty { - itemsSection( - title: L10n.episodes, - type: .episode, - items: episodes, - posterType: searchPosterType - ) - } - - if let musicVideos = viewModel.items[.musicVideo], musicVideos.isNotEmpty { - itemsSection( - title: L10n.musicVideos, - type: .musicVideo, - items: musicVideos, - posterType: .landscape - ) - } - - if let videos = viewModel.items[.video], videos.isNotEmpty { - itemsSection( - title: L10n.videos, - type: .video, - items: videos, - posterType: .landscape - ) - } - - if let programs = viewModel.items[.program], programs.isNotEmpty { - itemsSection( - title: L10n.programs, - type: .program, - items: programs, - posterType: .landscape - ) - } - - if let channels = viewModel.items[.tvChannel], channels.isNotEmpty { - itemsSection( - title: L10n.channels, - type: .tvChannel, - items: channels, - posterType: .square - ) - } - - if let musicArtists = viewModel.items[.musicArtist], musicArtists.isNotEmpty { - itemsSection( - title: L10n.artists, - type: .musicArtist, - items: musicArtists, - posterType: .portrait - ) - } - - if let people = viewModel.items[.person], people.isNotEmpty { - itemsSection( - title: L10n.people, - type: .person, - items: people, - posterType: .portrait - ) - } - } - .edgePadding(.vertical) - } - } - - private func select(_ item: BaseItemDto, in namespace: Namespace.ID) { - switch item.type { - case .program, .tvChannel: - let provider = item.getPlaybackItemProvider(userSession: viewModel.userSession) - router.route(to: .videoPlayer(provider: provider)) - default: - router.route(to: .item(item: item), in: namespace) - } - } - - @ViewBuilder - private func itemsSection( - title: String, - type: BaseItemKind, - items: [BaseItemDto], - posterType: PosterDisplayType - ) -> some View { - PosterHStack( - title: title, - type: posterType, - items: items, - action: select - ) - .trailing { - SeeAllButton() - .onSelect { - let viewModel = PagingLibraryViewModel( - title: title, - id: "search-\(type.hashValue)", - items - ) - router.route(to: .library(viewModel: viewModel)) - } - } - } - - var body: some View { - ZStack { - switch viewModel.state { - case .error: - viewModel.error.map { - ErrorView(error: $0) - } - case .initial: - if viewModel.hasNoResults { - if viewModel.canSearch { - ContentUnavailableView.search - } else { - suggestionsView - } - } else { - resultsView - } - case .searching: - ProgressView() - } - } - .animation(.linear(duration: 0.2), value: viewModel.items) - .animation(.linear(duration: 0.2), value: viewModel.state) - .ignoresSafeArea(.keyboard, edges: .bottom) - .navigationTitle(L10n.search) - .navigationBarTitleDisplayMode(.inline) - .refreshable { - viewModel.search(query: searchQuery) - } - .navigationBarFilterDrawer( - viewModel: viewModel.filterViewModel, - types: enabledDrawerFilters - ) - .onFirstAppear { - viewModel.getSuggestions() - } - .onChange(of: searchQuery) { newValue in - viewModel.search(query: newValue) - } - .searchable( - text: $searchQuery, - placement: .navigationBarDrawer(displayMode: .always), - prompt: L10n.search - ) - .backport - .searchFocused($isSearchFocused) - .onReceive(tabItemSelected) { event in - if event.isRepeat, event.isRoot { - isSearchFocused = true - } - } - } -} diff --git a/Swiftfin/Views/SelectUserView/Components/AddUserListRow.swift b/Swiftfin/Views/SelectUserView/Components/AddUserListRow.swift index 150df2113a..207322d758 100644 --- a/Swiftfin/Views/SelectUserView/Components/AddUserListRow.swift +++ b/Swiftfin/Views/SelectUserView/Components/AddUserListRow.swift @@ -57,15 +57,13 @@ extension SelectUserView { @ViewBuilder private var label: some View { ListRow(insets: .init(horizontal: EdgeInsets.edgePadding)) { - rowLeading - } content: { - rowContent - } - .isSeparatorVisible(false) - .onSelect { if let selectedServer { action(selectedServer) } + } leading: { + rowLeading + } content: { + rowContent } } diff --git a/Swiftfin/Views/SelectUserView/Components/UserListRow.swift b/Swiftfin/Views/SelectUserView/Components/UserListRow.swift index 0d559a1642..8bd5c3e7b2 100644 --- a/Swiftfin/Views/SelectUserView/Components/UserListRow.swift +++ b/Swiftfin/Views/SelectUserView/Components/UserListRow.swift @@ -95,7 +95,10 @@ extension SelectUserView { } var body: some View { - ListRow(insets: .init(horizontal: EdgeInsets.edgePadding)) { + ListRow( + insets: .init(horizontal: EdgeInsets.edgePadding), + action: action + ) { UserProfileImage( userID: user.id, source: user.profileImageSource( @@ -108,7 +111,6 @@ extension SelectUserView { } content: { rowContent } - .onSelect(perform: action) .contextMenu { Button(L10n.delete, role: .destructive) { onDelete() diff --git a/Swiftfin/Views/SelectUserView/SelectUserView.swift b/Swiftfin/Views/SelectUserView/SelectUserView.swift index de11fa15e4..986c9b1a83 100644 --- a/Swiftfin/Views/SelectUserView/SelectUserView.swift +++ b/Swiftfin/Views/SelectUserView/SelectUserView.swift @@ -379,7 +379,7 @@ struct SelectUserView: View { } } .animation(.linear(duration: 0.1), value: userListDisplayType) - .environment(\.isOverComplexContent, true) + .withViewContext(.isOverComplexContent) .isEditing(isEditingUsers) .frame(maxHeight: .infinity) .mask { diff --git a/Swiftfin/Views/ServerCheckView.swift b/Swiftfin/Views/ServerCheckView.swift index f67470bd5e..6c41c5b14f 100644 --- a/Swiftfin/Views/ServerCheckView.swift +++ b/Swiftfin/Views/ServerCheckView.swift @@ -45,13 +45,7 @@ struct ServerCheckView: View { } } .topBarTrailing { - - SettingsBarButton( - server: viewModel.userSession.server, - user: viewModel.userSession.user - ) { - router.route(to: .settings) - } + SettingsBarButton() } } } diff --git a/Swiftfin/Views/VideoPlayerContainerView/PlaybackControls/Components/NavigationBar/ActionButtons/ActionButtons.swift b/Swiftfin/Views/VideoPlayerContainerView/PlaybackControls/Components/NavigationBar/ActionButtons/ActionButtons.swift index d081e2e2e1..d88a47c65d 100644 --- a/Swiftfin/Views/VideoPlayerContainerView/PlaybackControls/Components/NavigationBar/ActionButtons/ActionButtons.swift +++ b/Swiftfin/Views/VideoPlayerContainerView/PlaybackControls/Components/NavigationBar/ActionButtons/ActionButtons.swift @@ -95,7 +95,7 @@ extension VideoPlayer.PlaybackControls.NavigationBar { barActionButtons, content: view(for:) ) - .environment(\.isInMenu, true) + .withViewContext(.isInMenu) Divider() @@ -103,7 +103,7 @@ extension VideoPlayer.PlaybackControls.NavigationBar { menuActionButtons, content: view(for:) ) - .environment(\.isInMenu, true) + .withViewContext(.isInMenu) } } @@ -124,7 +124,7 @@ extension VideoPlayer.PlaybackControls.NavigationBar { menuActionButtons, content: view(for:) ) - .environment(\.isInMenu, true) + .withViewContext(.isInMenu) } } } diff --git a/Swiftfin/Views/VideoPlayerContainerView/PlaybackControls/Components/NavigationBar/ActionButtons/AudioActionButton.swift b/Swiftfin/Views/VideoPlayerContainerView/PlaybackControls/Components/NavigationBar/ActionButtons/AudioActionButton.swift index 68517ceed1..d83a5ef96b 100644 --- a/Swiftfin/Views/VideoPlayerContainerView/PlaybackControls/Components/NavigationBar/ActionButtons/AudioActionButton.swift +++ b/Swiftfin/Views/VideoPlayerContainerView/PlaybackControls/Components/NavigationBar/ActionButtons/AudioActionButton.swift @@ -12,7 +12,7 @@ extension VideoPlayer.PlaybackControls.NavigationBar.ActionButtons { struct Audio: View { - @Environment(\.isInMenu) + @ViewContextContains(.isInMenu) private var isInMenu @EnvironmentObject diff --git a/Swiftfin/Views/VideoPlayerContainerView/PlaybackControls/Components/NavigationBar/ActionButtons/AutoPlayActionButton.swift b/Swiftfin/Views/VideoPlayerContainerView/PlaybackControls/Components/NavigationBar/ActionButtons/AutoPlayActionButton.swift index 2bd1208b84..916d41cd1d 100644 --- a/Swiftfin/Views/VideoPlayerContainerView/PlaybackControls/Components/NavigationBar/ActionButtons/AutoPlayActionButton.swift +++ b/Swiftfin/Views/VideoPlayerContainerView/PlaybackControls/Components/NavigationBar/ActionButtons/AutoPlayActionButton.swift @@ -16,7 +16,7 @@ extension VideoPlayer.PlaybackControls.NavigationBar.ActionButtons { @Default(.VideoPlayer.autoPlayEnabled) private var isAutoPlayEnabled - @Environment(\.isInMenu) + @ViewContextContains(.isInMenu) private var isInMenu @EnvironmentObject diff --git a/Swiftfin/Views/VideoPlayerContainerView/PlaybackControls/Components/NavigationBar/ActionButtons/PlaybackQualityActionButton.swift b/Swiftfin/Views/VideoPlayerContainerView/PlaybackControls/Components/NavigationBar/ActionButtons/PlaybackQualityActionButton.swift index 4d21ee1db2..4d706661c8 100644 --- a/Swiftfin/Views/VideoPlayerContainerView/PlaybackControls/Components/NavigationBar/ActionButtons/PlaybackQualityActionButton.swift +++ b/Swiftfin/Views/VideoPlayerContainerView/PlaybackControls/Components/NavigationBar/ActionButtons/PlaybackQualityActionButton.swift @@ -22,7 +22,7 @@ extension VideoPlayer.PlaybackControls.NavigationBar.ActionButtons { struct PlaybackQuality: View { - @Environment(\.isInMenu) + @ViewContextContains(.isInMenu) private var isInMenu @EnvironmentObject diff --git a/Swiftfin/Views/VideoPlayerContainerView/PlaybackControls/Components/NavigationBar/ActionButtons/SubtitleActionButton.swift b/Swiftfin/Views/VideoPlayerContainerView/PlaybackControls/Components/NavigationBar/ActionButtons/SubtitleActionButton.swift index f401e0fbe3..a025e0ec03 100644 --- a/Swiftfin/Views/VideoPlayerContainerView/PlaybackControls/Components/NavigationBar/ActionButtons/SubtitleActionButton.swift +++ b/Swiftfin/Views/VideoPlayerContainerView/PlaybackControls/Components/NavigationBar/ActionButtons/SubtitleActionButton.swift @@ -12,7 +12,7 @@ extension VideoPlayer.PlaybackControls.NavigationBar.ActionButtons { struct Subtitles: View { - @Environment(\.isInMenu) + @ViewContextContains(.isInMenu) private var isInMenu @EnvironmentObject diff --git a/Swiftfin/Views/VideoPlayerContainerView/SupplementContainerView/SupplementContainerView.swift b/Swiftfin/Views/VideoPlayerContainerView/SupplementContainerView/SupplementContainerView.swift index 782826b2b2..70aea4301c 100644 --- a/Swiftfin/Views/VideoPlayerContainerView/SupplementContainerView/SupplementContainerView.swift +++ b/Swiftfin/Views/VideoPlayerContainerView/SupplementContainerView/SupplementContainerView.swift @@ -116,7 +116,7 @@ extension VideoPlayer.UIVideoPlayerContainerViewController { .animation(.linear(duration: 0.2), value: isPresentingOverlay) .animation(.linear(duration: 0.1), value: isScrubbing) .animation(.bouncy(duration: 0.3, extraBounce: 0.1), value: currentSupplements) - .environment(\.isOverComplexContent, true) + .withViewContext(.isOverComplexContent) .onReceive(manager.$supplements) { newValue in let newSupplements = IdentifiedArray( uniqueElements: newValue.map(AnyMediaPlayerSupplement.init)