jellyflood/Swiftfin tvOS/Components/CinematicItemSelector.swift

275 lines
9.3 KiB
Swift

//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If 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,
TrailingContent: 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 trailingContent: () -> TrailingContent
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)
.trailing(trailingContent)
.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,
TrailingContent == EmptyView
{
init(items: [Item]) {
self.init(
topContent: { _ in EmptyView() },
itemContent: { _ in EmptyView() },
itemImageOverlay: { _ in EmptyView() },
itemContextMenu: { _ in EmptyView() },
trailingContent: { EmptyView() },
onSelect: { _ in },
items: items
)
}
}
extension CinematicItemSelector {
@ViewBuilder
func topContent<T: View>(@ViewBuilder _ content: @escaping (Item) -> T)
-> CinematicItemSelector<Item, T, ItemContent, ItemImageOverlay, ItemContextMenu, TrailingContent> {
CinematicItemSelector<Item, T, ItemContent, ItemImageOverlay, ItemContextMenu, TrailingContent>(
topContent: content,
itemContent: itemContent,
itemImageOverlay: itemImageOverlay,
itemContextMenu: itemContextMenu,
trailingContent: trailingContent,
onSelect: onSelect,
items: items
)
}
@ViewBuilder
func content<C: View>(@ViewBuilder _ content: @escaping (Item) -> C)
-> CinematicItemSelector<Item, TopContent, C, ItemImageOverlay, ItemContextMenu, TrailingContent> {
CinematicItemSelector<Item, TopContent, C, ItemImageOverlay, ItemContextMenu, TrailingContent>(
topContent: topContent,
itemContent: content,
itemImageOverlay: itemImageOverlay,
itemContextMenu: itemContextMenu,
trailingContent: trailingContent,
onSelect: onSelect,
items: items
)
}
@ViewBuilder
func itemImageOverlay<O: View>(@ViewBuilder _ imageOverlay: @escaping (Item) -> O)
-> CinematicItemSelector<Item, TopContent, ItemContent, O, ItemContextMenu, TrailingContent> {
CinematicItemSelector<Item, TopContent, ItemContent, O, ItemContextMenu, TrailingContent>(
topContent: topContent,
itemContent: itemContent,
itemImageOverlay: imageOverlay,
itemContextMenu: itemContextMenu,
trailingContent: trailingContent,
onSelect: onSelect,
items: items
)
}
@ViewBuilder
func contextMenu<M: View>(@ViewBuilder _ contextMenu: @escaping (Item) -> M)
-> CinematicItemSelector<Item, TopContent, ItemContent, ItemImageOverlay, M, TrailingContent> {
CinematicItemSelector<Item, TopContent, ItemContent, ItemImageOverlay, M, TrailingContent>(
topContent: topContent,
itemContent: itemContent,
itemImageOverlay: itemImageOverlay,
itemContextMenu: contextMenu,
trailingContent: trailingContent,
onSelect: onSelect,
items: items
)
}
@ViewBuilder
func trailingContent<T: View>(@ViewBuilder _ content: @escaping () -> T)
-> CinematicItemSelector<Item, TopContent, ItemContent, ItemImageOverlay, ItemContextMenu, T> {
CinematicItemSelector<Item, TopContent, ItemContent, ItemImageOverlay, ItemContextMenu, T>(
topContent: topContent,
itemContent: itemContent,
itemImageOverlay: itemImageOverlay,
itemContextMenu: itemContextMenu,
trailingContent: content,
onSelect: onSelect,
items: items
)
}
func onSelect(_ action: @escaping (Item) -> Void) -> Self {
var copy = self
copy.onSelect = action
return copy
}
}