jellyflood/Swiftfin/Views/ItemEditorView/ItemImages/ItemImagesView.swift

223 lines
6.2 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) 2025 Jellyfin & Jellyfin Contributors
//
import Defaults
import JellyfinAPI
import SwiftUI
struct ItemImagesView: View {
// MARK: - Defaults
@Default(.accentColor)
private var accentColor
// MARK: - Observed & Environment Objects
@EnvironmentObject
private var router: ItemImagesCoordinator.Router
@StateObject
var viewModel: ItemImagesViewModel
// MARK: - Dialog State
@State
private var selectedImage: ImageInfo?
@State
private var selectedType: ImageType?
@State
private var isFilePickerPresented = false
// MARK: - Error State
@State
private var error: Error?
// MARK: - Body
var body: some View {
ZStack {
switch viewModel.state {
case .content:
imageView
case .initial:
DelayedProgressView()
case let .error(error):
ErrorView(error: error)
.onRetry {
viewModel.send(.refresh)
}
}
}
.navigationTitle(L10n.images)
.navigationBarTitleDisplayMode(.inline)
.onFirstAppear {
viewModel.send(.refresh)
}
.navigationBarCloseButton {
router.dismissCoordinator()
}
.sheet(item: $selectedImage) {
selectedImage = nil
} content: { imageInfo in
ItemImageDetailsView(
viewModel: viewModel,
imageSource: imageInfo.itemImageSource(
itemID: viewModel.item.id!,
client: viewModel.userSession.client
),
index: imageInfo.imageIndex,
width: imageInfo.width,
height: imageInfo.height,
onClose: {
selectedImage = nil
},
onDelete: {
viewModel.send(.deleteImage(imageInfo))
selectedImage = nil
}
)
.environment(\.isEditing, true)
}
.fileImporter(
isPresented: $isFilePickerPresented,
allowedContentTypes: [.png, .jpeg, .heic],
allowsMultipleSelection: false
) {
switch $0 {
case let .success(urls):
if let file = urls.first, let type = selectedType {
viewModel.send(.uploadFile(file: file, type: type))
selectedType = nil
}
case let .failure(fileError):
error = fileError
selectedType = nil
}
}
.onReceive(viewModel.events) { event in
switch event {
case .updated: ()
case let .error(eventError):
self.error = eventError
}
}
.errorMessage($error)
}
// MARK: - Image View
@ViewBuilder
private var imageView: some View {
ScrollView {
ForEach(ImageType.allCases.sorted(using: \.rawValue), id: \.self) { imageType in
Section {
imageScrollView(for: imageType)
RowDivider()
.padding(.vertical, 16)
} header: {
sectionHeader(for: imageType)
}
}
}
}
// MARK: - Image Scroll View
@ViewBuilder
private func imageScrollView(for imageType: ImageType) -> some View {
let images = viewModel.images[imageType] ?? []
if images.isNotEmpty {
ScrollView(.horizontal, showsIndicators: false) {
HStack {
ForEach(images, id: \.self) { imageInfo in
imageButton(imageInfo: imageInfo) {
selectedImage = imageInfo
}
}
}
.edgePadding(.horizontal)
}
}
}
// MARK: - Section Header
@ViewBuilder
private func sectionHeader(for imageType: ImageType) -> some View {
HStack {
Text(imageType.displayTitle)
.font(.headline)
Spacer()
Menu(L10n.options, systemImage: "plus") {
Button(L10n.search, systemImage: "magnifyingglass") {
router.route(
to: \.addImage,
imageType
)
}
Divider()
Button(L10n.uploadFile, systemImage: "document.badge.plus") {
selectedType = imageType
isFilePickerPresented = true
}
Button(L10n.uploadPhoto, systemImage: "photo.badge.plus") {
router.route(to: \.photoPicker, imageType)
}
}
.font(.body)
.labelStyle(.iconOnly)
.backport
.fontWeight(.semibold)
.foregroundStyle(accentColor)
}
.edgePadding(.horizontal)
}
// MARK: - Image Button
// TODO: instead of using `posterStyle`, should be sized based on
// the image type and just ignore and poster styling
@ViewBuilder
private func imageButton(
imageInfo: ImageInfo,
onSelect: @escaping () -> Void
) -> some View {
Button(action: onSelect) {
ZStack {
Color.secondarySystemFill
ImageView(
imageInfo.itemImageSource(
itemID: viewModel.item.id!,
client: viewModel.userSession.client
)
)
.placeholder { _ in
Image(systemName: "photo")
}
.failure {
Image(systemName: "photo")
}
.pipeline(.Swiftfin.other)
}
.posterStyle(imageInfo.height ?? 0 > imageInfo.width ?? 0 ? .portrait : .landscape)
.frame(maxHeight: 150)
.posterShadow()
}
}
}