feat(tv): add glass poster module and refactor grid layouts

This commit is contained in:
Fredrik Burmester
2026-01-25 17:02:10 +01:00
parent 2c6938c739
commit c2d61654b0
21 changed files with 980 additions and 130 deletions

View File

@@ -0,0 +1,6 @@
{
"platforms": ["apple"],
"apple": {
"modules": ["GlassPosterModule"]
}
}

View File

@@ -0,0 +1,8 @@
// Glass Poster - Native SwiftUI glass effect for tvOS 26+
export * from "./src/GlassPoster.types";
export {
default as GlassPosterModule,
isGlassEffectAvailable,
} from "./src/GlassPosterModule";
export { default as GlassPosterView } from "./src/GlassPosterView";

View File

@@ -0,0 +1,23 @@
Pod::Spec.new do |s|
s.name = 'GlassPoster'
s.version = '1.0.0'
s.summary = 'Native SwiftUI glass effect poster for tvOS'
s.description = 'Provides Liquid Glass effect poster cards for tvOS 26+'
s.author = 'Streamyfin'
s.homepage = 'https://github.com/streamyfin/streamyfin'
s.platforms = {
:ios => '15.1',
:tvos => '15.1'
}
s.source = { git: '' }
s.static_framework = true
s.dependency 'ExpoModulesCore'
s.pod_target_xcconfig = {
'DEFINES_MODULE' => 'YES',
'SWIFT_VERSION' => '5.9'
}
s.source_files = "*.{h,m,mm,swift}"
end

View File

@@ -0,0 +1,91 @@
import ExpoModulesCore
import SwiftUI
import UIKit
/// ExpoView wrapper that hosts the SwiftUI GlassPosterView
class GlassPosterExpoView: ExpoView {
private var hostingController: UIHostingController<GlassPosterView>?
private var posterView: GlassPosterView
// Stored dimensions for intrinsic content size
private var posterWidth: CGFloat = 260
private var posterAspectRatio: CGFloat = 10.0 / 15.0
// Event dispatchers
let onLoad = EventDispatcher()
let onError = EventDispatcher()
required init(appContext: AppContext? = nil) {
self.posterView = GlassPosterView()
super.init(appContext: appContext)
setupHostingController()
}
private func setupHostingController() {
let hostingController = UIHostingController(rootView: posterView)
hostingController.view.backgroundColor = .clear
hostingController.view.translatesAutoresizingMaskIntoConstraints = false
addSubview(hostingController.view)
NSLayoutConstraint.activate([
hostingController.view.topAnchor.constraint(equalTo: topAnchor),
hostingController.view.leadingAnchor.constraint(equalTo: leadingAnchor),
hostingController.view.trailingAnchor.constraint(equalTo: trailingAnchor),
hostingController.view.bottomAnchor.constraint(equalTo: bottomAnchor)
])
self.hostingController = hostingController
}
private func updateHostingController() {
hostingController?.rootView = posterView
}
// Override intrinsic content size for proper React Native layout
override var intrinsicContentSize: CGSize {
let height = posterWidth / posterAspectRatio
return CGSize(width: posterWidth, height: height)
}
// MARK: - Property Setters
func setImageUrl(_ url: String?) {
posterView.imageUrl = url
updateHostingController()
}
func setAspectRatio(_ ratio: Double) {
posterView.aspectRatio = ratio
posterAspectRatio = CGFloat(ratio)
invalidateIntrinsicContentSize()
updateHostingController()
}
func setWidth(_ width: Double) {
posterView.width = width
posterWidth = CGFloat(width)
invalidateIntrinsicContentSize()
updateHostingController()
}
func setCornerRadius(_ radius: Double) {
posterView.cornerRadius = radius
updateHostingController()
}
func setProgress(_ progress: Double) {
posterView.progress = progress
updateHostingController()
}
func setShowWatchedIndicator(_ show: Bool) {
posterView.showWatchedIndicator = show
updateHostingController()
}
func setIsFocused(_ focused: Bool) {
posterView.isFocused = focused
updateHostingController()
}
}

View File

@@ -0,0 +1,50 @@
import ExpoModulesCore
public class GlassPosterModule: Module {
public func definition() -> ModuleDefinition {
Name("GlassPoster")
// Check if glass effect is available (tvOS 26+)
Function("isGlassEffectAvailable") { () -> Bool in
#if os(tvOS)
if #available(tvOS 26.0, *) {
return true
}
#endif
return false
}
// Native view component
View(GlassPosterExpoView.self) {
Prop("imageUrl") { (view: GlassPosterExpoView, url: String?) in
view.setImageUrl(url)
}
Prop("aspectRatio") { (view: GlassPosterExpoView, ratio: Double) in
view.setAspectRatio(ratio)
}
Prop("cornerRadius") { (view: GlassPosterExpoView, radius: Double) in
view.setCornerRadius(radius)
}
Prop("progress") { (view: GlassPosterExpoView, progress: Double) in
view.setProgress(progress)
}
Prop("showWatchedIndicator") { (view: GlassPosterExpoView, show: Bool) in
view.setShowWatchedIndicator(show)
}
Prop("isFocused") { (view: GlassPosterExpoView, focused: Bool) in
view.setIsFocused(focused)
}
Prop("width") { (view: GlassPosterExpoView, width: Double) in
view.setWidth(width)
}
Events("onLoad", "onError")
}
}
}

View File

@@ -0,0 +1,195 @@
import SwiftUI
/// 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+ Glass Effect
#if os(tvOS)
@available(tvOS 26.0, *)
private var glassContent: some View {
return ZStack {
// Image content
imageContent
.clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
// Progress bar overlay
if progress > 0 {
progressOverlay
}
// Watched indicator
if showWatchedIndicator {
watchedIndicatorOverlay
}
}
.frame(width: width, height: height)
.glassEffect(.regular, in: RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
.focusable()
.focused($isInternallyFocused)
.scaleEffect(isCurrentlyFocused ? 1.08 : 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))
// Subtle overlay for depth
RoundedRectangle(cornerRadius: cornerRadius)
.fill(.ultraThinMaterial.opacity(0.15))
// Progress bar overlay
if progress > 0 {
progressOverlay
}
// Watched indicator
if showWatchedIndicator {
watchedIndicatorOverlay
}
}
.frame(width: width, height: height)
.scaleEffect(isFocused ? 1.08 : 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)
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

View File

@@ -0,0 +1,26 @@
import type { StyleProp, ViewStyle } from "react-native";
export interface GlassPosterViewProps {
/** URL of the image to display */
imageUrl: string | null;
/** Aspect ratio of the poster (width/height). Default: 10/15 for portrait, 16/9 for landscape */
aspectRatio: number;
/** Corner radius in points. Default: 24 */
cornerRadius: number;
/** Progress percentage (0-100). Shows progress bar at bottom when > 0 */
progress: number;
/** Whether to show the watched checkmark indicator */
showWatchedIndicator: boolean;
/** Whether the poster is currently focused (for scale animation) */
isFocused: boolean;
/** Width of the poster in points. Required for proper sizing. */
width: number;
/** Style for the container view */
style?: StyleProp<ViewStyle>;
/** Called when the image loads successfully */
onLoad?: () => void;
/** Called when image loading fails */
onError?: (error: string) => void;
}
export type GlassPosterModuleEvents = Record<string, never>;

View File

@@ -0,0 +1,36 @@
import { NativeModule, requireNativeModule } from "expo";
import { Platform } from "react-native";
import type { GlassPosterModuleEvents } from "./GlassPoster.types";
declare class GlassPosterModuleType extends NativeModule<GlassPosterModuleEvents> {
isGlassEffectAvailable(): boolean;
}
// Only load the native module on tvOS
let GlassPosterNativeModule: GlassPosterModuleType | null = null;
if (Platform.OS === "ios" && Platform.isTV) {
try {
GlassPosterNativeModule =
requireNativeModule<GlassPosterModuleType>("GlassPoster");
} catch {
// Module not available, will use fallback
}
}
/**
* Check if the native glass effect is available (tvOS 26+)
*/
export function isGlassEffectAvailable(): boolean {
if (!GlassPosterNativeModule) {
return false;
}
try {
return GlassPosterNativeModule.isGlassEffectAvailable();
} catch {
return false;
}
}
export default GlassPosterNativeModule;

View File

@@ -0,0 +1,46 @@
import { requireNativeView } from "expo";
import * as React from "react";
import { Platform, View } from "react-native";
import type { GlassPosterViewProps } from "./GlassPoster.types";
import { isGlassEffectAvailable } from "./GlassPosterModule";
// Only require the native view on tvOS
let NativeGlassPosterView: React.ComponentType<GlassPosterViewProps> | null =
null;
if (Platform.OS === "ios" && Platform.isTV) {
try {
NativeGlassPosterView =
requireNativeView<GlassPosterViewProps>("GlassPoster");
} catch {
// Module not available
}
}
/**
* GlassPosterView - Native SwiftUI glass effect poster for tvOS 26+
*
* On tvOS 26+: Renders with native Liquid Glass effect
* On older tvOS: Renders with subtle glass-like material effect
* On other platforms: Returns null (use existing poster components)
*/
const GlassPosterView: React.FC<GlassPosterViewProps> = (props) => {
// Only render on tvOS
if (!Platform.isTV || Platform.OS !== "ios") {
return null;
}
// Use native view if available
if (NativeGlassPosterView) {
return <NativeGlassPosterView {...props} />;
}
// Fallback: return empty view (caller should handle this)
return <View style={props.style} />;
};
export default GlassPosterView;
// Re-export availability check for convenience
export { isGlassEffectAvailable };

View File

@@ -0,0 +1,6 @@
export * from "./GlassPoster.types";
export {
default as GlassPosterModule,
isGlassEffectAvailable,
} from "./GlassPosterModule";
export { default as GlassPosterView } from "./GlassPosterView";

View File

@@ -7,7 +7,9 @@ export type {
DownloadStartedEvent,
} from "./background-downloader";
export { default as BackgroundDownloader } from "./background-downloader";
// Glass Poster (tvOS 26+)
export type { GlassPosterViewProps } from "./glass-poster";
export { GlassPosterView, isGlassEffectAvailable } from "./glass-poster";
// MPV Player (iOS + Android)
export type {
AudioTrack as MpvAudioTrack,