diff --git a/Shared/Coordinators/SettingsCoordinator.swift b/Shared/Coordinators/SettingsCoordinator.swift index 3d75241b..2dad306f 100644 --- a/Shared/Coordinators/SettingsCoordinator.swift +++ b/Shared/Coordinators/SettingsCoordinator.swift @@ -24,8 +24,6 @@ final class SettingsCoordinator: NavigationCoordinatable { @Route(.push) var customizeViewsSettings = makeCustomizeViewsSettings @Route(.push) - var missingSettings = makeMissingSettings - @Route(.push) var about = makeAbout #if !os(tvOS) @@ -37,8 +35,7 @@ final class SettingsCoordinator: NavigationCoordinatable { @ViewBuilder func makeServerDetail() -> some View { - let viewModel = ServerDetailViewModel(server: SessionManager.main.currentLogin.server) - ServerDetailView(viewModel: viewModel) + ServerDetailView(viewModel: .init(server: SessionManager.main.currentLogin.server)) } @ViewBuilder @@ -56,11 +53,6 @@ final class SettingsCoordinator: NavigationCoordinatable { CustomizeViewsSettings() } - @ViewBuilder - func makeMissingSettings() -> some View { - MissingItemsSettingsView() - } - @ViewBuilder func makeAbout() -> some View { AboutAppView() diff --git a/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+Poster.swift b/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+Poster.swift index 542c80a2..ff3c13c4 100644 --- a/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+Poster.swift +++ b/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+Poster.swift @@ -13,7 +13,7 @@ import UIKit // MARK: PortraitPoster -extension BaseItemDto: PortraitPoster { +extension BaseItemDto: Poster { var title: String { switch type { @@ -36,7 +36,7 @@ extension BaseItemDto: PortraitPoster { var showTitle: Bool { switch type { case .episode, .series, .movie, .boxSet: - return Defaults[.showPosterLabels] + return Defaults[.Customization.showPosterLabels] default: return true } @@ -50,20 +50,19 @@ extension BaseItemDto: PortraitPoster { return imageSource(.primary, maxWidth: maxWidth) } } -} -// MARK: LandscapePoster - -extension BaseItemDto { - func landscapePosterImageSources(maxWidth: CGFloat) -> [ImageSource] { + func landscapePosterImageSources(maxWidth: CGFloat, single: Bool = false) -> [ImageSource] { switch type { case .episode: - // TODO: Set episode image preference based on defaults - return [ - seriesImageSource(.thumb, maxWidth: maxWidth), - seriesImageSource(.backdrop, maxWidth: maxWidth), - imageSource(.primary, maxWidth: maxWidth), - ] + if single || Defaults[.Customization.Episodes.useSeriesLandscapeBackdrop] { + return [imageSource(.primary, maxWidth: maxWidth)] + } else { + return [ + seriesImageSource(.thumb, maxWidth: maxWidth), + seriesImageSource(.backdrop, maxWidth: maxWidth), + imageSource(.primary, maxWidth: maxWidth), + ] + } default: return [ imageSource(.thumb, maxWidth: maxWidth), diff --git a/Shared/Extensions/JellyfinAPIExtensions/BaseItemPerson+Poster.swift b/Shared/Extensions/JellyfinAPIExtensions/BaseItemPerson+Poster.swift index 9cebba93..48eb796c 100644 --- a/Shared/Extensions/JellyfinAPIExtensions/BaseItemPerson+Poster.swift +++ b/Shared/Extensions/JellyfinAPIExtensions/BaseItemPerson+Poster.swift @@ -12,7 +12,7 @@ import UIKit // MARK: PortraitImageStackable -extension BaseItemPerson: PortraitPoster { +extension BaseItemPerson: Poster { var title: String { self.name ?? "--" @@ -43,4 +43,8 @@ extension BaseItemPerson: PortraitPoster { return ImageSource(url: url, blurHash: blurHash) } + + func landscapePosterImageSources(maxWidth: CGFloat, single: Bool) -> [ImageSource] { + [] + } } diff --git a/Shared/Extensions/ViewExtensions/ViewExtensions.swift b/Shared/Extensions/ViewExtensions/ViewExtensions.swift index 09822496..b4aa201f 100644 --- a/Shared/Extensions/ViewExtensions/ViewExtensions.swift +++ b/Shared/Extensions/ViewExtensions/ViewExtensions.swift @@ -46,12 +46,28 @@ extension View { } } + func poster(type: PosterType, width: CGFloat) -> some View { + Group { + switch type { + case .portrait: + self.portraitPoster(width: width) + case .landscape: + self.landscapePoster(width: width) + } + } + } + /// Applies Portrait Poster frame with proper corner radius ratio against the width func portraitPoster(width: CGFloat) -> some View { self.frame(width: width, height: width * 1.5) .cornerRadius((width * 1.5) / 40) } + func landscapePoster(width: CGFloat) -> some View { + self.frame(width: width, height: width / 1.77) + .cornerRadius(width / 30) + } + @inlinable func padding2(_ edges: Edge.Set = .all) -> some View { self.padding(edges) @@ -74,4 +90,8 @@ extension View { func bottomEdgeGradient(bottomColor: Color) -> some View { self.modifier(BottomEdgeGradientModifier(bottomColor: bottomColor)) } + + func posterShadow() -> some View { + self.shadow(radius: 4, y: 2) + } } diff --git a/Shared/Objects/ItemViewType.swift b/Shared/Objects/ItemViewType.swift index c94a67c4..41225289 100644 --- a/Shared/Objects/ItemViewType.swift +++ b/Shared/Objects/ItemViewType.swift @@ -14,7 +14,7 @@ enum ItemViewType: String, CaseIterable, Defaults.Serializable { case compactLogo case cinematic - var label: String { + var localizedName: String { switch self { case .compactPoster: return L10n.compactPoster diff --git a/Shared/Objects/Poster.swift b/Shared/Objects/Poster.swift index d041a4ec..6940f181 100644 --- a/Shared/Objects/Poster.swift +++ b/Shared/Objects/Poster.swift @@ -14,6 +14,9 @@ protocol Poster: Hashable { var title: String { get } var subtitle: String? { get } var showTitle: Bool { get } + + func portraitPosterImageSource(maxWidth: CGFloat) -> ImageSource + func landscapePosterImageSources(maxWidth: CGFloat, single: Bool) -> [ImageSource] } extension Poster { @@ -22,11 +25,3 @@ extension Poster { hasher.combine(subtitle) } } - -protocol PortraitPoster: Poster { - func portraitPosterImageSource(maxWidth: CGFloat) -> ImageSource -} - -protocol LandscapePoster: Poster { - func landscapePosterImageSources(maxWidth: CGFloat) -> [ImageSource] -} diff --git a/Shared/Objects/PosterType.swift b/Shared/Objects/PosterType.swift new file mode 100644 index 00000000..d27f5635 --- /dev/null +++ b/Shared/Objects/PosterType.swift @@ -0,0 +1,24 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import Defaults +import Foundation + +enum PosterType: String, CaseIterable, Defaults.Serializable { + case portrait + case landscape + + var localizedName: String { + switch self { + case .portrait: + return "Portrait" + case .landscape: + return "Landscape" + } + } +} diff --git a/Shared/SwiftfinStore/SwiftfinStoreDefaults.swift b/Shared/SwiftfinStore/SwiftfinStoreDefaults.swift index be125f6f..f87df458 100644 --- a/Shared/SwiftfinStore/SwiftfinStoreDefaults.swift +++ b/Shared/SwiftfinStore/SwiftfinStoreDefaults.swift @@ -10,96 +10,95 @@ import Defaults import Foundation import UIKit -// TODO: Refactor... +// TODO: Organize -extension SwiftfinStore { - enum Defaults { - static let generalSuite: UserDefaults = .init(suiteName: "swiftfinstore-general-defaults")! - - static let universalSuite: UserDefaults = .init(suiteName: "swiftfinstore-universal-defaults")! - } +extension UserDefaults { + static let generalSuite = UserDefaults(suiteName: "swiftfinstore-general-defaults")! + static let universalSuite = UserDefaults(suiteName: "swiftfinstore-universal-defaults")! } extension Defaults.Keys { // Universal settings - static let defaultHTTPScheme = Key("defaultHTTPScheme", default: .http, suite: SwiftfinStore.Defaults.universalSuite) - static let appAppearance = Key("appAppearance", default: .system, suite: SwiftfinStore.Defaults.universalSuite) + static let defaultHTTPScheme = Key("defaultHTTPScheme", default: .http, suite: .universalSuite) + static let appAppearance = Key("appAppearance", default: .system, suite: .universalSuite) // General settings - static let lastServerUserID = Defaults.Key("lastServerUserID", suite: SwiftfinStore.Defaults.generalSuite) - static let inNetworkBandwidth = Key("InNetworkBandwidth", default: 40_000_000, suite: SwiftfinStore.Defaults.generalSuite) - static let outOfNetworkBandwidth = Key("OutOfNetworkBandwidth", default: 40_000_000, suite: SwiftfinStore.Defaults.generalSuite) - static let isAutoSelectSubtitles = Key("isAutoSelectSubtitles", default: false, suite: SwiftfinStore.Defaults.generalSuite) - static let autoSelectSubtitlesLangCode = Key( - "AutoSelectSubtitlesLangCode", - default: "Auto", - suite: SwiftfinStore.Defaults.generalSuite - ) - static let autoSelectAudioLangCode = Key("AutoSelectAudioLangCode", default: "Auto", suite: SwiftfinStore.Defaults.generalSuite) + static let lastServerUserID = Defaults.Key("lastServerUserID", suite: .generalSuite) + static let inNetworkBandwidth = Key("InNetworkBandwidth", default: 40_000_000, suite: .generalSuite) + static let outOfNetworkBandwidth = Key("OutOfNetworkBandwidth", default: 40_000_000, suite: .generalSuite) - // Customize settings - static let showPosterLabels = Key("showPosterLabels", default: true, suite: SwiftfinStore.Defaults.generalSuite) - static let showCastAndCrew = Key("showCastAndCrew", default: true, suite: SwiftfinStore.Defaults.generalSuite) - static let showFlattenView = Key("showFlattenView", default: true, suite: SwiftfinStore.Defaults.generalSuite) - static let itemViewType = Key("itemViewType", default: .compactLogo, suite: SwiftfinStore.Defaults.generalSuite) + enum Customization { + static let showFlattenView = Key("showFlattenView", default: true, suite: .generalSuite) + static let itemViewType = Key("itemViewType", default: .compactLogo, suite: .generalSuite) + + static let showPosterLabels = Key("showPosterLabels", default: true, suite: .generalSuite) + static let nextUpPosterType = Key("nextUpPosterType", default: .portrait, suite: .generalSuite) + static let recentlyAddedPosterType = Key("recentlyAddedPosterType", default: .portrait, suite: .generalSuite) + static let latestInLibraryPosterType = Key("latestInLibraryPosterType", default: .portrait, suite: .generalSuite) + static let recommendedPosterType = Key("recommendedPosterType", default: .portrait, suite: .generalSuite) + + enum Episodes { + static let useSeriesLandscapeBackdrop = Key("useSeriesBackdrop", default: true, suite: .generalSuite) + } + } // Video player / overlay settings - static let overlayType = Key("overlayType", default: .normal, suite: SwiftfinStore.Defaults.generalSuite) - static let jumpGesturesEnabled = Key("gesturesEnabled", default: true, suite: SwiftfinStore.Defaults.generalSuite) + static let overlayType = Key("overlayType", default: .normal, suite: .generalSuite) + static let jumpGesturesEnabled = Key("gesturesEnabled", default: true, suite: .generalSuite) static let systemControlGesturesEnabled = Key( "systemControlGesturesEnabled", default: true, - suite: SwiftfinStore.Defaults.generalSuite + suite: .generalSuite ) static let playerGesturesLockGestureEnabled = Key( "playerGesturesLockGestureEnabled", default: true, - suite: SwiftfinStore.Defaults.generalSuite + suite: .generalSuite ) static let seekSlideGestureEnabled = Key( "seekSlideGestureEnabled", default: true, - suite: SwiftfinStore.Defaults.generalSuite + suite: .generalSuite ) static let videoPlayerJumpForward = Key( "videoPlayerJumpForward", default: .fifteen, - suite: SwiftfinStore.Defaults.generalSuite + suite: .generalSuite ) static let videoPlayerJumpBackward = Key( "videoPlayerJumpBackward", default: .fifteen, - suite: SwiftfinStore.Defaults.generalSuite + suite: .generalSuite ) - static let autoplayEnabled = Key("autoPlayNextItem", default: true, suite: SwiftfinStore.Defaults.generalSuite) - static let resumeOffset = Key("resumeOffset", default: false, suite: SwiftfinStore.Defaults.generalSuite) + static let autoplayEnabled = Key("autoPlayNextItem", default: true, suite: .generalSuite) + static let resumeOffset = Key("resumeOffset", default: false, suite: .generalSuite) static let subtitleFontName = Key( "subtitleFontName", default: UIFont.systemFont(ofSize: 14).fontName, - suite: SwiftfinStore.Defaults.generalSuite + suite: .generalSuite ) - static let subtitleSize = Key("subtitleSize", default: .regular, suite: SwiftfinStore.Defaults.generalSuite) + static let subtitleSize = Key("subtitleSize", default: .regular, suite: .generalSuite) // Should show video player items - static let shouldShowPlayPreviousItem = Key("shouldShowPreviousItem", default: true, suite: SwiftfinStore.Defaults.generalSuite) - static let shouldShowPlayNextItem = Key("shouldShowNextItem", default: true, suite: SwiftfinStore.Defaults.generalSuite) - static let shouldShowAutoPlay = Key("shouldShowAutoPlayNextItem", default: true, suite: SwiftfinStore.Defaults.generalSuite) + static let shouldShowPlayPreviousItem = Key("shouldShowPreviousItem", default: true, suite: .generalSuite) + static let shouldShowPlayNextItem = Key("shouldShowNextItem", default: true, suite: .generalSuite) + static let shouldShowAutoPlay = Key("shouldShowAutoPlayNextItem", default: true, suite: .generalSuite) // Should show missing seasons and episodes - static let shouldShowMissingSeasons = Key("shouldShowMissingSeasons", default: true, suite: SwiftfinStore.Defaults.generalSuite) - static let shouldShowMissingEpisodes = Key("shouldShowMissingEpisodes", default: true, suite: SwiftfinStore.Defaults.generalSuite) + static let shouldShowMissingSeasons = Key("shouldShowMissingSeasons", default: true, suite: .generalSuite) + static let shouldShowMissingEpisodes = Key("shouldShowMissingEpisodes", default: true, suite: .generalSuite) // Should show video player items in overlay menu static let shouldShowJumpButtonsInOverlayMenu = Key( "shouldShowJumpButtonsInMenu", default: true, - suite: SwiftfinStore.Defaults.generalSuite + suite: .generalSuite ) static let shouldShowChaptersInfoInBottomOverlay = Key( "shouldShowChaptersInfoInBottomOverlay", default: true, - suite: SwiftfinStore.Defaults.generalSuite + suite: .generalSuite ) // Experimental settings @@ -107,16 +106,16 @@ extension Defaults.Keys { static let syncSubtitleStateWithAdjacent = Key( "experimental.syncSubtitleState", default: false, - suite: SwiftfinStore.Defaults.generalSuite + suite: .generalSuite ) - static let forceDirectPlay = Key("forceDirectPlay", default: false, suite: SwiftfinStore.Defaults.generalSuite) - static let nativePlayer = Key("nativePlayer", default: false, suite: SwiftfinStore.Defaults.generalSuite) - static let liveTVAlphaEnabled = Key("liveTVAlphaEnabled", default: false, suite: SwiftfinStore.Defaults.generalSuite) - static let liveTVForceDirectPlay = Key("liveTVForceDirectPlay", default: false, suite: SwiftfinStore.Defaults.generalSuite) - static let liveTVNativePlayer = Key("liveTVNativePlayer", default: false, suite: SwiftfinStore.Defaults.generalSuite) + static let forceDirectPlay = Key("forceDirectPlay", default: false, suite: .generalSuite) + static let nativePlayer = Key("nativePlayer", default: false, suite: .generalSuite) + static let liveTVAlphaEnabled = Key("liveTVAlphaEnabled", default: false, suite: .generalSuite) + static let liveTVForceDirectPlay = Key("liveTVForceDirectPlay", default: false, suite: .generalSuite) + static let liveTVNativePlayer = Key("liveTVNativePlayer", default: false, suite: .generalSuite) } // tvos specific - static let downActionShowsMenu = Key("downActionShowsMenu", default: true, suite: SwiftfinStore.Defaults.generalSuite) - static let confirmClose = Key("confirmClose", default: false, suite: SwiftfinStore.Defaults.generalSuite) + static let downActionShowsMenu = Key("downActionShowsMenu", default: true, suite: .generalSuite) + static let confirmClose = Key("confirmClose", default: false, suite: .generalSuite) } diff --git a/Shared/ViewModels/BasicAppSettingsViewModel.swift b/Shared/ViewModels/BasicAppSettingsViewModel.swift index 64972b44..22378cd7 100644 --- a/Shared/ViewModels/BasicAppSettingsViewModel.swift +++ b/Shared/ViewModels/BasicAppSettingsViewModel.swift @@ -13,11 +13,11 @@ final class BasicAppSettingsViewModel: ViewModel { let appearances = AppAppearance.allCases func resetUserSettings() { - SwiftfinStore.Defaults.generalSuite.removeAll() + UserDefaults.generalSuite.removeAll() } func resetAppSettings() { - SwiftfinStore.Defaults.universalSuite.removeAll() + UserDefaults.universalSuite.removeAll() } func removeAllUsers() { diff --git a/Shared/ViewModels/LibraryViewModel.swift b/Shared/ViewModels/LibraryViewModel.swift index 3e101c92..ebd9070b 100644 --- a/Shared/ViewModels/LibraryViewModel.swift +++ b/Shared/ViewModels/LibraryViewModel.swift @@ -96,7 +96,7 @@ final class LibraryViewModel: ViewModel { genreIDs = filters.withGenres.compactMap(\.id) } let sortBy = filters.sortBy.map(\.rawValue) - let queryRecursive = Defaults[.showFlattenView] || filters.filters.contains(.isFavorite) || + let queryRecursive = Defaults[.Customization.showFlattenView] || filters.filters.contains(.isFavorite) || self.person != nil || self.genre != nil || self.studio != nil @@ -104,7 +104,7 @@ final class LibraryViewModel: ViewModel { if filters.filters.contains(.isFavorite) { includeItemTypes = [.movie, .series, .season, .episode, .boxSet] } else { - includeItemTypes = [.movie, .series, .boxSet] + (Defaults[.showFlattenView] ? [] : [.folder]) + includeItemTypes = [.movie, .series, .boxSet] + (Defaults[.Customization.showFlattenView] ? [] : [.folder]) } ItemsAPI.getItemsByUserId( diff --git a/Shared/Views/ImageView.swift b/Shared/Views/ImageView.swift index 013cacf0..cf0754a5 100644 --- a/Shared/Views/ImageView.swift +++ b/Shared/Views/ImageView.swift @@ -70,8 +70,10 @@ struct ImageView: Vie _placeholder(currentSource) } else if let _image = state.image { image(_image.resizingMode(resizingMode)) - } else { - failure() + } else if state.error != nil { + failure().onAppear { + sources.removeFirst() + } } } .pipeline(ImagePipeline(configuration: .withDataCache)) diff --git a/Swiftfin tvOS/Components/PortraitButton.swift b/Swiftfin tvOS/Components/PortraitButton.swift deleted file mode 100644 index 9be925f2..00000000 --- a/Swiftfin tvOS/Components/PortraitButton.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) 2022 Jellyfin & Jellyfin Contributors -// - -import SwiftUI -import SwiftUICollection - -struct PortraitButton: View { - - let item: Item - let selectedAction: (Item) -> Void - - var body: some View { - VStack(alignment: .leading, spacing: 15) { - Button { - selectedAction(item) - } label: { - ImageView(item.portraitPosterImageSource(maxWidth: 270)) - .failure { - InitialFailureView(item.title.initials) - } - .frame(width: 270, height: 405) - } - .buttonStyle(CardButtonStyle()) - - VStack(alignment: .leading) { - if item.showTitle { - HStack { - Text(item.title) - .foregroundColor(.primary) - .multilineTextAlignment(.leading) - .lineLimit(2) - .frame(width: 250) - - Spacer() - } - } - - if let subtitle = item.subtitle { - Text(subtitle) - .font(.caption) - .fontWeight(.medium) - .foregroundColor(.secondary) - .lineLimit(1) - } - } - .zIndex(-1) - .frame(maxWidth: .infinity) - } - .focusSection() - } -} diff --git a/Swiftfin tvOS/Components/PortraitPosterHStack.swift b/Swiftfin tvOS/Components/PortraitPosterHStack.swift deleted file mode 100644 index 749646ac..00000000 --- a/Swiftfin tvOS/Components/PortraitPosterHStack.swift +++ /dev/null @@ -1,89 +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) 2022 Jellyfin & Jellyfin Contributors -// - -import JellyfinAPI -import SwiftUI -import SwiftUICollection -import TVUIKit - -struct PortraitPosterHStack: View { - - private let loading: Bool - private let title: String - private let items: [Item] - private let selectedAction: (Item) -> Void - private let trailingContent: () -> TrailingContent - - init( - loading: Bool = false, - title: String, - items: [Item], - @ViewBuilder trailingContent: @escaping () -> TrailingContent, - selectedAction: @escaping (Item) -> Void - ) { - self.loading = loading - self.title = title - self.items = items - self.trailingContent = trailingContent - self.selectedAction = selectedAction - } - - var body: some View { - VStack(alignment: .leading, spacing: 0) { - - Text(title) - .font(.title3) - .fontWeight(.semibold) - .padding(.leading, 50) - - ScrollView(.horizontal) { - HStack(alignment: .top, spacing: 0) { - if loading { - ForEach(0 ..< 10) { _ in - PortraitButton( - item: BaseItemDto.placeHolder, - selectedAction: { _ in } - ) - .redacted(reason: .placeholder) - } - } else if items.isEmpty { - PortraitButton( - item: BaseItemDto.noResults, - selectedAction: { _ in } - ) - } else { - ForEach(items, id: \.hashValue) { item in - PortraitButton(item: item) { item in - selectedAction(item) - } - } - } - - trailingContent() - } - .padding(.horizontal, 50) - .padding2(.vertical) - } - } - } -} - -extension PortraitPosterHStack where TrailingContent == EmptyView { - init( - loading: Bool = false, - title: String, - items: [Item], - selectedAction: @escaping (Item) -> Void - ) { - self.loading = loading - self.title = title - self.items = items - self.trailingContent = { EmptyView() } - self.selectedAction = selectedAction - } -} diff --git a/Swiftfin tvOS/Components/PosterButton.swift b/Swiftfin tvOS/Components/PosterButton.swift new file mode 100644 index 00000000..52705c7f --- /dev/null +++ b/Swiftfin tvOS/Components/PosterButton.swift @@ -0,0 +1,224 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import SwiftUI + +struct PosterButton: View { + + private let landscapePosterWidth = 490.0 + private let portraitPosterWidth = 250.0 + + private let item: Item + private let type: PosterType + private let itemScale: CGFloat + private let horizontalAlignment: HorizontalAlignment + private let content: (Item) -> Content + private let imageOverlay: (Item) -> ImageOverlay + private let contextMenu: (Item) -> ContextMenu + private let onSelect: (Item) -> Void + private let singleImage: Bool + + private var itemWidth: CGFloat { + switch type { + case .portrait: + return portraitPosterWidth * itemScale + case .landscape: + return landscapePosterWidth * itemScale + } + } + + private init( + item: Item, + type: PosterType, + itemScale: CGFloat, + horizontalAlignment: HorizontalAlignment, + @ViewBuilder content: @escaping (Item) -> Content, + @ViewBuilder imageOverlay: @escaping (Item) -> ImageOverlay, + @ViewBuilder contextMenu: @escaping (Item) -> ContextMenu, + onSelect: @escaping (Item) -> Void, + singleImage: Bool + ) { + self.item = item + self.type = type + self.itemScale = itemScale + self.horizontalAlignment = horizontalAlignment + self.content = content + self.imageOverlay = imageOverlay + self.contextMenu = contextMenu + self.onSelect = onSelect + self.singleImage = singleImage + } + + var body: some View { + VStack(alignment: horizontalAlignment) { + Button { + onSelect(item) + } label: { + switch type { + case .portrait: + ImageView(item.portraitPosterImageSource(maxWidth: itemWidth)) + .poster(type: type, width: itemWidth) + case .landscape: + ImageView(item.landscapePosterImageSources(maxWidth: itemWidth, single: singleImage)) + .poster(type: type, width: itemWidth) + } + } + .buttonStyle(CardButtonStyle()) + .contextMenu(menuItems: { + contextMenu(item) + }) + .overlay { + imageOverlay(item) + .poster(type: type, width: itemWidth) + } + .posterShadow() + + content(item) + .zIndex(-1) + } + .frame(width: itemWidth) + } +} + +extension PosterButton where Content == PosterButtonDefaultContentView, + ImageOverlay == EmptyView, + ContextMenu == EmptyView +{ + init(item: Item, type: PosterType, singleImage: Bool = false) { + self.init( + item: item, + type: type, + itemScale: 1, + horizontalAlignment: .leading, + content: { PosterButtonDefaultContentView(item: $0) }, + imageOverlay: { _ in EmptyView() }, + contextMenu: { _ in EmptyView() }, + onSelect: { _ in }, + singleImage: singleImage + ) + } +} + +extension PosterButton { + @ViewBuilder + func horizontalAlignment(_ alignment: HorizontalAlignment) -> PosterButton { + PosterButton( + item: item, + type: type, + itemScale: itemScale, + horizontalAlignment: alignment, + content: content, + imageOverlay: imageOverlay, + contextMenu: contextMenu, + onSelect: onSelect, + singleImage: singleImage + ) + } + + @ViewBuilder + func scaleItem(_ scale: CGFloat) -> PosterButton { + PosterButton( + item: item, + type: type, + itemScale: scale, + horizontalAlignment: horizontalAlignment, + content: content, + imageOverlay: imageOverlay, + contextMenu: contextMenu, + onSelect: onSelect, + singleImage: singleImage + ) + } + + @ViewBuilder + func content(@ViewBuilder _ content: @escaping (Item) -> C) -> PosterButton { + PosterButton( + item: item, + type: type, + itemScale: itemScale, + horizontalAlignment: horizontalAlignment, + content: content, + imageOverlay: imageOverlay, + contextMenu: contextMenu, + onSelect: onSelect, + singleImage: singleImage + ) + } + + @ViewBuilder + func imageOverlay(@ViewBuilder _ imageOverlay: @escaping (Item) -> O) -> PosterButton { + PosterButton( + item: item, + type: type, + itemScale: itemScale, + horizontalAlignment: horizontalAlignment, + content: content, + imageOverlay: imageOverlay, + contextMenu: contextMenu, + onSelect: onSelect, + singleImage: singleImage + ) + } + + @ViewBuilder + func contextMenu(@ViewBuilder _ contextMenu: @escaping (Item) -> M) -> PosterButton { + PosterButton( + item: item, + type: type, + itemScale: itemScale, + horizontalAlignment: horizontalAlignment, + content: content, + imageOverlay: imageOverlay, + contextMenu: contextMenu, + onSelect: onSelect, + singleImage: singleImage + ) + } + + @ViewBuilder + func onSelect(_ action: @escaping (Item) -> Void) -> PosterButton { + PosterButton( + item: item, + type: type, + itemScale: itemScale, + horizontalAlignment: horizontalAlignment, + content: content, + imageOverlay: imageOverlay, + contextMenu: contextMenu, + onSelect: action, + singleImage: singleImage + ) + } +} + +// MARK: default content view + +struct PosterButtonDefaultContentView: View { + + let item: Item + + var body: some View { + VStack(alignment: .leading) { + if item.showTitle { + Text(item.title) + .font(.footnote) + .fontWeight(.regular) + .foregroundColor(.primary) + .lineLimit(2) + } + + if let description = item.subtitle { + Text(description) + .font(.caption) + .fontWeight(.medium) + .foregroundColor(.secondary) + .lineLimit(2) + } + } + } +} diff --git a/Swiftfin tvOS/Components/PosterHStack.swift b/Swiftfin tvOS/Components/PosterHStack.swift new file mode 100644 index 00000000..a385dd87 --- /dev/null +++ b/Swiftfin tvOS/Components/PosterHStack.swift @@ -0,0 +1,195 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import SwiftUI + +struct PosterHStack: View { + + private let title: String + private let type: PosterType + private let items: [Item] + private let itemScale: CGFloat + private let content: (Item) -> Content + private let imageOverlay: (Item) -> ImageOverlay + private let contextMenu: (Item) -> ContextMenu + private let trailingContent: () -> TrailingContent + private let onSelect: (Item) -> Void + + private init( + title: String, + type: PosterType, + items: [Item], + itemScale: CGFloat, + @ViewBuilder content: @escaping (Item) -> Content, + @ViewBuilder imageOverlay: @escaping (Item) -> ImageOverlay, + @ViewBuilder contextMenu: @escaping (Item) -> ContextMenu, + @ViewBuilder trailingContent: @escaping () -> TrailingContent, + onSelect: @escaping (Item) -> Void + ) { + self.title = title + self.type = type + self.items = items + self.itemScale = itemScale + self.content = content + self.imageOverlay = imageOverlay + self.contextMenu = contextMenu + self.trailingContent = trailingContent + self.onSelect = onSelect + } + + var body: some View { + VStack(alignment: .leading) { + HStack { + Text(title) + .font(.title2) + .fontWeight(.semibold) + .accessibility(addTraits: [.isHeader]) + .padding(.leading, 50) + + Spacer() + } + + ScrollView(.horizontal, showsIndicators: false) { + HStack(alignment: .top) { + ForEach(items, id: \.hashValue) { item in + PosterButton(item: item, type: type) + .scaleItem(itemScale) + .imageOverlay(imageOverlay) + .contextMenu(contextMenu) + .onSelect(onSelect) + } + + trailingContent() + } + .padding(.horizontal, 50) + .padding2(.vertical) + } + } + .focusSection() + } +} + +extension PosterHStack where Content == PosterButtonDefaultContentView, + ImageOverlay == EmptyView, + ContextMenu == EmptyView, + TrailingContent == EmptyView +{ + init( + title: String, + type: PosterType, + items: [Item] + ) { + self.init( + title: title, + type: type, + items: items, + itemScale: 1, + content: { PosterButtonDefaultContentView(item: $0) }, + imageOverlay: { _ in EmptyView() }, + contextMenu: { _ in EmptyView() }, + trailingContent: { EmptyView() }, + onSelect: { _ in } + ) + } +} + +extension PosterHStack { + @ViewBuilder + func scaleItems(_ scale: CGFloat) -> PosterHStack { + PosterHStack( + title: title, + type: type, + items: items, + itemScale: scale, + content: content, + imageOverlay: imageOverlay, + contextMenu: contextMenu, + trailingContent: trailingContent, + onSelect: onSelect + ) + } + + @ViewBuilder + func content(@ViewBuilder _ content: @escaping (Item) -> C) + -> PosterHStack { + PosterHStack( + title: title, + type: type, + items: items, + itemScale: itemScale, + content: content, + imageOverlay: imageOverlay, + contextMenu: contextMenu, + trailingContent: trailingContent, + onSelect: onSelect + ) + } + + @ViewBuilder + func imageOverlay(@ViewBuilder _ imageOverlay: @escaping (Item) -> O) + -> PosterHStack { + PosterHStack( + title: title, + type: type, + items: items, + itemScale: itemScale, + content: content, + imageOverlay: imageOverlay, + contextMenu: contextMenu, + trailingContent: trailingContent, + onSelect: onSelect + ) + } + + @ViewBuilder + func contextMenu(@ViewBuilder _ contextMenu: @escaping (Item) -> M) + -> PosterHStack { + PosterHStack( + title: title, + type: type, + items: items, + itemScale: itemScale, + content: content, + imageOverlay: imageOverlay, + contextMenu: contextMenu, + trailingContent: trailingContent, + onSelect: onSelect + ) + } + + @ViewBuilder + func trailing(@ViewBuilder _ trailingContent: @escaping () -> T) + -> PosterHStack { + PosterHStack( + title: title, + type: type, + items: items, + itemScale: itemScale, + content: content, + imageOverlay: imageOverlay, + contextMenu: contextMenu, + trailingContent: trailingContent, + onSelect: onSelect + ) + } + + @ViewBuilder + func onSelect(_ onSelect: @escaping (Item) -> Void) -> PosterHStack { + PosterHStack( + title: title, + type: type, + items: items, + itemScale: itemScale, + content: content, + imageOverlay: imageOverlay, + contextMenu: contextMenu, + trailingContent: trailingContent, + onSelect: onSelect + ) + } +} diff --git a/Swiftfin tvOS/Views/HomeView.swift b/Swiftfin tvOS/Views/HomeView.swift index 5183e114..2c1b0b59 100644 --- a/Swiftfin tvOS/Views/HomeView.swift +++ b/Swiftfin tvOS/Views/HomeView.swift @@ -35,12 +35,10 @@ struct HomeView: View { ) if !viewModel.nextUpItems.isEmpty { - PortraitPosterHStack( - title: L10n.nextUp, - items: viewModel.nextUpItems - ) { item in - router.route(to: \.item, item) - } + PosterHStack(title: L10n.nextUp, type: .portrait, items: viewModel.nextUpItems) + .onSelect { item in + router.route(to: \.item, item) + } } } else { HomeCinematicView( @@ -49,21 +47,17 @@ struct HomeView: View { ) if !viewModel.nextUpItems.isEmpty { - PortraitPosterHStack( - title: L10n.nextUp, - items: viewModel.nextUpItems - ) { item in - router.route(to: \.item, item) - } + PosterHStack(title: L10n.nextUp, type: .portrait, items: viewModel.nextUpItems) + .onSelect { item in + router.route(to: \.item, item) + } } if !viewModel.latestAddedItems.isEmpty { - PortraitPosterHStack( - title: L10n.recentlyAdded, - items: viewModel.latestAddedItems - ) { item in - router.route(to: \.item, item) - } + PosterHStack(title: L10n.recentlyAdded, type: .portrait, items: viewModel.latestAddedItems) + .onSelect { item in + router.route(to: \.item, item) + } } } diff --git a/Swiftfin tvOS/Views/ItemView/CollectionItemView/CollectionItemContentView.swift b/Swiftfin tvOS/Views/ItemView/CollectionItemView/CollectionItemContentView.swift index d44a4382..54284125 100644 --- a/Swiftfin tvOS/Views/ItemView/CollectionItemView/CollectionItemContentView.swift +++ b/Swiftfin tvOS/Views/ItemView/CollectionItemView/CollectionItemContentView.swift @@ -53,13 +53,11 @@ extension CollectionItemView { .padding(.top, 5) } - PortraitPosterHStack( - title: L10n.items, - items: viewModel.collectionItems - ) { item in - itemRouter.route(to: \.item, item) - } - .focusGuide(focusGuide, tag: "items", top: "mediaButtons", bottom: "about") + PosterHStack(title: L10n.items, type: .portrait, items: viewModel.collectionItems) + .onSelect { item in + itemRouter.route(to: \.item, item) + } + .focusGuide(focusGuide, tag: "items", top: "mediaButtons", bottom: "about") ItemView.AboutView(viewModel: viewModel) .focusGuide(focusGuide, tag: "about", top: "items") diff --git a/Swiftfin tvOS/Views/ItemView/Components/EpisodeItemView/EpisodeItemContentView.swift b/Swiftfin tvOS/Views/ItemView/Components/EpisodeItemView/EpisodeItemContentView.swift index dc4d11fd..640384e3 100644 --- a/Swiftfin tvOS/Views/ItemView/Components/EpisodeItemView/EpisodeItemContentView.swift +++ b/Swiftfin tvOS/Views/ItemView/Components/EpisodeItemView/EpisodeItemContentView.swift @@ -45,13 +45,11 @@ extension EpisodeItemView { .foregroundColor(.white) } - PortraitPosterHStack( - title: L10n.recommended, - items: viewModel.similarItems - ) { item in - itemRouter.route(to: \.item, item) - } - .focusGuide(focusGuide, tag: "recommended", top: "mediaButtons", bottom: "about") + PosterHStack(title: L10n.recommended, type: .portrait, items: viewModel.similarItems) + .onSelect { item in + itemRouter.route(to: \.item, item) + } + .focusGuide(focusGuide, tag: "recommended", top: "mediaButtons", bottom: "about") ItemView.AboutView(viewModel: viewModel) .focusGuide(focusGuide, tag: "about", top: "recommended") diff --git a/Swiftfin tvOS/Views/ItemView/MovieItemView/MovieItemContentView.swift b/Swiftfin tvOS/Views/ItemView/MovieItemView/MovieItemContentView.swift index e33b8135..4553bebb 100644 --- a/Swiftfin tvOS/Views/ItemView/MovieItemView/MovieItemContentView.swift +++ b/Swiftfin tvOS/Views/ItemView/MovieItemView/MovieItemContentView.swift @@ -53,13 +53,11 @@ extension MovieItemView { .padding(.top, 5) } - PortraitPosterHStack( - title: L10n.recommended, - items: viewModel.similarItems - ) { item in - itemRouter.route(to: \.item, item) - } - .focusGuide(focusGuide, tag: "recommended", top: "mediaButtons", bottom: "about") + PosterHStack(title: L10n.recommended, type: .portrait, items: viewModel.similarItems) + .onSelect { item in + itemRouter.route(to: \.item, item) + } + .focusGuide(focusGuide, tag: "recommended", top: "mediaButtons", bottom: "about") ItemView.AboutView(viewModel: viewModel) .focusGuide(focusGuide, tag: "about", top: "recommended") diff --git a/Swiftfin tvOS/Views/ItemView/SeriesItemView/SeriesItemContentView.swift b/Swiftfin tvOS/Views/ItemView/SeriesItemView/SeriesItemContentView.swift index 99d2f854..3344f2b8 100644 --- a/Swiftfin tvOS/Views/ItemView/SeriesItemView/SeriesItemContentView.swift +++ b/Swiftfin tvOS/Views/ItemView/SeriesItemView/SeriesItemContentView.swift @@ -62,13 +62,11 @@ extension SeriesItemView { .frame(height: 0.5) .id("seasonsRecommendedContentDivider") - PortraitPosterHStack( - title: L10n.recommended, - items: viewModel.similarItems - ) { item in - itemRouter.route(to: \.item, item) - } - .focusGuide(focusGuide, tag: "recommended", top: "seasons", bottom: "about") + PosterHStack(title: L10n.recommended, type: .portrait, items: viewModel.similarItems) + .onSelect { item in + itemRouter.route(to: \.item, item) + } + .focusGuide(focusGuide, tag: "recommended", top: "seasons", bottom: "about") ItemView.AboutView(viewModel: viewModel) .focusGuide(focusGuide, tag: "about", top: "recommended") diff --git a/Swiftfin tvOS/Views/LatestInLibraryView.swift b/Swiftfin tvOS/Views/LatestInLibraryView.swift index c90f8cd6..a9368f43 100644 --- a/Swiftfin tvOS/Views/LatestInLibraryView.swift +++ b/Swiftfin tvOS/Views/LatestInLibraryView.swift @@ -17,40 +17,39 @@ struct LatestInLibraryView: View { var viewModel: LatestMediaViewModel var body: some View { - PortraitPosterHStack( - title: L10n.latestWithString(viewModel.library.displayName), - items: viewModel.items - ) { - Button { - router.route(to: \.library, ( - viewModel: .init( - parentID: viewModel.library.id!, - filters: LibraryFilters( - filters: [], - sortOrder: [.descending], - sortBy: [.dateAdded] - ) - ), - title: viewModel.library.displayName - )) - } label: { - ZStack { - Color(UIColor.darkGray) - .opacity(0.5) + PosterHStack(title: L10n.latestWithString(viewModel.library.displayName), type: .portrait, items: viewModel.items) + .trailing { + Button { + router.route(to: \.library, ( + viewModel: .init( + parentID: viewModel.library.id!, + filters: LibraryFilters( + filters: [], + sortOrder: [.descending], + sortBy: [.dateAdded] + ) + ), + title: viewModel.library.displayName + )) + } label: { + ZStack { + Color(UIColor.darkGray) + .opacity(0.5) - VStack(spacing: 20) { - Image(systemName: "chevron.right") - .font(.title) + VStack(spacing: 20) { + Image(systemName: "chevron.right") + .font(.title) - L10n.seeAll.text - .font(.title3) + L10n.seeAll.text + .font(.title3) + } } + .poster(type: .portrait, width: 250) } + .buttonStyle(PlainButtonStyle()) + } + .onSelect { item in + router.route(to: \.item, item) } - .frame(width: 257, height: 380) - .buttonStyle(PlainButtonStyle()) - } selectedAction: { item in - router.route(to: \.item, item) - } } } diff --git a/Swiftfin tvOS/Views/SettingsView/CustomizeViewsSettings.swift b/Swiftfin tvOS/Views/SettingsView/CustomizeViewsSettings.swift index bf4b6cf8..ef45edbf 100644 --- a/Swiftfin tvOS/Views/SettingsView/CustomizeViewsSettings.swift +++ b/Swiftfin tvOS/Views/SettingsView/CustomizeViewsSettings.swift @@ -11,11 +11,9 @@ import SwiftUI struct CustomizeViewsSettings: View { - @Default(.showPosterLabels) + @Default(.Customization.showPosterLabels) var showPosterLabels - @Default(.showCastAndCrew) - var showCastAndCrew - @Default(.showFlattenView) + @Default(.Customization.showFlattenView) var showFlattenView var body: some View { @@ -24,8 +22,6 @@ struct CustomizeViewsSettings: View { Toggle(L10n.showPosterLabels, isOn: $showPosterLabels) - // TODO: Uncomment when cast and crew implemented in item views - // Toggle(L10n.showCastAndCrew, isOn: $showCastAndCrew) Toggle(L10n.showFlattenView, isOn: $showFlattenView) } header: { diff --git a/Swiftfin tvOS/Views/SettingsView/SettingsView.swift b/Swiftfin tvOS/Views/SettingsView/SettingsView.swift index ccbce55f..7333b44c 100644 --- a/Swiftfin tvOS/Views/SettingsView/SettingsView.swift +++ b/Swiftfin tvOS/Views/SettingsView/SettingsView.swift @@ -18,8 +18,6 @@ struct SettingsView: View { @ObservedObject var viewModel: SettingsViewModel - @Default(.autoSelectAudioLangCode) - var autoSelectAudioLangcode @Default(.videoPlayerJumpForward) var jumpForwardLength @Default(.videoPlayerJumpBackward) @@ -28,8 +26,6 @@ struct SettingsView: View { var downActionShowsMenu @Default(.confirmClose) var confirmClose - @Default(.showPosterLabels) - var showPosterLabels @Default(.resumeOffset) var resumeOffset @Default(.subtitleSize) @@ -134,17 +130,6 @@ struct SettingsView: View { } } - Button { - settingsRouter.route(to: \.missingSettings) - } label: { - HStack { - L10n.missingItems.text - .foregroundColor(.primary) - Spacer() - Image(systemName: "chevron.right") - } - } - Picker(L10n.subtitleSize, selection: $subtitleSize) { ForEach(SubtitleSize.allCases, id: \.self) { size in Text(size.label).tag(size.rawValue) diff --git a/Swiftfin.xcodeproj/project.pbxproj b/Swiftfin.xcodeproj/project.pbxproj index d3bff267..9d338312 100644 --- a/Swiftfin.xcodeproj/project.pbxproj +++ b/Swiftfin.xcodeproj/project.pbxproj @@ -87,7 +87,6 @@ 53DF641E263D9C0600A7CD1A /* LibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53DF641D263D9C0600A7CD1A /* LibraryView.swift */; }; 53E4E649263F725B00F67C6B /* MultiSelectorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53E4E648263F725B00F67C6B /* MultiSelectorView.swift */; }; 53EE24E6265060780068F029 /* LibrarySearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53EE24E5265060780068F029 /* LibrarySearchView.swift */; }; - 53F866442687A45F00DCD1D7 /* PortraitPosterButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53F866432687A45F00DCD1D7 /* PortraitPosterButton.swift */; }; 5D1603FC278A3D5800D22B99 /* SubtitleSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D1603FB278A3D5700D22B99 /* SubtitleSize.swift */; }; 5D1603FD278A40DB00D22B99 /* SubtitleSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D1603FB278A3D5700D22B99 /* SubtitleSize.swift */; }; 5D160403278A41FD00D22B99 /* VLCPlayer+subtitles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D160402278A41FD00D22B99 /* VLCPlayer+subtitles.swift */; }; @@ -318,15 +317,14 @@ E154677A289AF48200087E35 /* CollectionItemContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1546779289AF48200087E35 /* CollectionItemContentView.swift */; }; E168BD10289A4162001A6922 /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E168BD08289A4162001A6922 /* HomeView.swift */; }; E168BD11289A4162001A6922 /* HomeContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E168BD09289A4162001A6922 /* HomeContentView.swift */; }; - E168BD12289A4162001A6922 /* ContinueWatchingCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = E168BD0C289A4162001A6922 /* ContinueWatchingCard.swift */; }; E168BD13289A4162001A6922 /* ContinueWatchingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E168BD0D289A4162001A6922 /* ContinueWatchingView.swift */; }; E168BD14289A4162001A6922 /* LatestInLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E168BD0E289A4162001A6922 /* LatestInLibraryView.swift */; }; E168BD15289A4162001A6922 /* HomeErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E168BD0F289A4162001A6922 /* HomeErrorView.swift */; }; + E16AA60828A364A6009A983C /* PosterButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E16AA60728A364A6009A983C /* PosterButton.swift */; }; E173DA5026D048D600CC4EB7 /* ServerDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E173DA4F26D048D600CC4EB7 /* ServerDetailView.swift */; }; E173DA5226D04AAF00CC4EB7 /* ColorExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = E173DA5126D04AAF00CC4EB7 /* ColorExtension.swift */; }; E173DA5426D050F500CC4EB7 /* ServerDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E173DA5326D050F500CC4EB7 /* ServerDetailViewModel.swift */; }; E176DE6D278E30D2001EFD8D /* EpisodeCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = E176DE6C278E30D2001EFD8D /* EpisodeCard.swift */; }; - E176DE70278E369F001EFD8D /* MissingItemsSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E176DE6F278E369F001EFD8D /* MissingItemsSettingsView.swift */; }; E178857D278037FD0094FBCF /* JellyfinAPI in Frameworks */ = {isa = PBXBuildFile; productRef = E178857C278037FD0094FBCF /* JellyfinAPI */; }; E178859B2780F1F40094FBCF /* tvOSSlider.swift in Sources */ = {isa = PBXBuildFile; fileRef = E178859A2780F1F40094FBCF /* tvOSSlider.swift */; }; E178859E2780F53B0094FBCF /* SliderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E178859D2780F53B0094FBCF /* SliderView.swift */; }; @@ -341,7 +339,6 @@ E18CE0B428A22EDA0092E7F1 /* RepeatingTimer.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18CE0B328A22EDA0092E7F1 /* RepeatingTimer.swift */; }; E18CE0B528A22EDD0092E7F1 /* RepeatingTimer.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18CE0B328A22EDA0092E7F1 /* RepeatingTimer.swift */; }; E18CE0B928A2322D0092E7F1 /* QuickConnectCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18CE0B828A2322D0092E7F1 /* QuickConnectCoordinator.swift */; }; - E18E01A9288746AF0022598C /* PortraitPosterHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E01A3288746AF0022598C /* PortraitPosterHStack.swift */; }; E18E01AA288746AF0022598C /* RefreshableScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E01A4288746AF0022598C /* RefreshableScrollView.swift */; }; E18E01AB288746AF0022598C /* PillHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E01A5288746AF0022598C /* PillHStack.swift */; }; E18E01AD288746AF0022598C /* DotHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18E01A7288746AF0022598C /* DotHStack.swift */; }; @@ -465,9 +462,12 @@ E1C926142887565C002A7A66 /* FocusGuide.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C926082887565C002A7A66 /* FocusGuide.swift */; }; E1C926152887565C002A7A66 /* EpisodeCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C926092887565C002A7A66 /* EpisodeCard.swift */; }; E1C926162887565C002A7A66 /* SeriesItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C9260A2887565C002A7A66 /* SeriesItemView.swift */; }; - E1C9261A288756BD002A7A66 /* PortraitButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C92617288756BD002A7A66 /* PortraitButton.swift */; }; + E1C9261A288756BD002A7A66 /* PosterButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C92617288756BD002A7A66 /* PosterButton.swift */; }; E1C9261B288756BD002A7A66 /* DotHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C92618288756BD002A7A66 /* DotHStack.swift */; }; - E1C9261C288756BD002A7A66 /* PortraitPosterHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C92619288756BD002A7A66 /* PortraitPosterHStack.swift */; }; + E1C9261C288756BD002A7A66 /* PosterHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C92619288756BD002A7A66 /* PosterHStack.swift */; }; + E1CCF12E28ABF989006CAC9E /* PosterType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1CCF12D28ABF989006CAC9E /* PosterType.swift */; }; + E1CCF12F28ABF989006CAC9E /* PosterType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1CCF12D28ABF989006CAC9E /* PosterType.swift */; }; + E1CCF13128AC07EC006CAC9E /* PosterHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1CCF13028AC07EC006CAC9E /* PosterHStack.swift */; }; E1CEFBF527914C7700F60429 /* CustomizeViewsSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1CEFBF427914C7700F60429 /* CustomizeViewsSettings.swift */; }; E1CEFBF727914E6400F60429 /* CustomizeViewsSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1CEFBF627914E6400F60429 /* CustomizeViewsSettings.swift */; }; E1D4BF7C2719D05000A11E64 /* BasicAppSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D4BF7B2719D05000A11E64 /* BasicAppSettingsView.swift */; }; @@ -623,7 +623,6 @@ 53E4E646263F6CF100F67C6B /* LibraryFilterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryFilterView.swift; sourceTree = ""; }; 53E4E648263F725B00F67C6B /* MultiSelectorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiSelectorView.swift; sourceTree = ""; }; 53EE24E5265060780068F029 /* LibrarySearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibrarySearchView.swift; sourceTree = ""; }; - 53F866432687A45F00DCD1D7 /* PortraitPosterButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PortraitPosterButton.swift; sourceTree = ""; }; 5D1603FB278A3D5700D22B99 /* SubtitleSize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubtitleSize.swift; sourceTree = ""; }; 5D160402278A41FD00D22B99 /* VLCPlayer+subtitles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "VLCPlayer+subtitles.swift"; sourceTree = ""; }; 5D64683C277B1649009E09AE /* PreferenceUIHostingSwizzling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferenceUIHostingSwizzling.swift; sourceTree = ""; }; @@ -781,15 +780,14 @@ E1546779289AF48200087E35 /* CollectionItemContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionItemContentView.swift; sourceTree = ""; }; E168BD08289A4162001A6922 /* HomeView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = ""; }; E168BD09289A4162001A6922 /* HomeContentView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HomeContentView.swift; sourceTree = ""; }; - E168BD0C289A4162001A6922 /* ContinueWatchingCard.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ContinueWatchingCard.swift; sourceTree = ""; }; E168BD0D289A4162001A6922 /* ContinueWatchingView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ContinueWatchingView.swift; sourceTree = ""; }; E168BD0E289A4162001A6922 /* LatestInLibraryView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LatestInLibraryView.swift; sourceTree = ""; }; E168BD0F289A4162001A6922 /* HomeErrorView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HomeErrorView.swift; sourceTree = ""; }; + E16AA60728A364A6009A983C /* PosterButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PosterButton.swift; sourceTree = ""; }; E173DA4F26D048D600CC4EB7 /* ServerDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerDetailView.swift; sourceTree = ""; }; E173DA5126D04AAF00CC4EB7 /* ColorExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorExtension.swift; sourceTree = ""; }; E173DA5326D050F500CC4EB7 /* ServerDetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerDetailViewModel.swift; sourceTree = ""; }; E176DE6C278E30D2001EFD8D /* EpisodeCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EpisodeCard.swift; sourceTree = ""; }; - E176DE6F278E369F001EFD8D /* MissingItemsSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MissingItemsSettingsView.swift; sourceTree = ""; }; E178859A2780F1F40094FBCF /* tvOSSlider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = tvOSSlider.swift; sourceTree = ""; }; E178859D2780F53B0094FBCF /* SliderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SliderView.swift; sourceTree = ""; }; E178859F2780F55C0094FBCF /* tvOSVLCOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = tvOSVLCOverlay.swift; sourceTree = ""; }; @@ -800,7 +798,6 @@ E18CE0B128A229E70092E7F1 /* UserDtoExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDtoExtensions.swift; sourceTree = ""; }; E18CE0B328A22EDA0092E7F1 /* RepeatingTimer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RepeatingTimer.swift; sourceTree = ""; }; E18CE0B828A2322D0092E7F1 /* QuickConnectCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickConnectCoordinator.swift; sourceTree = ""; }; - E18E01A3288746AF0022598C /* PortraitPosterHStack.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PortraitPosterHStack.swift; sourceTree = ""; }; E18E01A4288746AF0022598C /* RefreshableScrollView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RefreshableScrollView.swift; sourceTree = ""; }; E18E01A5288746AF0022598C /* PillHStack.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PillHStack.swift; sourceTree = ""; }; E18E01A7288746AF0022598C /* DotHStack.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DotHStack.swift; sourceTree = ""; }; @@ -888,9 +885,11 @@ E1C926082887565C002A7A66 /* FocusGuide.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FocusGuide.swift; sourceTree = ""; }; E1C926092887565C002A7A66 /* EpisodeCard.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EpisodeCard.swift; sourceTree = ""; }; E1C9260A2887565C002A7A66 /* SeriesItemView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SeriesItemView.swift; sourceTree = ""; }; - E1C92617288756BD002A7A66 /* PortraitButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PortraitButton.swift; sourceTree = ""; }; + E1C92617288756BD002A7A66 /* PosterButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PosterButton.swift; sourceTree = ""; }; E1C92618288756BD002A7A66 /* DotHStack.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DotHStack.swift; sourceTree = ""; }; - E1C92619288756BD002A7A66 /* PortraitPosterHStack.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PortraitPosterHStack.swift; sourceTree = ""; }; + E1C92619288756BD002A7A66 /* PosterHStack.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PosterHStack.swift; sourceTree = ""; }; + E1CCF12D28ABF989006CAC9E /* PosterType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PosterType.swift; sourceTree = ""; }; + E1CCF13028AC07EC006CAC9E /* PosterHStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PosterHStack.swift; sourceTree = ""; }; E1CEFBF427914C7700F60429 /* CustomizeViewsSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomizeViewsSettings.swift; sourceTree = ""; }; E1CEFBF627914E6400F60429 /* CustomizeViewsSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomizeViewsSettings.swift; sourceTree = ""; }; E1D4BF7B2719D05000A11E64 /* BasicAppSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BasicAppSettingsView.swift; sourceTree = ""; }; @@ -1160,6 +1159,7 @@ E193D4DA27193CCA00900D82 /* PillStackable.swift */, E1C812B4277A8E5D00918266 /* PlaybackSpeed.swift */, E1937A60288F32DB00CB80AA /* Poster.swift */, + E1CCF12D28ABF989006CAC9E /* PosterType.swift */, E18CE0B328A22EDA0092E7F1 /* RepeatingTimer.swift */, 5D1603FB278A3D5700D22B99 /* SubtitleSize.swift */, E1D4BF832719D25A00A11E64 /* TrackLanguage.swift */, @@ -1176,8 +1176,8 @@ E103A6A1278A7EB500820EC7 /* HomeCinematicView */, E1E5D5432783BB5100692DFE /* ItemDetailsView.swift */, 531690F6267ACC00005D8AB9 /* LandscapeItemElement.swift */, - E1C92617288756BD002A7A66 /* PortraitButton.swift */, - E1C92619288756BD002A7A66 /* PortraitPosterHStack.swift */, + E1C92617288756BD002A7A66 /* PosterButton.swift */, + E1C92619288756BD002A7A66 /* PosterHStack.swift */, 536D3D80267BDFC60004248C /* PortraitItemElement.swift */, E17885A3278105170094FBCF /* SFSymbolButton.swift */, ); @@ -1412,8 +1412,8 @@ E111DE212790BB46008118A3 /* DetectBottomScrollView.swift */, E18E01A7288746AF0022598C /* DotHStack.swift */, E18E01A5288746AF0022598C /* PillHStack.swift */, - 53F866432687A45F00DCD1D7 /* PortraitPosterButton.swift */, - E18E01A3288746AF0022598C /* PortraitPosterHStack.swift */, + E16AA60728A364A6009A983C /* PosterButton.swift */, + E1CCF13028AC07EC006CAC9E /* PosterHStack.swift */, E1AA331C2782541500F6439C /* PrimaryButton.swift */, E18E01A4288746AF0022598C /* RefreshableScrollView.swift */, ); @@ -1749,21 +1749,12 @@ E168BD0A289A4162001A6922 /* Components */ = { isa = PBXGroup; children = ( - E168BD0B289A4162001A6922 /* ContinueWatchingView */, + E168BD0D289A4162001A6922 /* ContinueWatchingView.swift */, E168BD0E289A4162001A6922 /* LatestInLibraryView.swift */, ); path = Components; sourceTree = ""; }; - E168BD0B289A4162001A6922 /* ContinueWatchingView */ = { - isa = PBXGroup; - children = ( - E168BD0C289A4162001A6922 /* ContinueWatchingCard.swift */, - E168BD0D289A4162001A6922 /* ContinueWatchingView.swift */, - ); - path = ContinueWatchingView; - sourceTree = ""; - }; E176DE6E278E3522001EFD8D /* EpisodesRowView */ = { isa = PBXGroup; children = ( @@ -2094,7 +2085,6 @@ children = ( E1CEFBF427914C7700F60429 /* CustomizeViewsSettings.swift */, E1E5D54B2783E27200692DFE /* ExperimentalSettingsView.swift */, - E176DE6F278E369F001EFD8D /* MissingItemsSettingsView.swift */, E1E5D5472783CCF900692DFE /* OverlaySettingsView.swift */, 539B2DA4263BA5B8007FF1A4 /* SettingsView.swift */, 6334175A287DDFB9000603CE /* QuickConnectSettingsView.swift */, @@ -2433,10 +2423,10 @@ E17885A4278105170094FBCF /* SFSymbolButton.swift in Sources */, E13DD3ED27178A54009D4DAF /* UserSignInViewModel.swift in Sources */, 62EC3530267666A5000E9F2D /* SessionManager.swift in Sources */, - E1C9261C288756BD002A7A66 /* PortraitPosterHStack.swift in Sources */, + E1C9261C288756BD002A7A66 /* PosterHStack.swift in Sources */, C453497F279A2DA50045F1E2 /* LiveTVPlayerViewController.swift in Sources */, C4BE078E27298818003F4AD1 /* LiveTVHomeView.swift in Sources */, - E1C9261A288756BD002A7A66 /* PortraitButton.swift in Sources */, + E1C9261A288756BD002A7A66 /* PosterButton.swift in Sources */, E13DD3E227176BD3009D4DAF /* ServerListViewModel.swift in Sources */, E11895AA289383BC0042947B /* ScrollViewOffsetModifier.swift in Sources */, E1EF473A289A0F610034046B /* TruncatedTextView.swift in Sources */, @@ -2496,6 +2486,7 @@ 62E1DCC4273CE19800C9AE76 /* URLExtensions.swift in Sources */, E10EAA54277BBD17000269ED /* BaseItemDto+VideoPlayerViewModel.swift in Sources */, 091B5A8D268315D400D78B61 /* ServerDiscovery.swift in Sources */, + E1CCF12F28ABF989006CAC9E /* PosterType.swift in Sources */, E18E021F2887492B0022598C /* InitialFailureView.swift in Sources */, E1D4BF882719D27100A11E64 /* Bitrates.swift in Sources */, E193D5432719407E00900D82 /* tvOSMainCoordinator.swift in Sources */, @@ -2598,12 +2589,12 @@ E18E01AA288746AF0022598C /* RefreshableScrollView.swift in Sources */, E18E0208288749200022598C /* BlurView.swift in Sources */, E18E01E7288747230022598C /* CollectionItemContentView.swift in Sources */, - 53F866442687A45F00DCD1D7 /* PortraitPosterButton.swift in Sources */, E18E01DF288747230022598C /* iPadOSMovieItemView.swift in Sources */, E168BD13289A4162001A6922 /* ContinueWatchingView.swift in Sources */, C45942CD27F6994A00C54FE7 /* LiveTVPlayerView.swift in Sources */, 62C29EA126D102A500C1D2E7 /* iOSMainTabCoordinator.swift in Sources */, E18E01E8288747230022598C /* SeriesItemContentView.swift in Sources */, + E16AA60828A364A6009A983C /* PosterButton.swift in Sources */, E18CE0AF28A222240092E7F1 /* PublicUserSignInView.swift in Sources */, E18E01E5288747230022598C /* CinematicScrollView.swift in Sources */, E1C812C0277A8E5D00918266 /* VLCPlayerView.swift in Sources */, @@ -2639,7 +2630,6 @@ E18E01E1288747230022598C /* EpisodeItemContentView.swift in Sources */, 62E632DA267D2BC40063E547 /* LatestMediaViewModel.swift in Sources */, C400DB6A27FE894F007B65FE /* LiveTVChannelsView.swift in Sources */, - E18E01A9288746AF0022598C /* PortraitPosterHStack.swift in Sources */, E18E01F2288747230022598C /* ActionButtonHStack.swift in Sources */, E18E0204288749200022598C /* Divider.swift in Sources */, E18E01DA288747230022598C /* iPadOSEpisodeContentView.swift in Sources */, @@ -2715,6 +2705,7 @@ 531AC8BF26750DE20091C7EB /* ImageView.swift in Sources */, C4BE07762725EBEA003F4AD1 /* LiveTVProgramsViewModel.swift in Sources */, E13DD3E927177ED6009D4DAF /* ServerListCoordinator.swift in Sources */, + E1CCF12E28ABF989006CAC9E /* PosterType.swift in Sources */, E1C812BD277A8E5D00918266 /* PlayerOverlayDelegate.swift in Sources */, C45942C527F67DA400C54FE7 /* LiveTVCoordinator.swift in Sources */, E13DD3C227164941009D4DAF /* SwiftfinStore.swift in Sources */, @@ -2737,7 +2728,6 @@ 62EC352F267666A5000E9F2D /* SessionManager.swift in Sources */, 62E632E3267D3BA60063E547 /* MovieItemViewModel.swift in Sources */, E1937A3E288F0D3D00CB80AA /* UIScreenExtensions.swift in Sources */, - E176DE70278E369F001EFD8D /* MissingItemsSettingsView.swift in Sources */, C4BE076F2720FEFF003F4AD1 /* PlainNavigationLinkButton.swift in Sources */, E1EBCB46278BD595009FE6E9 /* ItemOverviewView.swift in Sources */, E18CE0B428A22EDA0092E7F1 /* RepeatingTimer.swift in Sources */, @@ -2745,6 +2735,7 @@ 62E632EF267D43320063E547 /* LibraryFilterViewModel.swift in Sources */, 5D64683D277B1649009E09AE /* PreferenceUIHostingSwizzling.swift in Sources */, C45942C927F697CA00C54FE7 /* iOSLiveTVVideoPlayerCoordinator.swift in Sources */, + E1CCF13128AC07EC006CAC9E /* PosterHStack.swift in Sources */, E13DD3C827164B1E009D4DAF /* UIDeviceExtensions.swift in Sources */, C45640D0281A43EF007096DE /* LiveTVNativePlayerViewController.swift in Sources */, E10EAA53277BBD17000269ED /* BaseItemDto+VideoPlayerViewModel.swift in Sources */, @@ -2767,7 +2758,6 @@ 539B2DA5263BA5B8007FF1A4 /* SettingsView.swift in Sources */, 5338F74E263B61370014BF09 /* ConnectToServerView.swift in Sources */, E18E01EA288747230022598C /* MovieItemView.swift in Sources */, - E168BD12289A4162001A6922 /* ContinueWatchingCard.swift in Sources */, 09389CC726819B4600AE350E /* VideoPlayerModel.swift in Sources */, E1D4BF872719D27100A11E64 /* Bitrates.swift in Sources */, 6220D0B726D5EE1100B8E046 /* SearchCoordinator.swift in Sources */, diff --git a/Swiftfin/Components/PillHStack.swift b/Swiftfin/Components/PillHStack.swift index e9bef9d1..032c9eb5 100644 --- a/Swiftfin/Components/PillHStack.swift +++ b/Swiftfin/Components/PillHStack.swift @@ -12,7 +12,17 @@ struct PillHStack: View { let title: String let items: [Item] - let selectedAction: (Item) -> Void + let onSelect: (Item) -> Void + + private init( + title: String, + items: [Item], + onSelect: @escaping (Item) -> Void + ) { + self.title = title + self.items = items + self.onSelect = onSelect + } var body: some View { VStack(alignment: .leading) { @@ -29,21 +39,17 @@ struct PillHStack: View { HStack { ForEach(items, id: \.title) { item in Button { - selectedAction(item) + onSelect(item) } label: { - ZStack { - Color(UIColor.systemFill) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .cornerRadius(10) - - Text(item.title) - .font(.caption) - .fontWeight(.semibold) - .foregroundColor(.primary) - .fixedSize() - .padding(10) - } - .fixedSize() + Text(item.title) + .font(.caption) + .fontWeight(.semibold) + .foregroundColor(.primary) + .padding(10) + .background { + Color.systemFill + .cornerRadius(10) + } } } } @@ -55,3 +61,19 @@ struct PillHStack: View { } } } + +extension PillHStack { + + init(title: String, items: [Item]) { + self.init(title: title, items: items, onSelect: { _ in }) + } + + @ViewBuilder + func onSelect(_ onSelect: @escaping (Item) -> Void) -> PillHStack { + PillHStack( + title: title, + items: items, + onSelect: onSelect + ) + } +} diff --git a/Swiftfin/Components/PortraitPosterButton.swift b/Swiftfin/Components/PortraitPosterButton.swift deleted file mode 100644 index 85b422e6..00000000 --- a/Swiftfin/Components/PortraitPosterButton.swift +++ /dev/null @@ -1,72 +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) 2022 Jellyfin & Jellyfin Contributors -// - -import SwiftUI - -struct PortraitPosterButton: View { - - @Environment(\.colorScheme) - private var colorScheme - - let item: Item - let maxWidth: CGFloat - let horizontalAlignment: HorizontalAlignment - let textAlignment: TextAlignment - let selectedAction: (Item) -> Void - - init( - item: Item, - maxWidth: CGFloat = 110, - horizontalAlignment: HorizontalAlignment = .leading, - textAlignment: TextAlignment = .leading, - selectedAction: @escaping (Item) -> Void - ) { - self.item = item - self.maxWidth = maxWidth - self.horizontalAlignment = horizontalAlignment - self.textAlignment = textAlignment - self.selectedAction = selectedAction - } - - var body: some View { - Button { - selectedAction(item) - } label: { - VStack(alignment: horizontalAlignment) { - ImageView(item.portraitPosterImageSource(maxWidth: maxWidth)) - .failure { - InitialFailureView(item.title.initials) - } - .portraitPoster(width: maxWidth) - .accessibilityIgnoresInvertColors() - - if item.showTitle { - Text(item.title) - .font(.footnote) - .fontWeight(.regular) - .foregroundColor(.primary) - .multilineTextAlignment(textAlignment) - .lineLimit(2) - } - - if let description = item.subtitle { - Text(description) - .font(.caption) - .fontWeight(.medium) - .foregroundColor(.secondary) - .multilineTextAlignment(textAlignment) - .lineLimit(2) - } - } - .frame(width: maxWidth) - } - .if(colorScheme == .light) { view in - view.shadow(radius: 4, y: 2) - } - } -} diff --git a/Swiftfin/Components/PortraitPosterHStack.swift b/Swiftfin/Components/PortraitPosterHStack.swift deleted file mode 100644 index e7a31763..00000000 --- a/Swiftfin/Components/PortraitPosterHStack.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) 2022 Jellyfin & Jellyfin Contributors -// - -import SwiftUI - -struct PortraitPosterHStack: View { - - private let title: String - private let items: [Item] - private let itemWidth: CGFloat - private let trailingContent: () -> TrailingContent - private let selectedAction: (Item) -> Void - - init( - title: String, - items: [Item], - itemWidth: CGFloat = 110, - @ViewBuilder trailingContent: @escaping () -> TrailingContent, - selectedAction: @escaping (Item) -> Void - ) { - self.title = title - self.items = items - self.itemWidth = itemWidth - self.trailingContent = trailingContent - self.selectedAction = selectedAction - } - - var body: some View { - VStack(alignment: .leading) { - HStack { - Text(title) - .font(.title2) - .fontWeight(.semibold) - .accessibility(addTraits: [.isHeader]) - .padding(.leading) - .if(UIDevice.isIPad) { view in - view.padding(.leading) - } - - Spacer() - - trailingContent() - } - - ScrollView(.horizontal, showsIndicators: false) { - HStack(alignment: .top, spacing: 15) { - ForEach(items, id: \.hashValue) { item in - PortraitPosterButton( - item: item, - maxWidth: itemWidth, - horizontalAlignment: .leading - ) { item in - selectedAction(item) - } - } - } - .padding(.horizontal) - .if(UIDevice.isIPad) { view in - view.padding(.horizontal) - } - } - } - } -} - -extension PortraitPosterHStack where TrailingContent == EmptyView { - init( - title: String, - items: [Item], - itemWidth: CGFloat = 110, - selectedAction: @escaping (Item) -> Void - ) { - self.title = title - self.items = items - self.itemWidth = itemWidth - self.trailingContent = { EmptyView() } - self.selectedAction = selectedAction - } -} diff --git a/Swiftfin/Components/PosterButton.swift b/Swiftfin/Components/PosterButton.swift new file mode 100644 index 00000000..f993ab3b --- /dev/null +++ b/Swiftfin/Components/PosterButton.swift @@ -0,0 +1,224 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import SwiftUI + +struct PosterButton: View { + + @ScaledMetric(relativeTo: .largeTitle) + private var landscapePosterWidth = 200.0 + + @ScaledMetric(relativeTo: .largeTitle) + private var portraitPosterWidth = 100.0 + + private let item: Item + private let type: PosterType + private let itemScale: CGFloat + private let horizontalAlignment: HorizontalAlignment + private let content: (Item) -> Content + private let imageOverlay: (Item) -> ImageOverlay + private let contextMenu: (Item) -> ContextMenu + private let onSelect: (Item) -> Void + private let singleImage: Bool + + private var itemWidth: CGFloat { + switch type { + case .portrait: + return portraitPosterWidth * itemScale + case .landscape: + return landscapePosterWidth * itemScale + } + } + + private init( + item: Item, + type: PosterType, + itemScale: CGFloat, + horizontalAlignment: HorizontalAlignment, + @ViewBuilder content: @escaping (Item) -> Content, + @ViewBuilder imageOverlay: @escaping (Item) -> ImageOverlay, + @ViewBuilder contextMenu: @escaping (Item) -> ContextMenu, + onSelect: @escaping (Item) -> Void, + singleImage: Bool + ) { + self.item = item + self.type = type + self.itemScale = itemScale + self.horizontalAlignment = horizontalAlignment + self.content = content + self.imageOverlay = imageOverlay + self.contextMenu = contextMenu + self.onSelect = onSelect + self.singleImage = singleImage + } + + var body: some View { + VStack(alignment: horizontalAlignment) { + Button { + onSelect(item) + } label: { + switch type { + case .portrait: + ImageView(item.portraitPosterImageSource(maxWidth: itemWidth)) + case .landscape: + ImageView(item.landscapePosterImageSources(maxWidth: itemWidth, single: singleImage)) + } + } + .contextMenu(menuItems: { + contextMenu(item) + }) + .poster(type: type, width: itemWidth) + .overlay { + imageOverlay(item) + .poster(type: type, width: itemWidth) + } + .posterShadow() + + content(item) + } + .frame(width: itemWidth) + } +} + +extension PosterButton where Content == PosterButtonDefaultContentView, + ImageOverlay == EmptyView, + ContextMenu == EmptyView +{ + init(item: Item, type: PosterType, singleImage: Bool = false) { + self.init( + item: item, + type: type, + itemScale: 1, + horizontalAlignment: .leading, + content: { PosterButtonDefaultContentView(item: $0) }, + imageOverlay: { _ in EmptyView() }, + contextMenu: { _ in EmptyView() }, + onSelect: { _ in }, + singleImage: singleImage + ) + } +} + +extension PosterButton { + @ViewBuilder + func horizontalAlignment(_ alignment: HorizontalAlignment) -> PosterButton { + PosterButton( + item: item, + type: type, + itemScale: itemScale, + horizontalAlignment: alignment, + content: content, + imageOverlay: imageOverlay, + contextMenu: contextMenu, + onSelect: onSelect, + singleImage: singleImage + ) + } + + @ViewBuilder + func scaleItem(_ scale: CGFloat) -> PosterButton { + PosterButton( + item: item, + type: type, + itemScale: scale, + horizontalAlignment: horizontalAlignment, + content: content, + imageOverlay: imageOverlay, + contextMenu: contextMenu, + onSelect: onSelect, + singleImage: singleImage + ) + } + + @ViewBuilder + func content(@ViewBuilder _ content: @escaping (Item) -> C) -> PosterButton { + PosterButton( + item: item, + type: type, + itemScale: itemScale, + horizontalAlignment: horizontalAlignment, + content: content, + imageOverlay: imageOverlay, + contextMenu: contextMenu, + onSelect: onSelect, + singleImage: singleImage + ) + } + + @ViewBuilder + func imageOverlay(@ViewBuilder _ imageOverlay: @escaping (Item) -> O) -> PosterButton { + PosterButton( + item: item, + type: type, + itemScale: itemScale, + horizontalAlignment: horizontalAlignment, + content: content, + imageOverlay: imageOverlay, + contextMenu: contextMenu, + onSelect: onSelect, + singleImage: singleImage + ) + } + + @ViewBuilder + func contextMenu(@ViewBuilder _ contextMenu: @escaping (Item) -> M) -> PosterButton { + PosterButton( + item: item, + type: type, + itemScale: itemScale, + horizontalAlignment: horizontalAlignment, + content: content, + imageOverlay: imageOverlay, + contextMenu: contextMenu, + onSelect: onSelect, + singleImage: singleImage + ) + } + + @ViewBuilder + func onSelect(_ action: @escaping (Item) -> Void) -> PosterButton { + PosterButton( + item: item, + type: type, + itemScale: itemScale, + horizontalAlignment: horizontalAlignment, + content: content, + imageOverlay: imageOverlay, + contextMenu: contextMenu, + onSelect: action, + singleImage: singleImage + ) + } +} + +// MARK: default content view + +struct PosterButtonDefaultContentView: View { + + let item: Item + + var body: some View { + VStack(alignment: .leading) { + if item.showTitle { + Text(item.title) + .font(.footnote) + .fontWeight(.regular) + .foregroundColor(.primary) + .lineLimit(2) + } + + if let description = item.subtitle { + Text(description) + .font(.caption) + .fontWeight(.medium) + .foregroundColor(.secondary) + .lineLimit(2) + } + } + } +} diff --git a/Swiftfin/Components/PosterHStack.swift b/Swiftfin/Components/PosterHStack.swift new file mode 100644 index 00000000..acab5396 --- /dev/null +++ b/Swiftfin/Components/PosterHStack.swift @@ -0,0 +1,199 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import SwiftUI + +struct PosterHStack: View { + + private let title: String + private let type: PosterType + private let items: [Item] + private let itemScale: CGFloat + private let content: (Item) -> Content + private let imageOverlay: (Item) -> ImageOverlay + private let contextMenu: (Item) -> ContextMenu + private let trailingContent: () -> TrailingContent + private let onSelect: (Item) -> Void + + private init( + title: String, + type: PosterType, + items: [Item], + itemScale: CGFloat, + @ViewBuilder content: @escaping (Item) -> Content, + @ViewBuilder imageOverlay: @escaping (Item) -> ImageOverlay, + @ViewBuilder contextMenu: @escaping (Item) -> ContextMenu, + @ViewBuilder trailingContent: @escaping () -> TrailingContent, + onSelect: @escaping (Item) -> Void + ) { + self.title = title + self.type = type + self.items = items + self.itemScale = itemScale + self.content = content + self.imageOverlay = imageOverlay + self.contextMenu = contextMenu + self.trailingContent = trailingContent + self.onSelect = onSelect + } + + var body: some View { + VStack(alignment: .leading) { + HStack { + Text(title) + .font(.title2) + .fontWeight(.semibold) + .accessibility(addTraits: [.isHeader]) + + Spacer() + + trailingContent() + } + .padding(.horizontal) + .if(UIDevice.isIPad) { view in + view.padding(.horizontal) + } + + ScrollView(.horizontal, showsIndicators: false) { + HStack(alignment: .top, spacing: 15) { + ForEach(items, id: \.hashValue) { item in + PosterButton(item: item, type: type) + .scaleItem(itemScale) + .imageOverlay(imageOverlay) + .contextMenu(contextMenu) + .onSelect(onSelect) + } + } + .padding(.horizontal) + .if(UIDevice.isIPad) { view in + view.padding(.horizontal) + } + } + } + } +} + +extension PosterHStack where Content == PosterButtonDefaultContentView, + ImageOverlay == EmptyView, + ContextMenu == EmptyView, + TrailingContent == EmptyView +{ + init( + title: String, + type: PosterType, + items: [Item] + ) { + self.init( + title: title, + type: type, + items: items, + itemScale: 1, + content: { PosterButtonDefaultContentView(item: $0) }, + imageOverlay: { _ in EmptyView() }, + contextMenu: { _ in EmptyView() }, + trailingContent: { EmptyView() }, + onSelect: { _ in } + ) + } +} + +extension PosterHStack { + @ViewBuilder + func scaleItems(_ scale: CGFloat) -> PosterHStack { + PosterHStack( + title: title, + type: type, + items: items, + itemScale: scale, + content: content, + imageOverlay: imageOverlay, + contextMenu: contextMenu, + trailingContent: trailingContent, + onSelect: onSelect + ) + } + + @ViewBuilder + func content(@ViewBuilder _ content: @escaping (Item) -> C) + -> PosterHStack { + PosterHStack( + title: title, + type: type, + items: items, + itemScale: itemScale, + content: content, + imageOverlay: imageOverlay, + contextMenu: contextMenu, + trailingContent: trailingContent, + onSelect: onSelect + ) + } + + @ViewBuilder + func imageOverlay(@ViewBuilder _ imageOverlay: @escaping (Item) -> O) + -> PosterHStack { + PosterHStack( + title: title, + type: type, + items: items, + itemScale: itemScale, + content: content, + imageOverlay: imageOverlay, + contextMenu: contextMenu, + trailingContent: trailingContent, + onSelect: onSelect + ) + } + + @ViewBuilder + func contextMenu(@ViewBuilder _ contextMenu: @escaping (Item) -> M) + -> PosterHStack { + PosterHStack( + title: title, + type: type, + items: items, + itemScale: itemScale, + content: content, + imageOverlay: imageOverlay, + contextMenu: contextMenu, + trailingContent: trailingContent, + onSelect: onSelect + ) + } + + @ViewBuilder + func trailing(@ViewBuilder _ trailingContent: @escaping () -> T) + -> PosterHStack { + PosterHStack( + title: title, + type: type, + items: items, + itemScale: itemScale, + content: content, + imageOverlay: imageOverlay, + contextMenu: contextMenu, + trailingContent: trailingContent, + onSelect: onSelect + ) + } + + @ViewBuilder + func onSelect(_ onSelect: @escaping (Item) -> Void) -> PosterHStack { + PosterHStack( + title: title, + type: type, + items: items, + itemScale: itemScale, + content: content, + imageOverlay: imageOverlay, + contextMenu: contextMenu, + trailingContent: trailingContent, + onSelect: onSelect + ) + } +} diff --git a/Swiftfin/Views/HomeView/Components/ContinueWatchingView.swift b/Swiftfin/Views/HomeView/Components/ContinueWatchingView.swift new file mode 100644 index 00000000..6523fef5 --- /dev/null +++ b/Swiftfin/Views/HomeView/Components/ContinueWatchingView.swift @@ -0,0 +1,64 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import JellyfinAPI +import SwiftUI + +struct ContinueWatchingView: View { + + @EnvironmentObject + private var homeRouter: HomeCoordinator.Router + @ObservedObject + var viewModel: HomeViewModel + + var body: some View { + PosterHStack(title: "", type: .landscape, items: viewModel.resumeItems) + .scaleItems(1.5) + .onSelect { item in + homeRouter.route(to: \.item, item) + } + .contextMenu { item in + Button(role: .destructive) { + viewModel.removeItemFromResume(item) + } label: { + Label(L10n.removeFromResume, systemImage: "minus.circle") + } + } + .imageOverlay { item in + VStack { + + Spacer() + + ZStack(alignment: .bottom) { + + LinearGradient( + colors: [.clear, .black.opacity(0.5), .black.opacity(0.7)], + startPoint: .top, + endPoint: .bottom + ) + .frame(height: 35) + + VStack(alignment: .leading, spacing: 0) { + Text(item.getItemProgressString() ?? L10n.continue) + .font(.subheadline) + .padding(.bottom, 5) + .padding(.leading, 10) + .foregroundColor(.white) + + HStack { + Color.jellyfinPurple + .frame(width: 320 * (item.userData?.playedPercentage ?? 0) / 100, height: 7) + + Spacer(minLength: 0) + } + } + } + } + } + } +} diff --git a/Swiftfin/Views/HomeView/Components/ContinueWatchingView/ContinueWatchingCard.swift b/Swiftfin/Views/HomeView/Components/ContinueWatchingView/ContinueWatchingCard.swift deleted file mode 100644 index f07d9c85..00000000 --- a/Swiftfin/Views/HomeView/Components/ContinueWatchingView/ContinueWatchingCard.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) 2022 Jellyfin & Jellyfin Contributors -// - -import JellyfinAPI -import SwiftUI - -struct ContinueWatchingLandscapeButton: View { - - @EnvironmentObject - private var homeRouter: HomeCoordinator.Router - - let item: BaseItemDto - - var body: some View { - Button { - homeRouter.route(to: \.item, item) - } label: { - VStack(alignment: .leading) { - - ZStack { - Group { - if item.type == .episode { - ImageView([ - item.seriesImageSource(.thumb, maxWidth: 320), - item.seriesImageSource(.backdrop, maxWidth: 320), - ]) - .frame(width: 320, height: 180) - } else { - ImageView([ - item.imageSource(.thumb, maxWidth: 320), - item.imageSource(.backdrop, maxWidth: 320), - ]) - .frame(width: 320, height: 180) - } - } - .accessibilityIgnoresInvertColors() - - HStack { - VStack { - - Spacer() - - ZStack(alignment: .bottom) { - - LinearGradient( - colors: [.clear, .black.opacity(0.5), .black.opacity(0.7)], - startPoint: .top, - endPoint: .bottom - ) - .frame(height: 35) - - VStack(alignment: .leading, spacing: 0) { - Text(item.getItemProgressString() ?? L10n.continue) - .font(.subheadline) - .padding(.bottom, 5) - .padding(.leading, 10) - .foregroundColor(.white) - - HStack { - Color.jellyfinPurple - .frame(width: 320 * (item.userData?.playedPercentage ?? 0) / 100, height: 7) - - Spacer(minLength: 0) - } - } - } - } - } - } - .frame(width: 320, height: 180) - .mask(Rectangle().cornerRadius(10)) - .shadow(radius: 4, y: 2) - - VStack(alignment: .leading) { - Text("\(item.seriesName ?? item.name ?? "")") - .font(.callout) - .fontWeight(.semibold) - .foregroundColor(.primary) - .lineLimit(1) - - if item.type == .episode { - Text(item.seasonEpisodeLocator ?? "") - .font(.callout) - .fontWeight(.medium) - .foregroundColor(.secondary) - .lineLimit(1) - } - } - } - } - } -} diff --git a/Swiftfin/Views/HomeView/Components/ContinueWatchingView/ContinueWatchingView.swift b/Swiftfin/Views/HomeView/Components/ContinueWatchingView/ContinueWatchingView.swift deleted file mode 100644 index 74c67bc1..00000000 --- a/Swiftfin/Views/HomeView/Components/ContinueWatchingView/ContinueWatchingView.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) 2022 Jellyfin & Jellyfin Contributors -// - -import JellyfinAPI -import SwiftUI - -struct ContinueWatchingView: View { - - @EnvironmentObject - private var homeRouter: HomeCoordinator.Router - @ObservedObject - var viewModel: HomeViewModel - - var body: some View { - ScrollView(.horizontal, showsIndicators: false) { - HStack(alignment: .top, spacing: 20) { - ForEach(viewModel.resumeItems, id: \.id) { item in - ContinueWatchingLandscapeButton(item: item) - .contextMenu { - Button(role: .destructive) { - viewModel.removeItemFromResume(item) - } label: { - Label(L10n.removeFromResume, systemImage: "minus.circle") - } - } - } - } - .padding(.horizontal) - } - } -} diff --git a/Swiftfin/Views/HomeView/Components/LatestInLibraryView.swift b/Swiftfin/Views/HomeView/Components/LatestInLibraryView.swift index 5556ee10..9078a0e0 100644 --- a/Swiftfin/Views/HomeView/Components/LatestInLibraryView.swift +++ b/Swiftfin/Views/HomeView/Components/LatestInLibraryView.swift @@ -6,6 +6,7 @@ // Copyright (c) 2022 Jellyfin & Jellyfin Contributors // +import Defaults import JellyfinAPI import SwiftUI @@ -16,24 +17,25 @@ struct LatestInLibraryView: View { @ObservedObject var viewModel: LatestMediaViewModel + @Default(.Customization.latestInLibraryPosterType) + var latestInLibraryPosterType + var body: some View { - PortraitPosterHStack( - title: L10n.latestWithString(viewModel.library.displayName), - items: viewModel.items, - itemWidth: UIDevice.isIPad ? 130 : 110 - ) { - Button { - let libraryViewModel = LibraryViewModel(parentID: viewModel.library.id, filters: HomeViewModel.recentFilterSet) - homeRouter.route(to: \.library, (viewModel: libraryViewModel, title: viewModel.library.displayName)) - } label: { - HStack { - L10n.seeAll.text - Image(systemName: "chevron.right") + PosterHStack(title: L10n.latestWithString(viewModel.library.displayName), type: latestInLibraryPosterType, items: viewModel.items) + .trailing { + Button { + let libraryViewModel = LibraryViewModel(parentID: viewModel.library.id, filters: HomeViewModel.recentFilterSet) + homeRouter.route(to: \.library, (viewModel: libraryViewModel, title: viewModel.library.displayName)) + } label: { + HStack { + L10n.seeAll.text + Image(systemName: "chevron.right") + } + .font(.subheadline.bold()) } - .font(.subheadline.bold()) } - } selectedAction: { item in - homeRouter.route(to: \.item, item) - } + .onSelect { item in + homeRouter.route(to: \.item, item) + } } } diff --git a/Swiftfin/Views/HomeView/HomeContentView.swift b/Swiftfin/Views/HomeView/HomeContentView.swift index d9ed697e..e7c8ca3e 100644 --- a/Swiftfin/Views/HomeView/HomeContentView.swift +++ b/Swiftfin/Views/HomeView/HomeContentView.swift @@ -6,6 +6,7 @@ // Copyright (c) 2022 Jellyfin & Jellyfin Contributors // +import Defaults import SwiftUI extension HomeView { @@ -17,6 +18,11 @@ extension HomeView { @ObservedObject var viewModel: HomeViewModel + @Default(.Customization.nextUpPosterType) + var nextUpPosterType + @Default(.Customization.recentlyAddedPosterType) + var recentlyAddedPosterType + var body: some View { RefreshableScrollView { VStack(alignment: .leading, spacing: 20) { @@ -25,23 +31,17 @@ extension HomeView { } if !viewModel.nextUpItems.isEmpty { - PortraitPosterHStack( - title: L10n.nextUp, - items: viewModel.nextUpItems, - itemWidth: UIDevice.isIPad ? 130 : 110 - ) { item in - homeRouter.route(to: \.item, item) - } + PosterHStack(title: L10n.nextUp, type: nextUpPosterType, items: viewModel.nextUpItems) + .onSelect { item in + homeRouter.route(to: \.item, item) + } } if !viewModel.latestAddedItems.isEmpty { - PortraitPosterHStack( - title: L10n.recentlyAdded, - items: viewModel.latestAddedItems, - itemWidth: UIDevice.isIPad ? 130 : 110 - ) { item in - homeRouter.route(to: \.item, item) - } + PosterHStack(title: L10n.recentlyAdded, type: recentlyAddedPosterType, items: viewModel.latestAddedItems) + .onSelect { item in + homeRouter.route(to: \.item, item) + } } ForEach(viewModel.libraries, id: \.self) { library in diff --git a/Swiftfin/Views/ItemView/Components/EpisodesRowView/EpisodeCard.swift b/Swiftfin/Views/ItemView/Components/EpisodesRowView/EpisodeCard.swift index b6775c41..48d41a07 100644 --- a/Swiftfin/Views/ItemView/Components/EpisodesRowView/EpisodeCard.swift +++ b/Swiftfin/Views/ItemView/Components/EpisodesRowView/EpisodeCard.swift @@ -15,23 +15,26 @@ struct EpisodeCard: View { private var itemRouter: ItemCoordinator.Router @ScaledMetric private var staticOverviewHeight: CGFloat = 50 - @Environment(\.colorScheme) - private var colorScheme let episode: BaseItemDto var body: some View { - Button { - if episode != .placeHolder && episode != .noResults { - itemRouter.route(to: \.item, episode) - } - } label: { - VStack(alignment: .leading) { - ImageView(episode.imageSource(.primary, maxWidth: 200)) - .frame(width: 200, height: 112) - .cornerRadius(10) - .accessibilityIgnoresInvertColors() + PosterButton(item: episode, type: .landscape, singleImage: true) + .scaleItem(1.2) + .imageOverlay { _ in + if episode.userData?.played ?? false { + ZStack(alignment: .bottomTrailing) { + Color.clear + Image(systemName: "checkmark.circle.fill") + .resizable() + .frame(width: 30, height: 30, alignment: .bottomTrailing) + .foregroundColor(.white) + .padding() + } + } + } + .content { _ in VStack(alignment: .leading) { Text(episode.episodeLocator ?? L10n.unknown) .font(.footnote) @@ -58,11 +61,8 @@ struct EpisodeCard: View { .multilineTextAlignment(.leading) } } - .frame(width: 200) - } - .buttonStyle(PlainButtonStyle()) - .if(colorScheme == .light) { view in - view.shadow(radius: 4, y: 2) - } + .onSelect { _ in + itemRouter.route(to: \.item, episode) + } } } diff --git a/Swiftfin/Views/ItemView/iOS/CollectionItemView/CollectionItemContentView.swift b/Swiftfin/Views/ItemView/iOS/CollectionItemView/CollectionItemContentView.swift index 4dcbe4f4..59949b61 100644 --- a/Swiftfin/Views/ItemView/iOS/CollectionItemView/CollectionItemContentView.swift +++ b/Swiftfin/Views/ItemView/iOS/CollectionItemView/CollectionItemContentView.swift @@ -26,7 +26,7 @@ extension CollectionItemView { PillHStack( title: L10n.genres, items: genres - ) { genre in + ).onSelect { genre in itemRouter.route(to: \.library, (viewModel: .init(genre: genre), title: genre.title)) } @@ -39,7 +39,7 @@ extension CollectionItemView { PillHStack( title: L10n.studios, items: studios - ) { studio in + ).onSelect { studio in itemRouter.route(to: \.library, (viewModel: .init(studio: studio), title: studio.name ?? "")) } @@ -49,12 +49,10 @@ extension CollectionItemView { // MARK: Items if !viewModel.collectionItems.isEmpty { - PortraitPosterHStack( - title: L10n.items, - items: viewModel.collectionItems - ) { item in - itemRouter.route(to: \.item, item) - } + PosterHStack(title: L10n.items, type: .portrait, items: viewModel.collectionItems) + .onSelect { item in + itemRouter.route(to: \.item, item) + } } } } diff --git a/Swiftfin/Views/ItemView/iOS/CollectionItemView/CollectionItemView.swift b/Swiftfin/Views/ItemView/iOS/CollectionItemView/CollectionItemView.swift index add70278..c558f563 100644 --- a/Swiftfin/Views/ItemView/iOS/CollectionItemView/CollectionItemView.swift +++ b/Swiftfin/Views/ItemView/iOS/CollectionItemView/CollectionItemView.swift @@ -13,7 +13,7 @@ struct CollectionItemView: View { @ObservedObject var viewModel: CollectionItemViewModel - @Default(.itemViewType) + @Default(.Customization.itemViewType) private var itemViewType var body: some View { diff --git a/Swiftfin/Views/ItemView/iOS/EpisodeItemView/EpisodeItemContentView.swift b/Swiftfin/Views/ItemView/iOS/EpisodeItemView/EpisodeItemContentView.swift index 427c42e3..6c9a12c1 100644 --- a/Swiftfin/Views/ItemView/iOS/EpisodeItemView/EpisodeItemContentView.swift +++ b/Swiftfin/Views/ItemView/iOS/EpisodeItemView/EpisodeItemContentView.swift @@ -47,11 +47,10 @@ extension EpisodeItemView { if let genres = viewModel.item.genreItems, !genres.isEmpty { PillHStack( title: L10n.genres, - items: genres, - selectedAction: { genre in - itemRouter.route(to: \.library, (viewModel: .init(genre: genre), title: genre.title)) - } - ) + items: genres + ).onSelect { genre in + itemRouter.route(to: \.library, (viewModel: .init(genre: genre), title: genre.title)) + } Divider() } @@ -60,7 +59,7 @@ extension EpisodeItemView { PillHStack( title: L10n.studios, items: studios - ) { studio in + ).onSelect { studio in itemRouter.route(to: \.library, (viewModel: .init(studio: studio), title: studio.name ?? "")) } @@ -70,12 +69,10 @@ extension EpisodeItemView { if let castAndCrew = viewModel.item.people?.filter(\.isDisplayed), !castAndCrew.isEmpty { - PortraitPosterHStack( - title: L10n.castAndCrew, - items: castAndCrew - ) { person in - itemRouter.route(to: \.library, (viewModel: .init(person: person), title: person.title)) - } + PosterHStack(title: L10n.castAndCrew, type: .portrait, items: castAndCrew) + .onSelect { person in + itemRouter.route(to: \.library, (viewModel: .init(person: person), title: person.title)) + } Divider() } diff --git a/Swiftfin/Views/ItemView/iOS/MovieItemView/MovieItemContentView.swift b/Swiftfin/Views/ItemView/iOS/MovieItemView/MovieItemContentView.swift index d7dfb745..05b18f71 100644 --- a/Swiftfin/Views/ItemView/iOS/MovieItemView/MovieItemContentView.swift +++ b/Swiftfin/Views/ItemView/iOS/MovieItemView/MovieItemContentView.swift @@ -28,7 +28,7 @@ extension MovieItemView { PillHStack( title: L10n.genres, items: genres - ) { genre in + ).onSelect { genre in itemRouter.route(to: \.library, (viewModel: .init(genre: genre), title: genre.title)) } @@ -41,7 +41,7 @@ extension MovieItemView { PillHStack( title: L10n.studios, items: studios - ) { studio in + ).onSelect { studio in itemRouter.route(to: \.library, (viewModel: .init(studio: studio), title: studio.name ?? "")) } @@ -53,12 +53,10 @@ extension MovieItemView { if let castAndCrew = viewModel.item.people?.filter(\.isDisplayed), !castAndCrew.isEmpty { - PortraitPosterHStack( - title: L10n.castAndCrew, - items: castAndCrew - ) { person in - itemRouter.route(to: \.library, (viewModel: .init(person: person), title: person.title)) - } + PosterHStack(title: L10n.castAndCrew, type: .portrait, items: castAndCrew) + .onSelect { person in + itemRouter.route(to: \.library, (viewModel: .init(person: person), title: person.title)) + } Divider() } @@ -66,12 +64,10 @@ extension MovieItemView { // MARK: Similar if !viewModel.similarItems.isEmpty { - PortraitPosterHStack( - title: L10n.recommended, - items: viewModel.similarItems - ) { item in - itemRouter.route(to: \.item, item) - } + PosterHStack(title: L10n.recommended, type: .portrait, items: viewModel.similarItems) + .onSelect { item in + itemRouter.route(to: \.item, item) + } Divider() } diff --git a/Swiftfin/Views/ItemView/iOS/MovieItemView/MovieItemView.swift b/Swiftfin/Views/ItemView/iOS/MovieItemView/MovieItemView.swift index 3cd60a6b..7f137300 100644 --- a/Swiftfin/Views/ItemView/iOS/MovieItemView/MovieItemView.swift +++ b/Swiftfin/Views/ItemView/iOS/MovieItemView/MovieItemView.swift @@ -14,7 +14,7 @@ struct MovieItemView: View { @ObservedObject var viewModel: MovieItemViewModel - @Default(.itemViewType) + @Default(.Customization.itemViewType) private var itemViewType var body: some View { diff --git a/Swiftfin/Views/ItemView/iOS/SeriesItemView/SeriesItemContentView.swift b/Swiftfin/Views/ItemView/iOS/SeriesItemView/SeriesItemContentView.swift index 042bef0b..e0991e57 100644 --- a/Swiftfin/Views/ItemView/iOS/SeriesItemView/SeriesItemContentView.swift +++ b/Swiftfin/Views/ItemView/iOS/SeriesItemView/SeriesItemContentView.swift @@ -29,10 +29,7 @@ extension SeriesItemView { // MARK: Genres if let genres = viewModel.item.genreItems, !genres.isEmpty { - PillHStack( - title: L10n.genres, - items: genres - ) { genre in + PillHStack(title: L10n.genres, items: genres).onSelect { genre in itemRouter.route(to: \.library, (viewModel: .init(genre: genre), title: genre.title)) } @@ -45,7 +42,7 @@ extension SeriesItemView { PillHStack( title: L10n.studios, items: studios - ) { studio in + ).onSelect { studio in itemRouter.route(to: \.library, (viewModel: .init(studio: studio), title: studio.name ?? "")) } @@ -55,12 +52,10 @@ extension SeriesItemView { // MARK: Cast and Crew if let castAndCrew = viewModel.item.people?.filter(\.isDisplayed), !castAndCrew.isEmpty { - PortraitPosterHStack( - title: L10n.castAndCrew, - items: castAndCrew - ) { person in - itemRouter.route(to: \.library, (viewModel: .init(person: person), title: person.title)) - } + PosterHStack(title: L10n.castAndCrew, type: .portrait, items: castAndCrew) + .onSelect { person in + itemRouter.route(to: \.library, (viewModel: .init(person: person), title: person.title)) + } Divider() } @@ -68,12 +63,10 @@ extension SeriesItemView { // MARK: Similar if !viewModel.similarItems.isEmpty { - PortraitPosterHStack( - title: L10n.recommended, - items: viewModel.similarItems - ) { item in - itemRouter.route(to: \.item, item) - } + PosterHStack(title: L10n.recommended, type: .portrait, items: viewModel.similarItems) + .onSelect { item in + itemRouter.route(to: \.item, item) + } Divider() } diff --git a/Swiftfin/Views/ItemView/iOS/SeriesItemView/SeriesItemView.swift b/Swiftfin/Views/ItemView/iOS/SeriesItemView/SeriesItemView.swift index 243dbf8c..2df68ff3 100644 --- a/Swiftfin/Views/ItemView/iOS/SeriesItemView/SeriesItemView.swift +++ b/Swiftfin/Views/ItemView/iOS/SeriesItemView/SeriesItemView.swift @@ -14,7 +14,7 @@ struct SeriesItemView: View { @ObservedObject var viewModel: SeriesItemViewModel - @Default(.itemViewType) + @Default(.Customization.itemViewType) private var itemViewType var body: some View { diff --git a/Swiftfin/Views/ItemView/iPadOS/CollectionItemView/iPadOSCollectionItemContentView.swift b/Swiftfin/Views/ItemView/iPadOS/CollectionItemView/iPadOSCollectionItemContentView.swift index 6d6ccf45..673d497e 100644 --- a/Swiftfin/Views/ItemView/iPadOS/CollectionItemView/iPadOSCollectionItemContentView.swift +++ b/Swiftfin/Views/ItemView/iPadOS/CollectionItemView/iPadOSCollectionItemContentView.swift @@ -26,7 +26,7 @@ extension iPadOSCollectionItemView { PillHStack( title: L10n.genres, items: genres - ) { genre in + ).onSelect { genre in itemRouter.route(to: \.library, (viewModel: .init(genre: genre), title: genre.title)) } @@ -39,7 +39,7 @@ extension iPadOSCollectionItemView { PillHStack( title: L10n.studios, items: studios - ) { studio in + ).onSelect { studio in itemRouter.route(to: \.library, (viewModel: .init(studio: studio), title: studio.name ?? "")) } @@ -49,13 +49,10 @@ extension iPadOSCollectionItemView { // MARK: Items if !viewModel.collectionItems.isEmpty { - PortraitPosterHStack( - title: L10n.items, - items: viewModel.collectionItems, - itemWidth: 130 - ) { item in - itemRouter.route(to: \.item, item) - } + PosterHStack(title: L10n.items, type: .portrait, items: viewModel.collectionItems) + .onSelect { item in + itemRouter.route(to: \.item, item) + } } ItemView.AboutView(viewModel: viewModel) diff --git a/Swiftfin/Views/ItemView/iPadOS/EpisodeItemView/iPadOSEpisodeContentView.swift b/Swiftfin/Views/ItemView/iPadOS/EpisodeItemView/iPadOSEpisodeContentView.swift index 67b45904..91b02cd6 100644 --- a/Swiftfin/Views/ItemView/iPadOS/EpisodeItemView/iPadOSEpisodeContentView.swift +++ b/Swiftfin/Views/ItemView/iPadOS/EpisodeItemView/iPadOSEpisodeContentView.swift @@ -26,11 +26,10 @@ extension iPadOSEpisodeItemView { if let genres = viewModel.item.genreItems, !genres.isEmpty { PillHStack( title: L10n.genres, - items: genres, - selectedAction: { genre in - itemRouter.route(to: \.library, (viewModel: .init(genre: genre), title: genre.title)) - } - ) + items: genres + ).onSelect { genre in + itemRouter.route(to: \.library, (viewModel: .init(genre: genre), title: genre.title)) + } Divider() } @@ -39,7 +38,7 @@ extension iPadOSEpisodeItemView { PillHStack( title: L10n.studios, items: studios - ) { studio in + ).onSelect { studio in itemRouter.route(to: \.library, (viewModel: .init(studio: studio), title: studio.name ?? "")) } @@ -49,13 +48,10 @@ extension iPadOSEpisodeItemView { if let castAndCrew = viewModel.item.people?.filter(\.isDisplayed), !castAndCrew.isEmpty { - PortraitPosterHStack( - title: L10n.castAndCrew, - items: castAndCrew, - itemWidth: 130 - ) { person in - itemRouter.route(to: \.library, (viewModel: .init(person: person), title: person.title)) - } + PosterHStack(title: L10n.castAndCrew, type: .portrait, items: castAndCrew) + .onSelect { person in + itemRouter.route(to: \.library, (viewModel: .init(person: person), title: person.title)) + } Divider() } diff --git a/Swiftfin/Views/ItemView/iPadOS/MovieItemView/iPadOSMovieItemContentView.swift b/Swiftfin/Views/ItemView/iPadOS/MovieItemView/iPadOSMovieItemContentView.swift index dbcf1ca6..82dad318 100644 --- a/Swiftfin/Views/ItemView/iPadOS/MovieItemView/iPadOSMovieItemContentView.swift +++ b/Swiftfin/Views/ItemView/iPadOS/MovieItemView/iPadOSMovieItemContentView.swift @@ -27,7 +27,8 @@ extension iPadOSMovieItemView { PillHStack( title: L10n.genres, items: genres - ) { genre in + ) + .onSelect { genre in itemRouter.route(to: \.library, (viewModel: .init(genre: genre), title: genre.title)) } @@ -40,7 +41,7 @@ extension iPadOSMovieItemView { PillHStack( title: L10n.studios, items: studios - ) { studio in + ).onSelect { studio in itemRouter.route(to: \.library, (viewModel: .init(studio: studio), title: studio.name ?? "")) } @@ -52,13 +53,10 @@ extension iPadOSMovieItemView { if let castAndCrew = viewModel.item.people?.filter(\.isDisplayed), !castAndCrew.isEmpty { - PortraitPosterHStack( - title: L10n.castAndCrew, - items: castAndCrew, - itemWidth: UIDevice.isIPad ? 130 : 110 - ) { person in - itemRouter.route(to: \.library, (viewModel: .init(person: person), title: person.title)) - } + PosterHStack(title: L10n.castAndCrew, type: .portrait, items: castAndCrew) + .onSelect { person in + itemRouter.route(to: \.library, (viewModel: .init(person: person), title: person.title)) + } Divider() } @@ -66,13 +64,10 @@ extension iPadOSMovieItemView { // MARK: Similar if !viewModel.similarItems.isEmpty { - PortraitPosterHStack( - title: L10n.recommended, - items: viewModel.similarItems, - itemWidth: UIDevice.isIPad ? 130 : 110 - ) { item in - itemRouter.route(to: \.item, item) - } + PosterHStack(title: L10n.recommended, type: .portrait, items: viewModel.similarItems) + .onSelect { item in + itemRouter.route(to: \.item, item) + } Divider() } diff --git a/Swiftfin/Views/ItemView/iPadOS/SeriesItemView/iPadOSSeriesItemContentView.swift b/Swiftfin/Views/ItemView/iPadOS/SeriesItemView/iPadOSSeriesItemContentView.swift index 29071e05..469637da 100644 --- a/Swiftfin/Views/ItemView/iPadOS/SeriesItemView/iPadOSSeriesItemContentView.swift +++ b/Swiftfin/Views/ItemView/iPadOS/SeriesItemView/iPadOSSeriesItemContentView.swift @@ -31,7 +31,7 @@ extension iPadOSSeriesItemView { PillHStack( title: L10n.genres, items: genres - ) { genre in + ).onSelect { genre in itemRouter.route(to: \.library, (viewModel: .init(genre: genre), title: genre.title)) } @@ -44,7 +44,7 @@ extension iPadOSSeriesItemView { PillHStack( title: L10n.studios, items: studios - ) { studio in + ).onSelect { studio in itemRouter.route(to: \.library, (viewModel: .init(studio: studio), title: studio.name ?? "")) } @@ -56,13 +56,10 @@ extension iPadOSSeriesItemView { if let castAndCrew = viewModel.item.people?.filter(\.isDisplayed), !castAndCrew.isEmpty { - PortraitPosterHStack( - title: L10n.castAndCrew, - items: castAndCrew, - itemWidth: 130 - ) { person in - itemRouter.route(to: \.library, (viewModel: .init(person: person), title: person.title)) - } + PosterHStack(title: L10n.castAndCrew, type: .portrait, items: castAndCrew) + .onSelect { person in + itemRouter.route(to: \.library, (viewModel: .init(person: person), title: person.title)) + } Divider() } @@ -70,13 +67,10 @@ extension iPadOSSeriesItemView { // MARK: Similar if !viewModel.similarItems.isEmpty { - PortraitPosterHStack( - title: L10n.recommended, - items: viewModel.similarItems, - itemWidth: 130 - ) { item in - itemRouter.route(to: \.item, item) - } + PosterHStack(title: L10n.recommended, type: .portrait, items: viewModel.similarItems) + .onSelect { item in + itemRouter.route(to: \.item, item) + } Divider() } diff --git a/Swiftfin/Views/LibrarySearchView.swift b/Swiftfin/Views/LibrarySearchView.swift index 12a94302..95d112d7 100644 --- a/Swiftfin/Views/LibrarySearchView.swift +++ b/Swiftfin/Views/LibrarySearchView.swift @@ -84,9 +84,10 @@ struct LibrarySearchView: View { if !items.isEmpty { LazyVGrid(columns: tracks) { ForEach(items, id: \.id) { item in - PortraitPosterButton(item: item) { item in - searchRouter.route(to: \.item, item) - } + PosterButton(item: item, type: .portrait) + .onSelect { item in + searchRouter.route(to: \.item, item) + } } } } diff --git a/Swiftfin/Views/LibraryView.swift b/Swiftfin/Views/LibraryView.swift index 0ef898b8..b2945058 100644 --- a/Swiftfin/Views/LibraryView.swift +++ b/Swiftfin/Views/LibraryView.swift @@ -47,9 +47,10 @@ struct LibraryView: View { VStack { LazyVGrid(columns: tracks) { ForEach(viewModel.items, id: \.id) { item in - PortraitPosterButton(item: item) { item in - libraryRouter.route(to: \.item, item) - } + PosterButton(item: item, type: .portrait) + .onSelect { item in + libraryRouter.route(to: \.item, item) + } } } .ignoresSafeArea() diff --git a/Swiftfin/Views/LiveTVProgramsView.swift b/Swiftfin/Views/LiveTVProgramsView.swift index 27cd65b6..5d4ea314 100644 --- a/Swiftfin/Views/LiveTVProgramsView.swift +++ b/Swiftfin/Views/LiveTVProgramsView.swift @@ -21,99 +21,86 @@ struct LiveTVProgramsView: View { if !viewModel.recommendedItems.isEmpty, let items = viewModel.recommendedItems { - - PortraitPosterHStack( - title: "On Now", - items: items - ) { item in - if let chanId = item.channelId, - let chan = viewModel.findChannel(id: chanId) - { - self.viewModel.fetchVideoPlayerViewModel(item: chan) { playerViewModel in - self.programsRouter.route(to: \.videoPlayer, playerViewModel) + PosterHStack(title: "On Now", type: .portrait, items: items) + .onSelect { item in + if let chanId = item.channelId, + let chan = viewModel.findChannel(id: chanId) + { + self.viewModel.fetchVideoPlayerViewModel(item: chan) { playerViewModel in + self.programsRouter.route(to: \.videoPlayer, playerViewModel) + } } } - } } if !viewModel.seriesItems.isEmpty, let items = viewModel.seriesItems { - PortraitPosterHStack( - title: "Shows", - items: items - ) { item in - if let chanId = item.channelId, - let chan = viewModel.findChannel(id: chanId) - { - self.viewModel.fetchVideoPlayerViewModel(item: chan) { playerViewModel in - self.programsRouter.route(to: \.videoPlayer, playerViewModel) + PosterHStack(title: "Shows", type: .portrait, items: items) + .onSelect { item in + if let chanId = item.channelId, + let chan = viewModel.findChannel(id: chanId) + { + self.viewModel.fetchVideoPlayerViewModel(item: chan) { playerViewModel in + self.programsRouter.route(to: \.videoPlayer, playerViewModel) + } } } - } } if !viewModel.movieItems.isEmpty, let items = viewModel.movieItems { - PortraitPosterHStack( - title: "Movies", - items: items - ) { item in - if let chanId = item.channelId, - let chan = viewModel.findChannel(id: chanId) - { - self.viewModel.fetchVideoPlayerViewModel(item: chan) { playerViewModel in - self.programsRouter.route(to: \.videoPlayer, playerViewModel) + PosterHStack(title: "Movies", type: .portrait, items: items) + .onSelect { item in + if let chanId = item.channelId, + let chan = viewModel.findChannel(id: chanId) + { + self.viewModel.fetchVideoPlayerViewModel(item: chan) { playerViewModel in + self.programsRouter.route(to: \.videoPlayer, playerViewModel) + } } } - } } if !viewModel.sportsItems.isEmpty, let items = viewModel.sportsItems { - PortraitPosterHStack( - title: "Sports", - items: items - ) { item in - if let chanId = item.channelId, - let chan = viewModel.findChannel(id: chanId) - { - self.viewModel.fetchVideoPlayerViewModel(item: chan) { playerViewModel in - self.programsRouter.route(to: \.videoPlayer, playerViewModel) + PosterHStack(title: "Sports", type: .portrait, items: items) + .onSelect { item in + if let chanId = item.channelId, + let chan = viewModel.findChannel(id: chanId) + { + self.viewModel.fetchVideoPlayerViewModel(item: chan) { playerViewModel in + self.programsRouter.route(to: \.videoPlayer, playerViewModel) + } } } - } } if !viewModel.kidsItems.isEmpty, let items = viewModel.kidsItems { - PortraitPosterHStack( - title: "Kids", - items: items - ) { item in - if let chanId = item.channelId, - let chan = viewModel.findChannel(id: chanId) - { - self.viewModel.fetchVideoPlayerViewModel(item: chan) { playerViewModel in - self.programsRouter.route(to: \.videoPlayer, playerViewModel) + PosterHStack(title: "Kids", type: .portrait, items: items) + .onSelect { item in + if let chanId = item.channelId, + let chan = viewModel.findChannel(id: chanId) + { + self.viewModel.fetchVideoPlayerViewModel(item: chan) { playerViewModel in + self.programsRouter.route(to: \.videoPlayer, playerViewModel) + } } } - } } if !viewModel.newsItems.isEmpty, let items = viewModel.newsItems { - PortraitPosterHStack( - title: "News", - items: items - ) { item in - if let chanId = item.channelId, - let chan = viewModel.findChannel(id: chanId) - { - self.viewModel.fetchVideoPlayerViewModel(item: chan) { playerViewModel in - self.programsRouter.route(to: \.videoPlayer, playerViewModel) + PosterHStack(title: "News", type: .portrait, items: items) + .onSelect { item in + if let chanId = item.channelId, + let chan = viewModel.findChannel(id: chanId) + { + self.viewModel.fetchVideoPlayerViewModel(item: chan) { playerViewModel in + self.programsRouter.route(to: \.videoPlayer, playerViewModel) + } } } - } } } } diff --git a/Swiftfin/Views/SettingsView/CustomizeViewsSettings.swift b/Swiftfin/Views/SettingsView/CustomizeViewsSettings.swift index ee86e523..5c4123f1 100644 --- a/Swiftfin/Views/SettingsView/CustomizeViewsSettings.swift +++ b/Swiftfin/Views/SettingsView/CustomizeViewsSettings.swift @@ -11,24 +11,95 @@ import SwiftUI struct CustomizeViewsSettings: View { - @Default(.showPosterLabels) - var showPosterLabels - @Default(.showCastAndCrew) - var showCastAndCrew - @Default(.showFlattenView) + @Default(.Customization.showFlattenView) var showFlattenView + @Default(.Customization.itemViewType) + var itemViewType + + @Default(.shouldShowMissingSeasons) + var shouldShowMissingSeasons + @Default(.shouldShowMissingEpisodes) + var shouldShowMissingEpisodes + + @Default(.Customization.showPosterLabels) + var showPosterLabels + @Default(.Customization.nextUpPosterType) + var nextUpPosterType + @Default(.Customization.recentlyAddedPosterType) + var recentlyAddedPosterType + @Default(.Customization.latestInLibraryPosterType) + var latestInLibraryPosterType + @Default(.Customization.recommendedPosterType) + var recommendedPosterType + + @Default(.Customization.Episodes.useSeriesLandscapeBackdrop) + var useSeriesLandscapeBackdrop var body: some View { - Form { + List { + Section { + + Toggle(L10n.showFlattenView, isOn: $showFlattenView) + + Picker(L10n.items, selection: $itemViewType) { + ForEach(ItemViewType.allCases, id: \.self) { type in + Text(type.localizedName).tag(type.rawValue) + } + } + + } header: { + EmptyView() + } + + Section { + Toggle(L10n.showMissingSeasons, isOn: $shouldShowMissingSeasons) + Toggle(L10n.showMissingEpisodes, isOn: $shouldShowMissingEpisodes) + } header: { + L10n.missingItems.text + } + Section { Toggle(L10n.showPosterLabels, isOn: $showPosterLabels) - Toggle(L10n.showCastAndCrew, isOn: $showCastAndCrew) - Toggle(L10n.showFlattenView, isOn: $showFlattenView) + + Picker(L10n.nextUp, selection: $nextUpPosterType) { + ForEach(PosterType.allCases, id: \.self) { type in + Text(type.localizedName).tag(type.rawValue) + } + } + + Picker(L10n.recentlyAdded, selection: $recentlyAddedPosterType) { + ForEach(PosterType.allCases, id: \.self) { type in + Text(type.localizedName).tag(type.rawValue) + } + } + + Picker(L10n.library, selection: $latestInLibraryPosterType) { + ForEach(PosterType.allCases, id: \.self) { type in + Text(type.localizedName).tag(type.rawValue) + } + } + + // TODO: Take time to do this for a lot of views +// Picker(L10n.recommended, selection: $recommendedPosterType) { +// ForEach(PosterType.allCases, id: \.self) { type in +// Text(type.localizedName).tag(type.rawValue) +// } +// } } header: { - L10n.customize.text + // TODO: localize after organization + Text("Posters") + } + + Section { + Toggle("Series Backdrop", isOn: $useSeriesLandscapeBackdrop) + } header: { + // TODO: think of a better name + // TODO: localize after organization + Text("Episode Landscape Poster") } } + .navigationTitle(L10n.customize) } } diff --git a/Swiftfin/Views/SettingsView/MissingItemsSettingsView.swift b/Swiftfin/Views/SettingsView/MissingItemsSettingsView.swift deleted file mode 100644 index cfc1a06f..00000000 --- a/Swiftfin/Views/SettingsView/MissingItemsSettingsView.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) 2022 Jellyfin & Jellyfin Contributors -// - -import Defaults -import SwiftUI - -struct MissingItemsSettingsView: View { - - @Default(.shouldShowMissingSeasons) - var shouldShowMissingSeasons - - @Default(.shouldShowMissingEpisodes) - var shouldShowMissingEpisodes - - var body: some View { - Form { - Section { - Toggle(L10n.showMissingSeasons, isOn: $shouldShowMissingSeasons) - Toggle(L10n.showMissingEpisodes, isOn: $shouldShowMissingEpisodes) - } header: { - L10n.missingItems.text - } - } - } -} diff --git a/Swiftfin/Views/SettingsView/SettingsView.swift b/Swiftfin/Views/SettingsView/SettingsView.swift index 074e53bc..aa41559c 100644 --- a/Swiftfin/Views/SettingsView/SettingsView.swift +++ b/Swiftfin/Views/SettingsView/SettingsView.swift @@ -38,8 +38,6 @@ struct SettingsView: View { var resumeOffset @Default(.subtitleSize) var subtitleSize - @Default(.itemViewType) - var itemViewType @Default(.subtitleFontName) var subtitleFontName @@ -148,24 +146,6 @@ struct SettingsView: View { } } - // Not localized yet. Will be in a settings re-organization - Picker("Item View", selection: $itemViewType) { - ForEach(ItemViewType.allCases, id: \.self) { itemViewType in - Text(itemViewType.label).tag(itemViewType.rawValue) - } - } - - Button { - settingsRouter.route(to: \.missingSettings) - } label: { - HStack { - L10n.missingItems.text - .foregroundColor(.primary) - Spacer() - Image(systemName: "chevron.right") - } - } - Picker(L10n.appearance, selection: $appAppearance) { ForEach(AppAppearance.allCases, id: \.self) { appearance in Text(appearance.localizedName).tag(appearance.rawValue)