tvOS - Refactor Cinematic Item Selector (#564)

This commit is contained in:
Ethan Pippin 2022-09-09 19:11:09 -06:00 committed by GitHub
parent 859a47803f
commit 3c81c7532f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 631 additions and 814 deletions

View File

@ -56,9 +56,7 @@ extension BaseItemDto {
}
func seriesImageURL(_ type: ImageType, maxWidth: CGFloat? = nil, maxHeight: CGFloat? = nil) -> URL {
let maxWidth = maxWidth != nil ? Int(maxWidth!) : nil
let maxHeight = maxHeight != nil ? Int(maxHeight!) : nil
return _imageURL(type, maxWidth: maxWidth, maxHeight: maxHeight, itemID: seriesId ?? "")
_imageURL(type, maxWidth: Int(maxWidth), maxHeight: Int(maxHeight), itemID: seriesId ?? "")
}
func seriesImageSource(_ type: ImageType, maxWidth: Int? = nil, maxHeight: Int? = nil) -> ImageSource {
@ -66,6 +64,10 @@ extension BaseItemDto {
return ImageSource(url: url, blurHash: nil)
}
func seriesImageSource(_ type: ImageType, maxWidth: CGFloat? = nil, maxHeight: CGFloat? = nil) -> ImageSource {
seriesImageSource(type, maxWidth: Int(maxWidth), maxHeight: Int(maxWidth))
}
func seriesImageSource(_ type: ImageType, maxWidth: CGFloat) -> ImageSource {
seriesImageSource(type, maxWidth: Int(maxWidth))
}

View File

@ -11,6 +11,7 @@ import JellyfinAPI
import UIKit
extension BaseItemDto: Identifiable {}
extension BaseItemDto: LibraryParent {}
extension BaseItemDto {
@ -247,5 +248,3 @@ extension BaseItemDtoImageBlurHashes {
}
}
}
extension BaseItemDto: LibraryParent {}

View File

@ -34,9 +34,9 @@ enum PosterType: String, CaseIterable, Defaults.Serializable {
enum Width {
#if os(tvOS)
static let portrait = 250.0
static let portrait = 200.0
static let landscape = 490.0
static let landscape = 350.0
#else
@ScaledMetric(relativeTo: .largeTitle)
static var portrait = 100.0

View File

@ -23,6 +23,5 @@ struct ProgressBar: View {
.scaleEffect(x: progress, y: 1, anchor: .leading)
}
.frame(maxWidth: .infinity)
.frame(height: 3)
}
}

View File

@ -0,0 +1,245 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If 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 Combine
import JellyfinAPI
import Nuke
import SwiftUI
struct CinematicItemSelector<Item: Poster, TopContent: View, ItemContent: View, ItemImageOverlay: View, ItemContextMenu: View>: View {
@ObservedObject
private var viewModel: CinematicBackgroundView.ViewModel = .init()
private var topContent: (Item) -> TopContent
private var itemContent: (Item) -> ItemContent
private var itemImageOverlay: (Item) -> ItemImageOverlay
private var itemContextMenu: (Item) -> ItemContextMenu
private var onSelect: (Item) -> Void
let items: [Item]
var body: some View {
ZStack(alignment: .bottomLeading) {
ZStack {
CinematicBackgroundView(viewModel: viewModel, initialItem: items.first)
.ignoresSafeArea()
LinearGradient(
stops: [
.init(color: .clear, location: 0.5),
.init(color: .black.opacity(0.4), location: 0.6),
.init(color: .black, location: 1),
],
startPoint: .top,
endPoint: .bottom
)
}
.mask {
LinearGradient(
stops: [
.init(color: .white, location: 0.9),
.init(color: .clear, location: 1),
],
startPoint: .top,
endPoint: .bottom
)
}
VStack(alignment: .leading, spacing: 10) {
if let currentItem = viewModel.currentItem {
topContent(currentItem)
.id(currentItem.displayName)
}
PosterHStack(type: .landscape, items: items)
.content(itemContent)
.imageOverlay(itemImageOverlay)
.contextMenu(itemContextMenu)
.onSelect(onSelect)
.onFocus { item in
viewModel.select(item: item)
}
}
}
.frame(height: UIScreen.main.bounds.height - 75)
.frame(maxWidth: .infinity)
}
struct CinematicBackgroundView: UIViewRepresentable {
@ObservedObject
var viewModel: ViewModel
var initialItem: Item?
@ViewBuilder
private func imageView(for item: Item?) -> some View {
ImageView(item?.landscapePosterImageSources(maxWidth: UIScreen.main.bounds.width, single: false) ?? [])
}
func makeUIView(context: Context) -> UIRotateImageView {
let hostingController = UIHostingController(rootView: imageView(for: initialItem), ignoreSafeArea: true)
return UIRotateImageView(initialView: hostingController.view)
}
func updateUIView(_ uiView: UIRotateImageView, context: Context) {
let hostingController = UIHostingController(rootView: imageView(for: viewModel.currentItem), ignoreSafeArea: true)
uiView.update(with: hostingController.view)
}
class ViewModel: ObservableObject {
@Published
var currentItem: Item?
private var cancellables = Set<AnyCancellable>()
private var currentItemSubject = CurrentValueSubject<Item?, Never>(nil)
init() {
currentItemSubject
.debounce(for: 0.5, scheduler: DispatchQueue.main)
.sink { newItem in
self.currentItem = newItem
}
.store(in: &cancellables)
}
func select(item: Item) {
guard currentItem != item else { return }
currentItemSubject.send(item)
}
}
}
class UIRotateImageView: UIView {
private var currentView: UIView?
init(initialView: UIView) {
super.init(frame: .zero)
initialView.translatesAutoresizingMaskIntoConstraints = false
initialView.alpha = 0
addSubview(initialView)
NSLayoutConstraint.activate([
initialView.topAnchor.constraint(equalTo: topAnchor),
initialView.bottomAnchor.constraint(equalTo: bottomAnchor),
initialView.leftAnchor.constraint(equalTo: leftAnchor),
initialView.rightAnchor.constraint(equalTo: rightAnchor),
])
self.currentView = initialView
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func update(with newView: UIView) {
newView.translatesAutoresizingMaskIntoConstraints = false
newView.alpha = 0
addSubview(newView)
NSLayoutConstraint.activate([
newView.topAnchor.constraint(equalTo: topAnchor),
newView.bottomAnchor.constraint(equalTo: bottomAnchor),
newView.leftAnchor.constraint(equalTo: leftAnchor),
newView.rightAnchor.constraint(equalTo: rightAnchor),
])
UIView.animate(withDuration: 0.3) {
newView.alpha = 1
self.currentView?.alpha = 0
} completion: { _ in
self.currentView?.removeFromSuperview()
self.currentView = newView
}
}
}
}
extension CinematicItemSelector where TopContent == EmptyView,
ItemContent == EmptyView,
ItemImageOverlay == EmptyView,
ItemContextMenu == EmptyView
{
init(items: [Item]) {
self.init(
topContent: { _ in EmptyView() },
itemContent: { _ in EmptyView() },
itemImageOverlay: { _ in EmptyView() },
itemContextMenu: { _ in EmptyView() },
onSelect: { _ in },
items: items
)
}
}
extension CinematicItemSelector {
@ViewBuilder
func topContent<T: View>(@ViewBuilder _ content: @escaping (Item) -> T)
-> CinematicItemSelector<Item, T, ItemContent, ItemImageOverlay, ItemContextMenu> {
CinematicItemSelector<Item, T, ItemContent, ItemImageOverlay, ItemContextMenu>(
topContent: content,
itemContent: itemContent,
itemImageOverlay: itemImageOverlay,
itemContextMenu: itemContextMenu,
onSelect: onSelect,
items: items
)
}
@ViewBuilder
func content<C: View>(@ViewBuilder _ content: @escaping (Item) -> C)
-> CinematicItemSelector<Item, TopContent, C, ItemImageOverlay, ItemContextMenu> {
CinematicItemSelector<Item, TopContent, C, ItemImageOverlay, ItemContextMenu>(
topContent: topContent,
itemContent: content,
itemImageOverlay: itemImageOverlay,
itemContextMenu: itemContextMenu,
onSelect: onSelect,
items: items
)
}
@ViewBuilder
func itemImageOverlay<O: View>(@ViewBuilder _ imageOverlay: @escaping (Item) -> O)
-> CinematicItemSelector<Item, TopContent, ItemContent, O, ItemContextMenu> {
CinematicItemSelector<Item, TopContent, ItemContent, O, ItemContextMenu>(
topContent: topContent,
itemContent: itemContent,
itemImageOverlay: imageOverlay,
itemContextMenu: itemContextMenu,
onSelect: onSelect,
items: items
)
}
@ViewBuilder
func contextMenu<M: View>(@ViewBuilder _ contextMenu: @escaping (Item) -> M)
-> CinematicItemSelector<Item, TopContent, ItemContent, ItemImageOverlay, M> {
CinematicItemSelector<Item, TopContent, ItemContent, ItemImageOverlay, M>(
topContent: topContent,
itemContent: itemContent,
itemImageOverlay: itemImageOverlay,
itemContextMenu: contextMenu,
onSelect: onSelect,
items: items
)
}
func onSelect(_ action: @escaping (Item) -> Void) -> Self {
var copy = self
copy.onSelect = action
return copy
}
}

View File

@ -1,59 +0,0 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2022 Jellyfin & Jellyfin Contributors
//
import JellyfinAPI
import Nuke
import NukeExtensions
import SwiftUI
import UIKit
class DynamicCinematicBackgroundViewModel: ObservableObject {
@Published
var currentItem: BaseItemDto?
@Published
var currentImageView: UIImageView?
@MainActor
func select(item: BaseItemDto) {
guard item.id != currentItem?.id else { return }
currentItem = item
let itemImageView = UIImageView()
let backdropImage: URL
if item.type == .episode {
backdropImage = item.seriesImageURL(.backdrop, maxWidth: 1920)
} else {
backdropImage = item.imageURL(.backdrop, maxWidth: 1920)
}
let options = ImageLoadingOptions(transition: .fadeIn(duration: 0.2))
loadImage(with: backdropImage, options: options, into: itemImageView, completion: { _ in })
currentImageView = itemImageView
}
}
struct CinematicBackgroundView: UIViewRepresentable {
@ObservedObject
var viewModel: DynamicCinematicBackgroundViewModel
func updateUIView(_ uiView: UICinematicBackgroundView, context: Context) {
uiView.update(imageView: viewModel.currentImageView ?? UIImageView())
}
func makeUIView(context: Context) -> UICinematicBackgroundView {
UICinematicBackgroundView(initialImageView: viewModel.currentImageView ?? UIImageView())
}
}

View File

@ -1,70 +0,0 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2022 Jellyfin & Jellyfin Contributors
//
import JellyfinAPI
import SwiftUI
struct CinematicNextUpCardView: View {
@EnvironmentObject
private var homeRouter: HomeCoordinator.Router
let item: BaseItemDto
let showOverlay: Bool
var body: some View {
VStack(alignment: .leading) {
Button {
homeRouter.route(to: \.item, item)
} label: {
ZStack(alignment: .bottomLeading) {
if item.type == .episode {
ImageView([
item.seriesImageSource(.thumb, maxWidth: 350),
item.seriesImageSource(.backdrop, maxWidth: 350),
])
.frame(width: 350, height: 210)
} else {
ImageView([
item.imageSource(.thumb, maxWidth: 350),
item.imageSource(.backdrop, maxWidth: 350),
])
.frame(width: 350, height: 210)
}
LinearGradient(
colors: [.clear, .black],
startPoint: .top,
endPoint: .bottom
)
.frame(height: 105)
.ignoresSafeArea()
if showOverlay {
VStack(alignment: .leading, spacing: 0) {
L10n.next.text
.font(.subheadline)
.padding(.vertical, 5)
.padding(.leading, 10)
.foregroundColor(.white)
HStack {
Color.clear
.frame(width: 1, height: 7)
}
}
}
}
.frame(width: 350, height: 210)
}
.buttonStyle(.card)
.padding(.top)
}
.padding(.vertical)
}
}

View File

@ -1,78 +0,0 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2022 Jellyfin & Jellyfin Contributors
//
import JellyfinAPI
import SwiftUI
struct CinematicResumeCardView: View {
@EnvironmentObject
private var homeRouter: HomeCoordinator.Router
@ObservedObject
var viewModel: HomeViewModel
let item: BaseItemDto
var body: some View {
VStack(alignment: .leading) {
Button {
homeRouter.route(to: \.item, item)
} label: {
ZStack(alignment: .bottom) {
if item.type == .episode {
ImageView([
item.seriesImageSource(.thumb, maxWidth: 350),
item.seriesImageSource(.backdrop, maxWidth: 350),
])
.frame(width: 350, height: 210)
} else {
ImageView([
item.imageSource(.thumb, maxWidth: 350),
item.imageSource(.backdrop, maxWidth: 350),
])
.frame(width: 350, height: 210)
}
LinearGradient(
colors: [.clear, .black],
startPoint: .top,
endPoint: .bottom
)
.frame(height: 105)
.ignoresSafeArea()
VStack(alignment: .leading, spacing: 0) {
Text(item.progress ?? "")
.font(.subheadline)
.padding(.vertical, 5)
.padding(.leading, 10)
.foregroundColor(.white)
HStack {
Color.jellyfinPurple
.frame(width: 350 * (item.userData?.playedPercentage ?? 0) / 100, height: 7)
Spacer(minLength: 0)
}
}
}
.frame(width: 350, height: 210)
}
.buttonStyle(.card)
.padding(.top)
.contextMenu {
Button(role: .destructive) {
viewModel.removeItemFromResume(item)
} label: {
L10n.removeFromResume.text
}
}
}
.padding(.vertical)
}
}

View File

@ -1,134 +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 UIKit
// TODO: Generalize this view such that it can be used in other contexts like for a library
struct HomeCinematicViewItem: Hashable {
enum TopRowType {
case resume
case nextUp
case plain
}
let item: BaseItemDto
let type: TopRowType
func hash(into hasher: inout Hasher) {
hasher.combine(item)
hasher.combine(type)
}
}
struct HomeCinematicView: View {
@FocusState
var selectedItem: BaseItemDto?
@ObservedObject
var viewModel: HomeViewModel
@State
private var updatedSelectedItem: BaseItemDto?
@State
private var initiallyAppeared = false
private let forcedItemSubtitle: String?
private let items: [HomeCinematicViewItem]
private let backgroundViewModel = DynamicCinematicBackgroundViewModel()
init(viewModel: HomeViewModel, items: [HomeCinematicViewItem], forcedItemSubtitle: String? = nil) {
self.viewModel = viewModel
self.items = items
self.forcedItemSubtitle = forcedItemSubtitle
}
var body: some View {
ZStack(alignment: .bottom) {
CinematicBackgroundView(viewModel: backgroundViewModel)
.frame(height: UIScreen.main.bounds.height - 50)
LinearGradient(
stops: [
.init(color: .clear, location: 0.5),
.init(color: .black.opacity(0.6), location: 0.7),
.init(color: .black, location: 1),
],
startPoint: .top,
endPoint: .bottom
)
.ignoresSafeArea()
VStack(alignment: .leading, spacing: 0) {
VStack(alignment: .leading, spacing: 0) {
if let forcedItemSubtitle = forcedItemSubtitle {
Text(forcedItemSubtitle)
.font(.callout)
.fontWeight(.medium)
.foregroundColor(Color.secondary)
} else {
if updatedSelectedItem?.type == .episode {
Text(updatedSelectedItem?.episodeLocator ?? "")
.font(.callout)
.fontWeight(.medium)
.foregroundColor(Color.secondary)
} else {
Text("")
}
}
Text("\(updatedSelectedItem?.seriesName ?? updatedSelectedItem?.name ?? "")")
.font(.title)
.fontWeight(.semibold)
.foregroundColor(.primary)
.lineLimit(1)
.fixedSize(horizontal: false, vertical: true)
}
.padding(.horizontal, 50)
ScrollView(.horizontal, showsIndicators: false) {
HStack {
ForEach(items, id: \.self) { item in
switch item.type {
case .nextUp:
CinematicNextUpCardView(item: item.item, showOverlay: true)
.focused($selectedItem, equals: item.item)
case .resume:
CinematicResumeCardView(viewModel: viewModel, item: item.item)
.focused($selectedItem, equals: item.item)
case .plain:
CinematicNextUpCardView(item: item.item, showOverlay: false)
.focused($selectedItem, equals: item.item)
}
}
}
.padding(.horizontal, 50)
.padding(.bottom)
}
.focusSection()
}
}
.onChange(of: selectedItem) { newValue in
if let newItem = newValue {
backgroundViewModel.select(item: newItem)
updatedSelectedItem = newItem
}
}
.onAppear {
guard !initiallyAppeared else { return }
selectedItem = items.first?.item
updatedSelectedItem = items.first?.item
initiallyAppeared = true
}
}
}

View File

@ -1,76 +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 UIKit
class UICinematicBackgroundView: UIView {
private var currentImageView: UIView?
private var selectDelayTimer: Timer?
init(initialImageView: UIImageView) {
super.init(frame: .zero)
initialImageView.translatesAutoresizingMaskIntoConstraints = false
initialImageView.alpha = 0
addSubview(initialImageView)
NSLayoutConstraint.activate([
initialImageView.topAnchor.constraint(equalTo: topAnchor),
initialImageView.bottomAnchor.constraint(equalTo: bottomAnchor),
initialImageView.leftAnchor.constraint(equalTo: leftAnchor),
initialImageView.rightAnchor.constraint(equalTo: rightAnchor),
])
self.currentImageView = initialImageView
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func update(imageView: UIImageView) {
selectDelayTimer?.invalidate()
selectDelayTimer = Timer.scheduledTimer(
timeInterval: 0.5,
target: self,
selector: #selector(delayTimerTimed),
userInfo: imageView,
repeats: false
)
}
@objc
private func delayTimerTimed(timer: Timer) {
let newImageView = timer.userInfo as! UIImageView
newImageView.translatesAutoresizingMaskIntoConstraints = false
newImageView.alpha = 0
addSubview(newImageView)
NSLayoutConstraint.activate([
newImageView.topAnchor.constraint(equalTo: topAnchor),
newImageView.bottomAnchor.constraint(equalTo: bottomAnchor),
newImageView.leftAnchor.constraint(equalTo: leftAnchor),
newImageView.rightAnchor.constraint(equalTo: rightAnchor),
])
UIView.animate(withDuration: 0.2) {
newImageView.alpha = 1
self.currentImageView?.alpha = 0
} completion: { _ in
self.currentImageView?.removeFromSuperview()
self.currentImageView = newImageView
}
}
}

View File

@ -0,0 +1,40 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If 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 LandscapePosterProgressBar: View {
let title: String
let progress: CGFloat
var body: some View {
ZStack(alignment: .bottom) {
LinearGradient(
stops: [
.init(color: .clear, location: 0.7),
.init(color: .black.opacity(0.7), location: 1),
],
startPoint: .top,
endPoint: .bottom
)
VStack(alignment: .leading, spacing: 3) {
Text(title)
.font(.subheadline)
.foregroundColor(.white)
ProgressBar(progress: progress)
.frame(height: 5)
}
.padding(.horizontal, 5)
.padding(.bottom, 7)
}
}
}

View File

@ -10,42 +10,24 @@ import SwiftUI
struct PosterButton<Item: Poster, Content: View, ImageOverlay: View, ContextMenu: View>: View {
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
@FocusState
private var isFocused: Bool
private var item: Item
private var type: PosterType
private var itemScale: CGFloat
private var horizontalAlignment: HorizontalAlignment
private var content: (Item) -> Content
private var imageOverlay: (Item) -> ImageOverlay
private var contextMenu: (Item) -> ContextMenu
private var onSelect: (Item) -> Void
private var onFocus: () -> Void
private var singleImage: Bool
private var itemWidth: CGFloat {
type.width * 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 {
@ -71,11 +53,16 @@ struct PosterButton<Item: Poster, Content: View, ImageOverlay: View, ContextMenu
contextMenu(item)
})
.posterShadow()
.focused($isFocused)
content(item)
.zIndex(-1)
}
.frame(width: itemWidth)
.onChange(of: isFocused) { newValue in
guard newValue else { return }
onFocus()
}
}
}
@ -93,40 +80,23 @@ extension PosterButton where Content == PosterButtonDefaultContentView<Item>,
imageOverlay: { _ in EmptyView() },
contextMenu: { _ in EmptyView() },
onSelect: { _ in },
onFocus: {},
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
)
func horizontalAlignment(_ alignment: HorizontalAlignment) -> Self {
var copy = self
copy.horizontalAlignment = alignment
return copy
}
@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
)
func scaleItem(_ scale: CGFloat) -> Self {
var copy = self
copy.itemScale = scale
return copy
}
@ViewBuilder
@ -140,6 +110,7 @@ extension PosterButton {
imageOverlay: imageOverlay,
contextMenu: contextMenu,
onSelect: onSelect,
onFocus: onFocus,
singleImage: singleImage
)
}
@ -155,6 +126,7 @@ extension PosterButton {
imageOverlay: imageOverlay,
contextMenu: contextMenu,
onSelect: onSelect,
onFocus: onFocus,
singleImage: singleImage
)
}
@ -170,23 +142,21 @@ extension PosterButton {
imageOverlay: imageOverlay,
contextMenu: contextMenu,
onSelect: onSelect,
onFocus: onFocus,
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
)
func onSelect(_ action: @escaping (Item) -> Void) -> Self {
var copy = self
copy.onSelect = action
return copy
}
func onFocus(_ action: @escaping () -> Void) -> Self {
var copy = self
copy.onFocus = action
return copy
}
}

View File

@ -10,58 +10,42 @@ 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
}
private var title: String?
private var type: PosterType
private var items: [Item]
private var itemScale: CGFloat
private var content: (Item) -> Content
private var imageOverlay: (Item) -> ImageOverlay
private var contextMenu: (Item) -> ContextMenu
private var trailingContent: () -> TrailingContent
private var onSelect: (Item) -> Void
private var onFocus: (Item) -> Void
var body: some View {
VStack(alignment: .leading) {
HStack {
Text(title)
.font(.title2)
.fontWeight(.semibold)
.accessibility(addTraits: [.isHeader])
.padding(.leading, 50)
Spacer()
if let title = title {
HStack {
Text(title)
.font(.title2)
.fontWeight(.semibold)
.accessibility(addTraits: [.isHeader])
.padding(.leading, 50)
Spacer()
}
}
ScrollView(.horizontal, showsIndicators: false) {
HStack(alignment: .top) {
HStack(alignment: .top, spacing: 30) {
ForEach(items, id: \.hashValue) { item in
PosterButton(item: item, type: type)
.scaleItem(itemScale)
.content(content)
.imageOverlay(imageOverlay)
.contextMenu(contextMenu)
.onSelect(onSelect)
.onFocus { onFocus(item) }
}
trailingContent()
@ -80,7 +64,7 @@ extension PosterHStack where Content == PosterButtonDefaultContentView<Item>,
TrailingContent == EmptyView
{
init(
title: String,
title: String? = nil,
type: PosterType,
items: [Item]
) {
@ -93,25 +77,17 @@ extension PosterHStack where Content == PosterButtonDefaultContentView<Item>,
imageOverlay: { _ in EmptyView() },
contextMenu: { _ in EmptyView() },
trailingContent: { EmptyView() },
onSelect: { _ in }
onSelect: { _ in },
onFocus: { _ 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
)
func scaleItems(_ scale: CGFloat) -> Self {
var copy = self
copy.itemScale = scale
return copy
}
@ViewBuilder
@ -126,7 +102,8 @@ extension PosterHStack {
imageOverlay: imageOverlay,
contextMenu: contextMenu,
trailingContent: trailingContent,
onSelect: onSelect
onSelect: onSelect,
onFocus: onFocus
)
}
@ -142,7 +119,8 @@ extension PosterHStack {
imageOverlay: imageOverlay,
contextMenu: contextMenu,
trailingContent: trailingContent,
onSelect: onSelect
onSelect: onSelect,
onFocus: onFocus
)
}
@ -158,7 +136,8 @@ extension PosterHStack {
imageOverlay: imageOverlay,
contextMenu: contextMenu,
trailingContent: trailingContent,
onSelect: onSelect
onSelect: onSelect,
onFocus: onFocus
)
}
@ -174,22 +153,20 @@ extension PosterHStack {
imageOverlay: imageOverlay,
contextMenu: contextMenu,
trailingContent: trailingContent,
onSelect: onSelect
onSelect: onSelect,
onFocus: onFocus
)
}
@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
)
func onSelect(_ action: @escaping (Item) -> Void) -> Self {
var copy = self
copy.onSelect = action
return copy
}
func onFocus(_ action: @escaping (Item) -> Void) -> Self {
var copy = self
copy.onFocus = action
return copy
}
}

View File

@ -1,85 +0,0 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2022 Jellyfin & Jellyfin Contributors
//
import JellyfinAPI
import SwiftUI
struct ContinueWatchingCard: View {
@EnvironmentObject
private var homeRouter: HomeCoordinator.Router
let item: BaseItemDto
var body: some View {
VStack(alignment: .leading) {
Button {
homeRouter.route(to: \.item, item)
} label: {
ZStack(alignment: .bottom) {
if item.type == .episode {
ImageView([
item.seriesImageSource(.thumb, maxWidth: 500),
item.imageSource(.primary, maxWidth: 500),
])
.frame(width: 500, height: 281.25)
} else {
ImageView(item.imageURL(.backdrop, maxWidth: 500))
.frame(width: 500, height: 281.25)
}
VStack(alignment: .leading, spacing: 0) {
Text(item.progress ?? "")
.font(.subheadline)
.padding(.vertical, 5)
.padding(.leading, 10)
.foregroundColor(.white)
HStack {
Color(UIColor.systemPurple)
.frame(width: 500 * (item.userData?.playedPercentage ?? 0) / 100, height: 13)
Spacer(minLength: 0)
}
}
.background {
LinearGradient(
colors: [.clear, .black.opacity(0.5), .black.opacity(0.7)],
startPoint: .top,
endPoint: .bottom
)
.ignoresSafeArea()
}
}
.frame(width: 500, height: 281.25)
}
.buttonStyle(.card)
.padding(.top)
VStack(alignment: .leading) {
Text("\(item.seriesName ?? item.name ?? "")")
.font(.callout)
.fontWeight(.semibold)
.foregroundColor(.primary)
.lineLimit(1)
.frame(width: 500, alignment: .leading)
if item.type == .episode {
Text(item.episodeLocator ?? .emptyDash)
.font(.callout)
.fontWeight(.medium)
.foregroundColor(.secondary)
.lineLimit(1)
} else {
Text("")
}
}
}
.padding(.vertical)
}
}

View File

@ -1,38 +0,0 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2022 Jellyfin & Jellyfin Contributors
//
import Combine
import JellyfinAPI
import Stinsen
import SwiftUI
struct ContinueWatchingView: View {
@EnvironmentObject
private var homeRouter: HomeCoordinator.Router
let items: [BaseItemDto]
var body: some View {
VStack(alignment: .leading) {
L10n.continueWatching.text
.font(.title3)
.fontWeight(.semibold)
.padding(.leading, 50)
ScrollView(.horizontal, showsIndicators: false) {
LazyHStack(alignment: .top) {
ForEach(items, id: \.self) { item in
ContinueWatchingCard(item: item)
}
}
.padding(.horizontal, 50)
}
}
}
}

View File

@ -1,73 +0,0 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2022 Jellyfin & Jellyfin Contributors
//
import Defaults
import Foundation
import Introspect
import JellyfinAPI
import SwiftUI
struct HomeView: View {
@EnvironmentObject
private var router: HomeCoordinator.Router
@ObservedObject
var viewModel: HomeViewModel
var body: some View {
if viewModel.isLoading {
ProgressView()
.scaleEffect(2)
} else {
ScrollView {
LazyVStack(alignment: .leading) {
if viewModel.resumeItems.isEmpty {
HomeCinematicView(
viewModel: viewModel,
items: viewModel.latestAddedItems.map { .init(item: $0, type: .plain) },
forcedItemSubtitle: L10n.recentlyAdded
)
if !viewModel.nextUpItems.isEmpty {
PosterHStack(title: L10n.nextUp, type: .portrait, items: viewModel.nextUpItems)
.onSelect { item in
router.route(to: \.item, item)
}
}
} else {
HomeCinematicView(
viewModel: viewModel,
items: viewModel.resumeItems.map { .init(item: $0, type: .resume) }
)
if !viewModel.nextUpItems.isEmpty {
PosterHStack(title: L10n.nextUp, type: .portrait, items: viewModel.nextUpItems)
.onSelect { item in
router.route(to: \.item, item)
}
}
if !viewModel.latestAddedItems.isEmpty {
PosterHStack(title: L10n.recentlyAdded, type: .portrait, items: viewModel.latestAddedItems)
.onSelect { item in
router.route(to: \.item, item)
}
}
}
ForEach(viewModel.libraries, id: \.self) { library in
LatestInLibraryView(viewModel: LatestMediaViewModel(library: library))
}
}
}
.edgesIgnoringSafeArea(.top)
.edgesIgnoringSafeArea(.horizontal)
}
}
}

View File

@ -0,0 +1,131 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If 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
extension HomeView {
struct ContentView: View {
@EnvironmentObject
private var router: HomeCoordinator.Router
@ObservedObject
var viewModel: HomeViewModel
private func itemSelectorImageSource(for item: BaseItemDto) -> ImageSource {
if item.type == .episode {
return item.seriesImageSource(
.logo,
maxWidth: UIScreen.main.bounds.width * 0.4,
maxHeight: 200
)
} else {
return item.imageSource(
.logo,
maxWidth: UIScreen.main.bounds.width * 0.4,
maxHeight: 200
)
}
}
@ViewBuilder
private var cinematicResumeItems: some View {
CinematicItemSelector(items: viewModel.resumeItems)
.topContent { item in
ImageView(itemSelectorImageSource(for: item))
.resizingMode(.bottomLeft)
.placeholder {
EmptyView()
}
.failure {
Text(item.displayName)
.font(.largeTitle)
.fontWeight(.semibold)
}
.padding2(.leading)
}
.content { item in
if let subtitle = item.subtitle {
Text(subtitle)
.font(.caption)
.fontWeight(.medium)
.foregroundColor(.secondary)
.lineLimit(2)
}
}
.itemImageOverlay { item in
LandscapePosterProgressBar(
title: item.progress ?? L10n.continue,
progress: (item.userData?.playedPercentage ?? 0) / 100
)
}
.onSelect { item in
router.route(to: \.item, item)
}
}
@ViewBuilder
private var cinematicLatestAddedItems: some View {
CinematicItemSelector(items: viewModel.latestAddedItems)
.topContent { item in
ImageView(itemSelectorImageSource(for: item))
.resizingMode(.bottomLeft)
.placeholder {
EmptyView()
}
.failure {
Text(item.displayName)
.font(.largeTitle)
.fontWeight(.semibold)
}
.padding2(.leading)
}
.onSelect { item in
router.route(to: \.item, item)
}
}
var body: some View {
ScrollView {
LazyVStack(alignment: .leading) {
if viewModel.resumeItems.isEmpty {
cinematicLatestAddedItems
if !viewModel.nextUpItems.isEmpty {
PosterHStack(title: L10n.nextUp, type: .portrait, items: viewModel.nextUpItems)
.onSelect { item in
router.route(to: \.item, item)
}
}
} else {
cinematicResumeItems
if !viewModel.nextUpItems.isEmpty {
PosterHStack(title: L10n.nextUp, type: .portrait, items: viewModel.nextUpItems)
.onSelect { item in
router.route(to: \.item, item)
}
}
if !viewModel.latestAddedItems.isEmpty {
PosterHStack(title: L10n.recentlyAdded, type: .portrait, items: viewModel.latestAddedItems)
.onSelect { item in
router.route(to: \.item, item)
}
}
}
ForEach(viewModel.libraries, id: \.self) { library in
LatestInLibraryView(viewModel: LatestMediaViewModel(library: library))
}
}
}
}
}
}

View File

@ -0,0 +1,53 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2022 Jellyfin & Jellyfin Contributors
//
import SwiftUI
extension HomeView {
struct ErrorView: View {
@ObservedObject
var viewModel: HomeViewModel
let errorMessage: ErrorMessage
var body: some View {
VStack {
if viewModel.isLoading {
ProgressView()
.frame(width: 100, height: 100)
.scaleEffect(2)
} else {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 72))
.foregroundColor(Color.red)
.frame(width: 100, height: 100)
}
Text("\(errorMessage.code)")
Text(errorMessage.message)
.frame(minWidth: 50, maxWidth: 240)
.multilineTextAlignment(.center)
Button {
viewModel.refresh()
} label: {
L10n.retry.text
.bold()
.font(.callout)
.frame(width: 400, height: 75)
.background(Color.jellyfinPurple)
}
.buttonStyle(.card)
}
.offset(y: -50)
}
}
}

View File

@ -0,0 +1,35 @@
//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2022 Jellyfin & Jellyfin Contributors
//
import Defaults
import Foundation
import Introspect
import JellyfinAPI
import SwiftUI
struct HomeView: View {
@EnvironmentObject
private var router: HomeCoordinator.Router
@ObservedObject
var viewModel: HomeViewModel
var body: some View {
Group {
if let errorMessage = viewModel.errorMessage {
ErrorView(viewModel: viewModel, errorMessage: errorMessage)
} else if viewModel.isLoading {
ProgressView()
} else {
ContentView(viewModel: viewModel)
}
}
.edgesIgnoringSafeArea(.top)
.edgesIgnoringSafeArea(.horizontal)
}
}

View File

@ -34,7 +34,7 @@ struct LatestInLibraryView: View {
.font(.title3)
}
}
.posterStyle(type: .portrait, width: 250)
.posterStyle(type: .portrait, width: PosterType.portrait.width)
}
.buttonStyle(.plain)
}

View File

@ -44,7 +44,7 @@ struct LibraryView: View {
.layout { _, layoutEnvironment in
.grid(
layoutEnvironment: layoutEnvironment,
layoutMode: .fixedNumberOfColumns(6),
layoutMode: .fixedNumberOfColumns(7),
lineSpacing: 50
)
}

View File

@ -23,7 +23,7 @@ struct MediaView: View {
var body: some View {
CollectionView(items: viewModel.libraryItems) { _, item, _ in
PosterButton(item: item, type: .landscape)
.scaleItem(0.8)
.scaleItem(1.12)
.onSelect { _ in
switch item.library.collectionType {
case "favorites":

View File

@ -13,7 +13,6 @@
09389CC726819B4600AE350E /* VideoPlayerModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09389CC626819B4500AE350E /* VideoPlayerModel.swift */; };
09389CC826819B4600AE350E /* VideoPlayerModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09389CC626819B4500AE350E /* VideoPlayerModel.swift */; };
531690E7267ABD79005D8AB9 /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531690E6267ABD79005D8AB9 /* HomeView.swift */; };
531690ED267ABF46005D8AB9 /* ContinueWatchingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531690EB267ABF46005D8AB9 /* ContinueWatchingView.swift */; };
531690F7267ACC00005D8AB9 /* LandscapeItemElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531690F6267ACC00005D8AB9 /* LandscapeItemElement.swift */; };
53192D5D265AA78A008A4215 /* DeviceProfileBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53192D5C265AA78A008A4215 /* DeviceProfileBuilder.swift */; };
531AC8BF26750DE20091C7EB /* ImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531AC8BE26750DE20091C7EB /* ImageView.swift */; };
@ -221,11 +220,6 @@
E1002B652793CEE800E47059 /* ChapterInfoExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1002B632793CEE700E47059 /* ChapterInfoExtensions.swift */; };
E1002B682793CFBA00E47059 /* Algorithms in Frameworks */ = {isa = PBXBuildFile; productRef = E1002B672793CFBA00E47059 /* Algorithms */; };
E1002B6B2793E36600E47059 /* Algorithms in Frameworks */ = {isa = PBXBuildFile; productRef = E1002B6A2793E36600E47059 /* Algorithms */; };
E103A6A0278A7E4500820EC7 /* UICinematicBackgroundView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E103A69F278A7E4500820EC7 /* UICinematicBackgroundView.swift */; };
E103A6A3278A7EC400820EC7 /* CinematicBackgroundView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E103A6A2278A7EC400820EC7 /* CinematicBackgroundView.swift */; };
E103A6A5278A82E500820EC7 /* HomeCinematicView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E103A6A4278A82E500820EC7 /* HomeCinematicView.swift */; };
E103A6A7278AB6D700820EC7 /* CinematicResumeCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E103A6A6278AB6D700820EC7 /* CinematicResumeCardView.swift */; };
E103A6A9278AB6FF00820EC7 /* CinematicNextUpCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E103A6A8278AB6FF00820EC7 /* CinematicNextUpCardView.swift */; };
E1047E2327E5880000CB0D4A /* InitialFailureView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1047E2227E5880000CB0D4A /* InitialFailureView.swift */; };
E107BB9327880A8F00354E07 /* CollectionItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E107BB9227880A8F00354E07 /* CollectionItemViewModel.swift */; };
E107BB9427880A8F00354E07 /* CollectionItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E107BB9227880A8F00354E07 /* CollectionItemViewModel.swift */; };
@ -436,6 +430,10 @@
E1A2C158279A7D76005EC829 /* BundleExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A2C157279A7D76005EC829 /* BundleExtensions.swift */; };
E1A2C15A279A7D76005EC829 /* BundleExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A2C157279A7D76005EC829 /* BundleExtensions.swift */; };
E1A2C160279A7DCA005EC829 /* AboutAppView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A2C15F279A7DCA005EC829 /* AboutAppView.swift */; };
E1A42E4A28CA6CCD00A14DCB /* CinematicItemSelector.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A42E4928CA6CCD00A14DCB /* CinematicItemSelector.swift */; };
E1A42E4C28CBD39300A14DCB /* HomeContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A42E4B28CBD39300A14DCB /* HomeContentView.swift */; };
E1A42E4F28CBD3E100A14DCB /* HomeErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A42E4E28CBD3E100A14DCB /* HomeErrorView.swift */; };
E1A42E5128CBE44500A14DCB /* LandscapePosterProgressBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A42E5028CBE44500A14DCB /* LandscapePosterProgressBar.swift */; };
E1AA331D2782541500F6439C /* PrimaryButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AA331C2782541500F6439C /* PrimaryButton.swift */; };
E1AA331F2782639D00F6439C /* OverlayType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AA331E2782639D00F6439C /* OverlayType.swift */; };
E1AA33202782639D00F6439C /* OverlayType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AA331E2782639D00F6439C /* OverlayType.swift */; };
@ -443,7 +441,6 @@
E1AD104E26D96CE3003E4A08 /* BaseItemDtoExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AD104C26D96CE3003E4A08 /* BaseItemDtoExtensions.swift */; };
E1AD105F26D9ADDD003E4A08 /* NameGUIDPairExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AD105E26D9ADDD003E4A08 /* NameGUIDPairExtensions.swift */; };
E1B2AB9928808E150072B3B9 /* GoogleCast.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = E1B2AB9628808CDF0072B3B9 /* GoogleCast.xcframework */; };
E1B59FD52786ADE500A5287E /* ContinueWatchingCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1B59FD42786ADE500A5287E /* ContinueWatchingCard.swift */; };
E1B6DCEA271A23880015B715 /* SwiftyJSON in Frameworks */ = {isa = PBXBuildFile; productRef = E1B6DCE9271A23880015B715 /* SwiftyJSON */; };
E1BDE359278E9ED2004E4022 /* MissingItemsSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1BDE358278E9ED2004E4022 /* MissingItemsSettingsView.swift */; };
E1C812BC277A8E5D00918266 /* PlaybackSpeed.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C812B4277A8E5D00918266 /* PlaybackSpeed.swift */; };
@ -531,7 +528,6 @@
E1FE69A728C29B720021BC93 /* ProgressBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1FE69A628C29B720021BC93 /* ProgressBar.swift */; };
E1FE69A828C29B720021BC93 /* ProgressBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1FE69A628C29B720021BC93 /* ProgressBar.swift */; };
E1FE69AA28C29CC20021BC93 /* LandscapePosterProgressBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1FE69A928C29CC20021BC93 /* LandscapePosterProgressBar.swift */; };
E1FE69AB28C29CC20021BC93 /* LandscapePosterProgressBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1FE69A928C29CC20021BC93 /* LandscapePosterProgressBar.swift */; };
/* End PBXBuildFile section */
/* Begin PBXCopyFilesBuildPhase section */
@ -584,7 +580,6 @@
091B5A872683142E00D78B61 /* ServerDiscovery.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ServerDiscovery.swift; sourceTree = "<group>"; };
09389CC626819B4500AE350E /* VideoPlayerModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerModel.swift; sourceTree = "<group>"; };
531690E6267ABD79005D8AB9 /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = "<group>"; };
531690EB267ABF46005D8AB9 /* ContinueWatchingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContinueWatchingView.swift; sourceTree = "<group>"; };
531690F6267ACC00005D8AB9 /* LandscapeItemElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LandscapeItemElement.swift; sourceTree = "<group>"; };
531690F9267AD6EC005D8AB9 /* PlainNavigationLinkButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlainNavigationLinkButton.swift; sourceTree = "<group>"; };
53192D5C265AA78A008A4215 /* DeviceProfileBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceProfileBuilder.swift; sourceTree = "<group>"; };
@ -750,11 +745,6 @@
C4E5598828124C10003DECA5 /* LiveTVChannelItemElement.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LiveTVChannelItemElement.swift; sourceTree = "<group>"; };
E1002B5E2793C3BE00E47059 /* VLCPlayerChapterOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VLCPlayerChapterOverlayView.swift; sourceTree = "<group>"; };
E1002B632793CEE700E47059 /* ChapterInfoExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChapterInfoExtensions.swift; sourceTree = "<group>"; };
E103A69F278A7E4500820EC7 /* UICinematicBackgroundView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UICinematicBackgroundView.swift; sourceTree = "<group>"; };
E103A6A2278A7EC400820EC7 /* CinematicBackgroundView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CinematicBackgroundView.swift; sourceTree = "<group>"; };
E103A6A4278A82E500820EC7 /* HomeCinematicView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeCinematicView.swift; sourceTree = "<group>"; };
E103A6A6278AB6D700820EC7 /* CinematicResumeCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CinematicResumeCardView.swift; sourceTree = "<group>"; };
E103A6A8278AB6FF00820EC7 /* CinematicNextUpCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CinematicNextUpCardView.swift; sourceTree = "<group>"; };
E1047E2227E5880000CB0D4A /* InitialFailureView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InitialFailureView.swift; sourceTree = "<group>"; };
E107BB9227880A8F00354E07 /* CollectionItemViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionItemViewModel.swift; sourceTree = "<group>"; };
E10D87DB2784EC5200BD264C /* SeriesEpisodesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeriesEpisodesView.swift; sourceTree = "<group>"; };
@ -890,12 +880,15 @@
E1A2C153279A7D5A005EC829 /* UIApplicationExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIApplicationExtensions.swift; sourceTree = "<group>"; };
E1A2C157279A7D76005EC829 /* BundleExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundleExtensions.swift; sourceTree = "<group>"; };
E1A2C15F279A7DCA005EC829 /* AboutAppView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutAppView.swift; sourceTree = "<group>"; };
E1A42E4928CA6CCD00A14DCB /* CinematicItemSelector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CinematicItemSelector.swift; sourceTree = "<group>"; };
E1A42E4B28CBD39300A14DCB /* HomeContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeContentView.swift; sourceTree = "<group>"; };
E1A42E4E28CBD3E100A14DCB /* HomeErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeErrorView.swift; sourceTree = "<group>"; };
E1A42E5028CBE44500A14DCB /* LandscapePosterProgressBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LandscapePosterProgressBar.swift; sourceTree = "<group>"; };
E1AA331C2782541500F6439C /* PrimaryButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrimaryButton.swift; sourceTree = "<group>"; };
E1AA331E2782639D00F6439C /* OverlayType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverlayType.swift; sourceTree = "<group>"; };
E1AD104C26D96CE3003E4A08 /* BaseItemDtoExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseItemDtoExtensions.swift; sourceTree = "<group>"; };
E1AD105E26D9ADDD003E4A08 /* NameGUIDPairExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NameGUIDPairExtensions.swift; sourceTree = "<group>"; };
E1B2AB9628808CDF0072B3B9 /* GoogleCast.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = GoogleCast.xcframework; path = Carthage/Build/GoogleCast.xcframework; sourceTree = "<group>"; };
E1B59FD42786ADE500A5287E /* ContinueWatchingCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContinueWatchingCard.swift; sourceTree = "<group>"; };
E1BDE358278E9ED2004E4022 /* MissingItemsSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MissingItemsSettingsView.swift; sourceTree = "<group>"; };
E1C812B4277A8E5D00918266 /* PlaybackSpeed.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlaybackSpeed.swift; sourceTree = "<group>"; };
E1C812B5277A8E5D00918266 /* PlayerOverlayDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlayerOverlayDelegate.swift; sourceTree = "<group>"; };
@ -1219,16 +1212,17 @@
536D3D77267BB9650004248C /* Components */ = {
isa = PBXGroup;
children = (
E1A42E4928CA6CCD00A14DCB /* CinematicItemSelector.swift */,
E1C92618288756BD002A7A66 /* DotHStack.swift */,
E103A6A1278A7EB500820EC7 /* HomeCinematicView */,
E1E5D5432783BB5100692DFE /* ItemDetailsView.swift */,
E1CCC3D128C858A50020ED54 /* UserProfileButton.swift */,
531690F6267ACC00005D8AB9 /* LandscapeItemElement.swift */,
E1A42E5028CBE44500A14DCB /* LandscapePosterProgressBar.swift */,
536D3D80267BDFC60004248C /* PortraitItemElement.swift */,
E1C92617288756BD002A7A66 /* PosterButton.swift */,
E1C92619288756BD002A7A66 /* PosterHStack.swift */,
E1E9EFE928C6B96400CC1F8B /* ServerButton.swift */,
E17885A3278105170094FBCF /* SFSymbolButton.swift */,
E1CCC3D128C858A50020ED54 /* UserProfileButton.swift */,
);
path = Components;
sourceTree = "<group>";
@ -1460,6 +1454,7 @@
children = (
E18E01A7288746AF0022598C /* DotHStack.swift */,
E1FE69AF28C2DA4A0021BC93 /* FilterDrawerHStack */,
E1FE69A928C29CC20021BC93 /* LandscapePosterProgressBar.swift */,
E18E01A5288746AF0022598C /* PillHStack.swift */,
E16AA60728A364A6009A983C /* PosterButton.swift */,
E1CCF13028AC07EC006CAC9E /* PosterHStack.swift */,
@ -1577,18 +1572,6 @@
path = Overlays;
sourceTree = "<group>";
};
E103A6A1278A7EB500820EC7 /* HomeCinematicView */ = {
isa = PBXGroup;
children = (
E103A6A2278A7EC400820EC7 /* CinematicBackgroundView.swift */,
E103A6A8278AB6FF00820EC7 /* CinematicNextUpCardView.swift */,
E103A6A6278AB6D700820EC7 /* CinematicResumeCardView.swift */,
E103A6A4278A82E500820EC7 /* HomeCinematicView.swift */,
E103A69F278A7E4500820EC7 /* UICinematicBackgroundView.swift */,
);
path = HomeCinematicView;
sourceTree = "<group>";
};
E107BB9127880A4000354E07 /* ItemViewModel */ = {
isa = PBXGroup;
children = (
@ -1681,8 +1664,7 @@
E1A2C15F279A7DCA005EC829 /* AboutAppView.swift */,
E1D4BF8E271A079A00A11E64 /* BasicAppSettingsView.swift */,
53ABFDEA2679753200886593 /* ConnectToServerView.swift */,
E1B59FD62786AE2C00A5287E /* ContinueWatchingView */,
531690E6267ABD79005D8AB9 /* HomeView.swift */,
E1A42E4D28CBD3B200A14DCB /* HomeView */,
E193D54E271942C000900D82 /* ItemView */,
E1C925F828875647002A7A66 /* LatestInLibraryView.swift */,
53A83C32268A309300DF3D92 /* LibraryView.swift */,
@ -2038,6 +2020,16 @@
path = AboutView;
sourceTree = "<group>";
};
E1A42E4D28CBD3B200A14DCB /* HomeView */ = {
isa = PBXGroup;
children = (
E1A42E4B28CBD39300A14DCB /* HomeContentView.swift */,
E1A42E4E28CBD3E100A14DCB /* HomeErrorView.swift */,
531690E6267ABD79005D8AB9 /* HomeView.swift */,
);
path = HomeView;
sourceTree = "<group>";
};
E1AD105226D96D5F003E4A08 /* JellyfinAPIExtensions */ = {
isa = PBXGroup;
children = (
@ -2069,7 +2061,6 @@
E18E01FF288749200022598C /* Divider.swift */,
531AC8BE26750DE20091C7EB /* ImageView.swift */,
E1047E2227E5880000CB0D4A /* InitialFailureView.swift */,
E1FE69A928C29CC20021BC93 /* LandscapePosterProgressBar.swift */,
531690F9267AD6EC005D8AB9 /* PlainNavigationLinkButton.swift */,
E1FE69A628C29B720021BC93 /* ProgressBar.swift */,
E1E1643D28BB074000323B0A /* SelectorView.swift */,
@ -2078,15 +2069,6 @@
path = Views;
sourceTree = "<group>";
};
E1B59FD62786AE2C00A5287E /* ContinueWatchingView */ = {
isa = PBXGroup;
children = (
E1B59FD42786ADE500A5287E /* ContinueWatchingCard.swift */,
531690EB267ABF46005D8AB9 /* ContinueWatchingView.swift */,
);
path = ContinueWatchingView;
sourceTree = "<group>";
};
E1C55AB228BD051700A9AD88 /* Components */ = {
isa = PBXGroup;
children = (
@ -2466,7 +2448,6 @@
C4BE07742725EB66003F4AD1 /* LiveTVProgramsView.swift in Sources */,
E18845F626DD631E00B0C5B7 /* BaseItemDto+Poster.swift in Sources */,
E193D53327193F7D00900D82 /* FilterCoordinator.swift in Sources */,
E103A6A9278AB6FF00820EC7 /* CinematicNextUpCardView.swift in Sources */,
C4B9B91427E1921B0063535C /* LiveTVNativeVideoPlayerView.swift in Sources */,
E18E021E2887492B0022598C /* Divider.swift in Sources */,
E107BB9427880A8F00354E07 /* CollectionItemViewModel.swift in Sources */,
@ -2498,20 +2479,17 @@
62E632EA267D3FF50063E547 /* SeasonItemViewModel.swift in Sources */,
E1546777289AF46E00087E35 /* CollectionItemView.swift in Sources */,
E1C812CC277AE40A00918266 /* VideoPlayerView.swift in Sources */,
E1A42E4F28CBD3E100A14DCB /* HomeErrorView.swift in Sources */,
53CD2A40268A49C2002ABD4E /* ItemView.swift in Sources */,
E122A9142788EAAD0060FA63 /* MediaStreamExtension.swift in Sources */,
E103A6A3278A7EC400820EC7 /* CinematicBackgroundView.swift in Sources */,
E178859E2780F53B0094FBCF /* SliderView.swift in Sources */,
E1384944278036C70024FB48 /* VLCPlayerViewController.swift in Sources */,
E1002B652793CEE800E47059 /* ChapterInfoExtensions.swift in Sources */,
E12B835F28C07D8500878399 /* LibraryParent.swift in Sources */,
E103A6A5278A82E500820EC7 /* HomeCinematicView.swift in Sources */,
E18E021A2887492B0022598C /* AppIcon.swift in Sources */,
E10EAA50277BBCC4000269ED /* CGSizeExtensions.swift in Sources */,
E126F742278A656C00A522BF /* ServerStreamType.swift in Sources */,
E1FE69AB28C29CC20021BC93 /* LandscapePosterProgressBar.swift in Sources */,
E1FCD08926C35A0D007C8DCF /* NetworkError.swift in Sources */,
531690ED267ABF46005D8AB9 /* ContinueWatchingView.swift in Sources */,
E17885A4278105170094FBCF /* SFSymbolButton.swift in Sources */,
E13DD3ED27178A54009D4DAF /* UserSignInViewModel.swift in Sources */,
62EC3530267666A5000E9F2D /* SessionManager.swift in Sources */,
@ -2542,9 +2520,9 @@
C4BE0767271FC109003F4AD1 /* TVLibrariesViewModel.swift in Sources */,
E193D53727193F8700900D82 /* MediaCoordinator.swift in Sources */,
E18E023C288749540022598C /* UIScrollViewExtensions.swift in Sources */,
E1A42E4C28CBD39300A14DCB /* HomeContentView.swift in Sources */,
C4534983279A40990045F1E2 /* tvOSLiveTVVideoPlayerCoordinator.swift in Sources */,
E1C9260F2887565C002A7A66 /* AttributeHStack.swift in Sources */,
E103A6A0278A7E4500820EC7 /* UICinematicBackgroundView.swift in Sources */,
E11CEB9428999D9E003E74C7 /* EpisodeItemContentView.swift in Sources */,
E17885A02780F55C0094FBCF /* tvOSVLCOverlay.swift in Sources */,
E148128328C1443D003B8787 /* NameGUIDPairExtensions.swift in Sources */,
@ -2562,6 +2540,7 @@
E11B1B6D2718CD68006DA3E8 /* JellyfinAPIError.swift in Sources */,
E18E02202887492B0022598C /* AttributeFillView.swift in Sources */,
E1937A3C288E54AD00CB80AA /* BaseItemDto+Images.swift in Sources */,
E1A42E4A28CA6CCD00A14DCB /* CinematicItemSelector.swift in Sources */,
E154677A289AF48200087E35 /* CollectionItemContentView.swift in Sources */,
E148128C28C15526003B8787 /* SortBy.swift in Sources */,
E1C812D1277AE4E300918266 /* tvOSVideoPlayerCoordinator.swift in Sources */,
@ -2574,7 +2553,6 @@
536D3D81267BDFC60004248C /* PortraitItemElement.swift in Sources */,
5D1603FD278A40DB00D22B99 /* SubtitleSize.swift in Sources */,
E13F05F228BC9016003499D2 /* LibraryItemRow.swift in Sources */,
E103A6A7278AB6D700820EC7 /* CinematicResumeCardView.swift in Sources */,
62E1DCC4273CE19800C9AE76 /* URLExtensions.swift in Sources */,
E10EAA54277BBD17000269ED /* BaseItemDto+VideoPlayerViewModel.swift in Sources */,
091B5A8D268315D400D78B61 /* ServerDiscovery.swift in Sources */,
@ -2605,7 +2583,6 @@
E18E02232887492B0022598C /* ImageView.swift in Sources */,
E1E5D54F2783E67100692DFE /* OverlaySettingsView.swift in Sources */,
09389CC526814E4500AE350E /* DeviceProfileBuilder.swift in Sources */,
E1B59FD52786ADE500A5287E /* ContinueWatchingCard.swift in Sources */,
E1C812D2277AE50A00918266 /* URLComponentsExtensions.swift in Sources */,
E1E00A36278628A40022235B /* DoubleExtensions.swift in Sources */,
E1E1643A28BAC2EF00323B0A /* SearchView.swift in Sources */,
@ -2646,6 +2623,7 @@
53ABFDE4267974EF00886593 /* MediaViewModel.swift in Sources */,
5364F456266CA0DC0026ECBA /* BaseItemPersonExtensions.swift in Sources */,
E1937A62288F32DB00CB80AA /* Poster.swift in Sources */,
E1A42E5128CBE44500A14DCB /* LandscapePosterProgressBar.swift in Sources */,
C4BE0764271FC0BB003F4AD1 /* TVLibrariesCoordinator.swift in Sources */,
E1E5D5512783E67700692DFE /* ExperimentalSettingsView.swift in Sources */,
E1A2C160279A7DCA005EC829 /* AboutAppView.swift in Sources */,

View File

@ -39,6 +39,7 @@ struct LandscapePosterProgressBar: View {
.foregroundColor(.white)
ProgressBar(progress: progress)
.frame(height: 3)
}
.padding(.horizontal, 5 * paddingScale)
.padding(.bottom, 7 * paddingScale)