jellyflood/Swiftfin tvOS/Components/PosterButton.swift

223 lines
6.4 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
private let landscapeMaxWidth: CGFloat = 500
private let portraitMaxWidth: CGFloat = 500
struct PosterButton<Item: Poster>: View {
@EnvironmentTypeValue<Item>(\.posterOverlayRegistry)
private var posterOverlayRegistry
@State
private var posterSize: CGSize = .zero
private var horizontalAlignment: HorizontalAlignment
private let item: Item
private let type: PosterDisplayType
private let label: any View
private let action: () -> Void
@ViewBuilder
private func poster(overlay: some View) -> some View {
PosterImage(item: item, type: type)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.overlay { overlay }
.contentShape(.contextMenuPreview, Rectangle())
.posterStyle(type)
.posterShadow()
.hoverEffect(.highlight)
}
var body: some View {
Button(action: action) {
let overlay = posterOverlayRegistry?(item) ??
PosterButton.DefaultOverlay(item: item)
.eraseToAnyView()
poster(overlay: overlay)
.trackingSize($posterSize)
label
.eraseToAnyView()
}
.buttonStyle(.borderless)
.buttonBorderShape(.roundedRectangle)
.focusedValue(\.focusedPoster, AnyPoster(item))
.accessibilityLabel(item.displayTitle)
.matchedContextMenu(for: item) {
EmptyView()
}
}
}
extension PosterButton {
init(
item: Item,
type: PosterDisplayType,
action: @escaping () -> Void,
@ViewBuilder label: @escaping () -> any View
) {
self.item = item
self.type = type
self.action = action
self.label = label()
self.horizontalAlignment = .leading
}
func horizontalAlignment(_ alignment: HorizontalAlignment) -> Self {
copy(modifying: \.horizontalAlignment, with: alignment)
}
}
// TODO: Shared default content with iOS?
// - check if content is generally same
extension PosterButton {
// MARK: Default Content
struct TitleContentView: View {
let item: Item
var body: some View {
Text(item.displayTitle)
.font(.footnote.weight(.regular))
.foregroundColor(.primary)
.accessibilityLabel(item.displayTitle)
}
}
struct SubtitleContentView: View {
let item: Item
var body: some View {
Text(item.subtitle ?? "")
.font(.caption.weight(.medium))
.foregroundColor(.secondary)
}
}
struct TitleSubtitleContentView: View {
let item: Item
var body: some View {
VStack(alignment: .leading) {
if item.showTitle {
TitleContentView(item: item)
.lineLimit(1, reservesSpace: true)
}
SubtitleContentView(item: item)
.lineLimit(1, reservesSpace: true)
}
}
}
// TODO: clean up
// Content specific for BaseItemDto episode items
struct EpisodeContentSubtitleContent: View {
let item: Item
var body: some View {
if let item = item as? BaseItemDto {
// Unsure why this needs 0 spacing
// compared to other default content
VStack(alignment: .leading, spacing: 0) {
if item.showTitle, let seriesName = item.seriesName {
Text(seriesName)
.font(.footnote.weight(.regular))
.foregroundColor(.primary)
.lineLimit(1, reservesSpace: true)
}
Subtitle(item: item)
}
}
}
struct Subtitle: View {
let item: BaseItemDto
var body: some View {
SeparatorHStack {
Circle()
.frame(width: 2, height: 2)
.padding(.horizontal, 3)
} content: {
SeparatorHStack {
Text(item.seasonEpisodeLabel ?? .emptyDash)
if item.showTitle {
Text(item.displayTitle)
} else if let seriesName = item.seriesName {
Text(seriesName)
}
}
}
.font(.caption)
.foregroundColor(.secondary)
.lineLimit(1)
}
}
}
// TODO: Find better way for these indicators, see EpisodeCard
struct DefaultOverlay: View {
@Default(.Customization.Indicators.showFavorited)
private var showFavorited
@Default(.Customization.Indicators.showProgress)
private var showProgress
@Default(.Customization.Indicators.showUnplayed)
private var showUnplayed
@Default(.Customization.Indicators.showPlayed)
private var showPlayed
let item: Item
var body: some View {
ZStack {
if let item = item as? BaseItemDto {
if item.canBePlayed, !item.isLiveStream, item.userData?.isPlayed == true {
WatchedIndicator(size: 45)
.isVisible(showPlayed)
} else {
if (item.userData?.playbackPositionTicks ?? 0) > 0 {
ProgressIndicator(progress: (item.userData?.playedPercentage ?? 0) / 100, height: 10)
.isVisible(showProgress)
} else if item.canBePlayed, !item.isLiveStream {
UnwatchedIndicator(size: 45)
.foregroundColor(.jellyfinPurple)
.isVisible(showUnplayed)
}
}
if item.userData?.isFavorite == true {
FavoriteIndicator(size: 45)
.isVisible(showFavorited)
}
}
}
}
}
}