resolve merge conflicts

This commit is contained in:
tapframe 2025-10-21 13:38:03 +05:30
commit 3164016752
98 changed files with 3271 additions and 1186 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2 KiB

After

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 28 KiB

View file

@ -10,7 +10,7 @@
"scheme": "nuvio", "scheme": "nuvio",
"newArchEnabled": true, "newArchEnabled": true,
"splash": { "splash": {
"image": "./assets/splash-icon.png", "image": "./src/assets/splash-icon-new.png",
"resizeMode": "contain", "resizeMode": "contain",
"backgroundColor": "#020404" "backgroundColor": "#020404"
}, },

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.8 KiB

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 85 KiB

View file

@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<color name="ic_launcher_background">#d1d1d2</color> <color name="ic_launcher_background">#2f2f2f</color>
</resources> </resources>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 675 B

After

Width:  |  Height:  |  Size: 785 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 KiB

After

Width:  |  Height:  |  Size: 280 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 KiB

After

Width:  |  Height:  |  Size: 280 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 288 KiB

After

Width:  |  Height:  |  Size: 583 KiB

View file

@ -16,6 +16,11 @@ RCT_EXPORT_VIEW_PROPERTY(paused, BOOL)
RCT_EXPORT_VIEW_PROPERTY(volume, NSNumber) RCT_EXPORT_VIEW_PROPERTY(volume, NSNumber)
RCT_EXPORT_VIEW_PROPERTY(audioTrack, NSNumber) RCT_EXPORT_VIEW_PROPERTY(audioTrack, NSNumber)
RCT_EXPORT_VIEW_PROPERTY(textTrack, NSNumber) RCT_EXPORT_VIEW_PROPERTY(textTrack, NSNumber)
RCT_EXPORT_VIEW_PROPERTY(allowsExternalPlayback, BOOL)
RCT_EXPORT_VIEW_PROPERTY(usesExternalPlaybackWhileExternalScreenIsActive, BOOL)
RCT_EXPORT_VIEW_PROPERTY(subtitleBottomOffset, NSNumber)
RCT_EXPORT_VIEW_PROPERTY(subtitleFontSize, NSNumber)
RCT_EXPORT_VIEW_PROPERTY(resizeMode, NSString)
// Event properties // Event properties
RCT_EXPORT_VIEW_PROPERTY(onLoad, RCTDirectEventBlock) RCT_EXPORT_VIEW_PROPERTY(onLoad, RCTDirectEventBlock)
@ -32,11 +37,17 @@ RCT_EXTERN_METHOD(setVolume:(nonnull NSNumber *)node volume:(nonnull NSNumber *)
RCT_EXTERN_METHOD(setAudioTrack:(nonnull NSNumber *)node trackId:(nonnull NSNumber *)trackId) RCT_EXTERN_METHOD(setAudioTrack:(nonnull NSNumber *)node trackId:(nonnull NSNumber *)trackId)
RCT_EXTERN_METHOD(setTextTrack:(nonnull NSNumber *)node trackId:(nonnull NSNumber *)trackId) RCT_EXTERN_METHOD(setTextTrack:(nonnull NSNumber *)node trackId:(nonnull NSNumber *)trackId)
RCT_EXTERN_METHOD(getTracks:(nonnull NSNumber *)node resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) RCT_EXTERN_METHOD(getTracks:(nonnull NSNumber *)node resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject)
RCT_EXTERN_METHOD(setAllowsExternalPlayback:(nonnull NSNumber *)node allows:(BOOL)allows)
RCT_EXTERN_METHOD(setUsesExternalPlaybackWhileExternalScreenIsActive:(nonnull NSNumber *)node uses:(BOOL)uses)
RCT_EXTERN_METHOD(getAirPlayState:(nonnull NSNumber *)node resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject)
RCT_EXTERN_METHOD(showAirPlayPicker:(nonnull NSNumber *)node)
@end @end
@interface RCT_EXTERN_MODULE(KSPlayerModule, RCTEventEmitter) @interface RCT_EXTERN_MODULE(KSPlayerModule, RCTEventEmitter)
RCT_EXTERN_METHOD(getTracks:(nonnull NSNumber *)nodeTag resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) RCT_EXTERN_METHOD(getTracks:(nonnull NSNumber *)nodeTag resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject)
RCT_EXTERN_METHOD(getAirPlayState:(nonnull NSNumber *)nodeTag resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject)
RCT_EXTERN_METHOD(showAirPlayPicker:(nonnull NSNumber *)nodeTag)
@end @end

View file

@ -34,4 +34,26 @@ class KSPlayerModule: RCTEventEmitter {
} }
} }
} }
@objc func getAirPlayState(_ nodeTag: NSNumber, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
DispatchQueue.main.async {
if let viewManager = self.bridge.module(for: KSPlayerViewManager.self) as? KSPlayerViewManager {
viewManager.getAirPlayState(nodeTag, resolve: resolve, reject: reject)
} else {
reject("NO_VIEW_MANAGER", "KSPlayerViewManager not found", nil)
}
}
}
@objc func showAirPlayPicker(_ nodeTag: NSNumber) {
print("[KSPlayerModule] showAirPlayPicker called for nodeTag: \(nodeTag)")
DispatchQueue.main.async {
if let viewManager = self.bridge.module(for: KSPlayerViewManager.self) as? KSPlayerViewManager {
print("[KSPlayerModule] Found KSPlayerViewManager, calling showAirPlayPicker")
viewManager.showAirPlayPicker(nodeTag)
} else {
print("[KSPlayerModule] Could not find KSPlayerViewManager")
}
}
}
} }

View file

@ -8,6 +8,7 @@
import Foundation import Foundation
import KSPlayer import KSPlayer
import React import React
import AVKit
@objc(KSPlayerView) @objc(KSPlayerView)
class KSPlayerView: UIView { class KSPlayerView: UIView {
@ -17,6 +18,11 @@ class KSPlayerView: UIView {
private var currentVolume: Float = 1.0 private var currentVolume: Float = 1.0
weak var viewManager: KSPlayerViewManager? weak var viewManager: KSPlayerViewManager?
// Store constraint references for dynamic updates
private var subtitleBottomConstraint: NSLayoutConstraint?
// AirPlay properties (removed duplicate declarations)
// Event blocks for Fabric // Event blocks for Fabric
@objc var onLoad: RCTDirectEventBlock? @objc var onLoad: RCTDirectEventBlock?
@objc var onProgress: RCTDirectEventBlock? @objc var onProgress: RCTDirectEventBlock?
@ -57,15 +63,52 @@ class KSPlayerView: UIView {
setTextTrack(textTrack.intValue) setTextTrack(textTrack.intValue)
} }
} }
// AirPlay properties
@objc var allowsExternalPlayback: Bool = true {
didSet {
setAllowsExternalPlayback(allowsExternalPlayback)
}
}
@objc var usesExternalPlaybackWhileExternalScreenIsActive: Bool = true {
didSet {
setUsesExternalPlaybackWhileExternalScreenIsActive(usesExternalPlaybackWhileExternalScreenIsActive)
}
}
@objc var subtitleBottomOffset: NSNumber = 60 {
didSet {
print("KSPlayerView: [PROP SETTER] subtitleBottomOffset setter called with value: \(subtitleBottomOffset.floatValue)")
updateSubtitlePositioning()
}
}
@objc var subtitleFontSize: NSNumber = 16 {
didSet {
let size = CGFloat(truncating: subtitleFontSize)
print("KSPlayerView: [PROP SETTER] subtitleFontSize setter called with value: \(size)")
updateSubtitleFont(size: size)
}
}
@objc var resizeMode: NSString = "contain" {
didSet {
print("KSPlayerView: [PROP SETTER] resizeMode setter called with value: \(resizeMode)")
applyVideoGravity()
}
}
override init(frame: CGRect) { override init(frame: CGRect) {
super.init(frame: frame) super.init(frame: frame)
setupPlayerView() setupPlayerView()
setupCustomSubtitlePositioning()
} }
required init?(coder: NSCoder) { required init?(coder: NSCoder) {
super.init(coder: coder) super.init(coder: coder)
setupPlayerView() setupPlayerView()
setupCustomSubtitlePositioning()
} }
private func setupPlayerView() { private func setupPlayerView() {
@ -88,9 +131,113 @@ class KSPlayerView: UIView {
playerView.bottomAnchor.constraint(equalTo: bottomAnchor) playerView.bottomAnchor.constraint(equalTo: bottomAnchor)
]) ])
// Ensure subtitle views are visible and on top
// KSPlayer's subtitleLabel renders internal subtitles
playerView.subtitleLabel.isHidden = false
playerView.subtitleBackView.isHidden = false
// Move subtitle view to main container for independence from video transformations
playerView.subtitleBackView.removeFromSuperview()
self.addSubview(playerView.subtitleBackView)
self.bringSubviewToFront(playerView.subtitleBackView)
print("KSPlayerView: [SETUP] Subtitle views made visible")
print("KSPlayerView: [SETUP] subtitleLabel.isHidden: \(playerView.subtitleLabel.isHidden)")
print("KSPlayerView: [SETUP] subtitleBackView.isHidden: \(playerView.subtitleBackView.isHidden)")
print("KSPlayerView: [SETUP] subtitleLabel.frame: \(playerView.subtitleLabel.frame)")
print("KSPlayerView: [SETUP] subtitleBackView.frame: \(playerView.subtitleBackView.frame)")
// Set up player delegates and callbacks // Set up player delegates and callbacks
setupPlayerCallbacks() setupPlayerCallbacks()
} }
private func setupCustomSubtitlePositioning() {
// Wait for the player view to be fully set up before modifying subtitle positioning
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
self?.adjustSubtitlePositioning()
}
}
private func adjustSubtitlePositioning() {
// Remove existing constraints for subtitle positioning
playerView.subtitleBackView.removeFromSuperview()
// Add subtitle view to main container (self) instead of playerView to make it independent of video transformations
self.addSubview(playerView.subtitleBackView)
// Ensure subtitles are always on top of video
self.bringSubviewToFront(playerView.subtitleBackView)
// Re-add subtitle label to subtitle back view
playerView.subtitleBackView.addSubview(playerView.subtitleLabel)
// Set up new constraints for better mobile visibility
playerView.subtitleBackView.translatesAutoresizingMaskIntoConstraints = false
playerView.subtitleLabel.translatesAutoresizingMaskIntoConstraints = false
// Store the bottom constraint reference for dynamic updates
// Constrain to main container (self) instead of playerView to make subtitles independent of video transformations
subtitleBottomConstraint = playerView.subtitleBackView.bottomAnchor.constraint(equalTo: self.bottomAnchor, constant: -CGFloat(subtitleBottomOffset.floatValue))
NSLayoutConstraint.activate([
// Position subtitles using dynamic offset from React Native
subtitleBottomConstraint!,
playerView.subtitleBackView.centerXAnchor.constraint(equalTo: self.centerXAnchor),
playerView.subtitleBackView.widthAnchor.constraint(lessThanOrEqualTo: self.widthAnchor, constant: -20),
playerView.subtitleBackView.heightAnchor.constraint(lessThanOrEqualToConstant: 100),
// Subtitle label constraints within the back view
playerView.subtitleLabel.leadingAnchor.constraint(equalTo: playerView.subtitleBackView.leadingAnchor, constant: 10),
playerView.subtitleLabel.trailingAnchor.constraint(equalTo: playerView.subtitleBackView.trailingAnchor, constant: -10),
playerView.subtitleLabel.topAnchor.constraint(equalTo: playerView.subtitleBackView.topAnchor, constant: 5),
playerView.subtitleLabel.bottomAnchor.constraint(equalTo: playerView.subtitleBackView.bottomAnchor, constant: -5),
])
// Ensure subtitle views are initially hidden
playerView.subtitleBackView.isHidden = true
playerView.subtitleLabel.isHidden = true
print("KSPlayerView: Custom subtitle positioning applied - positioned \(subtitleBottomOffset.floatValue)pts from bottom for mobile visibility")
}
private func updateSubtitlePositioning() {
// Update subtitle positioning when offset changes
print("KSPlayerView: [OFFSET UPDATE] subtitleBottomOffset changed to: \(subtitleBottomOffset.floatValue)")
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
print("KSPlayerView: [OFFSET UPDATE] Applying new positioning with offset: \(self.subtitleBottomOffset.floatValue)")
// Update the existing constraint instead of recreating everything
if let bottomConstraint = self.subtitleBottomConstraint {
bottomConstraint.constant = -CGFloat(self.subtitleBottomOffset.floatValue)
print("KSPlayerView: [OFFSET UPDATE] Updated constraint constant to: \(bottomConstraint.constant)")
} else {
// Fallback: recreate positioning if constraint reference is missing
print("KSPlayerView: [OFFSET UPDATE] No constraint reference found, recreating positioning")
self.adjustSubtitlePositioning()
}
}
}
private func applyVideoGravity() {
print("KSPlayerView: [VIDEO GRAVITY] Applying resizeMode: \(resizeMode)")
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
let contentMode: UIViewContentMode
switch self.resizeMode.lowercased {
case "cover":
contentMode = .scaleAspectFill
case "stretch":
contentMode = .scaleToFill
case "contain":
contentMode = .scaleAspectFit
default:
contentMode = .scaleAspectFit
}
// Set contentMode on the player itself, not the view
self.playerView.playerLayer?.player.contentMode = contentMode
print("KSPlayerView: [VIDEO GRAVITY] Set player contentMode to: \(contentMode)")
}
}
private func setupPlayerCallbacks() { private func setupPlayerCallbacks() {
// Configure KSOptions (use static defaults where required) // Configure KSOptions (use static defaults where required)
@ -103,6 +250,18 @@ class KSPlayerView: UIView {
#endif #endif
} }
private func updateSubtitleFont(size: CGFloat) {
// Update KSPlayer subtitle font size via SubtitleModel
SubtitleModel.textFontSize = size
// Also directly apply to current label for immediate effect
playerView.subtitleLabel.font = SubtitleModel.textFont
// Re-render current subtitle parts to apply font
if let currentTime = playerView.playerLayer?.player.currentPlaybackTime {
_ = playerView.srtControl.subtitle(currentTime: currentTime)
}
print("KSPlayerView: [FONT UPDATE] Applied subtitle font size: \(size)")
}
func setSource(_ source: NSDictionary) { func setSource(_ source: NSDictionary) {
currentSource = source currentSource = source
@ -151,7 +310,15 @@ class KSPlayerView: UIView {
playerView.set(resource: resource) playerView.set(resource: resource)
// Set up delegate after setting the resource // Set up delegate after setting the resource
playerView.playerLayer?.delegate = self if let playerLayer = playerView.playerLayer {
playerLayer.delegate = self
print("KSPlayerView: Delegate set successfully on playerLayer")
// Apply video gravity after player is set up
applyVideoGravity()
} else {
print("KSPlayerView: ERROR - playerLayer is nil, cannot set delegate")
}
// Apply current state // Apply current state
if isPaused { if isPaused {
@ -161,6 +328,12 @@ class KSPlayerView: UIView {
} }
setVolume(currentVolume) setVolume(currentVolume)
// Ensure AirPlay is properly configured after setting source
DispatchQueue.main.async {
self.setAllowsExternalPlayback(self.allowsExternalPlayback)
self.setUsesExternalPlaybackWhileExternalScreenIsActive(self.usesExternalPlaybackWhileExternalScreenIsActive)
}
} }
private func createOptions(with headers: [String: String]) -> KSOptions { private func createOptions(with headers: [String: String]) -> KSOptions {
@ -283,7 +456,7 @@ class KSPlayerView: UIView {
print("KSPlayerView: Successfully selected audio track \(trackId)") print("KSPlayerView: Successfully selected audio track \(trackId)")
// Verify the selection worked // Verify the selection worked
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
let tracksAfter = player.tracks(mediaType: .audio) let tracksAfter = player.tracks(mediaType: .audio)
for (index, track) in tracksAfter.enumerated() { for (index, track) in tracksAfter.enumerated() {
print("KSPlayerView: After selection - Track \(index) (ID: \(track.trackID)) isEnabled: \(track.isEnabled)") print("KSPlayerView: After selection - Track \(index) (ID: \(track.trackID)) isEnabled: \(track.isEnabled)")
@ -321,44 +494,110 @@ class KSPlayerView: UIView {
} }
func setTextTrack(_ trackId: Int) { func setTextTrack(_ trackId: Int) {
if let player = playerView.playerLayer?.player { print("KSPlayerView: [SET TEXT TRACK] Starting setTextTrack with trackId: \(trackId)")
let textTracks = player.tracks(mediaType: .subtitle)
print("KSPlayerView: Available text tracks count: \(textTracks.count)") // Wait slightly longer than the 1-second delay for subtitle data source connection
print("KSPlayerView: Requested text track ID: \(trackId)") // This ensures srtControl.addSubtitle(dataSouce:) has been called in VideoPlayerView
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { [weak self] in
// First try to find track by trackID (proper way) guard let self = self else {
var selectedTrack: MediaPlayerTrack? = nil print("KSPlayerView: [SET TEXT TRACK] self is nil, aborting")
var trackIndex: Int = -1 return
// Try to find by exact trackID match
if let track = textTracks.first(where: { Int($0.trackID) == trackId }) {
selectedTrack = track
trackIndex = textTracks.firstIndex(where: { $0.trackID == track.trackID }) ?? -1
print("KSPlayerView: Found text track by trackID \(trackId) at index \(trackIndex)")
}
// Fallback: treat trackId as array index
else if trackId >= 0 && trackId < textTracks.count {
selectedTrack = textTracks[trackId]
trackIndex = trackId
print("KSPlayerView: Found text track by array index \(trackId) (fallback)")
} }
print("KSPlayerView: [SET TEXT TRACK] Executing delayed track selection")
if let player = self.playerView.playerLayer?.player {
let textTracks = player.tracks(mediaType: .subtitle)
print("KSPlayerView: Available text tracks count: \(textTracks.count)")
print("KSPlayerView: Requested text track ID: \(trackId)")
if let track = selectedTrack { // First try to find track by trackID (proper way)
print("KSPlayerView: Selecting text track \(trackId) (index: \(trackIndex)): '\(track.name)' (ID: \(track.trackID))") var selectedTrack: MediaPlayerTrack? = nil
var trackIndex: Int = -1
// Use KSPlayer's select method which properly handles track selection // Try to find by exact trackID match
player.select(track: track) if let track = textTracks.first(where: { Int($0.trackID) == trackId }) {
selectedTrack = track
trackIndex = textTracks.firstIndex(where: { $0.trackID == track.trackID }) ?? -1
print("KSPlayerView: Found text track by trackID \(trackId) at index \(trackIndex)")
}
// Fallback: treat trackId as array index
else if trackId >= 0 && trackId < textTracks.count {
selectedTrack = textTracks[trackId]
trackIndex = trackId
print("KSPlayerView: Found text track by array index \(trackId) (fallback)")
}
print("KSPlayerView: Successfully selected text track \(trackId)") if let track = selectedTrack {
} else if trackId == -1 { print("KSPlayerView: Selecting text track \(trackId) (index: \(trackIndex)): '\(track.name)' (ID: \(track.trackID))")
// Disable all subtitles
for track in textTracks { track.isEnabled = false } // First disable all tracks to ensure only one is active
print("KSPlayerView: Disabled all text tracks") for t in textTracks {
t.isEnabled = false
}
// Use KSPlayer's select method which properly handles track selection
player.select(track: track)
// Sync srtControl with player track selection
// Find the corresponding SubtitleInfo in srtControl and select it
if let matchingSubtitleInfo = self.playerView.srtControl.subtitleInfos.first(where: { subtitleInfo in
// Try to match by name or track ID
subtitleInfo.name.lowercased() == track.name.lowercased() ||
subtitleInfo.subtitleID == String(track.trackID)
}) {
print("KSPlayerView: Found matching SubtitleInfo: \(matchingSubtitleInfo.name) (ID: \(matchingSubtitleInfo.subtitleID))")
self.playerView.srtControl.selectedSubtitleInfo = matchingSubtitleInfo
print("KSPlayerView: Set srtControl.selectedSubtitleInfo to: \(matchingSubtitleInfo.name)")
} else {
print("KSPlayerView: No matching SubtitleInfo found for track '\(track.name)' (ID: \(track.trackID))")
print("KSPlayerView: Available SubtitleInfos:")
for (index, info) in self.playerView.srtControl.subtitleInfos.enumerated() {
print("KSPlayerView: [\(index)] name='\(info.name)', subtitleID='\(info.subtitleID)'")
}
}
// Ensure subtitle views are visible after selection
self.playerView.subtitleLabel.isHidden = false
self.playerView.subtitleBackView.isHidden = false
// Debug: Check the enabled state of all tracks after selection
print("KSPlayerView: Track states after selection:")
for (index, t) in textTracks.enumerated() {
print("KSPlayerView: Track \(index): ID=\(t.trackID), Name='\(t.name)', Enabled=\(t.isEnabled)")
}
// Verify the selection worked after a delay
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
let tracksAfter = player.tracks(mediaType: .subtitle)
print("KSPlayerView: Verification after subtitle selection:")
for (index, track) in tracksAfter.enumerated() {
print("KSPlayerView: Track \(index) (ID: \(track.trackID)) isEnabled: \(track.isEnabled)")
}
// Also verify srtControl selection
if let selectedInfo = self.playerView.srtControl.selectedSubtitleInfo {
print("KSPlayerView: srtControl.selectedSubtitleInfo: \(selectedInfo.name) (ID: \(selectedInfo.subtitleID))")
} else {
print("KSPlayerView: srtControl.selectedSubtitleInfo is nil")
}
}
print("KSPlayerView: Successfully selected text track \(trackId)")
} else if trackId == -1 {
// Disable all subtitles
for track in textTracks { track.isEnabled = false }
// Clear srtControl selection and hide subtitle views
self.playerView.srtControl.selectedSubtitleInfo = nil
self.playerView.subtitleLabel.isHidden = true
self.playerView.subtitleBackView.isHidden = true
print("KSPlayerView: Disabled all text tracks and cleared srtControl selection")
} else {
print("KSPlayerView: Text track \(trackId) not found. Available track IDs: \(textTracks.map { Int($0.trackID) }), array indices: 0..\(textTracks.count - 1)")
}
} else { } else {
print("KSPlayerView: Text track \(trackId) not found. Available track IDs: \(textTracks.map { Int($0.trackID) }), array indices: 0..\(textTracks.count - 1)") print("KSPlayerView: No player available for text track selection")
} }
} else {
print("KSPlayerView: No player available for text track selection")
} }
} }
@ -382,10 +621,27 @@ class KSPlayerView: UIView {
} }
let textTracks = player.tracks(mediaType: .subtitle).enumerated().map { index, track in let textTracks = player.tracks(mediaType: .subtitle).enumerated().map { index, track in
// Create a better display name for subtitles
var displayName = track.name
if displayName.isEmpty || displayName == "Unknown" {
if let language = track.language, !language.isEmpty && language != "Unknown" {
displayName = language
} else if let languageCode = track.languageCode, !languageCode.isEmpty {
displayName = languageCode.uppercased()
} else {
displayName = "Subtitle \(index + 1)"
}
}
// Add language info if not already in the name
if let language = track.language, !language.isEmpty && language != "Unknown" && !displayName.lowercased().contains(language.lowercased()) {
displayName += " (\(language))"
}
return [ return [
"id": Int(track.trackID), // Use actual track ID, not array index "id": Int(track.trackID), // Use actual track ID, not array index
"index": index, // Keep index for backward compatibility "index": index, // Keep index for backward compatibility
"name": track.name, "name": displayName,
"language": track.language ?? "Unknown", "language": track.language ?? "Unknown",
"languageCode": track.languageCode ?? "", "languageCode": track.languageCode ?? "",
"isEnabled": track.isEnabled, "isEnabled": track.isEnabled,
@ -399,6 +655,94 @@ class KSPlayerView: UIView {
] ]
} }
// AirPlay methods
func setAllowsExternalPlayback(_ allows: Bool) {
print("[KSPlayerView] Setting allowsExternalPlayback: \(allows)")
playerView.playerLayer?.player.allowsExternalPlayback = allows
}
func setUsesExternalPlaybackWhileExternalScreenIsActive(_ uses: Bool) {
print("[KSPlayerView] Setting usesExternalPlaybackWhileExternalScreenIsActive: \(uses)")
playerView.playerLayer?.player.usesExternalPlaybackWhileExternalScreenIsActive = uses
}
func showAirPlayPicker() {
print("[KSPlayerView] showAirPlayPicker called")
DispatchQueue.main.async {
// Create a temporary route picker view for triggering AirPlay
let routePickerView = AVRoutePickerView()
routePickerView.tintColor = .white
routePickerView.alpha = 0.01 // Nearly invisible but still interactive
// Find the current view controller
guard let viewController = self.findHostViewController() else {
print("[KSPlayerView] Could not find view controller for AirPlay picker")
return
}
// Add to the view controller's view temporarily
viewController.view.addSubview(routePickerView)
// Position it off-screen but still in the view hierarchy
routePickerView.frame = CGRect(x: -100, y: -100, width: 44, height: 44)
// Force layout
viewController.view.setNeedsLayout()
viewController.view.layoutIfNeeded()
// Wait a bit for the view to be ready, then trigger
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
// Find and trigger the AirPlay button
self.triggerAirPlayButton(routePickerView)
// Clean up after a delay
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
routePickerView.removeFromSuperview()
print("[KSPlayerView] Cleaned up temporary AirPlay picker")
}
}
}
}
private func triggerAirPlayButton(_ routePickerView: AVRoutePickerView) {
// Recursively find the button in the route picker view
func findButton(in view: UIView) -> UIButton? {
if let button = view as? UIButton {
return button
}
for subview in view.subviews {
if let button = findButton(in: subview) {
return button
}
}
return nil
}
if let button = findButton(in: routePickerView) {
print("[KSPlayerView] Found AirPlay button, triggering tap")
button.sendActions(for: .touchUpInside)
} else {
print("[KSPlayerView] Could not find AirPlay button in route picker")
}
}
func getAirPlayState() -> [String: Any] {
guard let player = playerView.playerLayer?.player else {
return [
"allowsExternalPlayback": false,
"usesExternalPlaybackWhileExternalScreenIsActive": false,
"isExternalPlaybackActive": false
]
}
return [
"allowsExternalPlayback": player.allowsExternalPlayback,
"usesExternalPlaybackWhileExternalScreenIsActive": player.usesExternalPlaybackWhileExternalScreenIsActive,
"isExternalPlaybackActive": player.isExternalPlaybackActive
]
}
// Get current player state for React Native // Get current player state for React Native
func getCurrentState() -> [String: Any] { func getCurrentState() -> [String: Any] {
guard let player = playerView.playerLayer?.player else { guard let player = playerView.playerLayer?.player else {
@ -419,6 +763,81 @@ extension KSPlayerView: KSPlayerLayerDelegate {
func player(layer: KSPlayerLayer, state: KSPlayerState) { func player(layer: KSPlayerLayer, state: KSPlayerState) {
switch state { switch state {
case .readyToPlay: case .readyToPlay:
// Ensure AirPlay is properly configured when player is ready
layer.player.allowsExternalPlayback = allowsExternalPlayback
layer.player.usesExternalPlaybackWhileExternalScreenIsActive = usesExternalPlaybackWhileExternalScreenIsActive
// Debug: Check subtitle data source connection
let hasSubtitleDataSource = layer.player.subtitleDataSouce != nil
print("KSPlayerView: [READY TO PLAY] subtitle data source available: \(hasSubtitleDataSource)")
// Ensure subtitle views are visible
playerView.subtitleLabel.isHidden = false
playerView.subtitleBackView.isHidden = false
print("KSPlayerView: [READY TO PLAY] Verified subtitle views are visible")
print("KSPlayerView: [READY TO PLAY] subtitleLabel.isHidden: \(playerView.subtitleLabel.isHidden)")
print("KSPlayerView: [READY TO PLAY] subtitleBackView.isHidden: \(playerView.subtitleBackView.isHidden)")
print("KSPlayerView: [READY TO PLAY] subtitleLabel.frame: \(playerView.subtitleLabel.frame)")
print("KSPlayerView: [READY TO PLAY] subtitleBackView.frame: \(playerView.subtitleBackView.frame)")
// Manually connect subtitle data source to srtControl (this is the missing piece!)
if let subtitleDataSouce = layer.player.subtitleDataSouce {
print("KSPlayerView: [READY TO PLAY] Connecting subtitle data source to srtControl")
print("KSPlayerView: [READY TO PLAY] subtitleDataSouce type: \(type(of: subtitleDataSouce))")
// Check if subtitle data source has any subtitle infos
print("KSPlayerView: [READY TO PLAY] subtitleDataSouce has \(subtitleDataSouce.infos.count) subtitle infos")
for (index, info) in subtitleDataSouce.infos.enumerated() {
print("KSPlayerView: [READY TO PLAY] subtitleDataSouce info[\(index)]: ID=\(info.subtitleID), Name='\(info.name)', Enabled=\(info.isEnabled)")
}
// Wait 1 second like the original KSPlayer code does
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 1) { [weak self] in
guard let self = self else { return }
print("KSPlayerView: [READY TO PLAY] About to add subtitle data source to srtControl")
self.playerView.srtControl.addSubtitle(dataSouce: subtitleDataSouce)
print("KSPlayerView: [READY TO PLAY] Subtitle data source connected to srtControl")
print("KSPlayerView: [READY TO PLAY] srtControl.subtitleInfos.count: \(self.playerView.srtControl.subtitleInfos.count)")
// Log all subtitle infos
for (index, info) in self.playerView.srtControl.subtitleInfos.enumerated() {
print("KSPlayerView: [READY TO PLAY] SubtitleInfo[\(index)]: name=\(info.name), isEnabled=\(info.isEnabled), subtitleID=\(info.subtitleID)")
}
// Try to manually trigger subtitle parsing for the current time
let currentTime = self.playerView.playerLayer?.player.currentPlaybackTime ?? 0
print("KSPlayerView: [READY TO PLAY] Current playback time: \(currentTime)")
// Force subtitle search for current time
let hasSubtitle = self.playerView.srtControl.subtitle(currentTime: currentTime)
print("KSPlayerView: [READY TO PLAY] Manual subtitle search result: \(hasSubtitle)")
print("KSPlayerView: [READY TO PLAY] Parts count after manual search: \(self.playerView.srtControl.parts.count)")
if let firstPart = self.playerView.srtControl.parts.first {
print("KSPlayerView: [READY TO PLAY] Found subtitle part: start=\(firstPart.start), end=\(firstPart.end), text='\(firstPart.text?.string ?? "nil")'")
}
// Auto-select first enabled subtitle if none selected
if self.playerView.srtControl.selectedSubtitleInfo == nil {
self.playerView.srtControl.selectedSubtitleInfo = self.playerView.srtControl.subtitleInfos.first { $0.isEnabled }
if let selected = self.playerView.srtControl.selectedSubtitleInfo {
print("KSPlayerView: [READY TO PLAY] Auto-selected subtitle: \(selected.name)")
} else {
print("KSPlayerView: [READY TO PLAY] No enabled subtitle found for auto-selection")
}
} else {
print("KSPlayerView: [READY TO PLAY] Subtitle already selected: \(self.playerView.srtControl.selectedSubtitleInfo?.name ?? "unknown")")
}
}
} else {
print("KSPlayerView: [READY TO PLAY] ERROR: No subtitle data source available")
}
// Determine player backend type
let uriString = currentSource?["uri"] as? String
let isMKV = uriString?.lowercased().contains(".mkv") ?? false
let playerBackend = isMKV ? "KSMEPlayer" : "KSAVPlayer"
// Send onLoad event to React Native with track information // Send onLoad event to React Native with track information
let p = layer.player let p = layer.player
let tracks = getAvailableTracks() let tracks = getAvailableTracks()
@ -430,7 +849,8 @@ extension KSPlayerView: KSPlayerLayerDelegate {
"height": p.naturalSize.height "height": p.naturalSize.height
], ],
"audioTracks": tracks["audioTracks"] ?? [], "audioTracks": tracks["audioTracks"] ?? [],
"textTracks": tracks["textTracks"] ?? [] "textTracks": tracks["textTracks"] ?? [],
"playerBackend": playerBackend
]) ])
case .buffering: case .buffering:
sendEvent("onBuffering", ["isBuffering": true]) sendEvent("onBuffering", ["isBuffering": true])
@ -447,13 +867,86 @@ extension KSPlayerView: KSPlayerLayerDelegate {
} }
func player(layer: KSPlayerLayer, currentTime: TimeInterval, totalTime: TimeInterval) { func player(layer: KSPlayerLayer, currentTime: TimeInterval, totalTime: TimeInterval) {
// Debug: Confirm delegate method is being called
if currentTime.truncatingRemainder(dividingBy: 10.0) < 0.1 {
print("KSPlayerView: [DELEGATE CALLED] time=\(currentTime), total=\(totalTime)")
}
// Manually implement subtitle rendering logic from VideoPlayerView
// This is the critical missing piece that was preventing subtitle rendering
// Debug: Check srtControl state
let subtitleInfoCount = playerView.srtControl.subtitleInfos.count
let selectedSubtitle = playerView.srtControl.selectedSubtitleInfo
// Always log subtitle state every 10 seconds to see when it gets populated
if currentTime.truncatingRemainder(dividingBy: 10.0) < 0.1 {
print("KSPlayerView: [SUBTITLE DEBUG] time=\(currentTime.truncatingRemainder(dividingBy: 10.0)), subtitleInfos=\(subtitleInfoCount), selected=\(selectedSubtitle?.name ?? "none")")
// Also check if player has subtitle data source
let player = layer.player
let hasSubtitleDataSource = player.subtitleDataSouce != nil
print("KSPlayerView: [SUBTITLE DEBUG] player has subtitle data source: \(hasSubtitleDataSource)")
// Log subtitle view states
print("KSPlayerView: [SUBTITLE DEBUG] subtitleLabel.isHidden: \(playerView.subtitleLabel.isHidden)")
print("KSPlayerView: [SUBTITLE DEBUG] subtitleBackView.isHidden: \(playerView.subtitleBackView.isHidden)")
print("KSPlayerView: [SUBTITLE DEBUG] subtitleLabel.text: '\(playerView.subtitleLabel.text ?? "nil")'")
print("KSPlayerView: [SUBTITLE DEBUG] subtitleLabel.attributedText: \(playerView.subtitleLabel.attributedText != nil ? "exists" : "nil")")
print("KSPlayerView: [SUBTITLE DEBUG] subtitleBackView.image: \(playerView.subtitleBackView.image != nil ? "exists" : "nil")")
// Log all subtitle infos
for (index, info) in playerView.srtControl.subtitleInfos.enumerated() {
print("KSPlayerView: [SUBTITLE DEBUG] SubtitleInfo[\(index)]: name=\(info.name), isEnabled=\(info.isEnabled)")
}
}
let hasSubtitleParts = playerView.srtControl.subtitle(currentTime: currentTime)
// Debug: Check subtitle timing every 10 seconds
if currentTime.truncatingRemainder(dividingBy: 10.0) < 0.1 && subtitleInfoCount > 0 {
print("KSPlayerView: [SUBTITLE TIMING] time=\(currentTime), hasParts=\(hasSubtitleParts), partsCount=\(playerView.srtControl.parts.count)")
if let firstPart = playerView.srtControl.parts.first {
print("KSPlayerView: [SUBTITLE TIMING] firstPart start=\(firstPart.start), end=\(firstPart.end)")
print("KSPlayerView: [SUBTITLE TIMING] firstPart text='\(firstPart.text?.string ?? "nil")'")
print("KSPlayerView: [SUBTITLE TIMING] firstPart hasImage=\(firstPart.image != nil)")
} else {
print("KSPlayerView: [SUBTITLE TIMING] No parts available")
}
}
if hasSubtitleParts {
if let part = playerView.srtControl.parts.first {
print("KSPlayerView: [SUBTITLE RENDER] time=\(currentTime), text='\(part.text?.string ?? "nil")', hasImage=\(part.image != nil)")
playerView.subtitleBackView.image = part.image
playerView.subtitleLabel.attributedText = part.text
playerView.subtitleBackView.isHidden = false
playerView.subtitleLabel.isHidden = false
print("KSPlayerView: [SUBTITLE RENDER] Set subtitle text and made views visible")
print("KSPlayerView: [SUBTITLE RENDER] subtitleLabel.isHidden after: \(playerView.subtitleLabel.isHidden)")
print("KSPlayerView: [SUBTITLE RENDER] subtitleBackView.isHidden after: \(playerView.subtitleBackView.isHidden)")
} else {
print("KSPlayerView: [SUBTITLE RENDER] hasParts=true but no parts available - hiding views")
playerView.subtitleBackView.image = nil
playerView.subtitleLabel.attributedText = nil
playerView.subtitleBackView.isHidden = true
playerView.subtitleLabel.isHidden = true
}
} else {
// Only log this occasionally to avoid spam
if currentTime.truncatingRemainder(dividingBy: 30.0) < 0.1 {
print("KSPlayerView: [SUBTITLE RENDER] time=\(currentTime), hasParts=false - no subtitle at this time")
}
}
let p = layer.player let p = layer.player
// Ensure we have valid duration before sending progress updates // Ensure we have valid duration before sending progress updates
if totalTime > 0 { if totalTime > 0 {
sendEvent("onProgress", [ sendEvent("onProgress", [
"currentTime": currentTime, "currentTime": currentTime,
"duration": totalTime, "duration": totalTime,
"bufferTime": p.playableTime "bufferTime": p.playableTime,
"airPlayState": getAirPlayState()
]) ])
} }
} }

View file

@ -12,7 +12,7 @@ import React
@objc(KSPlayerViewManager) @objc(KSPlayerViewManager)
class KSPlayerViewManager: RCTViewManager { class KSPlayerViewManager: RCTViewManager {
// Not needed for RCTViewManager-based views; events are exported via RCT_EXPORT_VIEW_PROPERTY // Not needed for RCTViewManager-based views; events are exported via Objective-C externs in KSPlayerManager.m
override func view() -> UIView! { override func view() -> UIView! {
let view = KSPlayerView() let view = KSPlayerView()
view.viewManager = self view.viewManager = self
@ -96,4 +96,44 @@ class KSPlayerViewManager: RCTViewManager {
} }
} }
} }
}
// AirPlay methods
@objc func setAllowsExternalPlayback(_ node: NSNumber, allows: Bool) {
DispatchQueue.main.async {
if let view = self.bridge.uiManager.view(forReactTag: node) as? KSPlayerView {
view.setAllowsExternalPlayback(allows)
}
}
}
@objc func setUsesExternalPlaybackWhileExternalScreenIsActive(_ node: NSNumber, uses: Bool) {
DispatchQueue.main.async {
if let view = self.bridge.uiManager.view(forReactTag: node) as? KSPlayerView {
view.setUsesExternalPlaybackWhileExternalScreenIsActive(uses)
}
}
}
@objc func getAirPlayState(_ node: NSNumber, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
DispatchQueue.main.async {
if let view = self.bridge.uiManager.view(forReactTag: node) as? KSPlayerView {
let airPlayState = view.getAirPlayState()
resolve(airPlayState)
} else {
reject("NO_VIEW", "KSPlayerView not found", nil)
}
}
}
@objc func showAirPlayPicker(_ node: NSNumber) {
print("[KSPlayerViewManager] showAirPlayPicker called for node: \(node)")
DispatchQueue.main.async {
if let view = self.bridge.uiManager.view(forReactTag: node) as? KSPlayerView {
print("[KSPlayerViewManager] Found KSPlayerView, calling showAirPlayPicker")
view.showAirPlayPicker()
} else {
print("[KSPlayerViewManager] Could not find KSPlayerView for node: \(node)")
}
}
}
}

View file

@ -460,7 +460,7 @@
"-lc++", "-lc++",
); );
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG"; OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
PRODUCT_BUNDLE_IDENTIFIER = com.nuvio.app; PRODUCT_BUNDLE_IDENTIFIER = "com.nuvio.app";
PRODUCT_NAME = "Nuvio"; PRODUCT_NAME = "Nuvio";
SWIFT_OBJC_BRIDGING_HEADER = "Nuvio/Nuvio-Bridging-Header.h"; SWIFT_OBJC_BRIDGING_HEADER = "Nuvio/Nuvio-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_OPTIMIZATION_LEVEL = "-Onone";
@ -492,7 +492,7 @@
"-lc++", "-lc++",
); );
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
PRODUCT_BUNDLE_IDENTIFIER = com.nuvio.app; PRODUCT_BUNDLE_IDENTIFIER = "com.nuvio.app";
PRODUCT_NAME = "Nuvio"; PRODUCT_NAME = "Nuvio";
SWIFT_OBJC_BRIDGING_HEADER = "Nuvio/Nuvio-Bridging-Header.h"; SWIFT_OBJC_BRIDGING_HEADER = "Nuvio/Nuvio-Bridging-Header.h";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;

View file

@ -82,7 +82,7 @@
buildConfiguration = "Debug"> buildConfiguration = "Debug">
</AnalyzeAction> </AnalyzeAction>
<ArchiveAction <ArchiveAction
buildConfiguration = "Debug" buildConfiguration = "Release"
revealArchiveInOrganizer = "YES"> revealArchiveInOrganizer = "YES">
</ArchiveAction> </ArchiveAction>
</Scheme> </Scheme>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 211 KiB

After

Width:  |  Height:  |  Size: 237 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

After

Width:  |  Height:  |  Size: 263 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

After

Width:  |  Height:  |  Size: 263 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

After

Width:  |  Height:  |  Size: 263 KiB

22
package-lock.json generated
View file

@ -91,6 +91,7 @@
"@types/crypto-js": "^4.2.2", "@types/crypto-js": "^4.2.2",
"@types/react": "~18.3.12", "@types/react": "~18.3.12",
"@types/react-native": "^0.72.8", "@types/react-native": "^0.72.8",
"@types/react-native-vector-icons": "^6.4.18",
"babel-plugin-transform-remove-console": "^6.9.4", "babel-plugin-transform-remove-console": "^6.9.4",
"patch-package": "^8.0.1", "patch-package": "^8.0.1",
"react-native-svg-transformer": "^1.5.0", "react-native-svg-transformer": "^1.5.0",
@ -4208,6 +4209,27 @@
"@types/react": "*" "@types/react": "*"
} }
}, },
"node_modules/@types/react-native-vector-icons": {
"version": "6.4.18",
"resolved": "https://registry.npmjs.org/@types/react-native-vector-icons/-/react-native-vector-icons-6.4.18.tgz",
"integrity": "sha512-YGlNWb+k5laTBHd7+uZowB9DpIK3SXUneZqAiKQaj1jnJCZM0x71GDim5JCTMi4IFkhc9m8H/Gm28T5BjyivUw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/react": "*",
"@types/react-native": "^0.70"
}
},
"node_modules/@types/react-native-vector-icons/node_modules/@types/react-native": {
"version": "0.70.19",
"resolved": "https://registry.npmjs.org/@types/react-native/-/react-native-0.70.19.tgz",
"integrity": "sha512-c6WbyCgWTBgKKMESj/8b4w+zWcZSsCforson7UdXtXMecG3MxCinYi6ihhrHVPyUrVzORsvEzK8zg32z4pK6Sg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/react": "*"
}
},
"node_modules/@types/react-native-video": { "node_modules/@types/react-native-video": {
"version": "5.0.20", "version": "5.0.20",
"resolved": "https://registry.npmjs.org/@types/react-native-video/-/react-native-video-5.0.20.tgz", "resolved": "https://registry.npmjs.org/@types/react-native-video/-/react-native-video-5.0.20.tgz",

View file

@ -91,6 +91,7 @@
"@types/crypto-js": "^4.2.2", "@types/crypto-js": "^4.2.2",
"@types/react": "~18.3.12", "@types/react": "~18.3.12",
"@types/react-native": "^0.72.8", "@types/react-native": "^0.72.8",
"@types/react-native-vector-icons": "^6.4.18",
"babel-plugin-transform-remove-console": "^6.9.4", "babel-plugin-transform-remove-console": "^6.9.4",
"patch-package": "^8.0.1", "patch-package": "^8.0.1",
"react-native-svg-transformer": "^1.5.0", "react-native-svg-transformer": "^1.5.0",

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

View file

@ -29,7 +29,7 @@ const SplashScreen = ({ onFinish }: SplashScreenProps) => {
return ( return (
<Animated.View style={[styles.container, { opacity: fadeAnim }]}> <Animated.View style={[styles.container, { opacity: fadeAnim }]}>
<Image <Image
source={require('../../assets/splash-icon.png')} source={require('../assets/splash-icon-new.png')}
style={styles.image} style={styles.image}
resizeMode="contain" resizeMode="contain"
/> />

View file

@ -1,6 +1,6 @@
import React, { useCallback, useMemo, useRef } from 'react'; import React, { useCallback, useMemo, useRef } from 'react';
import { View, Text, StyleSheet, TouchableOpacity, Platform, Dimensions } from 'react-native'; import { View, Text, StyleSheet, TouchableOpacity, Platform, Dimensions } from 'react-native';
import { LegendList } from '@legendapp/list'; import { FlashList } from '@shopify/flash-list';
import { NavigationProp, useNavigation } from '@react-navigation/native'; import { NavigationProp, useNavigation } from '@react-navigation/native';
import { MaterialIcons } from '@expo/vector-icons'; import { MaterialIcons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
@ -16,6 +16,26 @@ interface CatalogSectionProps {
const { width } = Dimensions.get('window'); const { width } = Dimensions.get('window');
// Enhanced responsive breakpoints
const BREAKPOINTS = {
phone: 0,
tablet: 768,
largeTablet: 1024,
tv: 1440,
};
const getDeviceType = (deviceWidth: number) => {
if (deviceWidth >= BREAKPOINTS.tv) return 'tv';
if (deviceWidth >= BREAKPOINTS.largeTablet) return 'largeTablet';
if (deviceWidth >= BREAKPOINTS.tablet) return 'tablet';
return 'phone';
};
const deviceType = getDeviceType(width);
const isTablet = deviceType === 'tablet';
const isLargeTablet = deviceType === 'largeTablet';
const isTV = deviceType === 'tv';
// Dynamic poster calculation based on screen width - show 1/4 of next poster // Dynamic poster calculation based on screen width - show 1/4 of next poster
const calculatePosterLayout = (screenWidth: number) => { const calculatePosterLayout = (screenWidth: number) => {
const MIN_POSTER_WIDTH = 100; // Reduced minimum for more posters const MIN_POSTER_WIDTH = 100; // Reduced minimum for more posters
@ -70,21 +90,51 @@ const CatalogSection = ({ catalog }: CatalogSectionProps) => {
); );
}, [handleContentPress]); }, [handleContentPress]);
// Memoize the ItemSeparatorComponent to prevent re-creation // Memoize the ItemSeparatorComponent to prevent re-creation (responsive spacing)
const ItemSeparator = useCallback(() => <View style={{ width: 8 }} />, []); const separatorWidth = isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8;
const ItemSeparator = useCallback(() => <View style={{ width: separatorWidth }} />, [separatorWidth]);
// Memoize the keyExtractor to prevent re-creation // Memoize the keyExtractor to prevent re-creation
const keyExtractor = useCallback((item: StreamingContent) => `${item.id}-${item.type}`, []); const keyExtractor = useCallback((item: StreamingContent) => `${item.id}-${item.type}`, []);
// FlashList v2 optimization: getItemType for better performance
const getItemType = useCallback((item: StreamingContent) => {
// Return different types based on content for better recycling
return item.type === 'movie' ? 'movie' : 'series';
}, []);
return ( return (
<Animated.View <Animated.View
style={styles.catalogContainer} style={styles.catalogContainer}
entering={FadeIn.duration(400)} entering={FadeIn.duration(400)}
> >
<View style={styles.catalogHeader}> <View style={[
styles.catalogHeader,
{ paddingHorizontal: isTV ? 32 : isLargeTablet ? 28 : isTablet ? 24 : 16 }
]}>
<View style={styles.titleContainer}> <View style={styles.titleContainer}>
<Text style={[styles.catalogTitle, { color: currentTheme.colors.text }]} numberOfLines={1}>{catalog.name}</Text> <Text
<View style={[styles.titleUnderline, { backgroundColor: currentTheme.colors.primary }]} /> style={[
styles.catalogTitle,
{
color: currentTheme.colors.text,
fontSize: isTV ? 28 : isLargeTablet ? 26 : isTablet ? 24 : 22,
}
]}
numberOfLines={1}
>
{catalog.name}
</Text>
<View
style={[
styles.titleUnderline,
{
backgroundColor: currentTheme.colors.primary,
width: isTV ? 64 : isLargeTablet ? 56 : isTablet ? 48 : 40,
height: isTV ? 4 : isLargeTablet ? 3 : 3,
}
]}
/>
</View> </View>
<TouchableOpacity <TouchableOpacity
onPress={() => onPress={() =>
@ -94,25 +144,50 @@ const CatalogSection = ({ catalog }: CatalogSectionProps) => {
addonId: catalog.addon addonId: catalog.addon
}) })
} }
style={styles.viewAllButton} style={[
styles.viewAllButton,
{
paddingVertical: isTV ? 10 : isLargeTablet ? 9 : isTablet ? 8 : 8,
paddingHorizontal: isTV ? 12 : isLargeTablet ? 11 : isTablet ? 10 : 10,
borderRadius: isTV ? 22 : isLargeTablet ? 20 : isTablet ? 20 : 20,
}
]}
> >
<Text style={[styles.viewAllText, { color: currentTheme.colors.textMuted }]}>View All</Text> <Text style={[
<MaterialIcons name="chevron-right" size={20} color={currentTheme.colors.textMuted} /> styles.viewAllText,
{
color: currentTheme.colors.textMuted,
fontSize: isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 14,
marginRight: isTV ? 6 : isLargeTablet ? 5 : 4,
}
]}>View All</Text>
<MaterialIcons
name="chevron-right"
size={isTV ? 24 : isLargeTablet ? 22 : isTablet ? 20 : 20}
color={currentTheme.colors.textMuted}
/>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
<LegendList <FlashList
data={catalog.items} data={catalog.items}
renderItem={renderContentItem} renderItem={renderContentItem}
keyExtractor={keyExtractor} keyExtractor={keyExtractor}
getItemType={getItemType}
horizontal horizontal
showsHorizontalScrollIndicator={false} showsHorizontalScrollIndicator={false}
contentContainerStyle={StyleSheet.flatten([styles.catalogList, { paddingRight: 16 - posterLayout.partialPosterWidth }])} contentContainerStyle={StyleSheet.flatten([
styles.catalogList,
{
paddingHorizontal: isTV ? 32 : isLargeTablet ? 28 : isTablet ? 24 : 16,
paddingRight: (isTV ? 32 : isLargeTablet ? 28 : isTablet ? 24 : 16) - posterLayout.partialPosterWidth,
}
])}
ItemSeparatorComponent={ItemSeparator} ItemSeparatorComponent={ItemSeparator}
onEndReachedThreshold={0.7} onEndReachedThreshold={0.7}
onEndReached={() => {}} onEndReached={() => {}}
recycleItems={true} // FlashList v2 optimizations
maintainVisibleContentPosition drawDistance={500}
/> />
</Animated.View> </Animated.View>
); );
@ -126,7 +201,6 @@ const styles = StyleSheet.create({
flexDirection: 'row', flexDirection: 'row',
justifyContent: 'space-between', justifyContent: 'space-between',
alignItems: 'center', alignItems: 'center',
paddingHorizontal: 16,
marginBottom: 16, marginBottom: 16,
}, },
titleContainer: { titleContainer: {
@ -135,7 +209,7 @@ const styles = StyleSheet.create({
marginRight: 16, marginRight: 16,
}, },
catalogTitle: { catalogTitle: {
fontSize: 24, fontSize: 24, // will be overridden responsively
fontWeight: '800', fontWeight: '800',
letterSpacing: 0.5, letterSpacing: 0.5,
marginBottom: 4, marginBottom: 4,
@ -144,26 +218,26 @@ const styles = StyleSheet.create({
position: 'absolute', position: 'absolute',
bottom: -2, bottom: -2,
left: 0, left: 0,
width: 40, width: 40, // overridden responsively
height: 3, height: 3, // overridden responsively
borderRadius: 2, borderRadius: 2,
opacity: 0.8, opacity: 0.8,
}, },
viewAllButton: { viewAllButton: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
paddingVertical: 8, paddingVertical: 8, // overridden responsively
paddingHorizontal: 10, paddingHorizontal: 10, // overridden responsively
borderRadius: 20, borderRadius: 20, // overridden responsively
backgroundColor: 'rgba(255,255,255,0.1)', backgroundColor: 'rgba(255,255,255,0.1)',
}, },
viewAllText: { viewAllText: {
fontSize: 14, fontSize: 14, // overridden responsively
fontWeight: '600', fontWeight: '600',
marginRight: 4, marginRight: 4, // overridden responsively
}, },
catalogList: { catalogList: {
paddingHorizontal: 16, // padding will be applied responsively in JSX
}, },
}); });

View file

@ -23,21 +23,39 @@ interface ContentItemProps {
const { width } = Dimensions.get('window'); const { width } = Dimensions.get('window');
// Enhanced responsive breakpoints
const BREAKPOINTS = {
phone: 0,
tablet: 768,
largeTablet: 1024,
tv: 1440,
};
const getDeviceType = (screenWidth: number) => {
if (screenWidth >= BREAKPOINTS.tv) return 'tv';
if (screenWidth >= BREAKPOINTS.largeTablet) return 'largeTablet';
if (screenWidth >= BREAKPOINTS.tablet) return 'tablet';
return 'phone';
};
// Dynamic poster calculation based on screen width - show 1/4 of next poster // Dynamic poster calculation based on screen width - show 1/4 of next poster
const calculatePosterLayout = (screenWidth: number) => { const calculatePosterLayout = (screenWidth: number) => {
// Detect if device is a tablet (width >= 768px is common tablet breakpoint) const deviceType = getDeviceType(screenWidth);
const isTablet = screenWidth >= 768;
// Responsive sizing based on device type
const MIN_POSTER_WIDTH = isTablet ? 140 : 100; // Bigger minimum for tablets const MIN_POSTER_WIDTH = deviceType === 'tv' ? 180 : deviceType === 'largeTablet' ? 160 : deviceType === 'tablet' ? 140 : 100;
const MAX_POSTER_WIDTH = isTablet ? 180 : 130; // Bigger maximum for tablets const MAX_POSTER_WIDTH = deviceType === 'tv' ? 220 : deviceType === 'largeTablet' ? 200 : deviceType === 'tablet' ? 180 : 130;
const LEFT_PADDING = 16; // Left padding const LEFT_PADDING = deviceType === 'tv' ? 32 : deviceType === 'largeTablet' ? 28 : deviceType === 'tablet' ? 24 : 16;
const SPACING = 8; // Space between posters const SPACING = deviceType === 'tv' ? 12 : deviceType === 'largeTablet' ? 10 : deviceType === 'tablet' ? 8 : 8;
// Calculate available width for posters (reserve space for left padding) // Calculate available width for posters (reserve space for left padding)
const availableWidth = screenWidth - LEFT_PADDING; const availableWidth = screenWidth - LEFT_PADDING;
// Try different numbers of full posters to find the best fit // Try different numbers of full posters to find the best fit
let bestLayout = { numFullPosters: 3, posterWidth: isTablet ? 160 : 120 }; let bestLayout = {
numFullPosters: 3,
posterWidth: deviceType === 'tv' ? 200 : deviceType === 'largeTablet' ? 180 : deviceType === 'tablet' ? 160 : 120
};
for (let n = 3; n <= 6; n++) { for (let n = 3; n <= 6; n++) {
// Calculate poster width needed for N full posters + 0.25 partial poster // Calculate poster width needed for N full posters + 0.25 partial poster
@ -104,15 +122,20 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
const posterRadius = typeof settings.posterBorderRadius === 'number' ? settings.posterBorderRadius : 12; const posterRadius = typeof settings.posterBorderRadius === 'number' ? settings.posterBorderRadius : 12;
// Memoize poster width calculation to avoid recalculating on every render // Memoize poster width calculation to avoid recalculating on every render
const posterWidth = React.useMemo(() => { const posterWidth = React.useMemo(() => {
const deviceType = getDeviceType(width);
const sizeMultiplier = deviceType === 'tv' ? 1.2 : deviceType === 'largeTablet' ? 1.1 : deviceType === 'tablet' ? 1.0 : 0.9;
switch (settings.posterSize) { switch (settings.posterSize) {
case 'small': case 'small':
return Math.max(100, Math.min(POSTER_WIDTH - 10, POSTER_WIDTH)); return Math.max(90, POSTER_WIDTH - 15) * sizeMultiplier;
case 'medium':
return Math.max(110, POSTER_WIDTH + 10) * sizeMultiplier;
case 'large': case 'large':
return Math.min(POSTER_WIDTH + 20, POSTER_WIDTH + 30); return Math.max(130, POSTER_WIDTH + 25) * sizeMultiplier;
default: default:
return POSTER_WIDTH; return POSTER_WIDTH * sizeMultiplier;
} }
}, [settings.posterSize]); }, [settings.posterSize, width]);
// Intersection observer simulation for lazy loading // Intersection observer simulation for lazy loading
const itemRef = useRef<View>(null); const itemRef = useRef<View>(null);
@ -322,7 +345,16 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
</View> </View>
</TouchableOpacity> </TouchableOpacity>
{settings.showPosterTitles && ( {settings.showPosterTitles && (
<Text style={[styles.title, { color: currentTheme.colors.text }]} numberOfLines={2}> <Text
style={[
styles.title,
{
color: currentTheme.colors.text,
fontSize: getDeviceType(width) === 'tv' ? 16 : getDeviceType(width) === 'largeTablet' ? 15 : getDeviceType(width) === 'tablet' ? 14 : 13
}
]}
numberOfLines={2}
>
{item.name} {item.name}
</Text> </Text>
)} )}
@ -409,7 +441,7 @@ const styles = StyleSheet.create({
padding: 2, padding: 2,
}, },
title: { title: {
fontSize: 13, fontSize: 13, // Will be overridden responsively
fontWeight: '500', fontWeight: '500',
marginTop: 4, marginTop: 4,
textAlign: 'center', textAlign: 'center',

View file

@ -1,4 +1,4 @@
import React, { useState, useEffect, useCallback, useRef } from 'react'; import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { import {
View, View,
Text, Text,
@ -39,6 +39,14 @@ interface ContinueWatchingRef {
refresh: () => Promise<boolean>; refresh: () => Promise<boolean>;
} }
// Enhanced responsive breakpoints for Continue Watching section
const BREAKPOINTS = {
phone: 0,
tablet: 768,
largeTablet: 1024,
tv: 1440,
};
// Dynamic poster calculation based on screen width for Continue Watching section // Dynamic poster calculation based on screen width for Continue Watching section
const calculatePosterLayout = (screenWidth: number) => { const calculatePosterLayout = (screenWidth: number) => {
const MIN_POSTER_WIDTH = 120; // Slightly larger for continue watching items const MIN_POSTER_WIDTH = 120; // Slightly larger for continue watching items
@ -96,6 +104,78 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
const [deletingItemId, setDeletingItemId] = useState<string | null>(null); const [deletingItemId, setDeletingItemId] = useState<string | null>(null);
const longPressTimeoutRef = useRef<NodeJS.Timeout | null>(null); const longPressTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Enhanced responsive sizing for tablets and TV screens
const deviceWidth = Dimensions.get('window').width;
const deviceHeight = Dimensions.get('window').height;
// Determine device type based on width
const getDeviceType = useCallback(() => {
if (deviceWidth >= BREAKPOINTS.tv) return 'tv';
if (deviceWidth >= BREAKPOINTS.largeTablet) return 'largeTablet';
if (deviceWidth >= BREAKPOINTS.tablet) return 'tablet';
return 'phone';
}, [deviceWidth]);
const deviceType = getDeviceType();
const isTablet = deviceType === 'tablet';
const isLargeTablet = deviceType === 'largeTablet';
const isTV = deviceType === 'tv';
const isLargeScreen = isTablet || isLargeTablet || isTV;
// Enhanced responsive sizing for continue watching items
const computedItemWidth = useMemo(() => {
switch (deviceType) {
case 'tv':
return 400; // Larger items for TV
case 'largeTablet':
return 350; // Medium-large items for large tablets
case 'tablet':
return 320; // Medium items for tablets
default:
return 280; // Original phone size
}
}, [deviceType]);
const computedItemHeight = useMemo(() => {
switch (deviceType) {
case 'tv':
return 160; // Taller items for TV
case 'largeTablet':
return 140; // Medium-tall items for large tablets
case 'tablet':
return 130; // Medium items for tablets
default:
return 120; // Original phone height
}
}, [deviceType]);
// Enhanced spacing and padding
const horizontalPadding = useMemo(() => {
switch (deviceType) {
case 'tv':
return 32;
case 'largeTablet':
return 28;
case 'tablet':
return 24;
default:
return 16; // phone
}
}, [deviceType]);
const itemSpacing = useMemo(() => {
switch (deviceType) {
case 'tv':
return 20;
case 'largeTablet':
return 18;
case 'tablet':
return 16;
default:
return 16; // phone
}
}, [deviceType]);
// Alert state for CustomAlert // Alert state for CustomAlert
const [alertVisible, setAlertVisible] = useState(false); const [alertVisible, setAlertVisible] = useState(false);
const [alertTitle, setAlertTitle] = useState(''); const [alertTitle, setAlertTitle] = useState('');
@ -632,18 +712,28 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
// Memoized render function for continue watching items // Memoized render function for continue watching items
const renderContinueWatchingItem = useCallback(({ item }: { item: ContinueWatchingItem }) => ( const renderContinueWatchingItem = useCallback(({ item }: { item: ContinueWatchingItem }) => (
<TouchableOpacity <TouchableOpacity
style={[styles.wideContentItem, { style={[
backgroundColor: currentTheme.colors.elevation1, styles.wideContentItem,
borderColor: currentTheme.colors.border, {
shadowColor: currentTheme.colors.black backgroundColor: currentTheme.colors.elevation1,
}]} borderColor: currentTheme.colors.border,
shadowColor: currentTheme.colors.black,
width: computedItemWidth,
height: computedItemHeight
}
]}
activeOpacity={0.8} activeOpacity={0.8}
onPress={() => handleContentPress(item.id, item.type)} onPress={() => handleContentPress(item.id, item.type)}
onLongPress={() => handleLongPress(item)} onLongPress={() => handleLongPress(item)}
delayLongPress={800} delayLongPress={800}
> >
{/* Poster Image */} {/* Poster Image */}
<View style={styles.posterContainer}> <View style={[
styles.posterContainer,
{
width: isTV ? 100 : isLargeTablet ? 90 : isTablet ? 85 : 80
}
]}>
<FastImage <FastImage
source={{ source={{
uri: item.poster || 'https://via.placeholder.com/300x450', uri: item.poster || 'https://via.placeholder.com/300x450',
@ -663,21 +753,42 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
</View> </View>
{/* Content Details */} {/* Content Details */}
<View style={styles.contentDetails}> <View style={[
styles.contentDetails,
{
padding: isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 12
}
]}>
<View style={styles.titleRow}> <View style={styles.titleRow}>
{(() => { {(() => {
const isUpNext = item.type === 'series' && item.progress === 0; const isUpNext = item.type === 'series' && item.progress === 0;
return ( return (
<View style={styles.titleRow}> <View style={styles.titleRow}>
<Text <Text
style={[styles.contentTitle, { color: currentTheme.colors.highEmphasis }]} style={[
styles.contentTitle,
{
color: currentTheme.colors.highEmphasis,
fontSize: isTV ? 20 : isLargeTablet ? 18 : isTablet ? 17 : 16
}
]}
numberOfLines={1} numberOfLines={1}
> >
{item.name} {item.name}
</Text> </Text>
{isUpNext && ( {isUpNext && (
<View style={[styles.progressBadge, { backgroundColor: currentTheme.colors.primary }]}> <View style={[
<Text style={styles.progressText}>Up Next</Text> styles.progressBadge,
{
backgroundColor: currentTheme.colors.primary,
paddingHorizontal: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8,
paddingVertical: isTV ? 6 : isLargeTablet ? 5 : isTablet ? 4 : 3
}
]}>
<Text style={[
styles.progressText,
{ fontSize: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 12 : 12 }
]}>Up Next</Text>
</View> </View>
)} )}
</View> </View>
@ -690,12 +801,24 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
if (item.type === 'series' && item.season && item.episode) { if (item.type === 'series' && item.season && item.episode) {
return ( return (
<View style={styles.episodeRow}> <View style={styles.episodeRow}>
<Text style={[styles.episodeText, { color: currentTheme.colors.mediumEmphasis }]}> <Text style={[
styles.episodeText,
{
color: currentTheme.colors.mediumEmphasis,
fontSize: isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 13
}
]}>
Season {item.season} Season {item.season}
</Text> </Text>
{item.episodeTitle && ( {item.episodeTitle && (
<Text <Text
style={[styles.episodeTitle, { color: currentTheme.colors.mediumEmphasis }]} style={[
styles.episodeTitle,
{
color: currentTheme.colors.mediumEmphasis,
fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 13 : 12
}
]}
numberOfLines={1} numberOfLines={1}
> >
{item.episodeTitle} {item.episodeTitle}
@ -705,7 +828,13 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
); );
} else { } else {
return ( return (
<Text style={[styles.yearText, { color: currentTheme.colors.mediumEmphasis }]}> <Text style={[
styles.yearText,
{
color: currentTheme.colors.mediumEmphasis,
fontSize: isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 13
}
]}>
{item.year} {item.type === 'movie' ? 'Movie' : 'Series'} {item.year} {item.type === 'movie' ? 'Movie' : 'Series'}
</Text> </Text>
); );
@ -715,7 +844,12 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
{/* Progress Bar */} {/* Progress Bar */}
{item.progress > 0 && ( {item.progress > 0 && (
<View style={styles.wideProgressContainer}> <View style={styles.wideProgressContainer}>
<View style={styles.wideProgressTrack}> <View style={[
styles.wideProgressTrack,
{
height: isTV ? 6 : isLargeTablet ? 5 : isTablet ? 4 : 4
}
]}>
<View <View
style={[ style={[
styles.wideProgressBar, styles.wideProgressBar,
@ -726,20 +860,26 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
]} ]}
/> />
</View> </View>
<Text style={[styles.progressLabel, { color: currentTheme.colors.textMuted }]}> <Text style={[
styles.progressLabel,
{
color: currentTheme.colors.textMuted,
fontSize: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 12 : 11
}
]}>
{Math.round(item.progress)}% watched {Math.round(item.progress)}% watched
</Text> </Text>
</View> </View>
)} )}
</View> </View>
</TouchableOpacity> </TouchableOpacity>
), [currentTheme.colors, handleContentPress, handleLongPress, deletingItemId]); ), [currentTheme.colors, handleContentPress, handleLongPress, deletingItemId, computedItemWidth, computedItemHeight, isTV, isLargeTablet, isTablet]);
// Memoized key extractor // Memoized key extractor
const keyExtractor = useCallback((item: ContinueWatchingItem) => `continue-${item.id}-${item.type}`, []); const keyExtractor = useCallback((item: ContinueWatchingItem) => `continue-${item.id}-${item.type}`, []);
// Memoized item separator // Memoized item separator
const ItemSeparator = useCallback(() => <View style={{ width: 16 }} />, []); const ItemSeparator = useCallback(() => <View style={{ width: itemSpacing }} />, [itemSpacing]);
// If no continue watching items, don't render anything // If no continue watching items, don't render anything
if (continueWatchingItems.length === 0) { if (continueWatchingItems.length === 0) {
@ -751,10 +891,23 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
style={styles.container} style={styles.container}
entering={FadeIn.duration(350)} entering={FadeIn.duration(350)}
> >
<View style={styles.header}> <View style={[styles.header, { paddingHorizontal: horizontalPadding }]}>
<View style={styles.titleContainer}> <View style={styles.titleContainer}>
<Text style={[styles.title, { color: currentTheme.colors.text }]}>Continue Watching</Text> <Text style={[
<View style={[styles.titleUnderline, { backgroundColor: currentTheme.colors.primary }]} /> styles.title,
{
color: currentTheme.colors.text,
fontSize: isTV ? 32 : isLargeTablet ? 28 : isTablet ? 26 : 24
}
]}>Continue Watching</Text>
<View style={[
styles.titleUnderline,
{
backgroundColor: currentTheme.colors.primary,
width: isTV ? 50 : isLargeTablet ? 45 : isTablet ? 40 : 40,
height: isTV ? 4 : isLargeTablet ? 3.5 : isTablet ? 3 : 3
}
]} />
</View> </View>
</View> </View>
@ -764,7 +917,13 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
keyExtractor={keyExtractor} keyExtractor={keyExtractor}
horizontal horizontal
showsHorizontalScrollIndicator={false} showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.wideList} contentContainerStyle={[
styles.wideList,
{
paddingLeft: horizontalPadding,
paddingRight: horizontalPadding
}
]}
ItemSeparatorComponent={ItemSeparator} ItemSeparatorComponent={ItemSeparator}
onEndReachedThreshold={0.7} onEndReachedThreshold={0.7}
onEndReached={() => {}} onEndReached={() => {}}
@ -792,7 +951,6 @@ const styles = StyleSheet.create({
flexDirection: 'row', flexDirection: 'row',
justifyContent: 'space-between', justifyContent: 'space-between',
alignItems: 'center', alignItems: 'center',
paddingHorizontal: 16,
marginBottom: 16, marginBottom: 16,
}, },
titleContainer: { titleContainer: {
@ -814,7 +972,6 @@ const styles = StyleSheet.create({
opacity: 0.8, opacity: 0.8,
}, },
wideList: { wideList: {
paddingHorizontal: 16,
paddingBottom: 8, paddingBottom: 8,
paddingTop: 4, paddingTop: 4,
}, },

View file

@ -28,6 +28,14 @@ const { width } = Dimensions.get('window');
const ITEM_WIDTH = width * 0.75; // phone default const ITEM_WIDTH = width * 0.75; // phone default
const ITEM_HEIGHT = 180; // phone default const ITEM_HEIGHT = 180; // phone default
// Enhanced responsive breakpoints
const BREAKPOINTS = {
phone: 0,
tablet: 768,
largeTablet: 1024,
tv: 1440,
};
interface ThisWeekEpisode { interface ThisWeekEpisode {
id: string; id: string;
seriesId: string; seriesId: string;
@ -49,11 +57,77 @@ export const ThisWeekSection = React.memo(() => {
const { currentTheme } = useTheme(); const { currentTheme } = useTheme();
const { calendarData, loading } = useCalendarData(); const { calendarData, loading } = useCalendarData();
// Responsive sizing for tablets // Enhanced responsive sizing for tablets and TV screens
const deviceWidth = Dimensions.get('window').width; const deviceWidth = Dimensions.get('window').width;
const isTablet = deviceWidth >= 768; const deviceHeight = Dimensions.get('window').height;
const computedItemWidth = useMemo(() => (isTablet ? Math.min(deviceWidth * 0.46, 560) : ITEM_WIDTH), [isTablet, deviceWidth]);
const computedItemHeight = useMemo(() => (isTablet ? 220 : ITEM_HEIGHT), [isTablet]); // Determine device type based on width
const getDeviceType = useCallback(() => {
if (deviceWidth >= BREAKPOINTS.tv) return 'tv';
if (deviceWidth >= BREAKPOINTS.largeTablet) return 'largeTablet';
if (deviceWidth >= BREAKPOINTS.tablet) return 'tablet';
return 'phone';
}, [deviceWidth]);
const deviceType = getDeviceType();
const isTablet = deviceType === 'tablet';
const isLargeTablet = deviceType === 'largeTablet';
const isTV = deviceType === 'tv';
const isLargeScreen = isTablet || isLargeTablet || isTV;
// Enhanced responsive sizing
const computedItemWidth = useMemo(() => {
switch (deviceType) {
case 'tv':
return Math.min(deviceWidth * 0.25, 400); // 4 items per row on TV
case 'largeTablet':
return Math.min(deviceWidth * 0.35, 350); // 3 items per row on large tablet
case 'tablet':
return Math.min(deviceWidth * 0.46, 300); // 2 items per row on tablet
default:
return ITEM_WIDTH; // phone
}
}, [deviceType, deviceWidth]);
const computedItemHeight = useMemo(() => {
switch (deviceType) {
case 'tv':
return 280;
case 'largeTablet':
return 250;
case 'tablet':
return 220;
default:
return ITEM_HEIGHT; // phone
}
}, [deviceType]);
// Enhanced spacing and padding
const horizontalPadding = useMemo(() => {
switch (deviceType) {
case 'tv':
return 32;
case 'largeTablet':
return 28;
case 'tablet':
return 24;
default:
return 16; // phone
}
}, [deviceType]);
const itemSpacing = useMemo(() => {
switch (deviceType) {
case 'tv':
return 20;
case 'largeTablet':
return 18;
case 'tablet':
return 16;
default:
return 16; // phone
}
}, [deviceType]);
// Use the already memory-optimized calendar data instead of fetching separately // Use the already memory-optimized calendar data instead of fetching separately
const thisWeekEpisodes = useMemo(() => { const thisWeekEpisodes = useMemo(() => {
@ -144,35 +218,70 @@ export const ThisWeekSection = React.memo(() => {
'rgba(0,0,0,0.8)', 'rgba(0,0,0,0.8)',
'rgba(0,0,0,0.95)' 'rgba(0,0,0,0.95)'
]} ]}
style={styles.gradient} style={[
styles.gradient,
{
padding: isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 12
}
]}
locations={[0, 0.4, 0.6, 0.8, 1]} locations={[0, 0.4, 0.6, 0.8, 1]}
> >
{/* Content area */} {/* Content area */}
<View style={styles.contentArea}> <View style={styles.contentArea}>
<Text style={[styles.seriesName, { color: currentTheme.colors.white, fontSize: isTablet ? 18 : undefined }]} numberOfLines={1}> <Text style={[
styles.seriesName,
{
color: currentTheme.colors.white,
fontSize: isTV ? 22 : isLargeTablet ? 20 : isTablet ? 18 : 16
}
]} numberOfLines={1}>
{item.seriesName} {item.seriesName}
</Text> </Text>
<Text style={[styles.episodeTitle, { color: 'rgba(255,255,255,0.9)', fontSize: isTablet ? 16 : undefined }]} numberOfLines={2}> <Text style={[
styles.episodeTitle,
{
color: 'rgba(255,255,255,0.9)',
fontSize: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 14
}
]} numberOfLines={2}>
{item.title} {item.title}
</Text> </Text>
{item.overview && ( {item.overview && (
<Text style={[styles.overview, { color: 'rgba(255,255,255,0.8)', fontSize: isTablet ? 13 : undefined }]} numberOfLines={isTablet ? 3 : 2}> <Text style={[
styles.overview,
{
color: 'rgba(255,255,255,0.8)',
fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 13 : 12
}
]} numberOfLines={isLargeScreen ? 3 : 2}>
{item.overview} {item.overview}
</Text> </Text>
)} )}
<View style={styles.dateContainer}> <View style={styles.dateContainer}>
<Text style={[styles.episodeInfo, { color: 'rgba(255,255,255,0.7)', fontSize: isTablet ? 13 : undefined }]}> <Text style={[
styles.episodeInfo,
{
color: 'rgba(255,255,255,0.7)',
fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 13 : 12
}
]}>
S{item.season}:E{item.episode} S{item.season}:E{item.episode}
</Text> </Text>
<MaterialIcons <MaterialIcons
name="event" name="event"
size={isTablet ? 16 : 14} size={isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 14}
color={currentTheme.colors.primary} color={currentTheme.colors.primary}
/> />
<Text style={[styles.releaseDate, { color: currentTheme.colors.primary, fontSize: isTablet ? 14 : undefined }]}> <Text style={[
styles.releaseDate,
{
color: currentTheme.colors.primary,
fontSize: isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 13
}
]}>
{formattedDate} {formattedDate}
</Text> </Text>
</View> </View>
@ -189,14 +298,43 @@ export const ThisWeekSection = React.memo(() => {
style={styles.container} style={styles.container}
entering={FadeIn.duration(350)} entering={FadeIn.duration(350)}
> >
<View style={styles.header}> <View style={[styles.header, { paddingHorizontal: horizontalPadding }]}>
<View style={styles.titleContainer}> <View style={styles.titleContainer}>
<Text style={[styles.title, { color: currentTheme.colors.text }]}>This Week</Text> <Text style={[
<View style={[styles.titleUnderline, { backgroundColor: currentTheme.colors.primary }]} /> styles.title,
{
color: currentTheme.colors.text,
fontSize: isTV ? 32 : isLargeTablet ? 28 : isTablet ? 26 : 24
}
]}>This Week</Text>
<View style={[
styles.titleUnderline,
{
backgroundColor: currentTheme.colors.primary,
width: isTV ? 50 : isLargeTablet ? 45 : isTablet ? 40 : 40,
height: isTV ? 4 : isLargeTablet ? 3.5 : isTablet ? 3 : 3
}
]} />
</View> </View>
<TouchableOpacity onPress={handleViewAll} style={styles.viewAllButton}> <TouchableOpacity onPress={handleViewAll} style={[
<Text style={[styles.viewAllText, { color: currentTheme.colors.textMuted }]}>View All</Text> styles.viewAllButton,
<MaterialIcons name="chevron-right" size={20} color={currentTheme.colors.textMuted} /> {
paddingVertical: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8,
paddingHorizontal: isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 10
}
]}>
<Text style={[
styles.viewAllText,
{
color: currentTheme.colors.textMuted,
fontSize: isTV ? 18 : isLargeTablet ? 16 : isTablet ? 15 : 14
}
]}>View All</Text>
<MaterialIcons
name="chevron-right"
size={isTV ? 24 : isLargeTablet ? 22 : isTablet ? 20 : 20}
color={currentTheme.colors.textMuted}
/>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
@ -206,20 +344,26 @@ export const ThisWeekSection = React.memo(() => {
renderItem={renderEpisodeItem} renderItem={renderEpisodeItem}
horizontal horizontal
showsHorizontalScrollIndicator={false} showsHorizontalScrollIndicator={false}
contentContainerStyle={[styles.listContent, { paddingLeft: isTablet ? 24 : 16, paddingRight: isTablet ? 24 : 16 }]} contentContainerStyle={[
snapToInterval={computedItemWidth + 16} styles.listContent,
{
paddingLeft: horizontalPadding,
paddingRight: horizontalPadding
}
]}
snapToInterval={computedItemWidth + itemSpacing}
decelerationRate="fast" decelerationRate="fast"
snapToAlignment="start" snapToAlignment="start"
initialNumToRender={isTablet ? 4 : 3} initialNumToRender={isTV ? 6 : isLargeTablet ? 5 : isTablet ? 4 : 3}
windowSize={3} windowSize={isTV ? 4 : isLargeTablet ? 4 : 3}
maxToRenderPerBatch={3} maxToRenderPerBatch={isTV ? 4 : isLargeTablet ? 4 : 3}
removeClippedSubviews removeClippedSubviews
getItemLayout={(data, index) => { getItemLayout={(data, index) => {
const length = computedItemWidth + 16; const length = computedItemWidth + itemSpacing;
const offset = length * index; const offset = length * index;
return { length, offset, index }; return { length, offset, index };
}} }}
ItemSeparatorComponent={() => <View style={{ width: 16 }} />} ItemSeparatorComponent={() => <View style={{ width: itemSpacing }} />}
/> />
</Animated.View> </Animated.View>
); );
@ -233,7 +377,6 @@ const styles = StyleSheet.create({
flexDirection: 'row', flexDirection: 'row',
justifyContent: 'space-between', justifyContent: 'space-between',
alignItems: 'center', alignItems: 'center',
paddingHorizontal: 16,
marginBottom: 16, marginBottom: 16,
}, },
titleContainer: { titleContainer: {
@ -269,8 +412,6 @@ const styles = StyleSheet.create({
marginRight: 4, marginRight: 4,
}, },
listContent: { listContent: {
paddingLeft: 16,
paddingRight: 16,
paddingBottom: 8, paddingBottom: 8,
}, },
loadingContainer: { loadingContainer: {
@ -316,7 +457,7 @@ const styles = StyleSheet.create({
padding: 12, padding: 12,
borderRadius: 16, borderRadius: 16,
}, },
contentArea: { contentArea: {
width: '100%', width: '100%',
}, },
seriesName: { seriesName: {

View file

@ -1,4 +1,4 @@
import React from 'react'; import React, { useCallback, useMemo } from 'react';
import { import {
View, View,
Text, Text,
@ -6,6 +6,7 @@ import {
FlatList, FlatList,
TouchableOpacity, TouchableOpacity,
ActivityIndicator, ActivityIndicator,
Dimensions,
} from 'react-native'; } from 'react-native';
import FastImage from '@d11/react-native-fast-image'; import FastImage from '@d11/react-native-fast-image';
import Animated, { import Animated, {
@ -13,6 +14,14 @@ import Animated, {
} from 'react-native-reanimated'; } from 'react-native-reanimated';
import { useTheme } from '../../contexts/ThemeContext'; import { useTheme } from '../../contexts/ThemeContext';
// Enhanced responsive breakpoints for Cast Section
const BREAKPOINTS = {
phone: 0,
tablet: 768,
largeTablet: 1024,
tv: 1440,
};
interface CastSectionProps { interface CastSectionProps {
cast: any[]; cast: any[];
loadingCast: boolean; loadingCast: boolean;
@ -28,6 +37,78 @@ export const CastSection: React.FC<CastSectionProps> = ({
}) => { }) => {
const { currentTheme } = useTheme(); const { currentTheme } = useTheme();
// Enhanced responsive sizing for tablets and TV screens
const deviceWidth = Dimensions.get('window').width;
const deviceHeight = Dimensions.get('window').height;
// Determine device type based on width
const getDeviceType = useCallback(() => {
if (deviceWidth >= BREAKPOINTS.tv) return 'tv';
if (deviceWidth >= BREAKPOINTS.largeTablet) return 'largeTablet';
if (deviceWidth >= BREAKPOINTS.tablet) return 'tablet';
return 'phone';
}, [deviceWidth]);
const deviceType = getDeviceType();
const isTablet = deviceType === 'tablet';
const isLargeTablet = deviceType === 'largeTablet';
const isTV = deviceType === 'tv';
const isLargeScreen = isTablet || isLargeTablet || isTV;
// Enhanced spacing and padding
const horizontalPadding = useMemo(() => {
switch (deviceType) {
case 'tv':
return 32;
case 'largeTablet':
return 28;
case 'tablet':
return 24;
default:
return 16; // phone
}
}, [deviceType]);
// Enhanced cast card sizing
const castCardWidth = useMemo(() => {
switch (deviceType) {
case 'tv':
return 120;
case 'largeTablet':
return 110;
case 'tablet':
return 100;
default:
return 90; // phone
}
}, [deviceType]);
const castImageSize = useMemo(() => {
switch (deviceType) {
case 'tv':
return 100;
case 'largeTablet':
return 90;
case 'tablet':
return 85;
default:
return 80; // phone
}
}, [deviceType]);
const castCardSpacing = useMemo(() => {
switch (deviceType) {
case 'tv':
return 20;
case 'largeTablet':
return 18;
case 'tablet':
return 16;
default:
return 16; // phone
}
}, [deviceType]);
if (loadingCast) { if (loadingCast) {
return ( return (
<View style={styles.loadingContainer}> <View style={styles.loadingContainer}>
@ -45,25 +126,52 @@ export const CastSection: React.FC<CastSectionProps> = ({
style={styles.castSection} style={styles.castSection}
entering={FadeIn.duration(300).delay(150)} entering={FadeIn.duration(300).delay(150)}
> >
<View style={styles.sectionHeader}> <View style={[
<Text style={[styles.sectionTitle, { color: currentTheme.colors.highEmphasis }]}>Cast</Text> styles.sectionHeader,
{ paddingHorizontal: horizontalPadding }
]}>
<Text style={[
styles.sectionTitle,
{
color: currentTheme.colors.highEmphasis,
fontSize: isTV ? 24 : isLargeTablet ? 22 : isTablet ? 20 : 18,
marginBottom: isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 12
}
]}>Cast</Text>
</View> </View>
<FlatList <FlatList
horizontal horizontal
data={cast} data={cast}
showsHorizontalScrollIndicator={false} showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.castList} contentContainerStyle={[
styles.castList,
{ paddingHorizontal: horizontalPadding }
]}
keyExtractor={(item) => item.id.toString()} keyExtractor={(item) => item.id.toString()}
renderItem={({ item, index }) => ( renderItem={({ item, index }) => (
<Animated.View <Animated.View
entering={FadeIn.duration(300).delay(50 + index * 30)} entering={FadeIn.duration(300).delay(50 + index * 30)}
> >
<TouchableOpacity <TouchableOpacity
style={styles.castCard} style={[
styles.castCard,
{
width: castCardWidth,
marginRight: castCardSpacing
}
]}
onPress={() => onSelectCastMember(item)} onPress={() => onSelectCastMember(item)}
activeOpacity={0.7} activeOpacity={0.7}
> >
<View style={styles.castImageContainer}> <View style={[
styles.castImageContainer,
{
width: castImageSize,
height: castImageSize,
borderRadius: castImageSize / 2,
marginBottom: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8
}
]}>
{item.profile_path ? ( {item.profile_path ? (
<FastImage <FastImage
source={{ source={{
@ -73,16 +181,43 @@ export const CastSection: React.FC<CastSectionProps> = ({
resizeMode={FastImage.resizeMode.cover} resizeMode={FastImage.resizeMode.cover}
/> />
) : ( ) : (
<View style={[styles.castImagePlaceholder, { backgroundColor: currentTheme.colors.darkBackground }]}> <View style={[
<Text style={[styles.placeholderText, { color: currentTheme.colors.textMuted }]}> styles.castImagePlaceholder,
{
backgroundColor: currentTheme.colors.darkBackground,
borderRadius: castImageSize / 2
}
]}>
<Text style={[
styles.placeholderText,
{
color: currentTheme.colors.textMuted,
fontSize: isTV ? 32 : isLargeTablet ? 28 : isTablet ? 26 : 24
}
]}>
{item.name.split(' ').reduce((prev: string, current: string) => prev + current[0], '').substring(0, 2)} {item.name.split(' ').reduce((prev: string, current: string) => prev + current[0], '').substring(0, 2)}
</Text> </Text>
</View> </View>
)} )}
</View> </View>
<Text style={[styles.castName, { color: currentTheme.colors.text }]} numberOfLines={1}>{item.name}</Text> <Text style={[
styles.castName,
{
color: currentTheme.colors.text,
fontSize: isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 14,
width: castCardWidth
}
]} numberOfLines={1}>{item.name}</Text>
{isTmdbEnrichmentEnabled && item.character && ( {isTmdbEnrichmentEnabled && item.character && (
<Text style={[styles.characterName, { color: currentTheme.colors.textMuted }]} numberOfLines={1}>{item.character}</Text> <Text style={[
styles.characterName,
{
color: currentTheme.colors.textMuted,
fontSize: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 12 : 12,
width: castCardWidth,
marginTop: isTV ? 4 : isLargeTablet ? 3 : isTablet ? 2 : 2
}
]} numberOfLines={1}>{item.character}</Text>
)} )}
</TouchableOpacity> </TouchableOpacity>
</Animated.View> </Animated.View>
@ -107,14 +242,12 @@ const styles = StyleSheet.create({
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between', justifyContent: 'space-between',
marginBottom: 12, marginBottom: 12,
paddingHorizontal: 16,
}, },
sectionTitle: { sectionTitle: {
fontSize: 18, fontSize: 18,
fontWeight: '700', fontWeight: '700',
}, },
castList: { castList: {
paddingHorizontal: 16,
paddingBottom: 4, paddingBottom: 4,
}, },
castCard: { castCard: {

View file

@ -1,4 +1,4 @@
import React, { useCallback, useState, useRef } from 'react'; import React, { useCallback, useState, useRef, useMemo } from 'react';
import { import {
View, View,
Text, Text,
@ -21,7 +21,13 @@ import { useTraktComments } from '../../hooks/useTraktComments';
import { useSettings } from '../../hooks/useSettings'; import { useSettings } from '../../hooks/useSettings';
import BottomSheet, { BottomSheetView, BottomSheetScrollView } from '@gorhom/bottom-sheet'; import BottomSheet, { BottomSheetView, BottomSheetScrollView } from '@gorhom/bottom-sheet';
const { width } = Dimensions.get('window'); // Enhanced responsive breakpoints for Comments Section
const BREAKPOINTS = {
phone: 0,
tablet: 768,
largeTablet: 1024,
tv: 1440,
};
interface CommentsSectionProps { interface CommentsSectionProps {
imdbId: string; imdbId: string;
@ -191,6 +197,64 @@ const CompactCommentCard: React.FC<{
}).start(); }).start();
}, [fadeInOpacity]); }, [fadeInOpacity]);
// Enhanced responsive sizing for tablets and TV screens
const deviceWidth = Dimensions.get('window').width;
const deviceHeight = Dimensions.get('window').height;
// Determine device type based on width
const getDeviceType = useCallback(() => {
if (deviceWidth >= BREAKPOINTS.tv) return 'tv';
if (deviceWidth >= BREAKPOINTS.largeTablet) return 'largeTablet';
if (deviceWidth >= BREAKPOINTS.tablet) return 'tablet';
return 'phone';
}, [deviceWidth]);
const deviceType = getDeviceType();
const isTablet = deviceType === 'tablet';
const isLargeTablet = deviceType === 'largeTablet';
const isTV = deviceType === 'tv';
const isLargeScreen = isTablet || isLargeTablet || isTV;
// Enhanced comment card sizing
const commentCardWidth = useMemo(() => {
switch (deviceType) {
case 'tv':
return 360;
case 'largeTablet':
return 320;
case 'tablet':
return 300;
default:
return 280; // phone
}
}, [deviceType]);
const commentCardHeight = useMemo(() => {
switch (deviceType) {
case 'tv':
return 200;
case 'largeTablet':
return 185;
case 'tablet':
return 175;
default:
return 170; // phone
}
}, [deviceType]);
const commentCardSpacing = useMemo(() => {
switch (deviceType) {
case 'tv':
return 16;
case 'largeTablet':
return 14;
case 'tablet':
return 12;
default:
return 12; // phone
}
}, [deviceType]);
// Safety check - ensure comment data exists // Safety check - ensure comment data exists
if (!comment || !comment.comment) { if (!comment || !comment.comment) {
return null; return null;
@ -272,6 +336,11 @@ const CompactCommentCard: React.FC<{
borderColor: theme.colors.border, borderColor: theme.colors.border,
opacity: fadeInOpacity, opacity: fadeInOpacity,
transform: isPressed ? [{ scale: 0.98 }] : [{ scale: 1 }], transform: isPressed ? [{ scale: 0.98 }] : [{ scale: 1 }],
width: commentCardWidth,
height: commentCardHeight,
marginRight: commentCardSpacing,
padding: isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 12,
borderRadius: isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 12
}, },
]} ]}
> >
@ -287,18 +356,41 @@ const CompactCommentCard: React.FC<{
> >
{/* Trakt Icon - Top Right Corner */} {/* Trakt Icon - Top Right Corner */}
<View style={styles.traktIconContainer}> <View style={styles.traktIconContainer}>
<TraktIcon width={16} height={16} /> <TraktIcon width={isTV ? 20 : isLargeTablet ? 18 : isTablet ? 16 : 16} height={isTV ? 20 : isLargeTablet ? 18 : isTablet ? 16 : 16} />
</View> </View>
{/* Header Section - Fixed at top */} {/* Header Section - Fixed at top */}
<View style={styles.compactHeader}> <View style={[
styles.compactHeader,
{
marginBottom: isTV ? 10 : isLargeTablet ? 8 : isTablet ? 8 : 8
}
]}>
<View style={styles.usernameContainer}> <View style={styles.usernameContainer}>
<Text style={[styles.compactUsername, { color: theme.colors.highEmphasis }]}> <Text style={[
styles.compactUsername,
{
color: theme.colors.highEmphasis,
fontSize: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 16
}
]}>
{username} {username}
</Text> </Text>
{user.vip && ( {user.vip && (
<View style={styles.miniVipBadge}> <View style={[
<Text style={styles.miniVipText}>VIP</Text> styles.miniVipBadge,
{
paddingHorizontal: isTV ? 6 : isLargeTablet ? 5 : isTablet ? 4 : 4,
paddingVertical: isTV ? 2 : isLargeTablet ? 2 : isTablet ? 1 : 1,
borderRadius: isTV ? 8 : isLargeTablet ? 7 : isTablet ? 6 : 6
}
]}>
<Text style={[
styles.miniVipText,
{
fontSize: isTV ? 11 : isLargeTablet ? 10 : isTablet ? 9 : 9
}
]}>VIP</Text>
</View> </View>
)} )}
</View> </View>
@ -306,48 +398,107 @@ const CompactCommentCard: React.FC<{
{/* Rating - Show stars */} {/* Rating - Show stars */}
{comment.user_stats?.rating && ( {comment.user_stats?.rating && (
<View style={styles.compactRating}> <View style={[
styles.compactRating,
{
marginBottom: isTV ? 10 : isLargeTablet ? 8 : isTablet ? 8 : 8
}
]}>
{renderCompactStars(comment.user_stats.rating)} {renderCompactStars(comment.user_stats.rating)}
<Text style={[styles.compactRatingText, { color: theme.colors.mediumEmphasis }]}> <Text style={[
styles.compactRatingText,
{
color: theme.colors.mediumEmphasis,
fontSize: isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 14
}
]}>
{comment.user_stats.rating}/10 {comment.user_stats.rating}/10
</Text> </Text>
</View> </View>
)} )}
{/* Comment Preview - Flexible area that fills space */} {/* Comment Preview - Flexible area that fills space */}
<View style={[styles.commentContainer, shouldBlurContent ? styles.blurredContent : undefined]}> <View style={[
styles.commentContainer,
shouldBlurContent ? styles.blurredContent : undefined,
{
marginBottom: isTV ? 10 : isLargeTablet ? 8 : isTablet ? 8 : 8
}
]}>
{shouldBlurContent ? ( {shouldBlurContent ? (
<Text style={[styles.compactComment, { color: theme.colors.highEmphasis }]}> This comment contains spoilers. Tap to reveal.</Text> <Text style={[
styles.compactComment,
{
color: theme.colors.highEmphasis,
fontSize: isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 14,
lineHeight: isTV ? 22 : isLargeTablet ? 20 : isTablet ? 18 : 18
}
]}> This comment contains spoilers. Tap to reveal.</Text>
) : ( ) : (
<MarkdownText <MarkdownText
text={comment.comment} text={comment.comment}
theme={theme} theme={theme}
numberOfLines={3} numberOfLines={isLargeScreen ? 4 : 3}
revealedInlineSpoilers={isSpoilerRevealed} revealedInlineSpoilers={isSpoilerRevealed}
onSpoilerPress={onSpoilerPress} onSpoilerPress={onSpoilerPress}
textStyle={styles.compactComment} textStyle={[
styles.compactComment,
{
fontSize: isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 14,
lineHeight: isTV ? 22 : isLargeTablet ? 20 : isTablet ? 18 : 18
}
]}
/> />
)} )}
</View> </View>
{/* Meta Info - Fixed at bottom */} {/* Meta Info - Fixed at bottom */}
<View style={styles.compactMeta}> <View style={[
styles.compactMeta,
{
paddingTop: isTV ? 8 : isLargeTablet ? 6 : isTablet ? 6 : 6
}
]}>
<View style={styles.compactBadges}> <View style={styles.compactBadges}>
{comment.spoiler && ( {comment.spoiler && (
<Text style={[styles.spoilerMiniText, { color: theme.colors.error }]}>Spoiler</Text> <Text style={[
styles.spoilerMiniText,
{
color: theme.colors.error,
fontSize: isTV ? 13 : isLargeTablet ? 12 : isTablet ? 11 : 11
}
]}>Spoiler</Text>
)} )}
</View> </View>
<View style={styles.compactStats}> <View style={styles.compactStats}>
<Text style={[styles.compactTime, { color: theme.colors.mediumEmphasis }]}> <Text style={[
styles.compactTime,
{
color: theme.colors.mediumEmphasis,
fontSize: isTV ? 13 : isLargeTablet ? 12 : isTablet ? 11 : 11
}
]}>
{formatRelativeTime(comment.created_at)} {formatRelativeTime(comment.created_at)}
</Text> </Text>
{comment.likes > 0 && ( {comment.likes > 0 && (
<Text style={[styles.compactStat, { color: theme.colors.mediumEmphasis }]}> <Text style={[
styles.compactStat,
{
color: theme.colors.mediumEmphasis,
fontSize: isTV ? 13 : isLargeTablet ? 12 : isTablet ? 12 : 12
}
]}>
👍 {comment.likes} 👍 {comment.likes}
</Text> </Text>
)} )}
{comment.replies > 0 && ( {comment.replies > 0 && (
<Text style={[styles.compactStat, { color: theme.colors.mediumEmphasis }]}> <Text style={[
styles.compactStat,
{
color: theme.colors.mediumEmphasis,
fontSize: isTV ? 13 : isLargeTablet ? 12 : isTablet ? 12 : 12
}
]}>
💬 {comment.replies} 💬 {comment.replies}
</Text> </Text>
)} )}
@ -578,6 +729,38 @@ export const CommentsSection: React.FC<CommentsSectionProps> = ({
const { settings } = useSettings(); const { settings } = useSettings();
const [hasLoadedOnce, setHasLoadedOnce] = React.useState(false); const [hasLoadedOnce, setHasLoadedOnce] = React.useState(false);
// Enhanced responsive sizing for tablets and TV screens
const deviceWidth = Dimensions.get('window').width;
const deviceHeight = Dimensions.get('window').height;
// Determine device type based on width
const getDeviceType = useCallback(() => {
if (deviceWidth >= BREAKPOINTS.tv) return 'tv';
if (deviceWidth >= BREAKPOINTS.largeTablet) return 'largeTablet';
if (deviceWidth >= BREAKPOINTS.tablet) return 'tablet';
return 'phone';
}, [deviceWidth]);
const deviceType = getDeviceType();
const isTablet = deviceType === 'tablet';
const isLargeTablet = deviceType === 'largeTablet';
const isTV = deviceType === 'tv';
const isLargeScreen = isTablet || isLargeTablet || isTV;
// Enhanced spacing and padding
const horizontalPadding = useMemo(() => {
switch (deviceType) {
case 'tv':
return 32;
case 'largeTablet':
return 28;
case 'tablet':
return 24;
default:
return 16; // phone
}
}, [deviceType]);
const { const {
comments, comments,
loading, loading,
@ -654,41 +837,66 @@ export const CommentsSection: React.FC<CommentsSectionProps> = ({
const renderSkeletons = useCallback(() => { const renderSkeletons = useCallback(() => {
const placeholders = [0, 1, 2]; const placeholders = [0, 1, 2];
// Responsive skeleton sizes to match CompactCommentCard
const skWidth = isTV ? 360 : isLargeTablet ? 320 : isTablet ? 300 : 280;
const skHeight = isTV ? 200 : isLargeTablet ? 185 : isTablet ? 175 : 170;
const skPad = isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 12;
const gap = isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 12;
const headLineWidth = isTV ? 160 : isLargeTablet ? 140 : isTablet ? 130 : 120;
const ratingWidth = isTV ? 100 : isLargeTablet ? 90 : isTablet ? 85 : 80;
const statWidth = isTV ? 44 : isLargeTablet ? 40 : isTablet ? 38 : 36;
const badgeW = isTV ? 60 : isLargeTablet ? 56 : isTablet ? 52 : 50;
const badgeH = isTV ? 14 : isLargeTablet ? 13 : isTablet ? 12 : 12;
return ( return (
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.horizontalList}> <ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={[styles.horizontalList, { paddingRight: gap }]}>
{placeholders.map((i) => ( {placeholders.map((i) => (
<View key={`skeleton-${i}`} style={[styles.compactCard, { backgroundColor: currentTheme.colors.card, borderColor: currentTheme.colors.border }]}> <View
key={`skeleton-${i}`}
style={[
styles.compactCard,
{
backgroundColor: currentTheme.colors.card,
borderColor: currentTheme.colors.border,
width: skWidth,
height: skHeight,
marginRight: gap,
padding: skPad,
borderRadius: isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 12,
},
]}
>
<View style={styles.skeletonTraktContainer}> <View style={styles.skeletonTraktContainer}>
<View style={[styles.skeletonDot]} /> <View style={[styles.skeletonDot, { width: isTV ? 20 : isLargeTablet ? 18 : isTablet ? 16 : 16, height: isTV ? 20 : isLargeTablet ? 18 : isTablet ? 16 : 16, borderRadius: isTV ? 10 : isLargeTablet ? 9 : 8 }]} />
</View> </View>
<View style={styles.compactHeader}> <View style={[styles.compactHeader, { marginBottom: isTV ? 10 : isLargeTablet ? 8 : isTablet ? 8 : 8 }]}>
<View style={[styles.skeletonLine, { width: 120 }]} /> <View style={[styles.skeletonLine, { width: headLineWidth, height: isTV ? 14 : 12 }]} />
<View style={[styles.miniVipBadge, styles.skeletonBadge]} /> <View style={[styles.miniVipBadge, styles.skeletonBadge, { width: isTV ? 36 : isLargeTablet ? 32 : isTablet ? 28 : 24, height: isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 12, borderRadius: isTV ? 10 : isLargeTablet ? 9 : 8 }]} />
</View> </View>
<View style={styles.compactRating}> <View style={[styles.compactRating, { marginBottom: isTV ? 10 : isLargeTablet ? 8 : isTablet ? 8 : 8 }]}>
<View style={[styles.skeletonLine, { width: 80, height: 10 }]} /> <View style={[styles.skeletonLine, { width: ratingWidth, height: isTV ? 12 : 10 }]} />
</View> </View>
<View style={styles.commentContainer}> <View style={[styles.commentContainer, { marginBottom: isTV ? 10 : isLargeTablet ? 8 : isTablet ? 8 : 8 }]}>
<View style={[styles.skeletonLine, { width: '95%' }]} /> <View style={[styles.skeletonLine, { width: '95%', height: isTV ? 14 : 12 }]} />
<View style={[styles.skeletonLine, { width: '90%', marginTop: 6 }]} /> <View style={[styles.skeletonLine, { width: '90%', height: isTV ? 14 : 12, marginTop: 6 }]} />
<View style={[styles.skeletonLine, { width: '70%', marginTop: 6 }]} /> <View style={[styles.skeletonLine, { width: '70%', height: isTV ? 14 : 12, marginTop: 6 }]} />
</View> </View>
<View style={styles.compactMeta}> <View style={[styles.compactMeta, { paddingTop: isTV ? 8 : isLargeTablet ? 6 : isTablet ? 6 : 6 }]}>
<View style={[styles.skeletonBadge, { width: 50, height: 12, borderRadius: 6 }]} /> <View style={[styles.skeletonBadge, { width: badgeW, height: badgeH, borderRadius: Math.min(6, badgeH / 2) }]} />
<View style={{ flexDirection: 'row', gap: 8 }}> <View style={{ flexDirection: 'row', gap }}>
<View style={[styles.skeletonLine, { width: 36, height: 10 }]} /> <View style={[styles.skeletonLine, { width: statWidth, height: isTV ? 12 : 10 }]} />
<View style={[styles.skeletonLine, { width: 36, height: 10 }]} /> <View style={[styles.skeletonLine, { width: statWidth, height: isTV ? 12 : 10 }]} />
</View> </View>
</View> </View>
</View> </View>
))} ))}
</ScrollView> </ScrollView>
); );
}, [currentTheme]); }, [currentTheme, isTV, isLargeTablet, isTablet]);
// Don't show section if not authenticated, if comments are disabled in settings, or if still checking authentication // Don't show section if not authenticated, if comments are disabled in settings, or if still checking authentication
// Only show when authentication is definitively true and settings allow it // Only show when authentication is definitively true and settings allow it
@ -705,9 +913,23 @@ export const CommentsSection: React.FC<CommentsSectionProps> = ({
} }
return ( return (
<View style={styles.container}> <View style={[
<View style={styles.header}> styles.container,
<Text style={[styles.title, { color: currentTheme.colors.highEmphasis }]}> { paddingHorizontal: horizontalPadding }
]}>
<View style={[
styles.header,
{
marginBottom: isTV ? 20 : isLargeTablet ? 18 : isTablet ? 16 : 16
}
]}>
<Text style={[
styles.title,
{
color: currentTheme.colors.highEmphasis,
fontSize: isTV ? 28 : isLargeTablet ? 26 : isTablet ? 24 : 20
}
]}>
Trakt Comments Trakt Comments
</Text> </Text>
</View> </View>
@ -744,11 +966,14 @@ export const CommentsSection: React.FC<CommentsSectionProps> = ({
renderItem={renderComment} renderItem={renderComment}
contentContainerStyle={styles.horizontalList} contentContainerStyle={styles.horizontalList}
removeClippedSubviews={false} removeClippedSubviews={false}
getItemLayout={(data, index) => ({ getItemLayout={(data, index) => {
length: 292, // width + marginRight const itemWidth = isTV ? 376 : isLargeTablet ? 334 : isTablet ? 312 : 292; // width + marginRight
offset: 292 * index, return {
index, length: itemWidth,
})} offset: itemWidth * index,
index,
};
}}
onEndReached={() => { onEndReached={() => {
if (hasMore && !loading) { if (hasMore && !loading) {
loadMore(); loadMore();
@ -991,7 +1216,6 @@ export const CommentBottomSheet: React.FC<{
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
padding: 16,
marginBottom: 24, marginBottom: 24,
}, },
header: { header: {
@ -1008,11 +1232,7 @@ const styles = StyleSheet.create({
paddingRight: 16, paddingRight: 16,
}, },
compactCard: { compactCard: {
width: 280,
height: 170,
padding: 12,
paddingBottom: 16, paddingBottom: 16,
marginRight: 12,
borderRadius: 12, borderRadius: 12,
borderWidth: 1, borderWidth: 1,
shadowColor: '#000', shadowColor: '#000',

View file

@ -335,31 +335,192 @@ const ActionButtons = memo(({
return isWatched ? 'Play' : playButtonText; return isWatched ? 'Play' : playButtonText;
}, [isWatched, playButtonText, type, watchProgress, groupedEpisodes]); }, [isWatched, playButtonText, type, watchProgress, groupedEpisodes]);
// Determine if we should show buttons in a single row (Play, Save, and one other button = 3 total)
const hasAiChat = aiChatEnabled;
const hasTraktCollection = isAuthenticated;
const hasRatings = type === 'series';
// Count additional buttons (excluding Play and Save)
const additionalButtonCount = (hasAiChat ? 1 : 0) + (hasTraktCollection ? 1 : 0) + (hasRatings ? 1 : 0);
// Show single row when there's exactly 1 additional button (3 total buttons)
const shouldShowSingleRow = additionalButtonCount === 1;
return ( return (
<Animated.View style={[isTablet ? styles.tabletActionButtons : styles.actionButtons, animatedStyle]}> <Animated.View style={[isTablet ? styles.tabletActionButtons : styles.actionButtons, animatedStyle]}>
{/* Play Button Row - Only Play button */} {shouldShowSingleRow ? (
<View style={styles.playButtonRow}> /* Single Row Layout - Play, Save, and one other button (3 total) */
<TouchableOpacity <View style={styles.singleRowLayout}>
style={[playButtonStyle, isTablet && styles.tabletPlayButton]} <TouchableOpacity
onPress={handleShowStreams} style={[playButtonStyle, isTablet && styles.tabletPlayButton, styles.singleRowPlayButton]}
activeOpacity={0.85} onPress={handleShowStreams}
> activeOpacity={0.85}
<MaterialIcons >
name={(() => { <MaterialIcons
if (isWatched) { name={(() => {
return type === 'movie' ? 'replay' : 'play-arrow'; if (isWatched) {
} return type === 'movie' ? 'replay' : 'play-arrow';
return playButtonText === 'Resume' ? 'play-circle-outline' : 'play-arrow'; }
})()} return playButtonText === 'Resume' ? 'play-circle-outline' : 'play-arrow';
size={isTablet ? 28 : 24} })()}
color={isWatched && type === 'movie' ? "#fff" : "#000"} size={isTablet ? 28 : 24}
/> color={isWatched && type === 'movie' ? "#fff" : "#000"}
<Text style={[playButtonTextStyle, isTablet && styles.tabletPlayButtonText]}>{finalPlayButtonText}</Text> />
</TouchableOpacity> <Text style={[playButtonTextStyle, isTablet && styles.tabletPlayButtonText]}>{finalPlayButtonText}</Text>
</View> </TouchableOpacity>
{/* Secondary Action Row - All other buttons */} <TouchableOpacity
<View style={styles.secondaryActionRow}> style={[styles.actionButton, styles.infoButton, isTablet && styles.tabletInfoButton, styles.singleRowSaveButton]}
onPress={handleSaveAction}
activeOpacity={0.85}
>
{Platform.OS === 'ios' ? (
GlassViewComp && liquidGlassAvailable ? (
<GlassViewComp
style={styles.blurBackground}
glassEffectStyle="regular"
/>
) : (
<ExpoBlurView intensity={80} style={styles.blurBackground} tint="dark" />
)
) : (
<View style={styles.androidFallbackBlur} />
)}
<MaterialIcons
name={inLibrary ? "bookmark" : "bookmark-outline"}
size={isTablet ? 28 : 24}
color={inLibrary ? (isAuthenticated && isInWatchlist ? "#E74C3C" : currentTheme.colors.white) : currentTheme.colors.white}
/>
<Text style={[styles.infoButtonText, isTablet && styles.tabletInfoButtonText]}>
{inLibrary ? 'Saved' : 'Save'}
</Text>
</TouchableOpacity>
{/* Third Button - AI Chat, Trakt Collection, or Ratings */}
{hasAiChat && (
<TouchableOpacity
style={[styles.iconButton, isTablet && styles.tabletIconButton, styles.singleRowIconButton]}
onPress={() => {
// Extract episode info if it's a series
let episodeData = null;
if (type === 'series' && watchProgress?.episodeId) {
const parts = watchProgress.episodeId.split(':');
if (parts.length >= 3) {
episodeData = {
seasonNumber: parseInt(parts[1], 10),
episodeNumber: parseInt(parts[2], 10)
};
}
}
navigation.navigate('AIChat', {
contentId: id,
contentType: type,
episodeId: episodeData ? watchProgress.episodeId : undefined,
seasonNumber: episodeData?.seasonNumber,
episodeNumber: episodeData?.episodeNumber,
title: metadata?.name || metadata?.title || 'Unknown'
});
}}
activeOpacity={0.85}
>
{Platform.OS === 'ios' ? (
GlassViewComp && liquidGlassAvailable ? (
<GlassViewComp
style={styles.blurBackgroundRound}
glassEffectStyle="regular"
/>
) : (
<ExpoBlurView intensity={80} style={styles.blurBackgroundRound} tint="dark" />
)
) : (
<View style={styles.androidFallbackBlurRound} />
)}
<MaterialIcons
name="smart-toy"
size={isTablet ? 28 : 24}
color={currentTheme.colors.white}
/>
</TouchableOpacity>
)}
{hasTraktCollection && !hasAiChat && (
<TouchableOpacity
style={[styles.iconButton, isTablet && styles.tabletIconButton, styles.singleRowIconButton]}
onPress={handleCollectionAction}
activeOpacity={0.85}
>
{Platform.OS === 'ios' ? (
GlassViewComp && liquidGlassAvailable ? (
<GlassViewComp
style={styles.blurBackgroundRound}
glassEffectStyle="regular"
/>
) : (
<ExpoBlurView intensity={80} style={styles.blurBackgroundRound} tint="dark" />
)
) : (
<View style={styles.androidFallbackBlurRound} />
)}
<MaterialIcons
name={isInCollection ? "video-library" : "video-library"}
size={isTablet ? 28 : 24}
color={isInCollection ? "#3498DB" : currentTheme.colors.white}
/>
</TouchableOpacity>
)}
{hasRatings && !hasAiChat && !hasTraktCollection && (
<TouchableOpacity
style={[styles.iconButton, isTablet && styles.tabletIconButton, styles.singleRowIconButton]}
onPress={handleRatingsPress}
activeOpacity={0.85}
>
{Platform.OS === 'ios' ? (
GlassViewComp && liquidGlassAvailable ? (
<GlassViewComp
style={styles.blurBackgroundRound}
glassEffectStyle="regular"
/>
) : (
<ExpoBlurView intensity={80} style={styles.blurBackgroundRound} tint="dark" />
)
) : (
<View style={styles.androidFallbackBlurRound} />
)}
<MaterialIcons
name="assessment"
size={isTablet ? 28 : 24}
color={currentTheme.colors.white}
/>
</TouchableOpacity>
)}
</View>
) : (
<>
{/* Play Button Row - Only Play button */}
<View style={styles.playButtonRow}>
<TouchableOpacity
style={[playButtonStyle, isTablet && styles.tabletPlayButton]}
onPress={handleShowStreams}
activeOpacity={0.85}
>
<MaterialIcons
name={(() => {
if (isWatched) {
return type === 'movie' ? 'replay' : 'play-arrow';
}
return playButtonText === 'Resume' ? 'play-circle-outline' : 'play-arrow';
})()}
size={isTablet ? 28 : 24}
color={isWatched && type === 'movie' ? "#fff" : "#000"}
/>
<Text style={[playButtonTextStyle, isTablet && styles.tabletPlayButtonText]}>{finalPlayButtonText}</Text>
</TouchableOpacity>
</View>
{/* Secondary Action Row - All other buttons */}
<View style={styles.secondaryActionRow}>
{/* Save Button */} {/* Save Button */}
<TouchableOpacity <TouchableOpacity
style={[styles.actionButton, styles.infoButton, isTablet && styles.tabletInfoButton]} style={[styles.actionButton, styles.infoButton, isTablet && styles.tabletInfoButton]}
@ -490,6 +651,8 @@ const ActionButtons = memo(({
</TouchableOpacity> </TouchableOpacity>
)} )}
</View> </View>
</>
)}
</Animated.View> </Animated.View>
); );
}); });
@ -1679,7 +1842,7 @@ const HeroSection: React.FC<HeroSectionProps> = memo(({
// When unmuting, hide action buttons, genre, title card, and watch progress // When unmuting, hide action buttons, genre, title card, and watch progress
actionButtonsOpacity.value = withTiming(0, { duration: 300 }); actionButtonsOpacity.value = withTiming(0, { duration: 300 });
genreOpacity.value = withTiming(0, { duration: 300 }); genreOpacity.value = withTiming(0, { duration: 300 });
titleCardTranslateY.value = withTiming(60, { duration: 300 }); titleCardTranslateY.value = withTiming(100, { duration: 300 }); // Increased from 60 to 120 for further down movement
watchProgressOpacity.value = withTiming(0, { duration: 300 }); watchProgressOpacity.value = withTiming(0, { duration: 300 });
} else { } else {
// When muting, show action buttons, genre, title card, and watch progress // When muting, show action buttons, genre, title card, and watch progress
@ -1966,6 +2129,29 @@ const styles = StyleSheet.create({
maxWidth: isTablet ? 600 : '100%', maxWidth: isTablet ? 600 : '100%',
alignSelf: 'center', alignSelf: 'center',
}, },
singleRowLayout: {
flexDirection: 'row',
gap: 8,
alignItems: 'center',
justifyContent: 'center',
width: '100%',
maxWidth: isTablet ? 600 : '100%',
alignSelf: 'center',
},
singleRowPlayButton: {
flex: 2,
maxWidth: isTablet ? 200 : 150,
},
singleRowSaveButton: {
flex: 2,
maxWidth: isTablet ? 200 : 150,
},
singleRowIconButton: {
width: isTablet ? 50 : 44,
height: isTablet ? 50 : 44,
borderRadius: isTablet ? 25 : 22,
flex: 0,
},
primaryActionRow: { primaryActionRow: {
flexDirection: 'row', flexDirection: 'row',
gap: 12, gap: 12,

View file

@ -1,10 +1,11 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { import {
View, View,
Text, Text,
StyleSheet, StyleSheet,
TouchableOpacity, TouchableOpacity,
ActivityIndicator, ActivityIndicator,
Dimensions,
} from 'react-native'; } from 'react-native';
import { MaterialIcons } from '@expo/vector-icons'; import { MaterialIcons } from '@expo/vector-icons';
import FastImage from '@d11/react-native-fast-image'; import FastImage from '@d11/react-native-fast-image';
@ -20,6 +21,15 @@ import Animated, {
import { useTheme } from '../../contexts/ThemeContext'; import { useTheme } from '../../contexts/ThemeContext';
import { isMDBListEnabled } from '../../screens/MDBListSettingsScreen'; import { isMDBListEnabled } from '../../screens/MDBListSettingsScreen';
import { getAgeRatingColor } from '../../utils/ageRatingColors'; import { getAgeRatingColor } from '../../utils/ageRatingColors';
// Enhanced responsive breakpoints for Metadata Details
const BREAKPOINTS = {
phone: 0,
tablet: 768,
largeTablet: 1024,
tv: 1440,
};
// MetadataSourceSelector removed // MetadataSourceSelector removed
interface MetadataDetailsProps { interface MetadataDetailsProps {
@ -45,6 +55,38 @@ const MetadataDetails: React.FC<MetadataDetailsProps> = ({
const [isMDBEnabled, setIsMDBEnabled] = useState(false); const [isMDBEnabled, setIsMDBEnabled] = useState(false);
const [isTextTruncated, setIsTextTruncated] = useState(false); const [isTextTruncated, setIsTextTruncated] = useState(false);
// Enhanced responsive sizing for tablets and TV screens
const deviceWidth = Dimensions.get('window').width;
const deviceHeight = Dimensions.get('window').height;
// Determine device type based on width
const getDeviceType = useCallback(() => {
if (deviceWidth >= BREAKPOINTS.tv) return 'tv';
if (deviceWidth >= BREAKPOINTS.largeTablet) return 'largeTablet';
if (deviceWidth >= BREAKPOINTS.tablet) return 'tablet';
return 'phone';
}, [deviceWidth]);
const deviceType = getDeviceType();
const isTablet = deviceType === 'tablet';
const isLargeTablet = deviceType === 'largeTablet';
const isTV = deviceType === 'tv';
const isLargeScreen = isTablet || isLargeTablet || isTV;
// Enhanced spacing and padding
const horizontalPadding = useMemo(() => {
switch (deviceType) {
case 'tv':
return 32;
case 'largeTablet':
return 28;
case 'tablet':
return 24;
default:
return 16; // phone
}
}, [deviceType]);
// Animation values for smooth height transition // Animation values for smooth height transition
const animatedHeight = useSharedValue(0); const animatedHeight = useSharedValue(0);
const [measuredHeights, setMeasuredHeights] = useState({ collapsed: 0, expanded: 0 }); const [measuredHeights, setMeasuredHeights] = useState({ collapsed: 0, expanded: 0 });
@ -144,12 +186,31 @@ function formatRuntime(runtime: string): string {
)} )}
{/* Meta Info */} {/* Meta Info */}
<View style={[styles.metaInfo, loadingMetadata && styles.dimmed]}> <View style={[
styles.metaInfo,
loadingMetadata && styles.dimmed,
{
paddingHorizontal: horizontalPadding,
gap: isTV ? 24 : isLargeTablet ? 22 : isTablet ? 20 : 18
}
]}>
{metadata.year && ( {metadata.year && (
<Text style={[styles.metaText, { color: currentTheme.colors.text }]}>{metadata.year}</Text> <Text style={[
styles.metaText,
{
color: currentTheme.colors.text,
fontSize: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 15
}
]}>{metadata.year}</Text>
)} )}
{metadata.runtime && ( {metadata.runtime && (
<Text style={[styles.metaText, { color: currentTheme.colors.text }]}> <Text style={[
styles.metaText,
{
color: currentTheme.colors.text,
fontSize: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 15
}
]}>
{formatRuntime(metadata.runtime)} {formatRuntime(metadata.runtime)}
</Text> </Text>
)} )}
@ -157,17 +218,32 @@ function formatRuntime(runtime: string): string {
<Text style={[ <Text style={[
styles.metaText, styles.metaText,
styles.premiumOutlinedText, styles.premiumOutlinedText,
{ color: getAgeRatingColor(metadata.certification, type === 'series' ? 'series' : 'movie') } {
color: getAgeRatingColor(metadata.certification, type === 'series' ? 'series' : 'movie'),
fontSize: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 15
}
]}>{metadata.certification}</Text> ]}>{metadata.certification}</Text>
)} )}
{metadata.imdbRating && !isMDBEnabled && ( {metadata.imdbRating && !isMDBEnabled && (
<View style={styles.ratingContainer}> <View style={styles.ratingContainer}>
<FastImage <FastImage
source={{ uri: 'https://upload.wikimedia.org/wikipedia/commons/thumb/6/69/IMDB_Logo_2016.svg/575px-IMDB_Logo_2016.svg.png' }} source={{ uri: 'https://upload.wikimedia.org/wikipedia/commons/thumb/6/69/IMDB_Logo_2016.svg/575px-IMDB_Logo_2016.svg.png' }}
style={styles.imdbLogo} style={[
styles.imdbLogo,
{
width: isTV ? 42 : isLargeTablet ? 38 : isTablet ? 35 : 35,
height: isTV ? 22 : isLargeTablet ? 20 : isTablet ? 18 : 18
}
]}
resizeMode={FastImage.resizeMode.contain} resizeMode={FastImage.resizeMode.contain}
/> />
<Text style={[styles.ratingText, { color: currentTheme.colors.text }]}>{metadata.imdbRating}</Text> <Text style={[
styles.ratingText,
{
color: currentTheme.colors.text,
fontSize: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 15
}
]}>{metadata.imdbRating}</Text>
</View> </View>
)} )}
</View> </View>
@ -178,18 +254,62 @@ function formatRuntime(runtime: string): string {
{/* Creator/Director Info */} {/* Creator/Director Info */}
<Animated.View <Animated.View
entering={FadeIn.duration(300).delay(100)} entering={FadeIn.duration(300).delay(100)}
style={[styles.creatorContainer, loadingMetadata && styles.dimmed]} style={[
styles.creatorContainer,
loadingMetadata && styles.dimmed,
{ paddingHorizontal: horizontalPadding }
]}
> >
{metadata.directors && metadata.directors.length > 0 && ( {metadata.directors && metadata.directors.length > 0 && (
<View style={styles.creatorSection}> <View style={[
<Text style={[styles.creatorLabel, { color: currentTheme.colors.white }]}>Director{metadata.directors.length > 1 ? 's' : ''}:</Text> styles.creatorSection,
<Text style={[styles.creatorText, { color: currentTheme.colors.mediumEmphasis }]}>{metadata.directors.join(', ')}</Text> {
height: isTV ? 24 : isLargeTablet ? 22 : isTablet ? 20 : 20,
marginBottom: isTV ? 6 : isLargeTablet ? 5 : isTablet ? 4 : 4
}
]}>
<Text style={[
styles.creatorLabel,
{
color: currentTheme.colors.white,
fontSize: isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 14,
lineHeight: isTV ? 24 : isLargeTablet ? 22 : isTablet ? 20 : 20
}
]}>Director{metadata.directors.length > 1 ? 's' : ''}:</Text>
<Text style={[
styles.creatorText,
{
color: currentTheme.colors.mediumEmphasis,
fontSize: isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 14,
lineHeight: isTV ? 24 : isLargeTablet ? 22 : isTablet ? 20 : 20
}
]}>{metadata.directors.join(', ')}</Text>
</View> </View>
)} )}
{metadata.creators && metadata.creators.length > 0 && ( {metadata.creators && metadata.creators.length > 0 && (
<View style={styles.creatorSection}> <View style={[
<Text style={[styles.creatorLabel, { color: currentTheme.colors.white }]}>Creator{metadata.creators.length > 1 ? 's' : ''}:</Text> styles.creatorSection,
<Text style={[styles.creatorText, { color: currentTheme.colors.mediumEmphasis }]}>{metadata.creators.join(', ')}</Text> {
height: isTV ? 24 : isLargeTablet ? 22 : isTablet ? 20 : 20,
marginBottom: isTV ? 6 : isLargeTablet ? 5 : isTablet ? 4 : 4
}
]}>
<Text style={[
styles.creatorLabel,
{
color: currentTheme.colors.white,
fontSize: isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 14,
lineHeight: isTV ? 24 : isLargeTablet ? 22 : isTablet ? 20 : 20
}
]}>Creator{metadata.creators.length > 1 ? 's' : ''}:</Text>
<Text style={[
styles.creatorText,
{
color: currentTheme.colors.mediumEmphasis,
fontSize: isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 14,
lineHeight: isTV ? 24 : isLargeTablet ? 22 : isTablet ? 20 : 20
}
]}>{metadata.creators.join(', ')}</Text>
</View> </View>
)} )}
</Animated.View> </Animated.View>
@ -197,19 +317,41 @@ function formatRuntime(runtime: string): string {
{/* Description */} {/* Description */}
{metadata.description && ( {metadata.description && (
<Animated.View <Animated.View
style={[styles.descriptionContainer, loadingMetadata && styles.dimmed]} style={[
styles.descriptionContainer,
loadingMetadata && styles.dimmed,
{ paddingHorizontal: horizontalPadding }
]}
entering={FadeIn.duration(300)} entering={FadeIn.duration(300)}
> >
{/* Hidden text elements to measure heights */} {/* Hidden text elements to measure heights */}
<Text <Text
style={[styles.description, { color: currentTheme.colors.mediumEmphasis, position: 'absolute', opacity: 0 }]} style={[
styles.description,
{
color: currentTheme.colors.mediumEmphasis,
position: 'absolute',
opacity: 0,
fontSize: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 15,
lineHeight: isTV ? 28 : isLargeTablet ? 26 : isTablet ? 24 : 24
}
]}
numberOfLines={3} numberOfLines={3}
onLayout={handleCollapsedTextLayout} onLayout={handleCollapsedTextLayout}
> >
{metadata.description} {metadata.description}
</Text> </Text>
<Text <Text
style={[styles.description, { color: currentTheme.colors.mediumEmphasis, position: 'absolute', opacity: 0 }]} style={[
styles.description,
{
color: currentTheme.colors.mediumEmphasis,
position: 'absolute',
opacity: 0,
fontSize: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 15,
lineHeight: isTV ? 28 : isLargeTablet ? 26 : isTablet ? 24 : 24
}
]}
onLayout={handleExpandedTextLayout} onLayout={handleExpandedTextLayout}
> >
{metadata.description} {metadata.description}
@ -222,7 +364,14 @@ function formatRuntime(runtime: string): string {
> >
<Animated.View style={animatedDescriptionStyle}> <Animated.View style={animatedDescriptionStyle}>
<Text <Text
style={[styles.description, { color: currentTheme.colors.mediumEmphasis }]} style={[
styles.description,
{
color: currentTheme.colors.mediumEmphasis,
fontSize: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 15,
lineHeight: isTV ? 28 : isLargeTablet ? 26 : isTablet ? 24 : 24
}
]}
numberOfLines={isFullDescriptionOpen ? undefined : 3} numberOfLines={isFullDescriptionOpen ? undefined : 3}
onTextLayout={handleTextLayout} onTextLayout={handleTextLayout}
> >
@ -230,13 +379,25 @@ function formatRuntime(runtime: string): string {
</Text> </Text>
</Animated.View> </Animated.View>
{(isTextTruncated || isFullDescriptionOpen) && ( {(isTextTruncated || isFullDescriptionOpen) && (
<View style={styles.showMoreButton}> <View style={[
<Text style={[styles.showMoreText, { color: currentTheme.colors.textMuted }]}> styles.showMoreButton,
{
marginTop: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8,
paddingVertical: isTV ? 6 : isLargeTablet ? 5 : isTablet ? 4 : 4
}
]}>
<Text style={[
styles.showMoreText,
{
color: currentTheme.colors.textMuted,
fontSize: isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 14
}
]}>
{isFullDescriptionOpen ? 'Show Less' : 'Show More'} {isFullDescriptionOpen ? 'Show Less' : 'Show More'}
</Text> </Text>
<MaterialIcons <MaterialIcons
name={isFullDescriptionOpen ? "keyboard-arrow-up" : "keyboard-arrow-down"} name={isFullDescriptionOpen ? "keyboard-arrow-up" : "keyboard-arrow-down"}
size={18} size={isTV ? 22 : isLargeTablet ? 20 : isTablet ? 18 : 18}
color={currentTheme.colors.textMuted} color={currentTheme.colors.textMuted}
/> />
</View> </View>
@ -267,8 +428,6 @@ const styles = StyleSheet.create({
metaInfo: { metaInfo: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
gap: 18,
paddingHorizontal: 16,
marginBottom: 12, marginBottom: 12,
}, },
metaText: { metaText: {
@ -303,7 +462,6 @@ const styles = StyleSheet.create({
}, },
creatorContainer: { creatorContainer: {
marginBottom: 2, marginBottom: 2,
paddingHorizontal: 16,
}, },
creatorSection: { creatorSection: {
flexDirection: 'row', flexDirection: 'row',
@ -324,7 +482,6 @@ const styles = StyleSheet.create({
}, },
descriptionContainer: { descriptionContainer: {
marginBottom: 16, marginBottom: 16,
paddingHorizontal: 16,
}, },
description: { description: {
fontSize: 15, fontSize: 15,

View file

@ -20,32 +20,13 @@ import CustomAlert from '../../components/CustomAlert';
const { width } = Dimensions.get('window'); const { width } = Dimensions.get('window');
// Dynamic poster calculation based on screen width for More Like This section // Breakpoints for responsive sizing
const calculatePosterLayout = (screenWidth: number) => { const BREAKPOINTS = {
const MIN_POSTER_WIDTH = 100; // Slightly smaller for more items in this section phone: 0,
const MAX_POSTER_WIDTH = 130; // Maximum poster width tablet: 768,
const HORIZONTAL_PADDING = 48; // Total horizontal padding/margins largeTablet: 1024,
tv: 1440,
// Calculate how many posters can fit (aim for slightly more items than main sections) } as const;
const availableWidth = screenWidth - HORIZONTAL_PADDING;
const maxColumns = Math.floor(availableWidth / MIN_POSTER_WIDTH);
// Limit to reasonable number of columns (3-7 for this section)
const numColumns = Math.min(Math.max(maxColumns, 3), 7);
// Calculate actual poster width
const posterWidth = Math.min(availableWidth / numColumns, MAX_POSTER_WIDTH);
return {
numColumns,
posterWidth,
spacing: 12 // Space between posters
};
};
const posterLayout = calculatePosterLayout(width);
const POSTER_WIDTH = posterLayout.posterWidth;
const POSTER_HEIGHT = POSTER_WIDTH * 1.5;
interface MoreLikeThisSectionProps { interface MoreLikeThisSectionProps {
recommendations: StreamingContent[]; recommendations: StreamingContent[];
@ -59,6 +40,48 @@ export const MoreLikeThisSection: React.FC<MoreLikeThisSectionProps> = ({
const { currentTheme } = useTheme(); const { currentTheme } = useTheme();
const navigation = useNavigation<NavigationProp<RootStackParamList>>(); const navigation = useNavigation<NavigationProp<RootStackParamList>>();
// Determine device type
const deviceWidth = Dimensions.get('window').width;
const getDeviceType = React.useCallback(() => {
if (deviceWidth >= BREAKPOINTS.tv) return 'tv';
if (deviceWidth >= BREAKPOINTS.largeTablet) return 'largeTablet';
if (deviceWidth >= BREAKPOINTS.tablet) return 'tablet';
return 'phone';
}, [deviceWidth]);
const deviceType = getDeviceType();
const isTablet = deviceType === 'tablet';
const isLargeTablet = deviceType === 'largeTablet';
const isTV = deviceType === 'tv';
// Responsive spacing & sizes
const horizontalPadding = React.useMemo(() => {
switch (deviceType) {
case 'tv': return 32;
case 'largeTablet': return 28;
case 'tablet': return 24;
default: return 16;
}
}, [deviceType]);
const itemSpacing = React.useMemo(() => {
switch (deviceType) {
case 'tv': return 14;
case 'largeTablet': return 12;
case 'tablet': return 12;
default: return 12;
}
}, [deviceType]);
const posterWidth = React.useMemo(() => {
switch (deviceType) {
case 'tv': return 180;
case 'largeTablet': return 160;
case 'tablet': return 140;
default: return 120;
}
}, [deviceType]);
const posterHeight = React.useMemo(() => posterWidth * 1.5, [posterWidth]);
const [alertVisible, setAlertVisible] = React.useState(false); const [alertVisible, setAlertVisible] = React.useState(false);
const [alertTitle, setAlertTitle] = React.useState(''); const [alertTitle, setAlertTitle] = React.useState('');
const [alertMessage, setAlertMessage] = React.useState(''); const [alertMessage, setAlertMessage] = React.useState('');
@ -94,15 +117,15 @@ export const MoreLikeThisSection: React.FC<MoreLikeThisSectionProps> = ({
const renderItem = ({ item }: { item: StreamingContent }) => ( const renderItem = ({ item }: { item: StreamingContent }) => (
<TouchableOpacity <TouchableOpacity
style={styles.itemContainer} style={[styles.itemContainer, { width: posterWidth, marginRight: itemSpacing }]}
onPress={() => handleItemPress(item)} onPress={() => handleItemPress(item)}
> >
<FastImage <FastImage
source={{ uri: item.poster }} source={{ uri: item.poster }}
style={[styles.poster, { backgroundColor: currentTheme.colors.elevation1 }]} style={[styles.poster, { backgroundColor: currentTheme.colors.elevation1, width: posterWidth, height: posterHeight, borderRadius: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 10 : 8 }]}
resizeMode={FastImage.resizeMode.cover} resizeMode={FastImage.resizeMode.cover}
/> />
<Text style={[styles.title, { color: currentTheme.colors.mediumEmphasis }]} numberOfLines={2}> <Text style={[styles.title, { color: currentTheme.colors.mediumEmphasis, fontSize: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 13 : 13, lineHeight: isTV ? 20 : 18 }]} numberOfLines={2}>
{item.name} {item.name}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
@ -121,15 +144,15 @@ export const MoreLikeThisSection: React.FC<MoreLikeThisSectionProps> = ({
} }
return ( return (
<View style={styles.container}> <View style={[styles.container, { paddingLeft: 0 }] }>
<Text style={[styles.sectionTitle, { color: currentTheme.colors.highEmphasis }]}>More Like This</Text> <Text style={[styles.sectionTitle, { color: currentTheme.colors.highEmphasis, fontSize: isTV ? 24 : isLargeTablet ? 22 : isTablet ? 20 : 20, paddingHorizontal: horizontalPadding }]}>More Like This</Text>
<FlatList <FlatList
data={recommendations} data={recommendations}
renderItem={renderItem} renderItem={renderItem}
keyExtractor={(item) => item.id} keyExtractor={(item) => item.id}
horizontal horizontal
showsHorizontalScrollIndicator={false} showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.listContentContainer} contentContainerStyle={[styles.listContentContainer, { paddingHorizontal: horizontalPadding, paddingRight: horizontalPadding + itemSpacing }]}
/> />
<CustomAlert <CustomAlert
visible={alertVisible} visible={alertVisible}
@ -146,36 +169,30 @@ const styles = StyleSheet.create({
container: { container: {
marginTop: 16, marginTop: 16,
marginBottom: 16, marginBottom: 16,
paddingLeft: 0,
}, },
sectionTitle: { sectionTitle: {
fontSize: 20, fontSize: 20,
fontWeight: '800', fontWeight: '800',
marginBottom: 12, marginBottom: 12,
marginTop: 8, marginTop: 8,
paddingHorizontal: 16,
}, },
listContentContainer: { listContentContainer: {
paddingHorizontal: 16, paddingRight: 32, // Will be overridden responsively
paddingRight: 32, // Ensure last item has padding
}, },
itemContainer: { itemContainer: {
marginRight: 12, marginRight: 12, // will be overridden responsively
width: POSTER_WIDTH,
}, },
poster: { poster: {
width: POSTER_WIDTH, borderRadius: 8, // overridden responsively
height: POSTER_HEIGHT,
borderRadius: 8,
marginBottom: 8, marginBottom: 8,
}, },
title: { title: {
fontSize: 13, fontSize: 13, // overridden responsively
fontWeight: '500', fontWeight: '500',
lineHeight: 18, lineHeight: 18, // overridden responsively
}, },
loadingContainer: { loadingContainer: {
height: POSTER_HEIGHT + 40, // Approximate height to prevent layout shifts // Approximate height to prevent layout shifts; not used in responsive version
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
}, },

View file

@ -1,5 +1,5 @@
import React, { useEffect, useState, useRef } from 'react'; import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react';
import { View, Text, StyleSheet, ActivityIndicator, Image, Animated } from 'react-native'; import { View, Text, StyleSheet, ActivityIndicator, Image, Animated, Dimensions } from 'react-native';
import { useTheme } from '../../contexts/ThemeContext'; import { useTheme } from '../../contexts/ThemeContext';
import { useMDBListRatings } from '../../hooks/useMDBListRatings'; import { useMDBListRatings } from '../../hooks/useMDBListRatings';
import AsyncStorage from '@react-native-async-storage/async-storage'; import AsyncStorage from '@react-native-async-storage/async-storage';
@ -13,6 +13,14 @@ import TMDBIcon from '../../../assets/rating-icons/tmdb.svg';
import TraktIcon from '../../../assets/rating-icons/trakt.svg'; import TraktIcon from '../../../assets/rating-icons/trakt.svg';
import AudienceScoreIcon from '../../../assets/rating-icons/audienscore.png'; import AudienceScoreIcon from '../../../assets/rating-icons/audienscore.png';
// Enhanced responsive breakpoints for Ratings Section
const BREAKPOINTS = {
phone: 0,
tablet: 768,
largeTablet: 1024,
tv: 1440,
};
export const RATING_PROVIDERS = { export const RATING_PROVIDERS = {
imdb: { imdb: {
name: 'IMDb', name: 'IMDb',
@ -56,6 +64,50 @@ export const RatingsSection: React.FC<RatingsSectionProps> = ({ imdbId, type })
const fadeAnim = useRef(new Animated.Value(0)).current; const fadeAnim = useRef(new Animated.Value(0)).current;
const { currentTheme } = useTheme(); const { currentTheme } = useTheme();
// Responsive device type
const deviceWidth = Dimensions.get('window').width;
const getDeviceType = useCallback(() => {
if (deviceWidth >= BREAKPOINTS.tv) return 'tv';
if (deviceWidth >= BREAKPOINTS.largeTablet) return 'largeTablet';
if (deviceWidth >= BREAKPOINTS.tablet) return 'tablet';
return 'phone';
}, [deviceWidth]);
const deviceType = getDeviceType();
const isTablet = deviceType === 'tablet';
const isLargeTablet = deviceType === 'largeTablet';
const isTV = deviceType === 'tv';
const horizontalPadding = useMemo(() => {
switch (deviceType) {
case 'tv':
return 32;
case 'largeTablet':
return 28;
case 'tablet':
return 24;
default:
return 16;
}
}, [deviceType]);
const iconSize = useMemo(() => {
switch (deviceType) {
case 'tv':
return 20;
case 'largeTablet':
return 18;
case 'tablet':
return 16;
default:
return 16;
}
}, [deviceType]);
const textSize = useMemo(() => (isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 14), [isTV, isLargeTablet, isTablet]);
const itemSpacing = useMemo(() => (isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 12), [isTV, isLargeTablet, isTablet]);
const iconTextGap = useMemo(() => (isTV ? 6 : isLargeTablet ? 5 : isTablet ? 4 : 4), [isTV, isLargeTablet, isTablet]);
useEffect(() => { useEffect(() => {
loadProviderSettings(); loadProviderSettings();
checkMDBListEnabled(); checkMDBListEnabled();
@ -164,6 +216,7 @@ export const RatingsSection: React.FC<RatingsSectionProps> = ({ imdbId, type })
style={[ style={[
styles.container, styles.container,
{ {
paddingHorizontal: horizontalPadding,
opacity: fadeAnim, opacity: fadeAnim,
transform: [{ transform: [{
translateY: fadeAnim.interpolate({ translateY: fadeAnim.interpolate({
@ -180,22 +233,22 @@ export const RatingsSection: React.FC<RatingsSectionProps> = ({ imdbId, type })
const displayValue = config.transform(parseFloat(value as string)); const displayValue = config.transform(parseFloat(value as string));
return ( return (
<View key={source} style={styles.compactRatingItem}> <View key={source} style={[styles.compactRatingItem, { marginRight: itemSpacing }]}>
{config.isImage ? ( {config.isImage ? (
<Image <Image
source={config.icon as any} source={config.icon as any}
style={styles.compactRatingIcon} style={[styles.compactRatingIcon, { width: iconSize, height: iconSize, marginRight: iconTextGap }]}
resizeMode="contain" resizeMode="contain"
/> />
) : ( ) : (
<View style={styles.compactSvgContainer}> <View style={[styles.compactSvgContainer, { marginRight: iconTextGap }]}>
{React.createElement(config.icon as any, { {React.createElement(config.icon as any, {
width: 16, width: iconSize,
height: 16, height: iconSize,
})} })}
</View> </View>
)} )}
<Text style={[styles.compactRatingValue, { color: config.color }]}> <Text style={[styles.compactRatingValue, { color: config.color, fontSize: textSize }]}>
{displayValue} {displayValue}
</Text> </Text>
</View> </View>
@ -210,7 +263,6 @@ const styles = StyleSheet.create({
container: { container: {
marginTop: 2, marginTop: 2,
marginBottom: 8, marginBottom: 8,
paddingHorizontal: 16,
}, },
loadingContainer: { loadingContainer: {
height: 40, height: 40,

View file

@ -1,4 +1,4 @@
import React, { useEffect, useState, useRef } from 'react'; import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react';
import { View, Text, StyleSheet, ScrollView, TouchableOpacity, ActivityIndicator, Dimensions, useWindowDimensions, useColorScheme, FlatList } from 'react-native'; import { View, Text, StyleSheet, ScrollView, TouchableOpacity, ActivityIndicator, Dimensions, useWindowDimensions, useColorScheme, FlatList } from 'react-native';
import FastImage from '@d11/react-native-fast-image'; import FastImage from '@d11/react-native-fast-image';
import { MaterialIcons } from '@expo/vector-icons'; import { MaterialIcons } from '@expo/vector-icons';
@ -15,6 +15,14 @@ import { TraktService } from '../../services/traktService';
import { logger } from '../../utils/logger'; import { logger } from '../../utils/logger';
import AsyncStorage from '@react-native-async-storage/async-storage'; import AsyncStorage from '@react-native-async-storage/async-storage';
// Enhanced responsive breakpoints for Seasons Section
const BREAKPOINTS = {
phone: 0,
tablet: 768,
largeTablet: 1024,
tv: 1440,
};
interface SeriesContentProps { interface SeriesContentProps {
episodes: Episode[]; episodes: Episode[];
selectedSeason: number; selectedSeason: number;
@ -42,8 +50,120 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
const { currentTheme } = useTheme(); const { currentTheme } = useTheme();
const { settings } = useSettings(); const { settings } = useSettings();
const { width } = useWindowDimensions(); const { width } = useWindowDimensions();
const isTablet = width > 768;
const isDarkMode = useColorScheme() === 'dark'; const isDarkMode = useColorScheme() === 'dark';
// Enhanced responsive sizing for tablets and TV screens
const deviceWidth = Dimensions.get('window').width;
const deviceHeight = Dimensions.get('window').height;
// Determine device type based on width
const getDeviceType = useCallback(() => {
if (deviceWidth >= BREAKPOINTS.tv) return 'tv';
if (deviceWidth >= BREAKPOINTS.largeTablet) return 'largeTablet';
if (deviceWidth >= BREAKPOINTS.tablet) return 'tablet';
return 'phone';
}, [deviceWidth]);
const deviceType = getDeviceType();
const isTablet = deviceType === 'tablet';
const isLargeTablet = deviceType === 'largeTablet';
const isTV = deviceType === 'tv';
const isLargeScreen = isTablet || isLargeTablet || isTV;
// Enhanced spacing and padding for seasons section
const horizontalPadding = useMemo(() => {
switch (deviceType) {
case 'tv':
return 32;
case 'largeTablet':
return 28;
case 'tablet':
return 24;
default:
return 16; // phone
}
}, [deviceType]);
// Match ThisWeekSection card sizing for horizontal episode cards
const horizontalCardWidth = useMemo(() => {
switch (deviceType) {
case 'tv':
return Math.min(deviceWidth * 0.25, 400);
case 'largeTablet':
return Math.min(deviceWidth * 0.35, 350);
case 'tablet':
return Math.min(deviceWidth * 0.46, 300);
default:
return width * 0.75;
}
}, [deviceType, deviceWidth, width]);
const horizontalCardHeight = useMemo(() => {
switch (deviceType) {
case 'tv':
return 280;
case 'largeTablet':
return 250;
case 'tablet':
return 220;
default:
return 180;
}
}, [deviceType]);
const horizontalItemSpacing = useMemo(() => {
switch (deviceType) {
case 'tv':
return 20;
case 'largeTablet':
return 18;
case 'tablet':
return 16;
default:
return 16;
}
}, [deviceType]);
// Enhanced season poster sizing
const seasonPosterWidth = useMemo(() => {
switch (deviceType) {
case 'tv':
return 140;
case 'largeTablet':
return 130;
case 'tablet':
return 120;
default:
return 100; // phone
}
}, [deviceType]);
const seasonPosterHeight = useMemo(() => {
switch (deviceType) {
case 'tv':
return 210;
case 'largeTablet':
return 195;
case 'tablet':
return 180;
default:
return 150; // phone
}
}, [deviceType]);
const seasonButtonSpacing = useMemo(() => {
switch (deviceType) {
case 'tv':
return 20;
case 'largeTablet':
return 18;
case 'tablet':
return 16;
default:
return 16; // phone
}
}, [deviceType]);
const [episodeProgress, setEpisodeProgress] = useState<{ [key: string]: { currentTime: number; duration: number; lastUpdated: number } }>({}); const [episodeProgress, setEpisodeProgress] = useState<{ [key: string]: { currentTime: number; duration: number; lastUpdated: number } }>({});
// Delay item entering animations to avoid FlashList initial layout glitches // Delay item entering animations to avoid FlashList initial layout glitches
const [enableItemAnimations, setEnableItemAnimations] = useState(false); const [enableItemAnimations, setEnableItemAnimations] = useState(false);
@ -342,12 +462,22 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
const seasons = Object.keys(groupedEpisodes).map(Number).sort((a, b) => a - b); const seasons = Object.keys(groupedEpisodes).map(Number).sort((a, b) => a - b);
return ( return (
<View style={[styles.seasonSelectorWrapper, isTablet && styles.seasonSelectorWrapperTablet]}> <View style={[
<View style={styles.seasonSelectorHeader}> styles.seasonSelectorWrapper,
{ paddingHorizontal: horizontalPadding }
]}>
<View style={[
styles.seasonSelectorHeader,
{
marginBottom: isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 12
}
]}>
<Text style={[ <Text style={[
styles.seasonSelectorTitle, styles.seasonSelectorTitle,
isTablet && styles.seasonSelectorTitleTablet, {
{ color: currentTheme.colors.highEmphasis } color: currentTheme.colors.highEmphasis,
fontSize: isTV ? 28 : isLargeTablet ? 26 : isTablet ? 24 : 18
}
]}>Seasons</Text> ]}>Seasons</Text>
{/* Dropdown Toggle Button */} {/* Dropdown Toggle Button */}
@ -360,7 +490,10 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
: currentTheme.colors.elevation3, : currentTheme.colors.elevation3,
borderColor: seasonViewMode === 'posters' borderColor: seasonViewMode === 'posters'
? 'rgba(255,255,255,0.2)' ? 'rgba(255,255,255,0.2)'
: 'rgba(255,255,255,0.3)' : 'rgba(255,255,255,0.3)',
paddingHorizontal: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8,
paddingVertical: isTV ? 6 : isLargeTablet ? 5 : isTablet ? 4 : 4,
borderRadius: isTV ? 10 : isLargeTablet ? 8 : isTablet ? 6 : 6
} }
]} ]}
onPress={() => { onPress={() => {
@ -375,7 +508,8 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
{ {
color: seasonViewMode === 'posters' color: seasonViewMode === 'posters'
? currentTheme.colors.mediumEmphasis ? currentTheme.colors.mediumEmphasis
: currentTheme.colors.highEmphasis : currentTheme.colors.highEmphasis,
fontSize: isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 12
} }
]}> ]}>
{seasonViewMode === 'posters' ? 'Posters' : 'Text'} {seasonViewMode === 'posters' ? 'Posters' : 'Text'}
@ -389,7 +523,12 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
horizontal horizontal
showsHorizontalScrollIndicator={false} showsHorizontalScrollIndicator={false}
style={styles.seasonSelectorContainer} style={styles.seasonSelectorContainer}
contentContainerStyle={[styles.seasonSelectorContent, isTablet && styles.seasonSelectorContentTablet]} contentContainerStyle={[
styles.seasonSelectorContent,
{
paddingBottom: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8
}
]}
initialNumToRender={5} initialNumToRender={5}
maxToRenderPerBatch={5} maxToRenderPerBatch={5}
windowSize={3} windowSize={3}
@ -416,7 +555,13 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
<TouchableOpacity <TouchableOpacity
style={[ style={[
styles.seasonTextButton, styles.seasonTextButton,
isTablet && styles.seasonTextButtonTablet, {
marginRight: seasonButtonSpacing,
width: isTV ? 150 : isLargeTablet ? 140 : isTablet ? 130 : 110,
paddingVertical: isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 12,
paddingHorizontal: isTV ? 20 : isLargeTablet ? 18 : isTablet ? 16 : 16,
borderRadius: isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 12
},
selectedSeason === season && styles.selectedSeasonTextButton selectedSeason === season && styles.selectedSeasonTextButton
]} ]}
onPress={() => onSeasonChange(season)} onPress={() => onSeasonChange(season)}
@ -448,12 +593,23 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
<TouchableOpacity <TouchableOpacity
style={[ style={[
styles.seasonButton, styles.seasonButton,
isTablet && styles.seasonButtonTablet, {
marginRight: seasonButtonSpacing,
width: seasonPosterWidth
},
selectedSeason === season && [styles.selectedSeasonButton, { borderColor: currentTheme.colors.primary }] selectedSeason === season && [styles.selectedSeasonButton, { borderColor: currentTheme.colors.primary }]
]} ]}
onPress={() => onSeasonChange(season)} onPress={() => onSeasonChange(season)}
> >
<View style={[styles.seasonPosterContainer, isTablet && styles.seasonPosterContainerTablet]}> <View style={[
styles.seasonPosterContainer,
{
width: seasonPosterWidth,
height: seasonPosterHeight,
borderRadius: isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 8,
marginBottom: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8
}
]}>
<FastImage <FastImage
source={{ uri: seasonPoster }} source={{ uri: seasonPoster }}
style={styles.seasonPoster} style={styles.seasonPoster}
@ -462,8 +618,10 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
{selectedSeason === season && ( {selectedSeason === season && (
<View style={[ <View style={[
styles.selectedSeasonIndicator, styles.selectedSeasonIndicator,
isTablet && styles.selectedSeasonIndicatorTablet, {
{ backgroundColor: currentTheme.colors.primary } backgroundColor: currentTheme.colors.primary,
height: isTV ? 6 : isLargeTablet ? 5 : isTablet ? 4 : 4
}
]} /> ]} />
)} )}
@ -471,18 +629,19 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
<Text <Text
style={[ style={[
styles.seasonButtonText, styles.seasonButtonText,
isTablet && styles.seasonButtonTextTablet, {
{ color: currentTheme.colors.mediumEmphasis }, color: currentTheme.colors.mediumEmphasis,
fontSize: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 14
},
selectedSeason === season && [ selectedSeason === season && [
styles.selectedSeasonButtonText, styles.selectedSeasonButtonText,
isTablet && styles.selectedSeasonButtonTextTablet,
{ color: currentTheme.colors.primary } { color: currentTheme.colors.primary }
] ]
]} ]}
> >
Season {season} Season {season}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
); );
}} }}
@ -550,22 +709,43 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
key={episode.id} key={episode.id}
style={[ style={[
styles.episodeCardVertical, styles.episodeCardVertical,
{ backgroundColor: currentTheme.colors.elevation2 } {
backgroundColor: currentTheme.colors.elevation2,
borderRadius: isTV ? 20 : isLargeTablet ? 18 : isTablet ? 16 : 16,
marginBottom: isTV ? 20 : isLargeTablet ? 18 : isTablet ? 16 : 16,
height: isTV ? 200 : isLargeTablet ? 180 : isTablet ? 160 : 120
}
]} ]}
onPress={() => onSelectEpisode(episode)} onPress={() => onSelectEpisode(episode)}
activeOpacity={0.7} activeOpacity={0.7}
> >
<View style={[ <View style={[
styles.episodeImageContainer, styles.episodeImageContainer,
isTablet && styles.episodeImageContainerTablet {
width: isTV ? 200 : isLargeTablet ? 180 : isTablet ? 160 : 120,
height: isTV ? 200 : isLargeTablet ? 180 : isTablet ? 160 : 120
}
]}> ]}>
<FastImage <FastImage
source={{ uri: episodeImage }} source={{ uri: episodeImage }}
style={styles.episodeImage} style={styles.episodeImage}
resizeMode={FastImage.resizeMode.cover} resizeMode={FastImage.resizeMode.cover}
/> />
<View style={styles.episodeNumberBadge}> <View style={[
<Text style={styles.episodeNumberText}>{episodeString}</Text> styles.episodeNumberBadge,
{
paddingHorizontal: isTV ? 8 : isLargeTablet ? 7 : isTablet ? 6 : 6,
paddingVertical: isTV ? 4 : isLargeTablet ? 3 : isTablet ? 2 : 2,
borderRadius: isTV ? 6 : isLargeTablet ? 5 : isTablet ? 4 : 4
}
]}>
<Text style={[
styles.episodeNumberText,
{
fontSize: isTV ? 13 : isLargeTablet ? 12 : isTablet ? 11 : 11,
fontWeight: '600'
}
]}>{episodeString}</Text>
</View> </View>
{showProgress && ( {showProgress && (
<View style={styles.progressBarContainer}> <View style={styles.progressBarContainer}>
@ -578,53 +758,112 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
</View> </View>
)} )}
{progressPercent >= 85 && ( {progressPercent >= 85 && (
<View style={[styles.completedBadge, { backgroundColor: currentTheme.colors.primary }]}> <View style={[
<MaterialIcons name="check" size={12} color={currentTheme.colors.white} /> styles.completedBadge,
{
backgroundColor: currentTheme.colors.primary,
width: isTV ? 24 : isLargeTablet ? 22 : isTablet ? 20 : 20,
height: isTV ? 24 : isLargeTablet ? 22 : isTablet ? 20 : 20,
borderRadius: isTV ? 12 : isLargeTablet ? 11 : isTablet ? 10 : 10
}
]}>
<MaterialIcons name="check" size={isTV ? 14 : isLargeTablet ? 13 : isTablet ? 12 : 12} color={currentTheme.colors.white} />
</View> </View>
)} )}
{(!progress || progressPercent === 0) && (
<View style={{
position: 'absolute',
top: 8,
left: 8,
width: isTV ? 24 : isLargeTablet ? 22 : isTablet ? 20 : 20,
height: isTV ? 24 : isLargeTablet ? 22 : isTablet ? 20 : 20,
borderRadius: isTV ? 12 : isLargeTablet ? 11 : isTablet ? 10 : 10,
borderWidth: 2,
borderStyle: 'dashed',
borderColor: currentTheme.colors.textMuted,
opacity: 0.85,
}} />
)}
</View> </View>
<View style={[ <View style={[
styles.episodeInfo, styles.episodeInfo,
isTablet && styles.episodeInfoTablet {
paddingLeft: isTV ? 20 : isLargeTablet ? 18 : isTablet ? 16 : 12,
flex: 1,
justifyContent: 'center'
}
]}> ]}>
<View style={[ <View style={[
styles.episodeHeader, styles.episodeHeader,
isTablet && styles.episodeHeaderTablet {
marginBottom: isTV ? 8 : isLargeTablet ? 6 : isTablet ? 6 : 4
}
]}> ]}>
<Text style={[ <Text style={[
styles.episodeTitle, styles.episodeTitle,
isTablet && styles.episodeTitleTablet, {
{ color: currentTheme.colors.text } color: currentTheme.colors.text,
]} numberOfLines={2}> fontSize: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 15,
lineHeight: isTV ? 24 : isLargeTablet ? 22 : isTablet ? 20 : 18,
marginBottom: isTV ? 4 : isLargeTablet ? 3 : isTablet ? 2 : 2
}
]} numberOfLines={isLargeScreen ? 3 : 2}>
{episode.name} {episode.name}
</Text> </Text>
<View style={[ <View style={[
styles.episodeMetadata, styles.episodeMetadata,
isTablet && styles.episodeMetadataTablet {
gap: isTV ? 8 : isLargeTablet ? 7 : isTablet ? 6 : 4,
flexWrap: 'wrap'
}
]}> ]}>
{effectiveVote > 0 && ( {effectiveVote > 0 && (
<View style={styles.ratingContainer}> <View style={styles.ratingContainer}>
<FastImage <FastImage
source={{ uri: TMDB_LOGO }} source={{ uri: TMDB_LOGO }}
style={styles.tmdbLogo} style={[
styles.tmdbLogo,
{
width: isTV ? 22 : isLargeTablet ? 20 : isTablet ? 20 : 20,
height: isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 14
}
]}
resizeMode={FastImage.resizeMode.contain} resizeMode={FastImage.resizeMode.contain}
/> />
<Text style={[styles.ratingText, { color: currentTheme.colors.textMuted }]}> <Text style={[
styles.ratingText,
{
color: currentTheme.colors.textMuted,
fontSize: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 13 : 13
}
]}>
{effectiveVote.toFixed(1)} {effectiveVote.toFixed(1)}
</Text> </Text>
</View> </View>
)} )}
{effectiveRuntime && ( {effectiveRuntime && (
<View style={styles.runtimeContainer}> <View style={styles.runtimeContainer}>
<MaterialIcons name="schedule" size={14} color={currentTheme.colors.textMuted} /> <MaterialIcons name="schedule" size={isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 14} color={currentTheme.colors.textMuted} />
<Text style={[styles.runtimeText, { color: currentTheme.colors.textMuted }]}> <Text style={[
styles.runtimeText,
{
color: currentTheme.colors.textMuted,
fontSize: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 13 : 13
}
]}>
{formatRuntime(effectiveRuntime)} {formatRuntime(effectiveRuntime)}
</Text> </Text>
</View> </View>
)} )}
{episode.air_date && ( {episode.air_date && (
<Text style={[styles.airDateText, { color: currentTheme.colors.textMuted }]}> <Text style={[
styles.airDateText,
{
color: currentTheme.colors.textMuted,
fontSize: isTV ? 13 : isLargeTablet ? 12 : isTablet ? 12 : 12
}
]}>
{formatDate(episode.air_date)} {formatDate(episode.air_date)}
</Text> </Text>
)} )}
@ -632,9 +871,12 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
</View> </View>
<Text style={[ <Text style={[
styles.episodeOverview, styles.episodeOverview,
isTablet && styles.episodeOverviewTablet, {
{ color: currentTheme.colors.mediumEmphasis } color: currentTheme.colors.mediumEmphasis,
]} numberOfLines={isTablet ? 3 : 2}> fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 13,
lineHeight: isTV ? 22 : isLargeTablet ? 20 : isTablet ? 20 : 18
}
]} numberOfLines={isLargeScreen ? 4 : isTablet ? 3 : 2}>
{episode.overview || 'No description available'} {episode.overview || 'No description available'}
</Text> </Text>
</View> </View>
@ -684,47 +926,25 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
key={episode.id} key={episode.id}
style={[ style={[
styles.episodeCardHorizontal, styles.episodeCardHorizontal,
isTablet && styles.episodeCardHorizontalTablet, {
borderRadius: isTV ? 20 : isLargeTablet ? 18 : isTablet ? 16 : 16,
height: horizontalCardHeight,
elevation: isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 8,
shadowOpacity: isTV ? 0.4 : isLargeTablet ? 0.35 : isTablet ? 0.3 : 0.3,
shadowRadius: isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 8
},
// Gradient border styling // Gradient border styling
{ {
borderWidth: 1, borderWidth: 1,
borderColor: 'transparent', borderColor: 'rgba(255,255,255,0.12)',
shadowColor: '#000', shadowColor: '#000',
shadowOffset: { width: 0, height: 4 }, shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 12,
} }
]} ]}
onPress={() => onSelectEpisode(episode)} onPress={() => onSelectEpisode(episode)}
activeOpacity={0.85} activeOpacity={0.85}
> >
{/* Gradient Border Container */} {/* Solid outline replaces gradient border */}
<View style={{
position: 'absolute',
top: -1,
left: -1,
right: -1,
bottom: -1,
borderRadius: 17,
zIndex: -1,
}}>
<LinearGradient
colors={[
'#ffffff80', // White with 50% opacity
'#ffffff40', // White with 25% opacity
'#ffffff20', // White with 12% opacity
'#ffffff40', // White with 25% opacity
'#ffffff80', // White with 50% opacity
]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={{
flex: 1,
borderRadius: 17,
}}
/>
</View>
{/* Background Image */} {/* Background Image */}
<FastImage <FastImage
@ -746,35 +966,88 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
style={styles.episodeGradient} style={styles.episodeGradient}
> >
{/* Content Container */} {/* Content Container */}
<View style={[styles.episodeContent, isTablet && styles.episodeContentTablet]}> <View style={[
styles.episodeContent,
{
padding: isTV ? 20 : isLargeTablet ? 18 : isTablet ? 16 : 12,
paddingBottom: isTV ? 24 : isLargeTablet ? 22 : isTablet ? 20 : 16
}
]}>
{/* Episode Number Badge */} {/* Episode Number Badge */}
<View style={[styles.episodeNumberBadgeHorizontal, isTablet && styles.episodeNumberBadgeHorizontalTablet]}> <View style={[
<Text style={[styles.episodeNumberHorizontal, isTablet && styles.episodeNumberHorizontalTablet]}>{episodeString}</Text> styles.episodeNumberBadgeHorizontal,
{
paddingHorizontal: isTV ? 10 : isLargeTablet ? 8 : isTablet ? 6 : 6,
paddingVertical: isTV ? 5 : isLargeTablet ? 4 : isTablet ? 3 : 3,
borderRadius: isTV ? 8 : isLargeTablet ? 6 : isTablet ? 4 : 4,
marginBottom: isTV ? 10 : isLargeTablet ? 8 : isTablet ? 6 : 6
}
]}>
<Text style={[
styles.episodeNumberHorizontal,
{
fontSize: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 12 : 10,
fontWeight: isTV ? '700' : isLargeTablet ? '700' : isTablet ? '600' : '600'
}
]}>{episodeString}</Text>
</View> </View>
{/* Episode Title */} {/* Episode Title */}
<Text style={[styles.episodeTitleHorizontal, isTablet && styles.episodeTitleHorizontalTablet]} numberOfLines={2}> <Text style={[
styles.episodeTitleHorizontal,
{
fontSize: isTV ? 20 : isLargeTablet ? 19 : isTablet ? 18 : 15,
fontWeight: isTV ? '800' : isLargeTablet ? '800' : isTablet ? '700' : '700',
lineHeight: isTV ? 26 : isLargeTablet ? 24 : isTablet ? 22 : 18,
marginBottom: isTV ? 8 : isLargeTablet ? 6 : isTablet ? 4 : 4
}
]} numberOfLines={2}>
{episode.name} {episode.name}
</Text> </Text>
{/* Episode Description */} {/* Episode Description */}
<Text style={[styles.episodeDescriptionHorizontal, isTablet && styles.episodeDescriptionHorizontalTablet]} numberOfLines={3}> <Text style={[
styles.episodeDescriptionHorizontal,
{
fontSize: isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 12,
lineHeight: isTV ? 22 : isLargeTablet ? 20 : isTablet ? 18 : 16,
marginBottom: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8,
opacity: isTV ? 0.95 : isLargeTablet ? 0.9 : isTablet ? 0.9 : 0.9
}
]} numberOfLines={isLargeScreen ? 4 : 3}>
{episode.overview || 'No description available'} {episode.overview || 'No description available'}
</Text> </Text>
{/* Metadata Row */} {/* Metadata Row */}
<View style={styles.episodeMetadataRowHorizontal}> <View style={[
styles.episodeMetadataRowHorizontal,
{
gap: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8
}
]}>
{episode.runtime && ( {episode.runtime && (
<View style={styles.runtimeContainerHorizontal}> <View style={styles.runtimeContainerHorizontal}>
<Text style={styles.runtimeTextHorizontal}> <Text style={[
styles.runtimeTextHorizontal,
{
fontSize: isTV ? 13 : isLargeTablet ? 12 : isTablet ? 11 : 11,
fontWeight: isTV ? '600' : isLargeTablet ? '500' : isTablet ? '500' : '500'
}
]}>
{formatRuntime(episode.runtime)} {formatRuntime(episode.runtime)}
</Text> </Text>
</View> </View>
)} )}
{episode.vote_average > 0 && ( {episode.vote_average > 0 && (
<View style={styles.ratingContainerHorizontal}> <View style={styles.ratingContainerHorizontal}>
<MaterialIcons name="star" size={14} color="#FFD700" /> <MaterialIcons name="star" size={isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 14} color="#FFD700" />
<Text style={styles.ratingTextHorizontal}> <Text style={[
styles.ratingTextHorizontal,
{
fontSize: isTV ? 13 : isLargeTablet ? 12 : isTablet ? 11 : 11,
fontWeight: isTV ? '600' : isLargeTablet ? '600' : isTablet ? '600' : '600'
}
]}>
{episode.vote_average.toFixed(1)} {episode.vote_average.toFixed(1)}
</Text> </Text>
</View> </View>
@ -799,12 +1072,34 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
{/* Completed Badge */} {/* Completed Badge */}
{progressPercent >= 85 && ( {progressPercent >= 85 && (
<View style={[styles.completedBadgeHorizontal, { <View style={[
backgroundColor: currentTheme.colors.primary, styles.completedBadgeHorizontal,
}]}> {
<MaterialIcons name="check" size={16} color="#fff" /> backgroundColor: currentTheme.colors.primary,
width: isTV ? 32 : isLargeTablet ? 28 : isTablet ? 24 : 24,
height: isTV ? 32 : isLargeTablet ? 28 : isTablet ? 24 : 24,
borderRadius: isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 12,
top: isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 12,
left: isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 12
}
]}>
<MaterialIcons name="check" size={isTV ? 20 : isLargeTablet ? 18 : isTablet ? 16 : 16} color="#fff" />
</View> </View>
)} )}
{(!progress || progressPercent === 0) && (
<View style={{
position: 'absolute',
top: isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 12,
left: isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 12,
width: isTV ? 32 : isLargeTablet ? 28 : isTablet ? 24 : 24,
height: isTV ? 32 : isLargeTablet ? 28 : isTablet ? 24 : 24,
borderRadius: isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 12,
borderWidth: 2,
borderStyle: 'dashed',
borderColor: currentTheme.colors.textMuted,
opacity: 0.9,
}} />
)}
</LinearGradient> </LinearGradient>
</TouchableOpacity> </TouchableOpacity>
@ -824,7 +1119,15 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
<Animated.View <Animated.View
entering={FadeIn.duration(300).delay(100)} entering={FadeIn.duration(300).delay(100)}
> >
<Text style={[styles.sectionTitle, { color: currentTheme.colors.highEmphasis }]}> <Text style={[
styles.sectionTitle,
{
color: currentTheme.colors.highEmphasis,
fontSize: isTV ? 24 : isLargeTablet ? 22 : isTablet ? 20 : 20,
marginBottom: isTV ? 20 : isLargeTablet ? 18 : isTablet ? 16 : 16,
paddingHorizontal: horizontalPadding
}
]}>
{currentSeasonEpisodes.length} {currentSeasonEpisodes.length === 1 ? 'Episode' : 'Episodes'} {currentSeasonEpisodes.length} {currentSeasonEpisodes.length === 1 ? 'Episode' : 'Episodes'}
</Text> </Text>
@ -854,7 +1157,10 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
entering={enableItemAnimations ? FadeIn.duration(300).delay(100 + index * 30) : undefined as any} entering={enableItemAnimations ? FadeIn.duration(300).delay(100 + index * 30) : undefined as any}
style={[ style={[
styles.episodeCardWrapperHorizontal, styles.episodeCardWrapperHorizontal,
isTablet && styles.episodeCardWrapperHorizontalTablet {
width: horizontalCardWidth,
marginRight: horizontalItemSpacing
}
]} ]}
> >
{renderHorizontalEpisodeCard(episode)} {renderHorizontalEpisodeCard(episode)}
@ -863,17 +1169,22 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
keyExtractor={episode => episode.id.toString()} keyExtractor={episode => episode.id.toString()}
horizontal horizontal
showsHorizontalScrollIndicator={false} showsHorizontalScrollIndicator={false}
contentContainerStyle={isTablet ? styles.episodeListContentHorizontalTablet : styles.episodeListContentHorizontal} contentContainerStyle={[
styles.episodeListContentHorizontal,
{
paddingLeft: horizontalPadding,
paddingRight: horizontalPadding
}
]}
removeClippedSubviews removeClippedSubviews
initialNumToRender={3} initialNumToRender={3}
maxToRenderPerBatch={5} maxToRenderPerBatch={5}
windowSize={5} windowSize={5}
getItemLayout={(data, index) => { getItemLayout={(data, index) => {
const cardWidth = isTablet ? width * 0.4 : width * 0.75; const length = horizontalCardWidth + horizontalItemSpacing;
const margin = isTablet ? 20 : 16;
return { return {
length: cardWidth + margin, length,
offset: (cardWidth + margin) * index, offset: length * index,
index, index,
}; };
}} }}
@ -892,7 +1203,13 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
</Animated.View> </Animated.View>
)} )}
keyExtractor={episode => episode.id.toString()} keyExtractor={episode => episode.id.toString()}
contentContainerStyle={isTablet ? styles.episodeListContentVerticalTablet : styles.episodeListContentVertical} contentContainerStyle={[
styles.episodeListContentVertical,
{
paddingHorizontal: horizontalPadding,
paddingBottom: isTV ? 32 : isLargeTablet ? 28 : isTablet ? 24 : 8
}
]}
removeClippedSubviews removeClippedSubviews
/> />
) )
@ -937,11 +1254,6 @@ const styles = StyleSheet.create({
// Vertical Layout Styles // Vertical Layout Styles
episodeListContentVertical: { episodeListContentVertical: {
paddingBottom: 8, paddingBottom: 8,
paddingHorizontal: 16,
},
episodeListContentVerticalTablet: {
paddingHorizontal: 16,
paddingBottom: 8,
}, },
episodeGridVertical: { episodeGridVertical: {
flexDirection: 'row', flexDirection: 'row',
@ -1098,20 +1410,10 @@ const styles = StyleSheet.create({
// Horizontal Layout Styles // Horizontal Layout Styles
episodeListContentHorizontal: { episodeListContentHorizontal: {
paddingLeft: 16, // Padding will be added responsively
paddingRight: 16,
},
episodeListContentHorizontalTablet: {
paddingLeft: 24,
paddingRight: 24,
}, },
episodeCardWrapperHorizontal: { episodeCardWrapperHorizontal: {
width: Dimensions.get('window').width * 0.75, // Dimensions will be set responsively
marginRight: 16,
},
episodeCardWrapperHorizontalTablet: {
width: Dimensions.get('window').width * 0.4,
marginRight: 20,
}, },
episodeCardHorizontal: { episodeCardHorizontal: {
borderRadius: 16, borderRadius: 16,
@ -1128,13 +1430,6 @@ const styles = StyleSheet.create({
width: '100%', width: '100%',
backgroundColor: 'transparent', backgroundColor: 'transparent',
}, },
episodeCardHorizontalTablet: {
height: 260,
borderRadius: 20,
elevation: 12,
shadowOpacity: 0.4,
shadowRadius: 16,
},
episodeBackgroundImage: { episodeBackgroundImage: {
width: '100%', width: '100%',
height: '100%', height: '100%',
@ -1273,11 +1568,6 @@ const styles = StyleSheet.create({
// Season Selector Styles // Season Selector Styles
seasonSelectorWrapper: { seasonSelectorWrapper: {
marginBottom: 20, marginBottom: 20,
paddingHorizontal: 16,
},
seasonSelectorWrapperTablet: {
marginBottom: 24,
paddingHorizontal: 24,
}, },
seasonSelectorHeader: { seasonSelectorHeader: {
flexDirection: 'row', flexDirection: 'row',
@ -1306,32 +1596,14 @@ const styles = StyleSheet.create({
}, },
seasonButton: { seasonButton: {
alignItems: 'center', alignItems: 'center',
marginRight: 16,
width: 100,
},
seasonButtonTablet: {
alignItems: 'center',
marginRight: 20,
width: 120,
}, },
selectedSeasonButton: { selectedSeasonButton: {
opacity: 1, opacity: 1,
}, },
seasonPosterContainer: { seasonPosterContainer: {
position: 'relative', position: 'relative',
width: 100,
height: 150,
borderRadius: 8, borderRadius: 8,
overflow: 'hidden', overflow: 'hidden',
marginBottom: 8,
},
seasonPosterContainerTablet: {
position: 'relative',
width: 120,
height: 180,
borderRadius: 12,
overflow: 'hidden',
marginBottom: 12,
}, },
seasonPoster: { seasonPoster: {
width: '100%', width: '100%',
@ -1382,22 +1654,7 @@ const styles = StyleSheet.create({
}, },
seasonTextButton: { seasonTextButton: {
alignItems: 'center', alignItems: 'center',
marginRight: 16,
width: 110,
justifyContent: 'center', justifyContent: 'center',
paddingVertical: 12,
paddingHorizontal: 16,
borderRadius: 12,
backgroundColor: 'transparent',
},
seasonTextButtonTablet: {
alignItems: 'center',
marginRight: 20,
width: 130,
justifyContent: 'center',
paddingVertical: 14,
paddingHorizontal: 18,
borderRadius: 14,
backgroundColor: 'transparent', backgroundColor: 'transparent',
}, },
selectedSeasonTextButton: { selectedSeasonTextButton: {

View file

@ -1,4 +1,4 @@
import React, { useState, useEffect, useCallback, memo, useRef } from 'react'; import React, { useState, useEffect, useCallback, memo, useRef, useMemo } from 'react';
import { import {
View, View,
Text, Text,
@ -21,8 +21,13 @@ import TrailerService from '../../services/trailerService';
import TrailerModal from './TrailerModal'; import TrailerModal from './TrailerModal';
import Animated, { useSharedValue, withTiming, withDelay, useAnimatedStyle } from 'react-native-reanimated'; import Animated, { useSharedValue, withTiming, withDelay, useAnimatedStyle } from 'react-native-reanimated';
const { width } = Dimensions.get('window'); // Enhanced responsive breakpoints for Trailers Section
const isTablet = width >= 768; const BREAKPOINTS = {
phone: 0,
tablet: 768,
largeTablet: 1024,
tv: 1440,
};
interface TrailerVideo { interface TrailerVideo {
id: string; id: string;
@ -66,6 +71,65 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
const [dropdownVisible, setDropdownVisible] = useState(false); const [dropdownVisible, setDropdownVisible] = useState(false);
const [backendAvailable, setBackendAvailable] = useState<boolean | null>(null); const [backendAvailable, setBackendAvailable] = useState<boolean | null>(null);
// Enhanced responsive sizing for tablets and TV screens
const deviceWidth = Dimensions.get('window').width;
const deviceHeight = Dimensions.get('window').height;
// Determine device type based on width
const getDeviceType = useCallback(() => {
if (deviceWidth >= BREAKPOINTS.tv) return 'tv';
if (deviceWidth >= BREAKPOINTS.largeTablet) return 'largeTablet';
if (deviceWidth >= BREAKPOINTS.tablet) return 'tablet';
return 'phone';
}, [deviceWidth]);
const deviceType = getDeviceType();
const isTablet = deviceType === 'tablet';
const isLargeTablet = deviceType === 'largeTablet';
const isTV = deviceType === 'tv';
const isLargeScreen = isTablet || isLargeTablet || isTV;
// Enhanced spacing and padding
const horizontalPadding = useMemo(() => {
switch (deviceType) {
case 'tv':
return 32;
case 'largeTablet':
return 28;
case 'tablet':
return 24;
default:
return 16; // phone
}
}, [deviceType]);
// Enhanced trailer card sizing
const trailerCardWidth = useMemo(() => {
switch (deviceType) {
case 'tv':
return 240;
case 'largeTablet':
return 220;
case 'tablet':
return 200;
default:
return 170; // phone
}
}, [deviceType]);
const trailerCardSpacing = useMemo(() => {
switch (deviceType) {
case 'tv':
return 16;
case 'largeTablet':
return 14;
case 'tablet':
return 12;
default:
return 12; // phone
}
}, [deviceType]);
// Smooth reveal animation after trailers are fetched // Smooth reveal animation after trailers are fetched
const sectionOpacitySV = useSharedValue(0); const sectionOpacitySV = useSharedValue(0);
const sectionTranslateYSV = useSharedValue(8); const sectionTranslateYSV = useSharedValue(8);
@ -462,22 +526,48 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
} }
return ( return (
<Animated.View style={[styles.container, sectionAnimatedStyle]}> <Animated.View style={[
styles.container,
sectionAnimatedStyle,
{ paddingHorizontal: horizontalPadding }
]}>
{/* Enhanced Header with Category Selector */} {/* Enhanced Header with Category Selector */}
<View style={styles.header}> <View style={styles.header}>
<Text style={[styles.headerTitle, { color: currentTheme.colors.highEmphasis }]}> <Text style={[
styles.headerTitle,
{
color: currentTheme.colors.highEmphasis,
fontSize: isTV ? 28 : isLargeTablet ? 26 : isTablet ? 24 : 20
}
]}>
Trailers & Videos Trailers & Videos
</Text> </Text>
{/* Category Selector - Right Aligned */} {/* Category Selector - Right Aligned */}
{trailerCategories.length > 0 && selectedCategory && ( {trailerCategories.length > 0 && selectedCategory && (
<TouchableOpacity <TouchableOpacity
style={[styles.categorySelector, { borderColor: 'rgba(255,255,255,0.6)' }]} style={[
styles.categorySelector,
{
borderColor: 'rgba(255,255,255,0.6)',
paddingHorizontal: isTV ? 14 : isLargeTablet ? 12 : isTablet ? 10 : 10,
paddingVertical: isTV ? 8 : isLargeTablet ? 6 : isTablet ? 5 : 5,
borderRadius: isTV ? 20 : isLargeTablet ? 18 : isTablet ? 16 : 16,
maxWidth: isTV ? 200 : isLargeTablet ? 180 : isTablet ? 160 : 160
}
]}
onPress={toggleDropdown} onPress={toggleDropdown}
activeOpacity={0.8} activeOpacity={0.8}
> >
<Text <Text
style={[styles.categorySelectorText, { color: currentTheme.colors.highEmphasis }]} style={[
styles.categorySelectorText,
{
color: currentTheme.colors.highEmphasis,
fontSize: isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 12,
maxWidth: isTV ? 150 : isLargeTablet ? 130 : isTablet ? 120 : 120
}
]}
numberOfLines={1} numberOfLines={1}
ellipsizeMode="tail" ellipsizeMode="tail"
> >
@ -485,7 +575,7 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
</Text> </Text>
<MaterialIcons <MaterialIcons
name={dropdownVisible ? "expand-less" : "expand-more"} name={dropdownVisible ? "expand-less" : "expand-more"}
size={18} size={isTV ? 22 : isLargeTablet ? 20 : isTablet ? 18 : 18}
color="rgba(255,255,255,0.7)" color="rgba(255,255,255,0.7)"
/> />
</TouchableOpacity> </TouchableOpacity>
@ -506,32 +596,58 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
> >
<View style={[styles.dropdownContainer, { <View style={[styles.dropdownContainer, {
backgroundColor: currentTheme.colors.background, backgroundColor: currentTheme.colors.background,
borderColor: currentTheme.colors.primary + '20' borderColor: currentTheme.colors.primary + '20',
maxWidth: isTV ? 400 : isLargeTablet ? 360 : isTablet ? 320 : 320,
borderRadius: isTV ? 20 : isLargeTablet ? 18 : isTablet ? 16 : 16
}]}> }]}>
{trailerCategories.map(category => ( {trailerCategories.map(category => (
<TouchableOpacity <TouchableOpacity
key={category} key={category}
style={styles.dropdownItem} style={[
styles.dropdownItem,
{
paddingHorizontal: isTV ? 20 : isLargeTablet ? 18 : isTablet ? 16 : 16,
paddingVertical: isTV ? 18 : isLargeTablet ? 16 : isTablet ? 14 : 14
}
]}
onPress={() => handleCategorySelect(category)} onPress={() => handleCategorySelect(category)}
activeOpacity={0.7} activeOpacity={0.7}
> >
<View style={styles.dropdownItemContent}> <View style={styles.dropdownItemContent}>
<View style={[styles.categoryIconContainer, { <View style={[
backgroundColor: currentTheme.colors.primary + '15' styles.categoryIconContainer,
}]}> {
backgroundColor: currentTheme.colors.primary + '15',
width: isTV ? 36 : isLargeTablet ? 32 : isTablet ? 28 : 28,
height: isTV ? 36 : isLargeTablet ? 32 : isTablet ? 28 : 28,
borderRadius: isTV ? 10 : isLargeTablet ? 9 : isTablet ? 8 : 8
}
]}>
<MaterialIcons <MaterialIcons
name={getTrailerTypeIcon(category) as any} name={getTrailerTypeIcon(category) as any}
size={14} size={isTV ? 18 : isLargeTablet ? 16 : isTablet ? 14 : 14}
color={currentTheme.colors.primary} color={currentTheme.colors.primary}
/> />
</View> </View>
<Text style={[ <Text style={[
styles.dropdownItemText, styles.dropdownItemText,
{ color: currentTheme.colors.highEmphasis } {
color: currentTheme.colors.highEmphasis,
fontSize: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 16
}
]}> ]}>
{formatTrailerType(category)} {formatTrailerType(category)}
</Text> </Text>
<Text style={[styles.dropdownItemCount, { color: currentTheme.colors.textMuted }]}> <Text style={[
styles.dropdownItemCount,
{
color: currentTheme.colors.textMuted,
fontSize: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 12 : 12,
paddingHorizontal: isTV ? 10 : isLargeTablet ? 8 : isTablet ? 8 : 8,
paddingVertical: isTV ? 6 : isLargeTablet ? 5 : isTablet ? 4 : 4,
borderRadius: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 10 : 10
}
]}>
{trailers[category].length} {trailers[category].length}
</Text> </Text>
</View> </View>
@ -548,16 +664,25 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
<ScrollView <ScrollView
horizontal horizontal
showsHorizontalScrollIndicator={false} showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.trailersScrollContent} contentContainerStyle={[
styles.trailersScrollContent,
{ gap: trailerCardSpacing }
]}
style={styles.trailersScrollView} style={styles.trailersScrollView}
decelerationRate="fast" decelerationRate="fast"
snapToInterval={isTablet ? 212 : 182} // card width + gap for smooth scrolling snapToInterval={trailerCardWidth + trailerCardSpacing} // card width + gap for smooth scrolling
snapToAlignment="start" snapToAlignment="start"
> >
{trailers[selectedCategory].map((trailer, index) => ( {trailers[selectedCategory].map((trailer, index) => (
<TouchableOpacity <TouchableOpacity
key={trailer.id} key={trailer.id}
style={styles.trailerCard} style={[
styles.trailerCard,
{
width: trailerCardWidth,
borderRadius: isTV ? 20 : isLargeTablet ? 18 : isTablet ? 16 : 16
}
]}
onPress={() => handleTrailerPress(trailer)} onPress={() => handleTrailerPress(trailer)}
activeOpacity={0.9} activeOpacity={0.9}
> >
@ -565,33 +690,71 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
<View style={styles.thumbnailWrapper}> <View style={styles.thumbnailWrapper}>
<FastImage <FastImage
source={{ uri: getYouTubeThumbnail(trailer.key, 'hq') }} source={{ uri: getYouTubeThumbnail(trailer.key, 'hq') }}
style={styles.thumbnail} style={[
styles.thumbnail,
{
borderTopLeftRadius: isTV ? 20 : isLargeTablet ? 18 : isTablet ? 16 : 16,
borderTopRightRadius: isTV ? 20 : isLargeTablet ? 18 : isTablet ? 16 : 16
}
]}
resizeMode={FastImage.resizeMode.cover} resizeMode={FastImage.resizeMode.cover}
/> />
{/* Subtle Gradient Overlay */} {/* Subtle Gradient Overlay */}
<View style={styles.thumbnailGradient} /> <View style={[
styles.thumbnailGradient,
{
borderTopLeftRadius: isTV ? 20 : isLargeTablet ? 18 : isTablet ? 16 : 16,
borderTopRightRadius: isTV ? 20 : isLargeTablet ? 18 : isTablet ? 16 : 16
}
]} />
</View> </View>
{/* Trailer Info */} {/* Trailer Info */}
<View style={styles.trailerInfo}> <View style={[
styles.trailerInfo,
{
padding: isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 12
}
]}>
<Text <Text
style={[styles.trailerTitle, { color: currentTheme.colors.highEmphasis }]} style={[
styles.trailerTitle,
{
color: currentTheme.colors.highEmphasis,
fontSize: isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 12,
lineHeight: isTV ? 22 : isLargeTablet ? 20 : isTablet ? 18 : 16,
marginBottom: isTV ? 6 : isLargeTablet ? 5 : isTablet ? 4 : 4
}
]}
numberOfLines={2} numberOfLines={2}
> >
{trailer.displayName || trailer.name} {trailer.displayName || trailer.name}
</Text> </Text>
<Text style={[styles.trailerMeta, { color: currentTheme.colors.textMuted }]}> <Text style={[
styles.trailerMeta,
{
color: currentTheme.colors.textMuted,
fontSize: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 12 : 10
}
]}>
{new Date(trailer.published_at).getFullYear()} {new Date(trailer.published_at).getFullYear()}
</Text> </Text>
</View> </View>
</TouchableOpacity> </TouchableOpacity>
))} ))}
{/* Scroll Indicator - shows when there are more items to scroll */} {/* Scroll Indicator - shows when there are more items to scroll */}
{trailers[selectedCategory].length > (isTablet ? 4 : 3) && ( {trailers[selectedCategory].length > (isTV ? 5 : isLargeTablet ? 4 : isTablet ? 4 : 3) && (
<View style={styles.scrollIndicator}> <View style={[
styles.scrollIndicator,
{
width: isTV ? 32 : isLargeTablet ? 28 : isTablet ? 24 : 24,
height: isTV ? 28 : isLargeTablet ? 24 : isTablet ? 20 : 20,
borderRadius: isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 12
}
]}>
<MaterialIcons <MaterialIcons
name="chevron-right" name="chevron-right"
size={20} size={isTV ? 24 : isLargeTablet ? 22 : isTablet ? 20 : 20}
color={currentTheme.colors.textMuted} color={currentTheme.colors.textMuted}
style={{ opacity: 0.6 }} style={{ opacity: 0.6 }}
/> />
@ -614,7 +777,6 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
paddingHorizontal: 16,
marginTop: 24, marginTop: 24,
marginBottom: 16, marginBottom: 16,
}, },
@ -749,13 +911,11 @@ const styles = StyleSheet.create({
}, },
trailersScrollContent: { trailersScrollContent: {
paddingHorizontal: 4, // Restore padding for first/last items paddingHorizontal: 4, // Restore padding for first/last items
gap: 12,
paddingRight: 20, // Extra padding at end for scroll indicator paddingRight: 20, // Extra padding at end for scroll indicator
}, },
// Enhanced Trailer Card Styles // Enhanced Trailer Card Styles
trailerCard: { trailerCard: {
width: isTablet ? 200 : 170,
backgroundColor: 'rgba(255,255,255,0.03)', backgroundColor: 'rgba(255,255,255,0.03)',
borderRadius: 16, borderRadius: 16,
borderWidth: 1, borderWidth: 1,

View file

@ -1588,6 +1588,11 @@ const AndroidVideoPlayer: React.FC = () => {
} }
controlsTimeout.current = setTimeout(hideControls, 5000); controlsTimeout.current = setTimeout(hideControls, 5000);
// Auto-fetch and load English external subtitles if available
if (imdbId) {
fetchAvailableSubtitles(undefined, true);
}
} catch (error) { } catch (error) {
logger.error('[AndroidVideoPlayer] Error in onLoad:', error); logger.error('[AndroidVideoPlayer] Error in onLoad:', error);
// Set fallback values to prevent crashes // Set fallback values to prevent crashes

View file

@ -12,6 +12,11 @@ interface KSPlayerViewProps {
volume?: number; volume?: number;
audioTrack?: number; audioTrack?: number;
textTrack?: number; textTrack?: number;
allowsExternalPlayback?: boolean;
usesExternalPlaybackWhileExternalScreenIsActive?: boolean;
subtitleBottomOffset?: number;
subtitleFontSize?: number;
resizeMode?: 'contain' | 'cover' | 'stretch';
onLoad?: (data: any) => void; onLoad?: (data: any) => void;
onProgress?: (data: any) => void; onProgress?: (data: any) => void;
onBuffering?: (data: any) => void; onBuffering?: (data: any) => void;
@ -32,6 +37,10 @@ export interface KSPlayerRef {
setAudioTrack: (trackId: number) => void; setAudioTrack: (trackId: number) => void;
setTextTrack: (trackId: number) => void; setTextTrack: (trackId: number) => void;
getTracks: () => Promise<{ audioTracks: any[]; textTracks: any[] }>; getTracks: () => Promise<{ audioTracks: any[]; textTracks: any[] }>;
setAllowsExternalPlayback: (allows: boolean) => void;
setUsesExternalPlaybackWhileExternalScreenIsActive: (uses: boolean) => void;
getAirPlayState: () => Promise<{ allowsExternalPlayback: boolean; usesExternalPlaybackWhileExternalScreenIsActive: boolean; isExternalPlaybackActive: boolean }>;
showAirPlayPicker: () => void;
} }
export interface KSPlayerProps { export interface KSPlayerProps {
@ -40,6 +49,11 @@ export interface KSPlayerProps {
volume?: number; volume?: number;
audioTrack?: number; audioTrack?: number;
textTrack?: number; textTrack?: number;
allowsExternalPlayback?: boolean;
usesExternalPlaybackWhileExternalScreenIsActive?: boolean;
subtitleBottomOffset?: number;
subtitleFontSize?: number;
resizeMode?: 'contain' | 'cover' | 'stretch';
onLoad?: (data: any) => void; onLoad?: (data: any) => void;
onProgress?: (data: any) => void; onProgress?: (data: any) => void;
onBuffering?: (data: any) => void; onBuffering?: (data: any) => void;
@ -109,6 +123,38 @@ const KSPlayer = forwardRef<KSPlayerRef, KSPlayerProps>((props, ref) => {
} }
return { audioTracks: [], textTracks: [] }; return { audioTracks: [], textTracks: [] };
}, },
setAllowsExternalPlayback: (allows: boolean) => {
if (nativeRef.current) {
const node = findNodeHandle(nativeRef.current);
// @ts-ignore legacy UIManager commands path for Paper
const commandId = UIManager.getViewManagerConfig('KSPlayerView').Commands.setAllowsExternalPlayback;
UIManager.dispatchViewManagerCommand(node, commandId, [allows]);
}
},
setUsesExternalPlaybackWhileExternalScreenIsActive: (uses: boolean) => {
if (nativeRef.current) {
const node = findNodeHandle(nativeRef.current);
// @ts-ignore legacy UIManager commands path for Paper
const commandId = UIManager.getViewManagerConfig('KSPlayerView').Commands.setUsesExternalPlaybackWhileExternalScreenIsActive;
UIManager.dispatchViewManagerCommand(node, commandId, [uses]);
}
},
getAirPlayState: async () => {
if (nativeRef.current) {
const node = findNodeHandle(nativeRef.current);
return await KSPlayerModule.getAirPlayState(node);
}
return { allowsExternalPlayback: false, usesExternalPlaybackWhileExternalScreenIsActive: false, isExternalPlaybackActive: false };
},
showAirPlayPicker: () => {
if (nativeRef.current) {
const node = findNodeHandle(nativeRef.current);
console.log('[KSPlayerComponent] Calling showAirPlayPicker with node:', node);
KSPlayerModule.showAirPlayPicker(node);
} else {
console.log('[KSPlayerComponent] nativeRef.current is null');
}
},
})); }));
// No need for event listeners - events are handled through props // No need for event listeners - events are handled through props
@ -129,6 +175,11 @@ const KSPlayer = forwardRef<KSPlayerRef, KSPlayerProps>((props, ref) => {
volume={props.volume} volume={props.volume}
audioTrack={props.audioTrack} audioTrack={props.audioTrack}
textTrack={props.textTrack} textTrack={props.textTrack}
allowsExternalPlayback={props.allowsExternalPlayback}
usesExternalPlaybackWhileExternalScreenIsActive={props.usesExternalPlaybackWhileExternalScreenIsActive}
subtitleBottomOffset={props.subtitleBottomOffset}
subtitleFontSize={props.subtitleFontSize}
resizeMode={props.resizeMode}
onLoad={(e: any) => props.onLoad?.(e?.nativeEvent ?? e)} onLoad={(e: any) => props.onLoad?.(e?.nativeEvent ?? e)}
onProgress={(e: any) => props.onProgress?.(e?.nativeEvent ?? e)} onProgress={(e: any) => props.onProgress?.(e?.nativeEvent ?? e)}
onBuffering={(e: any) => props.onBuffering?.(e?.nativeEvent ?? e)} onBuffering={(e: any) => props.onBuffering?.(e?.nativeEvent ?? e)}

View file

@ -94,13 +94,20 @@ const KSPlayerCore: React.FC = () => {
const screenData = Dimensions.get('screen'); const screenData = Dimensions.get('screen');
const [screenDimensions, setScreenDimensions] = useState(screenData); const [screenDimensions, setScreenDimensions] = useState(screenData);
// iPad-specific fullscreen handling // iPad/macOS-specific fullscreen handling
const isIPad = Platform.OS === 'ios' && (screenData.width > 1000 || screenData.height > 1000); const isIPad = Platform.OS === 'ios' && (screenData.width > 1000 || screenData.height > 1000);
const shouldUseFullscreen = isIPad; const isMacOS = Platform.OS === 'ios' && Platform.isPad === true;
const shouldUseFullscreen = isIPad || isMacOS;
// Use window dimensions for iPad instead of screen dimensions // Use window dimensions for iPad instead of screen dimensions
const windowData = Dimensions.get('window'); const windowData = Dimensions.get('window');
const effectiveDimensions = shouldUseFullscreen ? windowData : screenData; const effectiveDimensions = shouldUseFullscreen ? windowData : screenData;
// Helper to get appropriate dimensions for gesture areas and overlays
const getDimensions = () => ({
width: shouldUseFullscreen ? windowData.width : screenDimensions.width,
height: shouldUseFullscreen ? windowData.height : screenDimensions.height,
});
const [paused, setPaused] = useState(false); const [paused, setPaused] = useState(false);
const [currentTime, setCurrentTime] = useState(0); const [currentTime, setCurrentTime] = useState(0);
@ -111,6 +118,7 @@ const KSPlayerCore: React.FC = () => {
const [textTracks, setTextTracks] = useState<TextTrack[]>([]); const [textTracks, setTextTracks] = useState<TextTrack[]>([]);
const [selectedTextTrack, setSelectedTextTrack] = useState<number>(-1); const [selectedTextTrack, setSelectedTextTrack] = useState<number>(-1);
const [resizeMode, setResizeMode] = useState<ResizeModeType>('contain'); const [resizeMode, setResizeMode] = useState<ResizeModeType>('contain');
const [playerBackend, setPlayerBackend] = useState<string>('');
const [buffered, setBuffered] = useState(0); const [buffered, setBuffered] = useState(0);
const [seekPosition, setSeekPosition] = useState<number | null>(null); const [seekPosition, setSeekPosition] = useState<number | null>(null);
const ksPlayerRef = useRef<KSPlayerRef>(null); const ksPlayerRef = useRef<KSPlayerRef>(null);
@ -253,6 +261,10 @@ const KSPlayerCore: React.FC = () => {
const controlsTimeout = useRef<NodeJS.Timeout | null>(null); const controlsTimeout = useRef<NodeJS.Timeout | null>(null);
const [isSyncingBeforeClose, setIsSyncingBeforeClose] = useState(false); const [isSyncingBeforeClose, setIsSyncingBeforeClose] = useState(false);
// AirPlay state
const [isAirPlayActive, setIsAirPlayActive] = useState<boolean>(false);
const [allowsAirPlay, setAllowsAirPlay] = useState<boolean>(true);
// Silent startup-timeout retry state // Silent startup-timeout retry state
const startupRetryCountRef = useRef(0); const startupRetryCountRef = useRef(0);
const startupRetryTimerRef = useRef<NodeJS.Timeout | null>(null); const startupRetryTimerRef = useRef<NodeJS.Timeout | null>(null);
@ -949,6 +961,18 @@ const KSPlayerCore: React.FC = () => {
safeSetState(() => setBuffered(bufferedTime)); safeSetState(() => setBuffered(bufferedTime));
} }
// Update AirPlay state if available
if (event.airPlayState) {
const wasAirPlayActive = isAirPlayActive;
setIsAirPlayActive(event.airPlayState.isExternalPlaybackActive);
setAllowsAirPlay(event.airPlayState.allowsExternalPlayback);
// Log AirPlay state changes for debugging
if (wasAirPlayActive !== event.airPlayState.isExternalPlaybackActive) {
if (__DEV__) logger.log(`[VideoPlayer] AirPlay state changed: ${event.airPlayState.isExternalPlaybackActive ? 'ACTIVE' : 'INACTIVE'}`);
}
}
// Safety: if audio is advancing but onLoad didn't fire, dismiss opening overlay // Safety: if audio is advancing but onLoad didn't fire, dismiss opening overlay
if (!isOpeningAnimationComplete) { if (!isOpeningAnimationComplete) {
setIsVideoLoaded(true); setIsVideoLoaded(true);
@ -1032,6 +1056,24 @@ const KSPlayerCore: React.FC = () => {
logger.error('[VideoPlayer] onLoad called with null/undefined data'); logger.error('[VideoPlayer] onLoad called with null/undefined data');
return; return;
} }
// Extract player backend information
if (data.playerBackend) {
const newPlayerBackend = data.playerBackend;
setPlayerBackend(newPlayerBackend);
if (DEBUG_MODE) {
logger.log(`[VideoPlayer] Player backend: ${newPlayerBackend}`);
}
// Reset AirPlay state if switching to KSMEPlayer (which doesn't support AirPlay)
if (newPlayerBackend === 'KSMEPlayer' && (isAirPlayActive || allowsAirPlay)) {
setIsAirPlayActive(false);
setAllowsAirPlay(false);
if (DEBUG_MODE) {
logger.log('[VideoPlayer] Reset AirPlay state for KSMEPlayer');
}
}
}
// KSPlayer returns duration in seconds directly // KSPlayer returns duration in seconds directly
const videoDuration = data.duration; const videoDuration = data.duration;
if (DEBUG_MODE) { if (DEBUG_MODE) {
@ -1242,6 +1284,11 @@ const KSPlayerCore: React.FC = () => {
} }
controlsTimeout.current = setTimeout(hideControls, 5000); controlsTimeout.current = setTimeout(hideControls, 5000);
// Auto-fetch and load English external subtitles if available
if (imdbId) {
fetchAvailableSubtitles(undefined, true);
}
} catch (error) { } catch (error) {
logger.error('[VideoPlayer] Error in onLoad:', error); logger.error('[VideoPlayer] Error in onLoad:', error);
// Set fallback values to prevent crashes // Set fallback values to prevent crashes
@ -1268,6 +1315,12 @@ const KSPlayerCore: React.FC = () => {
}; };
const cycleAspectRatio = () => { const cycleAspectRatio = () => {
// iOS KSPlayer: toggle native resize mode so subtitles remain independent
if (Platform.OS === 'ios') {
setResizeMode((prev) => (prev === 'cover' ? 'contain' : 'cover'));
return;
}
// Fallback (noniOS paths): keep legacy zoom behavior
const newZoom = zoomScale === 1.1 ? 1 : 1.1; const newZoom = zoomScale === 1.1 ? 1 : 1.1;
setZoomScale(newZoom); setZoomScale(newZoom);
setZoomTranslateX(0); setZoomTranslateX(0);
@ -2204,7 +2257,7 @@ const KSPlayerCore: React.FC = () => {
if (typeof saved.subtitleOutlineColor === 'string') setSubtitleOutlineColor(saved.subtitleOutlineColor); if (typeof saved.subtitleOutlineColor === 'string') setSubtitleOutlineColor(saved.subtitleOutlineColor);
if (typeof saved.subtitleOutlineWidth === 'number') setSubtitleOutlineWidth(saved.subtitleOutlineWidth); if (typeof saved.subtitleOutlineWidth === 'number') setSubtitleOutlineWidth(saved.subtitleOutlineWidth);
if (typeof saved.subtitleAlign === 'string') setSubtitleAlign(saved.subtitleAlign as 'center' | 'left' | 'right'); if (typeof saved.subtitleAlign === 'string') setSubtitleAlign(saved.subtitleAlign as 'center' | 'left' | 'right');
if (typeof saved.subtitleBottomOffset === 'number') setSubtitleBottomOffset(saved.subtitleBottomOffset); if (typeof saved.subtitleBottomOffset === 'number') setSubtitleBottomOffset(saved.subtitleBottomOffset);
if (typeof saved.subtitleLetterSpacing === 'number') setSubtitleLetterSpacing(saved.subtitleLetterSpacing); if (typeof saved.subtitleLetterSpacing === 'number') setSubtitleLetterSpacing(saved.subtitleLetterSpacing);
if (typeof saved.subtitleLineHeightMultiplier === 'number') setSubtitleLineHeightMultiplier(saved.subtitleLineHeightMultiplier); if (typeof saved.subtitleLineHeightMultiplier === 'number') setSubtitleLineHeightMultiplier(saved.subtitleLineHeightMultiplier);
if (typeof saved.subtitleOffsetSec === 'number') setSubtitleOffsetSec(saved.subtitleOffsetSec); if (typeof saved.subtitleOffsetSec === 'number') setSubtitleOffsetSec(saved.subtitleOffsetSec);
@ -2244,7 +2297,7 @@ const KSPlayerCore: React.FC = () => {
subtitleOutlineColor, subtitleOutlineColor,
subtitleOutlineWidth, subtitleOutlineWidth,
subtitleAlign, subtitleAlign,
subtitleBottomOffset, subtitleBottomOffset,
subtitleLetterSpacing, subtitleLetterSpacing,
subtitleLineHeightMultiplier, subtitleLineHeightMultiplier,
subtitleOffsetSec, subtitleOffsetSec,
@ -2326,6 +2379,27 @@ const KSPlayerCore: React.FC = () => {
} }
}, [pendingSeek, isPlayerReady, isVideoLoaded, duration]); }, [pendingSeek, isPlayerReady, isVideoLoaded, duration]);
// AirPlay handler
const handleAirPlayPress = async () => {
if (!ksPlayerRef.current) return;
try {
// First ensure AirPlay is enabled
if (!allowsAirPlay) {
ksPlayerRef.current.setAllowsExternalPlayback(true);
setAllowsAirPlay(true);
logger.log(`[VideoPlayer] AirPlay enabled before showing picker`);
}
// Show the AirPlay picker
ksPlayerRef.current.showAirPlayPicker();
logger.log(`[VideoPlayer] AirPlay picker triggered - check console for native logs`);
} catch (error) {
logger.error('[VideoPlayer] Error showing AirPlay picker:', error);
}
};
const handleSelectStream = async (newStream: any) => { const handleSelectStream = async (newStream: any) => {
if (newStream.url === currentStreamUrl) { if (newStream.url === currentStreamUrl) {
setShowSourcesModal(false); setShowSourcesModal(false);
@ -2419,7 +2493,7 @@ const KSPlayerCore: React.FC = () => {
<View style={[ <View style={[
styles.container, styles.container,
shouldUseFullscreen ? { shouldUseFullscreen ? {
// iPad fullscreen: use flex layout instead of absolute positioning // iPad/macOS fullscreen: use flex layout instead of absolute positioning
flex: 1, flex: 1,
width: '100%', width: '100%',
height: '100%', height: '100%',
@ -2438,8 +2512,8 @@ const KSPlayerCore: React.FC = () => {
{ {
opacity: backgroundFadeAnim, opacity: backgroundFadeAnim,
zIndex: shouldHideOpeningOverlay ? -1 : 3000, zIndex: shouldHideOpeningOverlay ? -1 : 3000,
width: screenDimensions.width, width: shouldUseFullscreen ? '100%' : screenDimensions.width,
height: screenDimensions.height, height: shouldUseFullscreen ? '100%' : screenDimensions.height,
} }
]} ]}
pointerEvents={shouldHideOpeningOverlay ? 'none' : 'auto'} pointerEvents={shouldHideOpeningOverlay ? 'none' : 'auto'}
@ -2448,8 +2522,8 @@ const KSPlayerCore: React.FC = () => {
<Animated.View style={[ <Animated.View style={[
StyleSheet.absoluteFill, StyleSheet.absoluteFill,
{ {
width: screenDimensions.width, width: shouldUseFullscreen ? '100%' : screenDimensions.width,
height: screenDimensions.height, height: shouldUseFullscreen ? '100%' : screenDimensions.height,
opacity: backdropImageOpacityAnim opacity: backdropImageOpacityAnim
} }
]}> ]}>
@ -2514,8 +2588,8 @@ const KSPlayerCore: React.FC = () => {
style={[ style={[
styles.sourceChangeOverlay, styles.sourceChangeOverlay,
{ {
width: screenDimensions.width, width: shouldUseFullscreen ? '100%' : screenDimensions.width,
height: screenDimensions.height, height: shouldUseFullscreen ? '100%' : screenDimensions.height,
opacity: fadeAnim, opacity: fadeAnim,
} }
]} ]}
@ -2535,8 +2609,8 @@ const KSPlayerCore: React.FC = () => {
{ {
opacity: DISABLE_OPENING_OVERLAY ? 1 : openingFadeAnim, opacity: DISABLE_OPENING_OVERLAY ? 1 : openingFadeAnim,
transform: DISABLE_OPENING_OVERLAY ? [] : [{ scale: openingScaleAnim }], transform: DISABLE_OPENING_OVERLAY ? [] : [{ scale: openingScaleAnim }],
width: screenDimensions.width, width: shouldUseFullscreen ? '100%' : screenDimensions.width,
height: screenDimensions.height, height: shouldUseFullscreen ? '100%' : screenDimensions.height,
} }
]} ]}
> >
@ -2556,10 +2630,10 @@ const KSPlayerCore: React.FC = () => {
> >
<View style={{ <View style={{
position: 'absolute', position: 'absolute',
top: screenDimensions.height * 0.15, // Back to original margin top: getDimensions().height * 0.15, // Back to original margin
left: 0, left: 0,
width: screenDimensions.width * 0.4, // Back to larger area (40% of screen) width: getDimensions().width * 0.4, // Back to larger area (40% of screen)
height: screenDimensions.height * 0.7, // Back to larger middle portion (70% of screen) height: getDimensions().height * 0.7, // Back to larger middle portion (70% of screen)
zIndex: 10, // Higher z-index to capture gestures zIndex: 10, // Higher z-index to capture gestures
}} /> }} />
</TapGestureHandler> </TapGestureHandler>
@ -2581,10 +2655,10 @@ const KSPlayerCore: React.FC = () => {
> >
<View style={{ <View style={{
position: 'absolute', position: 'absolute',
top: screenDimensions.height * 0.15, // Back to original margin top: getDimensions().height * 0.15, // Back to original margin
right: 0, right: 0,
width: screenDimensions.width * 0.4, // Back to larger area (40% of screen) width: getDimensions().width * 0.4, // Back to larger area (40% of screen)
height: screenDimensions.height * 0.7, // Back to larger middle portion (70% of screen) height: getDimensions().height * 0.7, // Back to larger middle portion (70% of screen)
zIndex: 10, // Higher z-index to capture gestures zIndex: 10, // Higher z-index to capture gestures
}} /> }} />
</TapGestureHandler> </TapGestureHandler>
@ -2613,18 +2687,18 @@ const KSPlayerCore: React.FC = () => {
> >
<View style={{ <View style={{
position: 'absolute', position: 'absolute',
top: screenDimensions.height * 0.15, top: getDimensions().height * 0.15,
left: screenDimensions.width * 0.4, // Start after left gesture area left: getDimensions().width * 0.4, // Start after left gesture area
width: screenDimensions.width * 0.2, // Center area (20% of screen) width: getDimensions().width * 0.2, // Center area (20% of screen)
height: screenDimensions.height * 0.7, height: getDimensions().height * 0.7,
zIndex: 5, // Lower z-index, controls use box-none to allow touches through zIndex: 5, // Lower z-index, controls use box-none to allow touches through
}} /> }} />
</TapGestureHandler> </TapGestureHandler>
<View <View
style={[styles.videoContainer, { style={[styles.videoContainer, {
width: screenDimensions.width, width: shouldUseFullscreen ? '100%' : screenDimensions.width,
height: screenDimensions.height, height: shouldUseFullscreen ? '100%' : screenDimensions.height,
}]} }]}
> >
@ -2637,8 +2711,8 @@ const KSPlayerCore: React.FC = () => {
position: 'absolute', position: 'absolute',
top: 0, top: 0,
left: 0, left: 0,
width: screenDimensions.width, width: getDimensions().width,
height: screenDimensions.height, height: getDimensions().height,
}}> }}>
<TouchableOpacity <TouchableOpacity
style={{ flex: 1 }} style={{ flex: 1 }}
@ -2649,7 +2723,7 @@ const KSPlayerCore: React.FC = () => {
> >
<KSPlayerComponent <KSPlayerComponent
ref={ksPlayerRef} ref={ksPlayerRef}
style={[styles.video, customVideoStyles, { transform: [{ scale: zoomScale }] }]} style={styles.video}
source={{ source={{
uri: currentStreamUrl, uri: currentStreamUrl,
headers: headers && Object.keys(headers).length > 0 ? headers : undefined headers: headers && Object.keys(headers).length > 0 ? headers : undefined
@ -2658,6 +2732,11 @@ const KSPlayerCore: React.FC = () => {
volume={volume / 100} volume={volume / 100}
audioTrack={selectedAudioTrack ?? undefined} audioTrack={selectedAudioTrack ?? undefined}
textTrack={useCustomSubtitles ? -1 : selectedTextTrack} textTrack={useCustomSubtitles ? -1 : selectedTextTrack}
allowsExternalPlayback={allowsAirPlay}
usesExternalPlaybackWhileExternalScreenIsActive={true}
subtitleBottomOffset={subtitleBottomOffset}
subtitleFontSize={subtitleSize}
resizeMode={resizeMode === 'none' ? 'contain' : resizeMode}
onProgress={handleProgress} onProgress={handleProgress}
onLoad={onLoad} onLoad={onLoad}
onEnd={onEnd} onEnd={onEnd}
@ -2690,6 +2769,7 @@ const KSPlayerCore: React.FC = () => {
skip={skip} skip={skip}
handleClose={handleClose} handleClose={handleClose}
cycleAspectRatio={cycleAspectRatio} cycleAspectRatio={cycleAspectRatio}
currentResizeMode={resizeMode}
setShowAudioModal={setShowAudioModal} setShowAudioModal={setShowAudioModal}
setShowSubtitleModal={setShowSubtitleModal} setShowSubtitleModal={setShowSubtitleModal}
isSubtitleModalOpen={showSubtitleModal} isSubtitleModalOpen={showSubtitleModal}
@ -2699,8 +2779,12 @@ const KSPlayerCore: React.FC = () => {
onSlidingComplete={handleSlidingComplete} onSlidingComplete={handleSlidingComplete}
buffered={buffered} buffered={buffered}
formatTime={formatTime} formatTime={formatTime}
playerBackend={playerBackend}
cyclePlaybackSpeed={cyclePlaybackSpeed} cyclePlaybackSpeed={cyclePlaybackSpeed}
currentPlaybackSpeed={playbackSpeed} currentPlaybackSpeed={playbackSpeed}
isAirPlayActive={isAirPlayActive}
allowsAirPlay={allowsAirPlay}
onAirPlayPress={handleAirPlayPress}
/> />
{showPauseOverlay && ( {showPauseOverlay && (
@ -2727,7 +2811,7 @@ const KSPlayerCore: React.FC = () => {
}} }}
> >
{/* Strong horizontal fade from left side */} {/* Strong horizontal fade from left side */}
<View style={{ position: 'absolute', top: 0, left: 0, bottom: 0, width: screenDimensions.width * 0.7 }}> <View style={{ position: 'absolute', top: 0, left: 0, bottom: 0, width: getDimensions().width * 0.7 }}>
<LinearGradient <LinearGradient
start={{ x: 0, y: 0.5 }} start={{ x: 0, y: 0.5 }}
end={{ x: 1, y: 0.5 }} end={{ x: 1, y: 0.5 }}
@ -3085,8 +3169,8 @@ const KSPlayerCore: React.FC = () => {
<Animated.View <Animated.View
style={{ style={{
position: 'absolute', position: 'absolute',
left: screenDimensions.width / 2 - 60, left: getDimensions().width / 2 - 60,
top: screenDimensions.height / 2 - 60, top: getDimensions().height / 2 - 60,
opacity: volumeOverlayOpacity, opacity: volumeOverlayOpacity,
zIndex: 1000, zIndex: 1000,
}} }}
@ -3182,8 +3266,8 @@ const KSPlayerCore: React.FC = () => {
<Animated.View <Animated.View
style={{ style={{
position: 'absolute', position: 'absolute',
left: screenDimensions.width / 2 - 60, left: getDimensions().width / 2 - 60,
top: screenDimensions.height / 2 - 60, top: getDimensions().height / 2 - 60,
opacity: brightnessOverlayOpacity, opacity: brightnessOverlayOpacity,
zIndex: 1000, zIndex: 1000,
}} }}

View file

@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import { View, Text, TouchableOpacity, Animated, StyleSheet, Platform, Dimensions } from 'react-native'; import { View, Text, TouchableOpacity, Animated, StyleSheet, Platform, Dimensions } from 'react-native';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import Feather from 'react-native-vector-icons/Feather';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import Slider from '@react-native-community/slider'; import Slider from '@react-native-community/slider';
import { styles } from '../utils/playerStyles'; import { styles } from '../utils/playerStyles';
@ -43,6 +44,10 @@ interface PlayerControlsProps {
buffered: number; buffered: number;
formatTime: (seconds: number) => string; formatTime: (seconds: number) => string;
playerBackend?: string; playerBackend?: string;
// AirPlay props
isAirPlayActive?: boolean;
allowsAirPlay?: boolean;
onAirPlayPress?: () => void;
} }
export const PlayerControls: React.FC<PlayerControlsProps> = ({ export const PlayerControls: React.FC<PlayerControlsProps> = ({
@ -80,20 +85,42 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
buffered, buffered,
formatTime, formatTime,
playerBackend, playerBackend,
isAirPlayActive,
allowsAirPlay,
onAirPlayPress,
}) => { }) => {
const { currentTheme } = useTheme(); const { currentTheme } = useTheme();
/* Responsive Spacing - Merged with tablet support */
/* Responsive Spacing */
const screenWidth = Dimensions.get('window').width; const screenWidth = Dimensions.get('window').width;
const buttonSpacing = screenWidth * 0.15; const BREAKPOINTS = { phone: 0, tablet: 768, largeTablet: 1024, tv: 1440 } as const;
const getDeviceType = (w: number) => {
if (w >= BREAKPOINTS.tv) return 'tv';
if (w >= BREAKPOINTS.largeTablet) return 'largeTablet';
if (w >= BREAKPOINTS.tablet) return 'tablet';
return 'phone';
};
const deviceType = getDeviceType(screenWidth);
const isTablet = deviceType === 'tablet';
const isLargeTablet = deviceType === 'largeTablet';
const isTV = deviceType === 'tv';
const playButtonSize = screenWidth * 0.12; // 12% of screen width // Responsive button sizing - combines percentage-based with breakpoint scaling
const playIconSize = playButtonSize * 0.6; // 60% of button size const baseButtonSpacing = screenWidth * 0.15;
const seekButtonSize = screenWidth * 0.11; // 11% of screen width const buttonSpacing = isTV ? baseButtonSpacing * 1.2 : isLargeTablet ? baseButtonSpacing * 1.1 : isTablet ? baseButtonSpacing : baseButtonSpacing * 0.9;
const seekIconSize = seekButtonSize * 0.75; // 75% of button size
const seekNumberSize = seekButtonSize * 0.25; // 25% of button size const basePlayButtonSize = screenWidth * 0.12;
const arcBorderWidth = seekButtonSize * 0.05; // 5% of button size const playButtonSize = isTV ? basePlayButtonSize * 1.2 : isLargeTablet ? basePlayButtonSize * 1.1 : isTablet ? basePlayButtonSize : basePlayButtonSize * 0.9;
const playIconSize = playButtonSize * 0.6;
const baseSeekButtonSize = screenWidth * 0.11;
const seekButtonSize = isTV ? baseSeekButtonSize * 1.2 : isLargeTablet ? baseSeekButtonSize * 1.1 : isTablet ? baseSeekButtonSize : baseSeekButtonSize * 0.9;
const seekIconSize = seekButtonSize * 0.75;
const seekNumberSize = seekButtonSize * 0.25;
const arcBorderWidth = seekButtonSize * 0.05;
// Icon sizes for other controls
const closeIconSize = isTV ? 28 : isLargeTablet ? 26 : isTablet ? 24 : 24;
/* Animations - State & Refs */ /* Animations - State & Refs */
const [showBackwardSign, setShowBackwardSign] = React.useState(false); const [showBackwardSign, setShowBackwardSign] = React.useState(false);
@ -228,10 +255,6 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
togglePlayback(); togglePlayback();
}; };
return ( return (
<Animated.View <Animated.View
style={[StyleSheet.absoluteFill, { opacity: fadeAnim, zIndex: 20 }]} style={[StyleSheet.absoluteFill, { opacity: fadeAnim, zIndex: 20 }]}
@ -291,13 +314,12 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
)} )}
</View> </View>
<TouchableOpacity style={styles.closeButton} onPress={handleClose}> <TouchableOpacity style={styles.closeButton} onPress={handleClose}>
<Ionicons name="close" size={24} color="white" /> <Ionicons name="close" size={closeIconSize} color="white" />
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</LinearGradient> </LinearGradient>
{/* Center Controls - CloudStream Style with Responsive Sizing */}
{/* Center Controls - CloudStream Style */}
<View style={[styles.controls, { <View style={[styles.controls, {
transform: [{ translateY: -(playButtonSize / 2) }] transform: [{ translateY: -(playButtonSize / 2) }]
}]}> }]}>
@ -548,6 +570,26 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
)} )}
{/* AirPlay Button - iOS only, KSAVPlayer only */}
{Platform.OS === 'ios' && onAirPlayPress && playerBackend === 'KSAVPlayer' && (
<TouchableOpacity
style={styles.bottomButton}
onPress={onAirPlayPress}
>
<Feather
name="airplay"
size={20}
color={isAirPlayActive ? currentTheme.colors.primary : "white"}
/>
<Text style={[
styles.bottomButtonText,
isAirPlayActive && { color: currentTheme.colors.primary }
]}>
{allowsAirPlay ? 'AirPlay' : 'AirPlay Off'}
</Text>
</TouchableOpacity>
)}
</View> </View>
</View> </View>
</LinearGradient> </LinearGradient>

View file

@ -306,47 +306,75 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
Built-in Subtitles Built-in Subtitles
</Text> </Text>
{/* Notice about built-in subtitle limitations - only when KSPlayer active on iOS */} {/* Built-in subtitles now enabled for KSPlayer */}
{isIos && isKsPlayerActive && ( {isKsPlayerActive && (
<View style={{ <View style={{
backgroundColor: 'rgba(255, 193, 7, 0.1)', backgroundColor: 'rgba(34, 197, 94, 0.1)',
borderRadius: 12, borderRadius: 12,
padding: sectionPad, padding: sectionPad,
marginBottom: 15, marginBottom: 15,
borderWidth: 1, borderWidth: 1,
borderColor: 'rgba(255, 193, 7, 0.3)', borderColor: 'rgba(34, 197, 94, 0.3)',
}}> }}>
<View style={{ flexDirection: 'row', alignItems: 'flex-start', gap: 8 }}> <View style={{ flexDirection: 'row', alignItems: 'flex-start', gap: 8 }}>
<MaterialIcons name="info" size={18} color="#FFC107" /> <MaterialIcons name="check-circle" size={18} color="#22C55E" />
<View style={{ flex: 1 }}> <View style={{ flex: 1 }}>
<Text style={{ <Text style={{
color: '#FFC107', color: '#22C55E',
fontSize: isCompact ? 12 : 13, fontSize: isCompact ? 12 : 13,
fontWeight: '600', fontWeight: '600',
marginBottom: 4, marginBottom: 4,
}}> }}>
Built-in subtitles temporarily disabled Built-in subtitles enabled for KSPlayer
</Text> </Text>
<Text style={{ <Text style={{
color: 'rgba(255, 255, 255, 0.8)', color: 'rgba(255, 255, 255, 0.8)',
fontSize: isCompact ? 11 : 12, fontSize: isCompact ? 11 : 12,
lineHeight: isCompact ? 16 : 18, lineHeight: isCompact ? 16 : 18,
}}> }}>
Due to some React Native limitations with KSPlayer, built-in subtitle rendering is temporarily disabled. Please use external subtitles instead for the best experience. KSPlayer built-in subtitle rendering is now available. You can select from embedded subtitle tracks below.
</Text> </Text>
</View> </View>
</View> </View>
</View> </View>
)} )}
{(!isIos || (isIos && !isKsPlayerActive)) && ( {/* Disable Subtitles Button */}
<TouchableOpacity
style={{
backgroundColor: selectedTextTrack === -1 ? 'rgba(239, 68, 68, 0.15)' : 'rgba(255, 255, 255, 0.05)',
borderRadius: 16,
padding: sectionPad,
borderWidth: 1,
borderColor: selectedTextTrack === -1 ? 'rgba(239, 68, 68, 0.3)' : 'rgba(255, 255, 255, 0.1)',
marginBottom: 8,
}}
onPress={() => {
selectTextTrack(-1);
setSelectedOnlineSubtitleId(null);
}}
activeOpacity={0.7}
>
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<Text style={{
color: selectedTextTrack === -1 ? '#EF4444' : '#FFFFFF',
fontSize: isCompact ? 14 : 15,
fontWeight: '500',
flex: 1,
}}>
Disable All Subtitles
</Text>
{selectedTextTrack === -1 && (
<MaterialIcons name="check" size={20} color="#EF4444" />
)}
</View>
</TouchableOpacity>
{/* Always show built-in subtitles */}
{ksTextTracks.length > 0 && (
<View style={{ gap: 8 }}> <View style={{ gap: 8 }}>
{ksTextTracks.map((track) => { {ksTextTracks.map((track) => {
const isSelected = selectedTextTrack === track.id && !useCustomSubtitles; const isSelected = selectedTextTrack === track.id && !useCustomSubtitles;
// Debug logging for subtitle selection
if (__DEV__ && ksTextTracks.length > 0) {
console.log('[SubtitleModals] Track:', track.id, track.name, 'Selected:', selectedTextTrack, 'isSelected:', isSelected, 'useCustom:', useCustomSubtitles);
}
return ( return (
<TouchableOpacity <TouchableOpacity
key={track.id} key={track.id}

View file

@ -1,4 +1,38 @@
import { StyleSheet } from 'react-native'; import { StyleSheet, Dimensions } from 'react-native';
const deviceWidth = Dimensions.get('window').width;
const BREAKPOINTS = { phone: 0, tablet: 768, largeTablet: 1024, tv: 1440 } as const;
const getDeviceType = (w: number) => {
if (w >= BREAKPOINTS.tv) return 'tv';
if (w >= BREAKPOINTS.largeTablet) return 'largeTablet';
if (w >= BREAKPOINTS.tablet) return 'tablet';
return 'phone';
};
const deviceType = getDeviceType(deviceWidth);
const isTablet = deviceType === 'tablet';
const isLargeTablet = deviceType === 'largeTablet';
const isTV = deviceType === 'tv';
// Scales for larger displays
const padH = isTV ? 28 : isLargeTablet ? 24 : isTablet ? 20 : 20;
const padV = isTV ? 24 : isLargeTablet ? 20 : isTablet ? 16 : 16;
const titleFont = isTV ? 28 : isLargeTablet ? 24 : isTablet ? 22 : 18;
const episodeInfoFont = isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 14;
const metadataFont = isTV ? 14 : isLargeTablet ? 13 : isTablet ? 12 : 12;
const qualityPadH = isTV ? 10 : isLargeTablet ? 9 : isTablet ? 8 : 8;
const qualityPadV = isTV ? 4 : isLargeTablet ? 3 : isTablet ? 3 : 2;
const qualityRadius = isTV ? 6 : isLargeTablet ? 5 : isTablet ? 4 : 4;
const qualityTextFont = isTV ? 13 : isLargeTablet ? 12 : isTablet ? 11 : 11;
const controlsGap = isTV ? 56 : isLargeTablet ? 48 : isTablet ? 44 : 40;
const controlsTranslateY = isTV ? -48 : isLargeTablet ? -42 : isTablet ? -36 : -30;
const skipTextFont = isTV ? 14 : isLargeTablet ? 13 : isTablet ? 12 : 12;
const sliderBottom = isTV ? 80 : isLargeTablet ? 70 : isTablet ? 65 : 55;
const progressTouchHeight = isTV ? 48 : isLargeTablet ? 44 : isTablet ? 40 : 40;
const progressBarHeight = isTV ? 6 : isLargeTablet ? 5 : isTablet ? 5 : 4;
const progressThumbSize = isTV ? 24 : isLargeTablet ? 20 : isTablet ? 18 : 16;
const progressThumbTop = isTV ? -10 : isLargeTablet ? -8 : isTablet ? -7 : -6;
const durationFont = isTV ? 14 : isLargeTablet ? 13 : isTablet ? 12 : 12;
const bottomButtonTextFont = isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 12;
export const styles = StyleSheet.create({ export const styles = StyleSheet.create({
container: { container: {
@ -37,14 +71,14 @@ export const styles = StyleSheet.create({
padding: 0, padding: 0,
}, },
topGradient: { topGradient: {
paddingTop: 20, paddingTop: padV,
paddingHorizontal: 20, paddingHorizontal: padH,
paddingBottom: 10, paddingBottom: Math.max(10, Math.round(padV * 0.6)),
}, },
bottomGradient: { bottomGradient: {
paddingBottom: 20, paddingBottom: padV,
paddingHorizontal: 20, paddingHorizontal: padH,
paddingTop: 20, paddingTop: padV,
}, },
header: { header: {
flexDirection: 'row', flexDirection: 'row',
@ -57,12 +91,12 @@ export const styles = StyleSheet.create({
}, },
title: { title: {
color: 'white', color: 'white',
fontSize: 18, fontSize: titleFont,
fontWeight: 'bold', fontWeight: 'bold',
}, },
episodeInfo: { episodeInfo: {
color: 'rgba(255, 255, 255, 0.9)', color: 'rgba(255, 255, 255, 0.9)',
fontSize: 14, fontSize: episodeInfoFont,
marginTop: 3, marginTop: 3,
}, },
metadataRow: { metadataRow: {
@ -73,20 +107,20 @@ export const styles = StyleSheet.create({
}, },
metadataText: { metadataText: {
color: 'rgba(255, 255, 255, 0.7)', color: 'rgba(255, 255, 255, 0.7)',
fontSize: 12, fontSize: metadataFont,
marginRight: 8, marginRight: 8,
}, },
qualityBadge: { qualityBadge: {
backgroundColor: 'rgba(229, 9, 20, 0.2)', backgroundColor: 'rgba(229, 9, 20, 0.2)',
paddingHorizontal: 8, paddingHorizontal: qualityPadH,
paddingVertical: 2, paddingVertical: qualityPadV,
borderRadius: 4, borderRadius: qualityRadius,
marginRight: 8, marginRight: 8,
marginBottom: 4, marginBottom: 4,
}, },
qualityText: { qualityText: {
color: '#E50914', color: '#E50914',
fontSize: 11, fontSize: qualityTextFont,
fontWeight: 'bold', fontWeight: 'bold',
}, },
providerText: { providerText: {
@ -154,6 +188,11 @@ export const styles = StyleSheet.create({
justifyContent: 'center', justifyContent: 'center',
position: 'relative', position: 'relative',
}, },
skipText: {
color: 'white',
fontSize: skipTextFont,
marginTop: 2,
},
playIcon: { playIcon: {
color: '#FFFFFF', color: '#FFFFFF',
opacity: 1, opacity: 1,
@ -198,19 +237,19 @@ export const styles = StyleSheet.create({
}, },
sliderContainer: { sliderContainer: {
position: 'absolute', position: 'absolute',
bottom: 55, bottom: sliderBottom,
left: 0, left: 0,
right: 0, right: 0,
paddingHorizontal: 20, paddingHorizontal: padH,
zIndex: 1000, zIndex: 1000,
}, },
progressTouchArea: { progressTouchArea: {
height: 40, // Increased from 30 to give more space for the thumb height: progressTouchHeight, // Increased touch area for larger displays
justifyContent: 'center', justifyContent: 'center',
width: '100%', width: '100%',
}, },
progressBarContainer: { progressBarContainer: {
height: 4, height: progressBarHeight,
backgroundColor: 'rgba(255, 255, 255, 0.2)', backgroundColor: 'rgba(255, 255, 255, 0.2)',
borderRadius: 2, borderRadius: 2,
overflow: 'hidden', overflow: 'hidden',
@ -234,12 +273,12 @@ export const styles = StyleSheet.create({
}, },
progressThumb: { progressThumb: {
position: 'absolute', position: 'absolute',
width: 16, width: progressThumbSize,
height: 16, height: progressThumbSize,
borderRadius: 8, borderRadius: progressThumbSize / 2,
backgroundColor: '#E50914', backgroundColor: '#E50914',
top: -6, // Position to center on the progress bar top: progressThumbTop, // Position to center on the progress bar
marginLeft: -8, // Center the thumb horizontally marginLeft: -(progressThumbSize / 2), // Center the thumb horizontally
shadowColor: '#000', shadowColor: '#000',
shadowOffset: { width: 0, height: 2 }, shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.3, shadowOpacity: 0.3,
@ -257,7 +296,7 @@ export const styles = StyleSheet.create({
}, },
duration: { duration: {
color: 'white', color: 'white',
fontSize: 12, fontSize: durationFont,
fontWeight: '500', fontWeight: '500',
}, },
bottomButtons: { bottomButtons: {
@ -272,7 +311,7 @@ export const styles = StyleSheet.create({
}, },
bottomButtonText: { bottomButtonText: {
color: 'white', color: 'white',
fontSize: 12, fontSize: bottomButtonTextFont,
}, },
modalOverlay: { modalOverlay: {
flex: 1, flex: 1,

View file

@ -33,3 +33,4 @@ const styles = StyleSheet.create({
}); });
export default ToastManager; export default ToastManager;

View file

@ -69,3 +69,4 @@ export const ToastProvider: React.FC<ToastProviderProps> = ({ children }) => {
</ToastContext.Provider> </ToastContext.Provider>
); );
}; };

View file

@ -58,12 +58,13 @@ const SPACING = {
const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0; const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0;
// Dynamic column calculation based on screen width // Dynamic column and spacing calculation based on screen width
const calculateCatalogLayout = (screenWidth: number) => { const calculateCatalogLayout = (screenWidth: number) => {
const MIN_ITEM_WIDTH = 120; const MIN_ITEM_WIDTH = 120;
const MAX_ITEM_WIDTH = 180; // Increased for tablets const MAX_ITEM_WIDTH = 180; // Increased for tablets
const HORIZONTAL_PADDING = SPACING.lg * 2; // Increase padding and spacing on larger screens for proper breathing room
const ITEM_SPACING = SPACING.sm; const HORIZONTAL_PADDING = screenWidth >= 1600 ? SPACING.xl * 4 : screenWidth >= 1200 ? SPACING.xl * 3 : screenWidth >= 1000 ? SPACING.xl * 2 : SPACING.lg * 2;
const ITEM_SPACING = screenWidth >= 1600 ? SPACING.xl : screenWidth >= 1200 ? SPACING.lg : screenWidth >= 1000 ? SPACING.md : SPACING.sm;
// Calculate how many columns can fit // Calculate how many columns can fit
const availableWidth = screenWidth - HORIZONTAL_PADDING; const availableWidth = screenWidth - HORIZONTAL_PADDING;
@ -80,9 +81,12 @@ const calculateCatalogLayout = (screenWidth: number) => {
} else if (screenWidth < 1200) { } else if (screenWidth < 1200) {
// Large tablet: 4-6 columns // Large tablet: 4-6 columns
numColumns = Math.min(Math.max(maxColumns, 4), 6); numColumns = Math.min(Math.max(maxColumns, 4), 6);
} else { } else if (screenWidth < 1600) {
// Very large screens: 5-8 columns // Desktop-ish: 5-8 columns
numColumns = Math.min(Math.max(maxColumns, 5), 8); numColumns = Math.min(Math.max(maxColumns, 5), 8);
} else {
// Ultra-wide: 6-10 columns
numColumns = Math.min(Math.max(maxColumns, 6), 10);
} }
// Calculate actual item width with proper spacing // Calculate actual item width with proper spacing
@ -90,11 +94,13 @@ const calculateCatalogLayout = (screenWidth: number) => {
const itemWidth = (availableWidth - totalSpacing) / numColumns; const itemWidth = (availableWidth - totalSpacing) / numColumns;
// Ensure item width doesn't exceed maximum // Ensure item width doesn't exceed maximum
const finalItemWidth = Math.min(itemWidth, MAX_ITEM_WIDTH); const finalItemWidth = Math.floor(Math.min(itemWidth, MAX_ITEM_WIDTH));
return { return {
numColumns, numColumns,
itemWidth: finalItemWidth itemWidth: finalItemWidth,
itemSpacing: ITEM_SPACING,
containerPadding: HORIZONTAL_PADDING / 2, // use half per side for contentContainerStyle padding
}; };
}; };
@ -109,9 +115,6 @@ const createStyles = (colors: any) => StyleSheet.create({
alignItems: 'center', alignItems: 'center',
paddingHorizontal: 16, paddingHorizontal: 16,
paddingTop: Platform.OS === 'android' ? ANDROID_STATUSBAR_HEIGHT + 8 : 8, paddingTop: Platform.OS === 'android' ? ANDROID_STATUSBAR_HEIGHT + 8 : 8,
// Center header on very wide screens
alignSelf: 'center',
maxWidth: 1400,
width: '100%', width: '100%',
}, },
backButton: { backButton: {
@ -131,17 +134,11 @@ const createStyles = (colors: any) => StyleSheet.create({
paddingHorizontal: 16, paddingHorizontal: 16,
paddingBottom: 16, paddingBottom: 16,
paddingTop: 8, paddingTop: 8,
// Center title on very wide screens
alignSelf: 'center',
maxWidth: 1400,
width: '100%', width: '100%',
}, },
list: { list: {
padding: SPACING.lg, padding: SPACING.lg,
paddingTop: SPACING.sm, paddingTop: SPACING.sm,
// Center content on very wide screens
alignSelf: 'center',
maxWidth: 1400, // Prevent content from being too wide on large screens
width: '100%', width: '100%',
}, },
item: { item: {
@ -653,11 +650,12 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
const effectiveItemWidth = React.useMemo(() => { const effectiveItemWidth = React.useMemo(() => {
if (effectiveNumColumns === screenData.numColumns) return screenData.itemWidth; if (effectiveNumColumns === screenData.numColumns) return screenData.itemWidth;
// recompute width for custom columns on mobile to maintain spacing roughly similar // recompute width for custom columns on mobile to maintain spacing roughly similar
const HORIZONTAL_PADDING = 16 * 2; // SPACING.lg * 2 const HORIZONTAL_PADDING = (screenData as any).containerPadding ? (screenData as any).containerPadding * 2 : 16 * 2;
const ITEM_SPACING = 8; // SPACING.sm const ITEM_SPACING = (screenData as any).itemSpacing ?? 8;
const availableWidth = screenData.width - HORIZONTAL_PADDING; const availableWidth = screenData.width - HORIZONTAL_PADDING;
const totalSpacing = ITEM_SPACING * (effectiveNumColumns - 1); const totalSpacing = ITEM_SPACING * (effectiveNumColumns - 1);
return (availableWidth - totalSpacing) / effectiveNumColumns; const width = (availableWidth - totalSpacing) / effectiveNumColumns;
return Math.floor(width);
}, [effectiveNumColumns, screenData.width, screenData.itemWidth]); }, [effectiveNumColumns, screenData.width, screenData.itemWidth]);
// Helper function to optimize poster URLs // Helper function to optimize poster URLs
@ -678,7 +676,7 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
// Calculate if this is the last item in a row // Calculate if this is the last item in a row
const isLastInRow = (index + 1) % effectiveNumColumns === 0; const isLastInRow = (index + 1) % effectiveNumColumns === 0;
// For proper spacing // For proper spacing
const rightMargin = isLastInRow ? 0 : SPACING.sm; const rightMargin = isLastInRow ? 0 : ((screenData as any).itemSpacing ?? SPACING.sm);
return ( return (
<TouchableOpacity <TouchableOpacity
@ -841,6 +839,7 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
keyExtractor={(item) => `${item.id}-${item.type}`} keyExtractor={(item) => `${item.id}-${item.type}`}
numColumns={effectiveNumColumns} numColumns={effectiveNumColumns}
key={effectiveNumColumns} key={effectiveNumColumns}
ItemSeparatorComponent={() => <View style={{ height: ((screenData as any).itemSpacing ?? SPACING.sm) }} />}
refreshControl={ refreshControl={
<RefreshControl <RefreshControl
refreshing={refreshing} refreshing={refreshing}
@ -849,7 +848,7 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
tintColor={colors.primary} tintColor={colors.primary}
/> />
} }
contentContainerStyle={styles.list} contentContainerStyle={[styles.list, { paddingHorizontal: (screenData as any).containerPadding ?? SPACING.lg, paddingTop: SPACING.sm, paddingBottom: SPACING.lg }]}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
removeClippedSubviews={true} removeClippedSubviews={true}
getItemType={() => 'item'} getItemType={() => 'item'}

View file

@ -67,6 +67,14 @@ const MemoizedCastSection = memo(CastSection);
const MemoizedSeriesContent = memo(SeriesContent); const MemoizedSeriesContent = memo(SeriesContent);
const MemoizedMovieContent = memo(MovieContent); const MemoizedMovieContent = memo(MovieContent);
const MemoizedMoreLikeThisSection = memo(MoreLikeThisSection); const MemoizedMoreLikeThisSection = memo(MoreLikeThisSection);
// Enhanced responsive breakpoints for Metadata Screen
const BREAKPOINTS = {
phone: 0,
tablet: 768,
largeTablet: 1024,
tv: 1440,
};
const MemoizedRatingsSection = memo(RatingsSection); const MemoizedRatingsSection = memo(RatingsSection);
const MemoizedCommentsSection = memo(CommentsSection); const MemoizedCommentsSection = memo(CommentsSection);
const MemoizedCastDetailsModal = memo(CastDetailsModal); const MemoizedCastDetailsModal = memo(CastDetailsModal);
@ -90,6 +98,38 @@ const MetadataScreen: React.FC = () => {
// Trakt integration // Trakt integration
const { isAuthenticated, isInWatchlist, isInCollection, addToWatchlist, removeFromWatchlist, addToCollection, removeFromCollection } = useTraktContext(); const { isAuthenticated, isInWatchlist, isInCollection, addToWatchlist, removeFromWatchlist, addToCollection, removeFromCollection } = useTraktContext();
// Enhanced responsive sizing for tablets and TV screens
const deviceWidth = Dimensions.get('window').width;
const deviceHeight = Dimensions.get('window').height;
// Determine device type based on width
const getDeviceType = useCallback(() => {
if (deviceWidth >= BREAKPOINTS.tv) return 'tv';
if (deviceWidth >= BREAKPOINTS.largeTablet) return 'largeTablet';
if (deviceWidth >= BREAKPOINTS.tablet) return 'tablet';
return 'phone';
}, [deviceWidth]);
const deviceType = getDeviceType();
const isTablet = deviceType === 'tablet';
const isLargeTablet = deviceType === 'largeTablet';
const isTV = deviceType === 'tv';
const isLargeScreen = isTablet || isLargeTablet || isTV;
// Enhanced spacing and padding for production sections
const horizontalPadding = useMemo(() => {
switch (deviceType) {
case 'tv':
return 32;
case 'largeTablet':
return 28;
case 'tablet':
return 24;
default:
return 16; // phone
}
}, [deviceType]);
// Optimized state management - reduced state variables // Optimized state management - reduced state variables
const [isContentReady, setIsContentReady] = useState(false); const [isContentReady, setIsContentReady] = useState(false);
const [showCastModal, setShowCastModal] = useState(false); const [showCastModal, setShowCastModal] = useState(false);
@ -965,19 +1005,53 @@ const MetadataScreen: React.FC = () => {
{/* Production info row — shown below description and above cast for series */} {/* Production info row — shown below description and above cast for series */}
{shouldLoadSecondaryData && Object.keys(groupedEpisodes).length > 0 && metadata?.networks && metadata.networks.length > 0 && metadata?.description && ( {shouldLoadSecondaryData && Object.keys(groupedEpisodes).length > 0 && metadata?.networks && metadata.networks.length > 0 && metadata?.description && (
<Animated.View style={[styles.productionContainer, networkSectionAnimatedStyle]}> <Animated.View style={[
<Text style={styles.productionHeader}>Network</Text> styles.productionContainer,
<View style={styles.productionRow}> networkSectionAnimatedStyle,
{ paddingHorizontal: horizontalPadding }
]}>
<Text style={[
styles.productionHeader,
{
fontSize: isTV ? 20 : isLargeTablet ? 18 : isTablet ? 17 : 16,
marginBottom: isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 12
}
]}>Network</Text>
<View style={[
styles.productionRow,
{
gap: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8
}
]}>
{metadata.networks.slice(0, 6).map((net) => ( {metadata.networks.slice(0, 6).map((net) => (
<View key={String(net.id || net.name)} style={styles.productionChip}> <View key={String(net.id || net.name)} style={[
styles.productionChip,
{
paddingVertical: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8,
paddingHorizontal: isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 12,
minHeight: isTV ? 48 : isLargeTablet ? 44 : isTablet ? 40 : 36,
borderRadius: isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 12
}
]}>
{net.logo ? ( {net.logo ? (
<FastImage <FastImage
source={{ uri: net.logo }} source={{ uri: net.logo }}
style={styles.productionLogo} style={[
styles.productionLogo,
{
width: isTV ? 80 : isLargeTablet ? 72 : isTablet ? 64 : 64,
height: isTV ? 28 : isLargeTablet ? 26 : isTablet ? 24 : 22
}
]}
resizeMode={FastImage.resizeMode.contain} resizeMode={FastImage.resizeMode.contain}
/> />
) : ( ) : (
<Text style={styles.productionText}>{net.name}</Text> <Text style={[
styles.productionText,
{
fontSize: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 12 : 12
}
]}>{net.name}</Text>
)} )}
</View> </View>
))} ))}
@ -1001,17 +1075,46 @@ const MetadataScreen: React.FC = () => {
metadata?.networks && Array.isArray(metadata.networks) && metadata?.networks && Array.isArray(metadata.networks) &&
metadata.networks.some((n: any) => !!n?.logo) && metadata.networks.some((n: any) => !!n?.logo) &&
metadata?.description && ( metadata?.description && (
<Animated.View style={[styles.productionContainer, productionSectionAnimatedStyle]}> <Animated.View style={[
<Text style={styles.productionHeader}>Production</Text> styles.productionContainer,
<View style={styles.productionRow}> productionSectionAnimatedStyle,
{ paddingHorizontal: horizontalPadding }
]}>
<Text style={[
styles.productionHeader,
{
fontSize: isTV ? 20 : isLargeTablet ? 18 : isTablet ? 17 : 16,
marginBottom: isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 12
}
]}>Production</Text>
<View style={[
styles.productionRow,
{
gap: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8
}
]}>
{metadata.networks {metadata.networks
.filter((net: any) => !!net?.logo) .filter((net: any) => !!net?.logo)
.slice(0, 6) .slice(0, 6)
.map((net: any) => ( .map((net: any) => (
<View key={String(net.id || net.name)} style={styles.productionChip}> <View key={String(net.id || net.name)} style={[
styles.productionChip,
{
paddingVertical: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8,
paddingHorizontal: isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 12,
minHeight: isTV ? 48 : isLargeTablet ? 44 : isTablet ? 40 : 36,
borderRadius: isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 12
}
]}>
<FastImage <FastImage
source={{ uri: net.logo }} source={{ uri: net.logo }}
style={styles.productionLogo} style={[
styles.productionLogo,
{
width: isTV ? 80 : isLargeTablet ? 72 : isTablet ? 64 : 64,
height: isTV ? 28 : isLargeTablet ? 26 : isTablet ? 24 : 22
}
]}
resizeMode={FastImage.resizeMode.contain} resizeMode={FastImage.resizeMode.contain}
/> />
</View> </View>
@ -1041,29 +1144,38 @@ const MetadataScreen: React.FC = () => {
{/* Movie Details section - shown above recommendations for movies when TMDB enrichment is ON */} {/* Movie Details section - shown above recommendations for movies when TMDB enrichment is ON */}
{shouldLoadSecondaryData && Object.keys(groupedEpisodes).length === 0 && metadata?.movieDetails && ( {shouldLoadSecondaryData && Object.keys(groupedEpisodes).length === 0 && metadata?.movieDetails && (
<View style={styles.tvDetailsContainer}> <View style={[
<Text style={styles.tvDetailsHeader}>Movie Details</Text> styles.tvDetailsContainer,
{ paddingHorizontal: horizontalPadding }
]}>
<Text style={[
styles.tvDetailsHeader,
{
fontSize: isTV ? 20 : isLargeTablet ? 18 : isTablet ? 17 : 16,
marginBottom: isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 12
}
]}>Movie Details</Text>
{metadata.movieDetails.tagline && ( {metadata.movieDetails.tagline && (
<View style={styles.tvDetailRow}> <View style={[styles.tvDetailRow, { paddingVertical: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8 }]}>
<Text style={styles.tvDetailLabel}>Tagline</Text> <Text style={[styles.tvDetailLabel, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>Tagline</Text>
<Text style={[styles.tvDetailValue, { fontStyle: 'italic' }]}> <Text style={[styles.tvDetailValue, { fontStyle: 'italic', fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>
"{metadata.movieDetails.tagline}" "{metadata.movieDetails.tagline}"
</Text> </Text>
</View> </View>
)} )}
{metadata.movieDetails.status && ( {metadata.movieDetails.status && (
<View style={styles.tvDetailRow}> <View style={[styles.tvDetailRow, { paddingVertical: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8 }]}>
<Text style={styles.tvDetailLabel}>Status</Text> <Text style={[styles.tvDetailLabel, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>Status</Text>
<Text style={styles.tvDetailValue}>{metadata.movieDetails.status}</Text> <Text style={[styles.tvDetailValue, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>{metadata.movieDetails.status}</Text>
</View> </View>
)} )}
{metadata.movieDetails.releaseDate && ( {metadata.movieDetails.releaseDate && (
<View style={styles.tvDetailRow}> <View style={[styles.tvDetailRow, { paddingVertical: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8 }]}>
<Text style={styles.tvDetailLabel}>Release Date</Text> <Text style={[styles.tvDetailLabel, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>Release Date</Text>
<Text style={styles.tvDetailValue}> <Text style={[styles.tvDetailValue, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>
{new Date(metadata.movieDetails.releaseDate).toLocaleDateString('en-US', { {new Date(metadata.movieDetails.releaseDate).toLocaleDateString('en-US', {
year: 'numeric', year: 'numeric',
month: 'long', month: 'long',
@ -1074,43 +1186,43 @@ const MetadataScreen: React.FC = () => {
)} )}
{metadata.movieDetails.runtime && ( {metadata.movieDetails.runtime && (
<View style={styles.tvDetailRow}> <View style={[styles.tvDetailRow, { paddingVertical: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8 }]}>
<Text style={styles.tvDetailLabel}>Runtime</Text> <Text style={[styles.tvDetailLabel, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>Runtime</Text>
<Text style={styles.tvDetailValue}> <Text style={[styles.tvDetailValue, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>
{Math.floor(metadata.movieDetails.runtime / 60)}h {metadata.movieDetails.runtime % 60}m {Math.floor(metadata.movieDetails.runtime / 60)}h {metadata.movieDetails.runtime % 60}m
</Text> </Text>
</View> </View>
)} )}
{metadata.movieDetails.budget && metadata.movieDetails.budget > 0 && ( {metadata.movieDetails.budget && metadata.movieDetails.budget > 0 && (
<View style={styles.tvDetailRow}> <View style={[styles.tvDetailRow, { paddingVertical: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8 }]}>
<Text style={styles.tvDetailLabel}>Budget</Text> <Text style={[styles.tvDetailLabel, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>Budget</Text>
<Text style={styles.tvDetailValue}> <Text style={[styles.tvDetailValue, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>
${metadata.movieDetails.budget.toLocaleString()} ${metadata.movieDetails.budget.toLocaleString()}
</Text> </Text>
</View> </View>
)} )}
{metadata.movieDetails.revenue && metadata.movieDetails.revenue > 0 && ( {metadata.movieDetails.revenue && metadata.movieDetails.revenue > 0 && (
<View style={styles.tvDetailRow}> <View style={[styles.tvDetailRow, { paddingVertical: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8 }]}>
<Text style={styles.tvDetailLabel}>Revenue</Text> <Text style={[styles.tvDetailLabel, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>Revenue</Text>
<Text style={styles.tvDetailValue}> <Text style={[styles.tvDetailValue, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>
${metadata.movieDetails.revenue.toLocaleString()} ${metadata.movieDetails.revenue.toLocaleString()}
</Text> </Text>
</View> </View>
)} )}
{metadata.movieDetails.originCountry && metadata.movieDetails.originCountry.length > 0 && ( {metadata.movieDetails.originCountry && metadata.movieDetails.originCountry.length > 0 && (
<View style={styles.tvDetailRow}> <View style={[styles.tvDetailRow, { paddingVertical: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8 }]}>
<Text style={styles.tvDetailLabel}>Origin Country</Text> <Text style={[styles.tvDetailLabel, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>Origin Country</Text>
<Text style={styles.tvDetailValue}>{metadata.movieDetails.originCountry.join(', ')}</Text> <Text style={[styles.tvDetailValue, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>{metadata.movieDetails.originCountry.join(', ')}</Text>
</View> </View>
)} )}
{metadata.movieDetails.originalLanguage && ( {metadata.movieDetails.originalLanguage && (
<View style={styles.tvDetailRow}> <View style={[styles.tvDetailRow, { paddingVertical: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8 }]}>
<Text style={styles.tvDetailLabel}>Original Language</Text> <Text style={[styles.tvDetailLabel, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>Original Language</Text>
<Text style={styles.tvDetailValue}>{metadata.movieDetails.originalLanguage.toUpperCase()}</Text> <Text style={[styles.tvDetailValue, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>{metadata.movieDetails.originalLanguage.toUpperCase()}</Text>
</View> </View>
)} )}
</View> </View>
@ -1158,20 +1270,29 @@ const MetadataScreen: React.FC = () => {
{/* TV Details section - shown after episodes for series when TMDB enrichment is ON */} {/* TV Details section - shown after episodes for series when TMDB enrichment is ON */}
{shouldLoadSecondaryData && Object.keys(groupedEpisodes).length > 0 && metadata?.tvDetails && ( {shouldLoadSecondaryData && Object.keys(groupedEpisodes).length > 0 && metadata?.tvDetails && (
<View style={styles.tvDetailsContainer}> <View style={[
<Text style={styles.tvDetailsHeader}>Show Details</Text> styles.tvDetailsContainer,
{ paddingHorizontal: horizontalPadding }
]}>
<Text style={[
styles.tvDetailsHeader,
{
fontSize: isTV ? 20 : isLargeTablet ? 18 : isTablet ? 17 : 16,
marginBottom: isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 12
}
]}>Show Details</Text>
{metadata.tvDetails.status && ( {metadata.tvDetails.status && (
<View style={styles.tvDetailRow}> <View style={[styles.tvDetailRow, { paddingVertical: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8 }]}>
<Text style={styles.tvDetailLabel}>Status</Text> <Text style={[styles.tvDetailLabel, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>Status</Text>
<Text style={styles.tvDetailValue}>{metadata.tvDetails.status}</Text> <Text style={[styles.tvDetailValue, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>{metadata.tvDetails.status}</Text>
</View> </View>
)} )}
{metadata.tvDetails.firstAirDate && ( {metadata.tvDetails.firstAirDate && (
<View style={styles.tvDetailRow}> <View style={[styles.tvDetailRow, { paddingVertical: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8 }]}>
<Text style={styles.tvDetailLabel}>First Air Date</Text> <Text style={[styles.tvDetailLabel, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>First Air Date</Text>
<Text style={styles.tvDetailValue}> <Text style={[styles.tvDetailValue, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>
{new Date(metadata.tvDetails.firstAirDate).toLocaleDateString('en-US', { {new Date(metadata.tvDetails.firstAirDate).toLocaleDateString('en-US', {
year: 'numeric', year: 'numeric',
month: 'long', month: 'long',
@ -1182,9 +1303,9 @@ const MetadataScreen: React.FC = () => {
)} )}
{metadata.tvDetails.lastAirDate && ( {metadata.tvDetails.lastAirDate && (
<View style={styles.tvDetailRow}> <View style={[styles.tvDetailRow, { paddingVertical: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8 }]}>
<Text style={styles.tvDetailLabel}>Last Air Date</Text> <Text style={[styles.tvDetailLabel, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>Last Air Date</Text>
<Text style={styles.tvDetailValue}> <Text style={[styles.tvDetailValue, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>
{new Date(metadata.tvDetails.lastAirDate).toLocaleDateString('en-US', { {new Date(metadata.tvDetails.lastAirDate).toLocaleDateString('en-US', {
year: 'numeric', year: 'numeric',
month: 'long', month: 'long',
@ -1195,46 +1316,46 @@ const MetadataScreen: React.FC = () => {
)} )}
{metadata.tvDetails.numberOfSeasons && ( {metadata.tvDetails.numberOfSeasons && (
<View style={styles.tvDetailRow}> <View style={[styles.tvDetailRow, { paddingVertical: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8 }]}>
<Text style={styles.tvDetailLabel}>Seasons</Text> <Text style={[styles.tvDetailLabel, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>Seasons</Text>
<Text style={styles.tvDetailValue}>{metadata.tvDetails.numberOfSeasons}</Text> <Text style={[styles.tvDetailValue, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>{metadata.tvDetails.numberOfSeasons}</Text>
</View> </View>
)} )}
{metadata.tvDetails.numberOfEpisodes && ( {metadata.tvDetails.numberOfEpisodes && (
<View style={styles.tvDetailRow}> <View style={[styles.tvDetailRow, { paddingVertical: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8 }]}>
<Text style={styles.tvDetailLabel}>Total Episodes</Text> <Text style={[styles.tvDetailLabel, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>Total Episodes</Text>
<Text style={styles.tvDetailValue}>{metadata.tvDetails.numberOfEpisodes}</Text> <Text style={[styles.tvDetailValue, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>{metadata.tvDetails.numberOfEpisodes}</Text>
</View> </View>
)} )}
{metadata.tvDetails.episodeRunTime && metadata.tvDetails.episodeRunTime.length > 0 && ( {metadata.tvDetails.episodeRunTime && metadata.tvDetails.episodeRunTime.length > 0 && (
<View style={styles.tvDetailRow}> <View style={[styles.tvDetailRow, { paddingVertical: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8 }]}>
<Text style={styles.tvDetailLabel}>Episode Runtime</Text> <Text style={[styles.tvDetailLabel, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>Episode Runtime</Text>
<Text style={styles.tvDetailValue}> <Text style={[styles.tvDetailValue, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>
{metadata.tvDetails.episodeRunTime.join(' - ')} min {metadata.tvDetails.episodeRunTime.join(' - ')} min
</Text> </Text>
</View> </View>
)} )}
{metadata.tvDetails.originCountry && metadata.tvDetails.originCountry.length > 0 && ( {metadata.tvDetails.originCountry && metadata.tvDetails.originCountry.length > 0 && (
<View style={styles.tvDetailRow}> <View style={[styles.tvDetailRow, { paddingVertical: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8 }]}>
<Text style={styles.tvDetailLabel}>Origin Country</Text> <Text style={[styles.tvDetailLabel, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>Origin Country</Text>
<Text style={styles.tvDetailValue}>{metadata.tvDetails.originCountry.join(', ')}</Text> <Text style={[styles.tvDetailValue, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>{metadata.tvDetails.originCountry.join(', ')}</Text>
</View> </View>
)} )}
{metadata.tvDetails.originalLanguage && ( {metadata.tvDetails.originalLanguage && (
<View style={styles.tvDetailRow}> <View style={[styles.tvDetailRow, { paddingVertical: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8 }]}>
<Text style={styles.tvDetailLabel}>Original Language</Text> <Text style={[styles.tvDetailLabel, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>Original Language</Text>
<Text style={styles.tvDetailValue}>{metadata.tvDetails.originalLanguage.toUpperCase()}</Text> <Text style={[styles.tvDetailValue, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>{metadata.tvDetails.originalLanguage.toUpperCase()}</Text>
</View> </View>
)} )}
{metadata.tvDetails.createdBy && metadata.tvDetails.createdBy.length > 0 && ( {metadata.tvDetails.createdBy && metadata.tvDetails.createdBy.length > 0 && (
<View style={styles.tvDetailRow}> <View style={[styles.tvDetailRow, { paddingVertical: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8 }]}>
<Text style={styles.tvDetailLabel}>Created By</Text> <Text style={[styles.tvDetailLabel, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>Created By</Text>
<Text style={styles.tvDetailValue}> <Text style={[styles.tvDetailValue, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>
{metadata.tvDetails.createdBy.map(creator => creator.name).join(', ')} {metadata.tvDetails.createdBy.map(creator => creator.name).join(', ')}
</Text> </Text>
</View> </View>
@ -1400,7 +1521,6 @@ const styles = StyleSheet.create({
marginBottom: 8, marginBottom: 8,
}, },
productionContainer: { productionContainer: {
paddingHorizontal: 16,
marginTop: 0, marginTop: 0,
marginBottom: 20, marginBottom: 20,
}, },

View file

@ -45,14 +45,33 @@ import { useTheme } from '../contexts/ThemeContext';
import LoadingSpinner from '../components/common/LoadingSpinner'; import LoadingSpinner from '../components/common/LoadingSpinner';
const { width, height } = Dimensions.get('window'); const { width, height } = Dimensions.get('window');
const isTablet = width >= 768;
// Enhanced responsive breakpoints
const BREAKPOINTS = {
phone: 0,
tablet: 768,
largeTablet: 1024,
tv: 1440,
};
const getDeviceType = (deviceWidth: number) => {
if (deviceWidth >= BREAKPOINTS.tv) return 'tv';
if (deviceWidth >= BREAKPOINTS.largeTablet) return 'largeTablet';
if (deviceWidth >= BREAKPOINTS.tablet) return 'tablet';
return 'phone';
};
const deviceType = getDeviceType(width);
const isTablet = deviceType === 'tablet';
const isLargeTablet = deviceType === 'largeTablet';
const isTV = deviceType === 'tv';
const TAB_BAR_HEIGHT = 85; const TAB_BAR_HEIGHT = 85;
// Tablet-optimized poster sizes // Responsive poster sizes
const HORIZONTAL_ITEM_WIDTH = isTablet ? width * 0.18 : width * 0.3; const HORIZONTAL_ITEM_WIDTH = isTV ? width * 0.14 : isLargeTablet ? width * 0.16 : isTablet ? width * 0.18 : width * 0.3;
const HORIZONTAL_POSTER_HEIGHT = HORIZONTAL_ITEM_WIDTH * 1.5; const HORIZONTAL_POSTER_HEIGHT = HORIZONTAL_ITEM_WIDTH * 1.5;
const POSTER_WIDTH = isTablet ? 70 : 90; const POSTER_WIDTH = isTV ? 90 : isLargeTablet ? 80 : isTablet ? 70 : 90;
const POSTER_HEIGHT = isTablet ? 105 : 135; const POSTER_HEIGHT = POSTER_WIDTH * 1.5;
const RECENT_SEARCHES_KEY = 'recent_searches'; const RECENT_SEARCHES_KEY = 'recent_searches';
const MAX_RECENT_SEARCHES = 10; const MAX_RECENT_SEARCHES = 10;
@ -597,13 +616,20 @@ const SearchScreen = () => {
)} )}
</View> </View>
<Text <Text
style={[styles.horizontalItemTitle, { color: currentTheme.colors.white }]} style={[
styles.horizontalItemTitle,
{
color: currentTheme.colors.white,
fontSize: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 12 : 14,
lineHeight: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 18,
}
]}
numberOfLines={2} numberOfLines={2}
> >
{item.name} {item.name}
</Text> </Text>
{item.year && ( {item.year && (
<Text style={[styles.yearText, { color: currentTheme.colors.mediumGray }]}> <Text style={[styles.yearText, { color: currentTheme.colors.mediumGray, fontSize: isTV ? 12 : isLargeTablet ? 11 : isTablet ? 10 : 12 }]}>
{item.year} {item.year}
</Text> </Text>
)} )}
@ -652,8 +678,16 @@ const SearchScreen = () => {
{/* Movies */} {/* Movies */}
{movieResults.length > 0 && ( {movieResults.length > 0 && (
<Animated.View style={styles.carouselContainer} entering={FadeIn.duration(300)}> <Animated.View style={[styles.carouselContainer, { marginBottom: isTV ? 40 : isLargeTablet ? 36 : isTablet ? 32 : 24 }]} entering={FadeIn.duration(300)}>
<Text style={[styles.carouselSubtitle, { color: currentTheme.colors.lightGray }]}> <Text style={[
styles.carouselSubtitle,
{
color: currentTheme.colors.lightGray,
fontSize: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 14,
marginBottom: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 12 : 8,
paddingHorizontal: isTV ? 24 : isLargeTablet ? 20 : isTablet ? 16 : 16
}
]}>
Movies ({movieResults.length}) Movies ({movieResults.length})
</Text> </Text>
<FlatList <FlatList
@ -678,8 +712,16 @@ const SearchScreen = () => {
{/* TV Shows */} {/* TV Shows */}
{seriesResults.length > 0 && ( {seriesResults.length > 0 && (
<Animated.View style={styles.carouselContainer} entering={FadeIn.duration(300)}> <Animated.View style={[styles.carouselContainer, { marginBottom: isTV ? 40 : isLargeTablet ? 36 : isTablet ? 32 : 24 }]} entering={FadeIn.duration(300)}>
<Text style={[styles.carouselSubtitle, { color: currentTheme.colors.lightGray }]}> <Text style={[
styles.carouselSubtitle,
{
color: currentTheme.colors.lightGray,
fontSize: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 14,
marginBottom: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 12 : 8,
paddingHorizontal: isTV ? 24 : isLargeTablet ? 20 : isTablet ? 16 : 16
}
]}>
TV Shows ({seriesResults.length}) TV Shows ({seriesResults.length})
</Text> </Text>
<FlatList <FlatList
@ -704,8 +746,16 @@ const SearchScreen = () => {
{/* Other types */} {/* Other types */}
{otherResults.length > 0 && ( {otherResults.length > 0 && (
<Animated.View style={styles.carouselContainer} entering={FadeIn.duration(300)}> <Animated.View style={[styles.carouselContainer, { marginBottom: isTV ? 40 : isLargeTablet ? 36 : isTablet ? 32 : 24 }]} entering={FadeIn.duration(300)}>
<Text style={[styles.carouselSubtitle, { color: currentTheme.colors.lightGray }]}> <Text style={[
styles.carouselSubtitle,
{
color: currentTheme.colors.lightGray,
fontSize: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 14,
marginBottom: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 12 : 8,
paddingHorizontal: isTV ? 24 : isLargeTablet ? 20 : isTablet ? 16 : 16
}
]}>
{otherResults[0].type.charAt(0).toUpperCase() + otherResults[0].type.slice(1)} ({otherResults.length}) {otherResults[0].type.charAt(0).toUpperCase() + otherResults[0].type.slice(1)} ({otherResults.length})
</Text> </Text>
<FlatList <FlatList
@ -736,7 +786,7 @@ const SearchScreen = () => {
const headerBaseHeight = Platform.OS === 'android' ? 80 : 60; const headerBaseHeight = Platform.OS === 'android' ? 80 : 60;
// Keep header below floating top navigator on tablets by adding extra offset // Keep header below floating top navigator on tablets by adding extra offset
const tabletNavOffset = isTablet ? 64 : 0; const tabletNavOffset = (isTV || isLargeTablet || isTablet) ? 64 : 0;
const topSpacing = (Platform.OS === 'android' ? (StatusBar.currentHeight || 0) : insets.top) + tabletNavOffset; const topSpacing = (Platform.OS === 'android' ? (StatusBar.currentHeight || 0) : insets.top) + tabletNavOffset;
const headerHeight = headerBaseHeight + topSpacing + 60; const headerHeight = headerBaseHeight + topSpacing + 60;

View file

@ -339,9 +339,11 @@ class StremioService {
} }
} }
// Ensure Cinemeta is always installed as a pre-installed addon // Install Cinemeta for new users, but allow existing users to uninstall it
const cinemetaId = 'com.linvo.cinemeta'; const cinemetaId = 'com.linvo.cinemeta';
if (!this.installedAddons.has(cinemetaId)) { const hasUserRemovedCinemeta = await this.hasUserRemovedAddon(cinemetaId);
if (!this.installedAddons.has(cinemetaId) && !hasUserRemovedCinemeta) {
try { try {
const cinemetaManifest = await this.getManifest('https://v3-cinemeta.strem.io/manifest.json'); const cinemetaManifest = await this.getManifest('https://v3-cinemeta.strem.io/manifest.json');
this.installedAddons.set(cinemetaId, cinemetaManifest); this.installedAddons.set(cinemetaId, cinemetaManifest);
@ -432,8 +434,9 @@ class StremioService {
this.addonOrder = this.addonOrder.filter(id => this.installedAddons.has(id)); this.addonOrder = this.addonOrder.filter(id => this.installedAddons.has(id));
} }
// Ensure required pre-installed addons are present without forcing their position // Add Cinemeta to order only if user hasn't removed it
if (!this.addonOrder.includes(cinemetaId) && this.installedAddons.has(cinemetaId)) { const hasUserRemovedCinemetaOrder = await this.hasUserRemovedAddon(cinemetaId);
if (!this.addonOrder.includes(cinemetaId) && this.installedAddons.has(cinemetaId) && !hasUserRemovedCinemetaOrder) {
this.addonOrder.push(cinemetaId); this.addonOrder.push(cinemetaId);
} }

View file

@ -150,3 +150,4 @@ class ToastService {
export const toastService = ToastService.getInstance(); export const toastService = ToastService.getInstance();
export default toastService; export default toastService;

View file

@ -1,514 +0,0 @@
Scrobble / Start / Start watching in a media center POSThttps://api.trakt.tv/scrobble/startRequestStart watching a movie by sending a standard movie object.
HEADERS
Content-Type:application/json
Authorization:Bearer [access_token]
trakt-api-version:2
trakt-api-key:[client_id]
BODY
{
"movie": {
"title": "Guardians of the Galaxy",
"year": 2014,
"ids": {
"trakt": 28,
"slug": "guardians-of-the-galaxy-2014",
"imdb": "tt2015381",
"tmdb": 118340
}
},
"progress": 1.25
}
Response
201
HEADERS
Content-Type:application/json
BODY
{
"id": 0,
"action": "start",
"progress": 1.25,
"sharing": {
"twitter": true,
"mastodon": true,
"tumblr": false
},
"movie": {
"title": "Guardians of the Galaxy",
"year": 2014,
"ids": {
"trakt": 28,
"slug": "guardians-of-the-galaxy-2014",
"imdb": "tt2015381",
"tmdb": 118340
}
}
}
RequestStart watching an episode by sending a standard episode object.
HEADERS
Content-Type:application/json
Authorization:Bearer [access_token]
trakt-api-version:2
trakt-api-key:[client_id]
BODY
{
"episode": {
"ids": {
"trakt": 16
}
},
"progress": 10
}
Response
201
HEADERS
Content-Type:application/json
BODY
{
"id": 0,
"action": "start",
"progress": 10,
"sharing": {
"twitter": true,
"mastodon": true,
"tumblr": false
},
"episode": {
"season": 1,
"number": 1,
"title": "Pilot",
"ids": {
"trakt": 16,
"tvdb": 349232,
"imdb": "tt0959621",
"tmdb": 62085
}
},
"show": {
"title": "Breaking Bad",
"year": 2008,
"ids": {
"trakt": 1,
"slug": "breaking-bad",
"tvdb": 81189,
"imdb": "tt0903747",
"tmdb": 1396
}
}
}
RequestStart watching an episode if you don't have episode ids, but have show info. Send show and episode objects.
HEADERS
Content-Type:application/json
Authorization:Bearer [access_token]
trakt-api-version:2
trakt-api-key:[client_id]
BODY
{
"show": {
"title": "Breaking Bad",
"year": 2008,
"ids": {
"trakt": 1,
"tvdb": 81189
}
},
"episode": {
"season": 1,
"number": 1
},
"progress": 10
}
Response
201
HEADERS
Content-Type:application/json
BODY
{
"id": 0,
"action": "start",
"progress": 10,
"sharing": {
"twitter": true,
"mastodon": true,
"tumblr": false
},
"episode": {
"season": 1,
"number": 1,
"title": "Pilot",
"ids": {
"trakt": 16,
"tvdb": 349232,
"imdb": "tt0959621",
"tmdb": 62085
}
},
"show": {
"title": "Breaking Bad",
"year": 2008,
"ids": {
"trakt": 1,
"slug": "breaking-bad",
"tvdb": 81189,
"imdb": "tt0903747",
"tmdb": 1396
}
}
}
RequestStart watching an episode using absolute numbering (useful for Anime and Donghua). Send show and episode objects.
HEADERS
Content-Type:application/json
Authorization:Bearer [access_token]
trakt-api-version:2
trakt-api-key:[client_id]
BODY
{
"show": {
"title": "One Piece",
"year": 1999,
"ids": {
"trakt": 37696
}
},
"episode": {
"number_abs": 164
},
"sharing": {
"twitter": true,
"mastodon": true,
"tumblr": false
},
"progress": 10
}
Response
201
HEADERS
Content-Type:application/json
BODY
{
"id": 0,
"action": "start",
"progress": 10,
"sharing": {
"twitter": true,
"mastodon": true,
"tumblr": false
},
"episode": {
"season": 9,
"number": 21,
"title": "Light the Fire of Shandia! Wiper the Warrior",
"ids": {
"trakt": 856373,
"tvdb": 362082,
"imdb": null,
"tmdb": null
}
},
"show": {
"title": "One Piece",
"year": 1999,
"ids": {
"trakt": 37696,
"slug": "one-piece",
"tvdb": 81797,
"imdb": "tt0388629",
"tmdb": 37854
}
}
}
Scrobble / Pause / Pause watching in a media center POSThttps://api.trakt.tv/scrobble/pauseRequest
HEADERS
Content-Type:application/json
Authorization:Bearer [access_token]
trakt-api-version:2
trakt-api-key:[client_id]
BODY
{
"movie": {
"title": "Guardians of the Galaxy",
"year": 2014,
"ids": {
"trakt": 28,
"slug": "guardians-of-the-galaxy-2014",
"imdb": "tt2015381",
"tmdb": 118340
}
},
"progress": 75
}
Response
201
HEADERS
Content-Type:application/json
BODY
{
"id": 1337,
"action": "pause",
"progress": 75,
"sharing": {
"twitter": false,
"mastodon": false,
"tumblr": false
},
"movie": {
"title": "Guardians of the Galaxy",
"year": 2014,
"ids": {
"trakt": 28,
"slug": "guardians-of-the-galaxy-2014",
"imdb": "tt2015381",
"tmdb": 118340
}
}
}
BODY
{
"id": 3373536622,
"action": "scrobble",
"progress": 99.9,
"sharing": {
"twitter": true,
"mastodon": true,
"tumblr": false
},
"movie": {
"title": "Guardians of the Galaxy",
"year": 2014,
"ids": {
"trakt": 28,
"slug": "guardians-of-the-galaxy-2014",
"imdb": "tt2015381",
"tmdb": 118340
}
}
}
RequestScrobble an episode by sending a standard episode object.
HEADERS
Content-Type:application/json
Authorization:Bearer [access_token]
trakt-api-version:2
trakt-api-key:[client_id]
BODY
{
"episode": {
"ids": {
"trakt": 16
}
},
"progress": 85
}
Response
201
HEADERS
Content-Type:application/json
BODY
{
"id": 3373536623,
"action": "scrobble",
"progress": 85,
"sharing": {
"twitter": true,
"mastodon": true,
"tumblr": false
},
"episode": {
"season": 1,
"number": 1,
"title": "Pilot",
"ids": {
"trakt": 16,
"tvdb": 349232,
"imdb": "tt0959621",
"tmdb": 62085
}
},
"show": {
"title": "Breaking Bad",
"year": 2008,
"ids": {
"trakt": 1,
"slug": "breaking-bad",
"tvdb": 81189,
"imdb": "tt0903747",
"tmdb": 1396
}
}
}
RequestScrobble an episode if you don't have episode ids, but have show info. Send show and episode objects.
HEADERS
Content-Type:application/json
Authorization:Bearer [access_token]
trakt-api-version:2
trakt-api-key:[client_id]
BODY
{
"show": {
"title": "Breaking Bad",
"year": 2008,
"ids": {
"trakt": 1,
"tvdb": 81189
}
},
"episode": {
"season": 1,
"number": 1
},
"progress": 85
}
Response
201
HEADERS
Content-Type:application/json
BODY
{
"id": 3373536623,
"action": "scrobble",
"progress": 85,
"sharing": {
"twitter": true,
"mastodon": true,
"tumblr": false
},
"episode": {
"season": 1,
"number": 1,
"title": "Pilot",
"ids": {
"trakt": 16,
"tvdb": 349232,
"imdb": "tt0959621",
"tmdb": 62085
}
},
"show": {
"title": "Breaking Bad",
"year": 2008,
"ids": {
"trakt": 1,
"slug": "breaking-bad",
"tvdb": 81189,
"imdb": "tt0903747",
"tmdb": 1396
}
}
}
RequestScrobble an episode using absolute numbering (useful for Anime and Donghua). Send show and episode objects.
HEADERS
Content-Type:application/json
Authorization:Bearer [access_token]
trakt-api-version:2
trakt-api-key:[client_id]
BODY
{
"show": {
"title": "One Piece",
"year": 1999,
"ids": {
"trakt": 37696
}
},
"episode": {
"number_abs": 164
},
"sharing": {
"twitter": true,
"mastodon": true,
"tumblr": false
},
"progress": 90
}
Response
201
HEADERS
Content-Type:application/json
BODY
{
"id": 3373536624,
"action": "scrobble",
"progress": 90,
"sharing": {
"twitter": true,
"mastodon": true,
"tumblr": false
},
"episode": {
"season": 9,
"number": 21,
"title": "Light the Fire of Shandia! Wiper the Warrior",
"ids": {
"trakt": 856373,
"tvdb": 362082,
"imdb": null,
"tmdb": null
}
},
"show": {
"title": "One Piece",
"year": 1999,
"ids": {
"trakt": 37696,
"slug": "one-piece",
"tvdb": 81797,
"imdb": "tt0388629",
"tmdb": 37854
}
}
}
RequestIf the progress is < 80%, the video will be treated a a pause and the playback position will be saved.
HEADERS
Content-Type:application/json
Authorization:Bearer [access_token]
trakt-api-version:2
trakt-api-key:[client_id]
BODY
{
"movie": {
"title": "Guardians of the Galaxy",
"year": 2014,
"ids": {
"trakt": 28,
"slug": "guardians-of-the-galaxy-2014",
"imdb": "tt2015381",
"tmdb": 118340
}
},
"progress": 75
}
Response
201
HEADERS
Content-Type:application/json
BODY
{
"id": 1337,
"action": "pause",
"progress": 75,
"sharing": {
"twitter": false,
"mastodon": true,
"tumblr": false
},
"movie": {
"title": "Guardians of the Galaxy",
"year": 2014,
"ids": {
"trakt": 28,
"slug": "guardians-of-the-galaxy-2014",
"imdb": "tt2015381",
"tmdb": 118340
}
}
}
ResponseThe same item was recently scrobbled.
409
HEADERS
Content-Type:application/json
BODY
{
"watched_at": "2014-10-15T22:21:29.000Z",
"expires_at": "2014-10-15T23:21:29.000Z"
}