Files
streamyfin/modules/glass-poster/ios/GlassPosterView.swift

220 lines
5.4 KiB
Swift

import SwiftUI
/// Wrapper view that observes state changes from GlassPosterState
/// This allows SwiftUI to efficiently update only the changed properties
/// instead of rebuilding the entire view hierarchy on every prop change.
struct GlassPosterViewWrapper: View {
@ObservedObject var state: GlassPosterState
var body: some View {
GlassPosterView(
imageUrl: state.imageUrl,
aspectRatio: state.aspectRatio,
cornerRadius: state.cornerRadius,
progress: state.progress,
showWatchedIndicator: state.showWatchedIndicator,
isFocused: state.isFocused,
width: state.width
)
}
}
/// SwiftUI view with tvOS 26 Liquid Glass effect
struct GlassPosterView: View {
var imageUrl: String? = nil
var aspectRatio: Double = 10.0 / 15.0
var cornerRadius: Double = 24
var progress: Double = 0
var showWatchedIndicator: Bool = false
var isFocused: Bool = false
var width: Double = 260
// Internal focus state for tvOS
@FocusState private var isInternallyFocused: Bool
// Combined focus state (external prop OR internal focus)
private var isCurrentlyFocused: Bool {
isFocused || isInternallyFocused
}
// Calculated height based on width and aspect ratio
private var height: Double {
width / aspectRatio
}
var body: some View {
#if os(tvOS)
if #available(tvOS 26.0, *) {
glassContent
} else {
fallbackContent
}
#else
fallbackContent
#endif
}
// MARK: - tvOS 26+ Content (glass effect disabled for now)
#if os(tvOS)
@available(tvOS 26.0, *)
private var glassContent: some View {
return ZStack {
// Image content
imageContent
.clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
// White border on focus
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
.stroke(Color.white, lineWidth: isCurrentlyFocused ? 4 : 0)
// Progress bar overlay
if progress > 0 {
progressOverlay
}
// Watched indicator
if showWatchedIndicator {
watchedIndicatorOverlay
}
}
.frame(width: width, height: height)
.focusable()
.focused($isInternallyFocused)
.scaleEffect(isCurrentlyFocused ? 1.05 : 1.0)
.animation(.easeOut(duration: 0.15), value: isCurrentlyFocused)
}
#endif
// MARK: - Fallback for older tvOS versions
private var fallbackContent: some View {
ZStack {
// Main image
imageContent
.clipShape(RoundedRectangle(cornerRadius: cornerRadius))
// White border on focus
RoundedRectangle(cornerRadius: cornerRadius)
.stroke(Color.white, lineWidth: isFocused ? 4 : 0)
// Progress bar overlay
if progress > 0 {
progressOverlay
}
// Watched indicator
if showWatchedIndicator {
watchedIndicatorOverlay
}
}
.frame(width: width, height: height)
.scaleEffect(isFocused ? 1.05 : 1.0)
.animation(.easeOut(duration: 0.15), value: isFocused)
}
// MARK: - Shared Components
private var imageContent: some View {
Group {
if let urlString = imageUrl, let url = URL(string: urlString) {
AsyncImage(url: url) { phase in
switch phase {
case .empty:
placeholderView
case .success(let image):
image
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: width, height: height)
.clipped()
case .failure:
placeholderView
@unknown default:
placeholderView
}
}
} else {
placeholderView
}
}
}
private var placeholderView: some View {
Rectangle()
.fill(Color.gray.opacity(0.3))
}
private var progressOverlay: some View {
VStack {
Spacer()
GeometryReader { geometry in
ZStack(alignment: .leading) {
// Background track
Rectangle()
.fill(Color.white.opacity(0.3))
.frame(height: 4)
// Progress fill
Rectangle()
.fill(Color.white)
.frame(width: geometry.size.width * CGFloat(progress / 100), height: 4)
}
}
.frame(height: 4)
}
.clipShape(RoundedRectangle(cornerRadius: cornerRadius))
}
private var watchedIndicatorOverlay: some View {
VStack {
HStack {
Spacer()
ZStack {
Circle()
.fill(Color.white.opacity(0.9))
.frame(width: 28, height: 28)
Image(systemName: "checkmark")
.font(.system(size: 14, weight: .bold))
.foregroundColor(.black)
}
.padding(8)
}
Spacer()
}
}
}
// MARK: - Preview
#if DEBUG
struct GlassPosterView_Previews: PreviewProvider {
static var previews: some View {
VStack(spacing: 20) {
GlassPosterView(
imageUrl: "https://image.tmdb.org/t/p/w500/example.jpg",
aspectRatio: 10.0 / 15.0,
cornerRadius: 24,
progress: 45,
showWatchedIndicator: false,
isFocused: true,
width: 260
)
GlassPosterView(
imageUrl: "https://image.tmdb.org/t/p/w500/example.jpg",
aspectRatio: 16.0 / 9.0,
cornerRadius: 24,
progress: 75,
showWatchedIndicator: true,
isFocused: false,
width: 400
)
}
.padding()
.background(Color.black)
}
}
#endif