mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-04-27 02:54:43 +01:00
feat(tv): add glass poster module and refactor grid layouts
This commit is contained in:
6
modules/glass-poster/expo-module.config.json
Normal file
6
modules/glass-poster/expo-module.config.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"platforms": ["apple"],
|
||||
"apple": {
|
||||
"modules": ["GlassPosterModule"]
|
||||
}
|
||||
}
|
||||
8
modules/glass-poster/index.ts
Normal file
8
modules/glass-poster/index.ts
Normal 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";
|
||||
23
modules/glass-poster/ios/GlassPoster.podspec
Normal file
23
modules/glass-poster/ios/GlassPoster.podspec
Normal 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
|
||||
91
modules/glass-poster/ios/GlassPosterExpoView.swift
Normal file
91
modules/glass-poster/ios/GlassPosterExpoView.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
50
modules/glass-poster/ios/GlassPosterModule.swift
Normal file
50
modules/glass-poster/ios/GlassPosterModule.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
195
modules/glass-poster/ios/GlassPosterView.swift
Normal file
195
modules/glass-poster/ios/GlassPosterView.swift
Normal 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
|
||||
26
modules/glass-poster/src/GlassPoster.types.ts
Normal file
26
modules/glass-poster/src/GlassPoster.types.ts
Normal 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>;
|
||||
36
modules/glass-poster/src/GlassPosterModule.ts
Normal file
36
modules/glass-poster/src/GlassPosterModule.ts
Normal 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;
|
||||
46
modules/glass-poster/src/GlassPosterView.tsx
Normal file
46
modules/glass-poster/src/GlassPosterView.tsx
Normal 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 };
|
||||
6
modules/glass-poster/src/index.ts
Normal file
6
modules/glass-poster/src/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export * from "./GlassPoster.types";
|
||||
export {
|
||||
default as GlassPosterModule,
|
||||
isGlassEffectAvailable,
|
||||
} from "./GlassPosterModule";
|
||||
export { default as GlassPosterView } from "./GlassPosterView";
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user