iOS/iPadOS - Landscape/Thumb Posters (#526)

This commit is contained in:
Ethan Pippin 2022-08-18 11:00:33 -06:00 committed by GitHub
parent 8911ef9ec8
commit 8181db13de
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
52 changed files with 1412 additions and 949 deletions

View File

@ -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()

View File

@ -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),

View File

@ -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] {
[]
}
}

View File

@ -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)
}
}

View File

@ -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

View File

@ -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]
}

View File

@ -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"
}
}
}

View File

@ -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<HTTPScheme>("defaultHTTPScheme", default: .http, suite: SwiftfinStore.Defaults.universalSuite)
static let appAppearance = Key<AppAppearance>("appAppearance", default: .system, suite: SwiftfinStore.Defaults.universalSuite)
static let defaultHTTPScheme = Key<HTTPScheme>("defaultHTTPScheme", default: .http, suite: .universalSuite)
static let appAppearance = Key<AppAppearance>("appAppearance", default: .system, suite: .universalSuite)
// General settings
static let lastServerUserID = Defaults.Key<String?>("lastServerUserID", suite: SwiftfinStore.Defaults.generalSuite)
static let inNetworkBandwidth = Key<Int>("InNetworkBandwidth", default: 40_000_000, suite: SwiftfinStore.Defaults.generalSuite)
static let outOfNetworkBandwidth = Key<Int>("OutOfNetworkBandwidth", default: 40_000_000, suite: SwiftfinStore.Defaults.generalSuite)
static let isAutoSelectSubtitles = Key<Bool>("isAutoSelectSubtitles", default: false, suite: SwiftfinStore.Defaults.generalSuite)
static let autoSelectSubtitlesLangCode = Key<String>(
"AutoSelectSubtitlesLangCode",
default: "Auto",
suite: SwiftfinStore.Defaults.generalSuite
)
static let autoSelectAudioLangCode = Key<String>("AutoSelectAudioLangCode", default: "Auto", suite: SwiftfinStore.Defaults.generalSuite)
static let lastServerUserID = Defaults.Key<String?>("lastServerUserID", suite: .generalSuite)
static let inNetworkBandwidth = Key<Int>("InNetworkBandwidth", default: 40_000_000, suite: .generalSuite)
static let outOfNetworkBandwidth = Key<Int>("OutOfNetworkBandwidth", default: 40_000_000, suite: .generalSuite)
// Customize settings
static let showPosterLabels = Key<Bool>("showPosterLabels", default: true, suite: SwiftfinStore.Defaults.generalSuite)
static let showCastAndCrew = Key<Bool>("showCastAndCrew", default: true, suite: SwiftfinStore.Defaults.generalSuite)
static let showFlattenView = Key<Bool>("showFlattenView", default: true, suite: SwiftfinStore.Defaults.generalSuite)
static let itemViewType = Key<ItemViewType>("itemViewType", default: .compactLogo, suite: SwiftfinStore.Defaults.generalSuite)
enum Customization {
static let showFlattenView = Key<Bool>("showFlattenView", default: true, suite: .generalSuite)
static let itemViewType = Key<ItemViewType>("itemViewType", default: .compactLogo, suite: .generalSuite)
static let showPosterLabels = Key<Bool>("showPosterLabels", default: true, suite: .generalSuite)
static let nextUpPosterType = Key<PosterType>("nextUpPosterType", default: .portrait, suite: .generalSuite)
static let recentlyAddedPosterType = Key<PosterType>("recentlyAddedPosterType", default: .portrait, suite: .generalSuite)
static let latestInLibraryPosterType = Key<PosterType>("latestInLibraryPosterType", default: .portrait, suite: .generalSuite)
static let recommendedPosterType = Key<PosterType>("recommendedPosterType", default: .portrait, suite: .generalSuite)
enum Episodes {
static let useSeriesLandscapeBackdrop = Key<Bool>("useSeriesBackdrop", default: true, suite: .generalSuite)
}
}
// Video player / overlay settings
static let overlayType = Key<OverlayType>("overlayType", default: .normal, suite: SwiftfinStore.Defaults.generalSuite)
static let jumpGesturesEnabled = Key<Bool>("gesturesEnabled", default: true, suite: SwiftfinStore.Defaults.generalSuite)
static let overlayType = Key<OverlayType>("overlayType", default: .normal, suite: .generalSuite)
static let jumpGesturesEnabled = Key<Bool>("gesturesEnabled", default: true, suite: .generalSuite)
static let systemControlGesturesEnabled = Key<Bool>(
"systemControlGesturesEnabled",
default: true,
suite: SwiftfinStore.Defaults.generalSuite
suite: .generalSuite
)
static let playerGesturesLockGestureEnabled = Key<Bool>(
"playerGesturesLockGestureEnabled",
default: true,
suite: SwiftfinStore.Defaults.generalSuite
suite: .generalSuite
)
static let seekSlideGestureEnabled = Key<Bool>(
"seekSlideGestureEnabled",
default: true,
suite: SwiftfinStore.Defaults.generalSuite
suite: .generalSuite
)
static let videoPlayerJumpForward = Key<VideoPlayerJumpLength>(
"videoPlayerJumpForward",
default: .fifteen,
suite: SwiftfinStore.Defaults.generalSuite
suite: .generalSuite
)
static let videoPlayerJumpBackward = Key<VideoPlayerJumpLength>(
"videoPlayerJumpBackward",
default: .fifteen,
suite: SwiftfinStore.Defaults.generalSuite
suite: .generalSuite
)
static let autoplayEnabled = Key<Bool>("autoPlayNextItem", default: true, suite: SwiftfinStore.Defaults.generalSuite)
static let resumeOffset = Key<Bool>("resumeOffset", default: false, suite: SwiftfinStore.Defaults.generalSuite)
static let autoplayEnabled = Key<Bool>("autoPlayNextItem", default: true, suite: .generalSuite)
static let resumeOffset = Key<Bool>("resumeOffset", default: false, suite: .generalSuite)
static let subtitleFontName = Key<String>(
"subtitleFontName",
default: UIFont.systemFont(ofSize: 14).fontName,
suite: SwiftfinStore.Defaults.generalSuite
suite: .generalSuite
)
static let subtitleSize = Key<SubtitleSize>("subtitleSize", default: .regular, suite: SwiftfinStore.Defaults.generalSuite)
static let subtitleSize = Key<SubtitleSize>("subtitleSize", default: .regular, suite: .generalSuite)
// Should show video player items
static let shouldShowPlayPreviousItem = Key<Bool>("shouldShowPreviousItem", default: true, suite: SwiftfinStore.Defaults.generalSuite)
static let shouldShowPlayNextItem = Key<Bool>("shouldShowNextItem", default: true, suite: SwiftfinStore.Defaults.generalSuite)
static let shouldShowAutoPlay = Key<Bool>("shouldShowAutoPlayNextItem", default: true, suite: SwiftfinStore.Defaults.generalSuite)
static let shouldShowPlayPreviousItem = Key<Bool>("shouldShowPreviousItem", default: true, suite: .generalSuite)
static let shouldShowPlayNextItem = Key<Bool>("shouldShowNextItem", default: true, suite: .generalSuite)
static let shouldShowAutoPlay = Key<Bool>("shouldShowAutoPlayNextItem", default: true, suite: .generalSuite)
// Should show missing seasons and episodes
static let shouldShowMissingSeasons = Key<Bool>("shouldShowMissingSeasons", default: true, suite: SwiftfinStore.Defaults.generalSuite)
static let shouldShowMissingEpisodes = Key<Bool>("shouldShowMissingEpisodes", default: true, suite: SwiftfinStore.Defaults.generalSuite)
static let shouldShowMissingSeasons = Key<Bool>("shouldShowMissingSeasons", default: true, suite: .generalSuite)
static let shouldShowMissingEpisodes = Key<Bool>("shouldShowMissingEpisodes", default: true, suite: .generalSuite)
// Should show video player items in overlay menu
static let shouldShowJumpButtonsInOverlayMenu = Key<Bool>(
"shouldShowJumpButtonsInMenu",
default: true,
suite: SwiftfinStore.Defaults.generalSuite
suite: .generalSuite
)
static let shouldShowChaptersInfoInBottomOverlay = Key<Bool>(
"shouldShowChaptersInfoInBottomOverlay",
default: true,
suite: SwiftfinStore.Defaults.generalSuite
suite: .generalSuite
)
// Experimental settings
@ -107,16 +106,16 @@ extension Defaults.Keys {
static let syncSubtitleStateWithAdjacent = Key<Bool>(
"experimental.syncSubtitleState",
default: false,
suite: SwiftfinStore.Defaults.generalSuite
suite: .generalSuite
)
static let forceDirectPlay = Key<Bool>("forceDirectPlay", default: false, suite: SwiftfinStore.Defaults.generalSuite)
static let nativePlayer = Key<Bool>("nativePlayer", default: false, suite: SwiftfinStore.Defaults.generalSuite)
static let liveTVAlphaEnabled = Key<Bool>("liveTVAlphaEnabled", default: false, suite: SwiftfinStore.Defaults.generalSuite)
static let liveTVForceDirectPlay = Key<Bool>("liveTVForceDirectPlay", default: false, suite: SwiftfinStore.Defaults.generalSuite)
static let liveTVNativePlayer = Key<Bool>("liveTVNativePlayer", default: false, suite: SwiftfinStore.Defaults.generalSuite)
static let forceDirectPlay = Key<Bool>("forceDirectPlay", default: false, suite: .generalSuite)
static let nativePlayer = Key<Bool>("nativePlayer", default: false, suite: .generalSuite)
static let liveTVAlphaEnabled = Key<Bool>("liveTVAlphaEnabled", default: false, suite: .generalSuite)
static let liveTVForceDirectPlay = Key<Bool>("liveTVForceDirectPlay", default: false, suite: .generalSuite)
static let liveTVNativePlayer = Key<Bool>("liveTVNativePlayer", default: false, suite: .generalSuite)
}
// tvos specific
static let downActionShowsMenu = Key<Bool>("downActionShowsMenu", default: true, suite: SwiftfinStore.Defaults.generalSuite)
static let confirmClose = Key<Bool>("confirmClose", default: false, suite: SwiftfinStore.Defaults.generalSuite)
static let downActionShowsMenu = Key<Bool>("downActionShowsMenu", default: true, suite: .generalSuite)
static let confirmClose = Key<Bool>("confirmClose", default: false, suite: .generalSuite)
}

View File

@ -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() {

View File

@ -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(

View File

@ -70,8 +70,10 @@ struct ImageView<ImageType: View, PlaceholderView: View, FailureView: View>: 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))

View File

@ -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<Item: PortraitPoster>: 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()
}
}

View File

@ -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<Item: PortraitPoster, TrailingContent: View>: 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
}
}

View File

@ -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<Item: Poster, Content: View, ImageOverlay: View, ContextMenu: View>: 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<Item>,
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<C: View>(@ViewBuilder _ content: @escaping (Item) -> C) -> PosterButton<Item, C, ImageOverlay, ContextMenu> {
PosterButton<Item, C, ImageOverlay, ContextMenu>(
item: item,
type: type,
itemScale: itemScale,
horizontalAlignment: horizontalAlignment,
content: content,
imageOverlay: imageOverlay,
contextMenu: contextMenu,
onSelect: onSelect,
singleImage: singleImage
)
}
@ViewBuilder
func imageOverlay<O: View>(@ViewBuilder _ imageOverlay: @escaping (Item) -> O) -> PosterButton<Item, Content, O, ContextMenu> {
PosterButton<Item, Content, O, ContextMenu>(
item: item,
type: type,
itemScale: itemScale,
horizontalAlignment: horizontalAlignment,
content: content,
imageOverlay: imageOverlay,
contextMenu: contextMenu,
onSelect: onSelect,
singleImage: singleImage
)
}
@ViewBuilder
func contextMenu<M: View>(@ViewBuilder _ contextMenu: @escaping (Item) -> M) -> PosterButton<Item, Content, ImageOverlay, M> {
PosterButton<Item, Content, ImageOverlay, M>(
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<Item: Poster>: 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)
}
}
}
}

View File

@ -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<Item: Poster, Content: View, ImageOverlay: View, ContextMenu: View, TrailingContent: View>: 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<Item>,
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<C: View>(@ViewBuilder _ content: @escaping (Item) -> C)
-> PosterHStack<Item, C, ImageOverlay, ContextMenu, TrailingContent> {
PosterHStack<Item, C, ImageOverlay, ContextMenu, TrailingContent>(
title: title,
type: type,
items: items,
itemScale: itemScale,
content: content,
imageOverlay: imageOverlay,
contextMenu: contextMenu,
trailingContent: trailingContent,
onSelect: onSelect
)
}
@ViewBuilder
func imageOverlay<O: View>(@ViewBuilder _ imageOverlay: @escaping (Item) -> O)
-> PosterHStack<Item, Content, O, ContextMenu, TrailingContent> {
PosterHStack<Item, Content, O, ContextMenu, TrailingContent>(
title: title,
type: type,
items: items,
itemScale: itemScale,
content: content,
imageOverlay: imageOverlay,
contextMenu: contextMenu,
trailingContent: trailingContent,
onSelect: onSelect
)
}
@ViewBuilder
func contextMenu<M: View>(@ViewBuilder _ contextMenu: @escaping (Item) -> M)
-> PosterHStack<Item, Content, ImageOverlay, M, TrailingContent> {
PosterHStack<Item, Content, ImageOverlay, M, TrailingContent>(
title: title,
type: type,
items: items,
itemScale: itemScale,
content: content,
imageOverlay: imageOverlay,
contextMenu: contextMenu,
trailingContent: trailingContent,
onSelect: onSelect
)
}
@ViewBuilder
func trailing<T: View>(@ViewBuilder _ trailingContent: @escaping () -> T)
-> PosterHStack<Item, Content, ImageOverlay, ContextMenu, T> {
PosterHStack<Item, Content, ImageOverlay, ContextMenu, T>(
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
)
}
}

View File

@ -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)
}
}
}

View File

@ -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")

View File

@ -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")

View File

@ -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")

View File

@ -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")

View File

@ -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)
}
}
}

View File

@ -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: {

View File

@ -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)

View File

@ -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 = "<group>"; };
53E4E648263F725B00F67C6B /* MultiSelectorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiSelectorView.swift; sourceTree = "<group>"; };
53EE24E5265060780068F029 /* LibrarySearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibrarySearchView.swift; sourceTree = "<group>"; };
53F866432687A45F00DCD1D7 /* PortraitPosterButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PortraitPosterButton.swift; sourceTree = "<group>"; };
5D1603FB278A3D5700D22B99 /* SubtitleSize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubtitleSize.swift; sourceTree = "<group>"; };
5D160402278A41FD00D22B99 /* VLCPlayer+subtitles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "VLCPlayer+subtitles.swift"; sourceTree = "<group>"; };
5D64683C277B1649009E09AE /* PreferenceUIHostingSwizzling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferenceUIHostingSwizzling.swift; sourceTree = "<group>"; };
@ -781,15 +780,14 @@
E1546779289AF48200087E35 /* CollectionItemContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionItemContentView.swift; sourceTree = "<group>"; };
E168BD08289A4162001A6922 /* HomeView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = "<group>"; };
E168BD09289A4162001A6922 /* HomeContentView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HomeContentView.swift; sourceTree = "<group>"; };
E168BD0C289A4162001A6922 /* ContinueWatchingCard.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ContinueWatchingCard.swift; sourceTree = "<group>"; };
E168BD0D289A4162001A6922 /* ContinueWatchingView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ContinueWatchingView.swift; sourceTree = "<group>"; };
E168BD0E289A4162001A6922 /* LatestInLibraryView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LatestInLibraryView.swift; sourceTree = "<group>"; };
E168BD0F289A4162001A6922 /* HomeErrorView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HomeErrorView.swift; sourceTree = "<group>"; };
E16AA60728A364A6009A983C /* PosterButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PosterButton.swift; sourceTree = "<group>"; };
E173DA4F26D048D600CC4EB7 /* ServerDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerDetailView.swift; sourceTree = "<group>"; };
E173DA5126D04AAF00CC4EB7 /* ColorExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorExtension.swift; sourceTree = "<group>"; };
E173DA5326D050F500CC4EB7 /* ServerDetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerDetailViewModel.swift; sourceTree = "<group>"; };
E176DE6C278E30D2001EFD8D /* EpisodeCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EpisodeCard.swift; sourceTree = "<group>"; };
E176DE6F278E369F001EFD8D /* MissingItemsSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MissingItemsSettingsView.swift; sourceTree = "<group>"; };
E178859A2780F1F40094FBCF /* tvOSSlider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = tvOSSlider.swift; sourceTree = "<group>"; };
E178859D2780F53B0094FBCF /* SliderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SliderView.swift; sourceTree = "<group>"; };
E178859F2780F55C0094FBCF /* tvOSVLCOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = tvOSVLCOverlay.swift; sourceTree = "<group>"; };
@ -800,7 +798,6 @@
E18CE0B128A229E70092E7F1 /* UserDtoExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDtoExtensions.swift; sourceTree = "<group>"; };
E18CE0B328A22EDA0092E7F1 /* RepeatingTimer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RepeatingTimer.swift; sourceTree = "<group>"; };
E18CE0B828A2322D0092E7F1 /* QuickConnectCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickConnectCoordinator.swift; sourceTree = "<group>"; };
E18E01A3288746AF0022598C /* PortraitPosterHStack.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PortraitPosterHStack.swift; sourceTree = "<group>"; };
E18E01A4288746AF0022598C /* RefreshableScrollView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RefreshableScrollView.swift; sourceTree = "<group>"; };
E18E01A5288746AF0022598C /* PillHStack.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PillHStack.swift; sourceTree = "<group>"; };
E18E01A7288746AF0022598C /* DotHStack.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DotHStack.swift; sourceTree = "<group>"; };
@ -888,9 +885,11 @@
E1C926082887565C002A7A66 /* FocusGuide.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FocusGuide.swift; sourceTree = "<group>"; };
E1C926092887565C002A7A66 /* EpisodeCard.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EpisodeCard.swift; sourceTree = "<group>"; };
E1C9260A2887565C002A7A66 /* SeriesItemView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SeriesItemView.swift; sourceTree = "<group>"; };
E1C92617288756BD002A7A66 /* PortraitButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PortraitButton.swift; sourceTree = "<group>"; };
E1C92617288756BD002A7A66 /* PosterButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PosterButton.swift; sourceTree = "<group>"; };
E1C92618288756BD002A7A66 /* DotHStack.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DotHStack.swift; sourceTree = "<group>"; };
E1C92619288756BD002A7A66 /* PortraitPosterHStack.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PortraitPosterHStack.swift; sourceTree = "<group>"; };
E1C92619288756BD002A7A66 /* PosterHStack.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PosterHStack.swift; sourceTree = "<group>"; };
E1CCF12D28ABF989006CAC9E /* PosterType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PosterType.swift; sourceTree = "<group>"; };
E1CCF13028AC07EC006CAC9E /* PosterHStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PosterHStack.swift; sourceTree = "<group>"; };
E1CEFBF427914C7700F60429 /* CustomizeViewsSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomizeViewsSettings.swift; sourceTree = "<group>"; };
E1CEFBF627914E6400F60429 /* CustomizeViewsSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomizeViewsSettings.swift; sourceTree = "<group>"; };
E1D4BF7B2719D05000A11E64 /* BasicAppSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BasicAppSettingsView.swift; sourceTree = "<group>"; };
@ -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 = "<group>";
};
E168BD0B289A4162001A6922 /* ContinueWatchingView */ = {
isa = PBXGroup;
children = (
E168BD0C289A4162001A6922 /* ContinueWatchingCard.swift */,
E168BD0D289A4162001A6922 /* ContinueWatchingView.swift */,
);
path = ContinueWatchingView;
sourceTree = "<group>";
};
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 */,

View File

@ -12,7 +12,17 @@ struct PillHStack<Item: PillStackable>: 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<Item: PillStackable>: 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<Item: PillStackable>: 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
)
}
}

View File

@ -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<Item: PortraitPoster>: 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)
}
}
}

View File

@ -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<Item: PortraitPoster, TrailingContent: View>: 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
}
}

View File

@ -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<Item: Poster, Content: View, ImageOverlay: View, ContextMenu: View>: 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<Item>,
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<C: View>(@ViewBuilder _ content: @escaping (Item) -> C) -> PosterButton<Item, C, ImageOverlay, ContextMenu> {
PosterButton<Item, C, ImageOverlay, ContextMenu>(
item: item,
type: type,
itemScale: itemScale,
horizontalAlignment: horizontalAlignment,
content: content,
imageOverlay: imageOverlay,
contextMenu: contextMenu,
onSelect: onSelect,
singleImage: singleImage
)
}
@ViewBuilder
func imageOverlay<O: View>(@ViewBuilder _ imageOverlay: @escaping (Item) -> O) -> PosterButton<Item, Content, O, ContextMenu> {
PosterButton<Item, Content, O, ContextMenu>(
item: item,
type: type,
itemScale: itemScale,
horizontalAlignment: horizontalAlignment,
content: content,
imageOverlay: imageOverlay,
contextMenu: contextMenu,
onSelect: onSelect,
singleImage: singleImage
)
}
@ViewBuilder
func contextMenu<M: View>(@ViewBuilder _ contextMenu: @escaping (Item) -> M) -> PosterButton<Item, Content, ImageOverlay, M> {
PosterButton<Item, Content, ImageOverlay, M>(
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<Item: Poster>: 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)
}
}
}
}

View File

@ -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<Item: Poster, Content: View, ImageOverlay: View, ContextMenu: View, TrailingContent: View>: 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<Item>,
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<C: View>(@ViewBuilder _ content: @escaping (Item) -> C)
-> PosterHStack<Item, C, ImageOverlay, ContextMenu, TrailingContent> {
PosterHStack<Item, C, ImageOverlay, ContextMenu, TrailingContent>(
title: title,
type: type,
items: items,
itemScale: itemScale,
content: content,
imageOverlay: imageOverlay,
contextMenu: contextMenu,
trailingContent: trailingContent,
onSelect: onSelect
)
}
@ViewBuilder
func imageOverlay<O: View>(@ViewBuilder _ imageOverlay: @escaping (Item) -> O)
-> PosterHStack<Item, Content, O, ContextMenu, TrailingContent> {
PosterHStack<Item, Content, O, ContextMenu, TrailingContent>(
title: title,
type: type,
items: items,
itemScale: itemScale,
content: content,
imageOverlay: imageOverlay,
contextMenu: contextMenu,
trailingContent: trailingContent,
onSelect: onSelect
)
}
@ViewBuilder
func contextMenu<M: View>(@ViewBuilder _ contextMenu: @escaping (Item) -> M)
-> PosterHStack<Item, Content, ImageOverlay, M, TrailingContent> {
PosterHStack<Item, Content, ImageOverlay, M, TrailingContent>(
title: title,
type: type,
items: items,
itemScale: itemScale,
content: content,
imageOverlay: imageOverlay,
contextMenu: contextMenu,
trailingContent: trailingContent,
onSelect: onSelect
)
}
@ViewBuilder
func trailing<T: View>(@ViewBuilder _ trailingContent: @escaping () -> T)
-> PosterHStack<Item, Content, ImageOverlay, ContextMenu, T> {
PosterHStack<Item, Content, ImageOverlay, ContextMenu, T>(
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
)
}
}

View File

@ -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)
}
}
}
}
}
}
}

View File

@ -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)
}
}
}
}
}
}

View File

@ -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)
}
}
}

View File

@ -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)
}
}
}

View File

@ -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

View File

@ -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)
}
}
}

View File

@ -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)
}
}
}
}

View File

@ -13,7 +13,7 @@ struct CollectionItemView: View {
@ObservedObject
var viewModel: CollectionItemViewModel
@Default(.itemViewType)
@Default(.Customization.itemViewType)
private var itemViewType
var body: some View {

View File

@ -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()
}

View File

@ -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()
}

View File

@ -14,7 +14,7 @@ struct MovieItemView: View {
@ObservedObject
var viewModel: MovieItemViewModel
@Default(.itemViewType)
@Default(.Customization.itemViewType)
private var itemViewType
var body: some View {

View File

@ -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()
}

View File

@ -14,7 +14,7 @@ struct SeriesItemView: View {
@ObservedObject
var viewModel: SeriesItemViewModel
@Default(.itemViewType)
@Default(.Customization.itemViewType)
private var itemViewType
var body: some View {

View File

@ -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)

View File

@ -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()
}

View File

@ -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()
}

View File

@ -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()
}

View File

@ -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)
}
}
}
}

View File

@ -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()

View File

@ -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)
}
}
}
}
}
}
}

View File

@ -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)
}
}

View File

@ -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
}
}
}
}

View File

@ -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)