Merge branch 'main' into main
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 89 KiB |
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 154 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 2 KiB After Width: | Height: | Size: 2 KiB |
|
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 7.6 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 7 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 7.7 KiB After Width: | Height: | Size: 7.8 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 28 KiB |
2
app.json
|
|
@ -10,7 +10,7 @@
|
|||
"scheme": "nuvio",
|
||||
"newArchEnabled": true,
|
||||
"splash": {
|
||||
"image": "./assets/splash-icon.png",
|
||||
"image": "./src/assets/splash-icon-new.png",
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#020404"
|
||||
},
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 7.7 KiB After Width: | Height: | Size: 8.8 KiB |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 7.2 KiB After Width: | Height: | Size: 8 KiB |
|
Before Width: | Height: | Size: 8.8 KiB After Width: | Height: | Size: 9.8 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 85 KiB |
|
|
@ -1,4 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#d1d1d2</color>
|
||||
<color name="ic_launcher_background">#2f2f2f</color>
|
||||
</resources>
|
||||
|
Before Width: | Height: | Size: 675 B After Width: | Height: | Size: 785 B |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 2 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 2 KiB |
|
Before Width: | Height: | Size: 5.6 KiB After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 5 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 135 KiB After Width: | Height: | Size: 280 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 135 KiB After Width: | Height: | Size: 280 KiB |
|
Before Width: | Height: | Size: 288 KiB After Width: | Height: | Size: 583 KiB |
|
|
@ -16,6 +16,11 @@ RCT_EXPORT_VIEW_PROPERTY(paused, BOOL)
|
|||
RCT_EXPORT_VIEW_PROPERTY(volume, NSNumber)
|
||||
RCT_EXPORT_VIEW_PROPERTY(audioTrack, 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
|
||||
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(setTextTrack:(nonnull NSNumber *)node trackId:(nonnull NSNumber *)trackId)
|
||||
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
|
||||
|
||||
@interface RCT_EXTERN_MODULE(KSPlayerModule, RCTEventEmitter)
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
import Foundation
|
||||
import KSPlayer
|
||||
import React
|
||||
import AVKit
|
||||
|
||||
@objc(KSPlayerView)
|
||||
class KSPlayerView: UIView {
|
||||
|
|
@ -17,6 +18,11 @@ class KSPlayerView: UIView {
|
|||
private var currentVolume: Float = 1.0
|
||||
weak var viewManager: KSPlayerViewManager?
|
||||
|
||||
// Store constraint references for dynamic updates
|
||||
private var subtitleBottomConstraint: NSLayoutConstraint?
|
||||
|
||||
// AirPlay properties (removed duplicate declarations)
|
||||
|
||||
// Event blocks for Fabric
|
||||
@objc var onLoad: RCTDirectEventBlock?
|
||||
@objc var onProgress: RCTDirectEventBlock?
|
||||
|
|
@ -57,15 +63,52 @@ class KSPlayerView: UIView {
|
|||
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) {
|
||||
super.init(frame: frame)
|
||||
setupPlayerView()
|
||||
setupCustomSubtitlePositioning()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
super.init(coder: coder)
|
||||
setupPlayerView()
|
||||
setupCustomSubtitlePositioning()
|
||||
}
|
||||
|
||||
private func setupPlayerView() {
|
||||
|
|
@ -88,9 +131,113 @@ class KSPlayerView: UIView {
|
|||
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
|
||||
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() {
|
||||
// Configure KSOptions (use static defaults where required)
|
||||
|
|
@ -103,6 +250,18 @@ class KSPlayerView: UIView {
|
|||
#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) {
|
||||
currentSource = source
|
||||
|
||||
|
|
@ -151,7 +310,15 @@ class KSPlayerView: UIView {
|
|||
playerView.set(resource: 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
|
||||
if isPaused {
|
||||
|
|
@ -161,6 +328,12 @@ class KSPlayerView: UIView {
|
|||
}
|
||||
|
||||
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 {
|
||||
|
|
@ -283,7 +456,7 @@ class KSPlayerView: UIView {
|
|||
print("KSPlayerView: Successfully selected audio track \(trackId)")
|
||||
|
||||
// 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)
|
||||
for (index, track) in tracksAfter.enumerated() {
|
||||
print("KSPlayerView: After selection - Track \(index) (ID: \(track.trackID)) isEnabled: \(track.isEnabled)")
|
||||
|
|
@ -321,44 +494,110 @@ class KSPlayerView: UIView {
|
|||
}
|
||||
|
||||
func setTextTrack(_ trackId: Int) {
|
||||
if let player = playerView.playerLayer?.player {
|
||||
let textTracks = player.tracks(mediaType: .subtitle)
|
||||
print("KSPlayerView: Available text tracks count: \(textTracks.count)")
|
||||
print("KSPlayerView: Requested text track ID: \(trackId)")
|
||||
|
||||
// First try to find track by trackID (proper way)
|
||||
var selectedTrack: MediaPlayerTrack? = nil
|
||||
var trackIndex: Int = -1
|
||||
|
||||
// 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] Starting setTextTrack with trackId: \(trackId)")
|
||||
|
||||
// Wait slightly longer than the 1-second delay for subtitle data source connection
|
||||
// This ensures srtControl.addSubtitle(dataSouce:) has been called in VideoPlayerView
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { [weak self] in
|
||||
guard let self = self else {
|
||||
print("KSPlayerView: [SET TEXT TRACK] self is nil, aborting")
|
||||
return
|
||||
}
|
||||
|
||||
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 {
|
||||
print("KSPlayerView: Selecting text track \(trackId) (index: \(trackIndex)): '\(track.name)' (ID: \(track.trackID))")
|
||||
// First try to find track by trackID (proper way)
|
||||
var selectedTrack: MediaPlayerTrack? = nil
|
||||
var trackIndex: Int = -1
|
||||
|
||||
// Use KSPlayer's select method which properly handles track selection
|
||||
player.select(track: track)
|
||||
// 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: Successfully selected text track \(trackId)")
|
||||
} else if trackId == -1 {
|
||||
// Disable all subtitles
|
||||
for track in textTracks { track.isEnabled = false }
|
||||
print("KSPlayerView: Disabled all text tracks")
|
||||
if let track = selectedTrack {
|
||||
print("KSPlayerView: Selecting text track \(trackId) (index: \(trackIndex)): '\(track.name)' (ID: \(track.trackID))")
|
||||
|
||||
// First disable all tracks to ensure only one is active
|
||||
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 {
|
||||
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
|
||||
// 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 [
|
||||
"id": Int(track.trackID), // Use actual track ID, not array index
|
||||
"index": index, // Keep index for backward compatibility
|
||||
"name": track.name,
|
||||
"name": displayName,
|
||||
"language": track.language ?? "Unknown",
|
||||
"languageCode": track.languageCode ?? "",
|
||||
"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
|
||||
func getCurrentState() -> [String: Any] {
|
||||
guard let player = playerView.playerLayer?.player else {
|
||||
|
|
@ -419,6 +763,81 @@ extension KSPlayerView: KSPlayerLayerDelegate {
|
|||
func player(layer: KSPlayerLayer, state: KSPlayerState) {
|
||||
switch state {
|
||||
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
|
||||
let p = layer.player
|
||||
let tracks = getAvailableTracks()
|
||||
|
|
@ -430,7 +849,8 @@ extension KSPlayerView: KSPlayerLayerDelegate {
|
|||
"height": p.naturalSize.height
|
||||
],
|
||||
"audioTracks": tracks["audioTracks"] ?? [],
|
||||
"textTracks": tracks["textTracks"] ?? []
|
||||
"textTracks": tracks["textTracks"] ?? [],
|
||||
"playerBackend": playerBackend
|
||||
])
|
||||
case .buffering:
|
||||
sendEvent("onBuffering", ["isBuffering": true])
|
||||
|
|
@ -447,13 +867,86 @@ extension KSPlayerView: KSPlayerLayerDelegate {
|
|||
}
|
||||
|
||||
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
|
||||
// Ensure we have valid duration before sending progress updates
|
||||
if totalTime > 0 {
|
||||
sendEvent("onProgress", [
|
||||
"currentTime": currentTime,
|
||||
"duration": totalTime,
|
||||
"bufferTime": p.playableTime
|
||||
"bufferTime": p.playableTime,
|
||||
"airPlayState": getAirPlayState()
|
||||
])
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import React
|
|||
@objc(KSPlayerViewManager)
|
||||
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! {
|
||||
let view = KSPlayerView()
|
||||
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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -460,7 +460,7 @@
|
|||
"-lc++",
|
||||
);
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.nuvio.app;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "com.nuvio.app";
|
||||
PRODUCT_NAME = "Nuvio";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Nuvio/Nuvio-Bridging-Header.h";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
|
|
@ -492,7 +492,7 @@
|
|||
"-lc++",
|
||||
);
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.nuvio.app;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "com.nuvio.app";
|
||||
PRODUCT_NAME = "Nuvio";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Nuvio/Nuvio-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
|
|
|
|||
|
|
@ -82,7 +82,7 @@
|
|||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Debug"
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 211 KiB After Width: | Height: | Size: 237 KiB |
|
Before Width: | Height: | Size: 122 KiB After Width: | Height: | Size: 263 KiB |
|
Before Width: | Height: | Size: 122 KiB After Width: | Height: | Size: 263 KiB |
|
Before Width: | Height: | Size: 122 KiB After Width: | Height: | Size: 263 KiB |
22
package-lock.json
generated
|
|
@ -91,6 +91,7 @@
|
|||
"@types/crypto-js": "^4.2.2",
|
||||
"@types/react": "~18.3.12",
|
||||
"@types/react-native": "^0.72.8",
|
||||
"@types/react-native-vector-icons": "^6.4.18",
|
||||
"babel-plugin-transform-remove-console": "^6.9.4",
|
||||
"patch-package": "^8.0.1",
|
||||
"react-native-svg-transformer": "^1.5.0",
|
||||
|
|
@ -4208,6 +4209,27 @@
|
|||
"@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": {
|
||||
"version": "5.0.20",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-native-video/-/react-native-video-5.0.20.tgz",
|
||||
|
|
|
|||
|
|
@ -91,6 +91,7 @@
|
|||
"@types/crypto-js": "^4.2.2",
|
||||
"@types/react": "~18.3.12",
|
||||
"@types/react-native": "^0.72.8",
|
||||
"@types/react-native-vector-icons": "^6.4.18",
|
||||
"babel-plugin-transform-remove-console": "^6.9.4",
|
||||
"patch-package": "^8.0.1",
|
||||
"react-native-svg-transformer": "^1.5.0",
|
||||
|
|
|
|||
BIN
src/assets/splash-icon-new.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
|
|
@ -29,7 +29,7 @@ const SplashScreen = ({ onFinish }: SplashScreenProps) => {
|
|||
return (
|
||||
<Animated.View style={[styles.container, { opacity: fadeAnim }]}>
|
||||
<Image
|
||||
source={require('../../assets/splash-icon.png')}
|
||||
source={require('../assets/splash-icon-new.png')}
|
||||
style={styles.image}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import React, { useCallback, useMemo, useRef } from 'react';
|
||||
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 { MaterialIcons } from '@expo/vector-icons';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
|
|
@ -16,6 +16,26 @@ interface CatalogSectionProps {
|
|||
|
||||
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
|
||||
const calculatePosterLayout = (screenWidth: number) => {
|
||||
const MIN_POSTER_WIDTH = 100; // Reduced minimum for more posters
|
||||
|
|
@ -70,21 +90,51 @@ const CatalogSection = ({ catalog }: CatalogSectionProps) => {
|
|||
);
|
||||
}, [handleContentPress]);
|
||||
|
||||
// Memoize the ItemSeparatorComponent to prevent re-creation
|
||||
const ItemSeparator = useCallback(() => <View style={{ width: 8 }} />, []);
|
||||
// Memoize the ItemSeparatorComponent to prevent re-creation (responsive spacing)
|
||||
const separatorWidth = isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8;
|
||||
const ItemSeparator = useCallback(() => <View style={{ width: separatorWidth }} />, [separatorWidth]);
|
||||
|
||||
// Memoize the keyExtractor to prevent re-creation
|
||||
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 (
|
||||
<Animated.View
|
||||
style={styles.catalogContainer}
|
||||
entering={FadeIn.duration(400)}
|
||||
>
|
||||
<View style={styles.catalogHeader}>
|
||||
<View style={[
|
||||
styles.catalogHeader,
|
||||
{ paddingHorizontal: isTV ? 32 : isLargeTablet ? 28 : isTablet ? 24 : 16 }
|
||||
]}>
|
||||
<View style={styles.titleContainer}>
|
||||
<Text style={[styles.catalogTitle, { color: currentTheme.colors.text }]} numberOfLines={1}>{catalog.name}</Text>
|
||||
<View style={[styles.titleUnderline, { backgroundColor: currentTheme.colors.primary }]} />
|
||||
<Text
|
||||
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>
|
||||
<TouchableOpacity
|
||||
onPress={() =>
|
||||
|
|
@ -94,25 +144,50 @@ const CatalogSection = ({ catalog }: CatalogSectionProps) => {
|
|||
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>
|
||||
<MaterialIcons name="chevron-right" size={20} color={currentTheme.colors.textMuted} />
|
||||
<Text style={[
|
||||
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>
|
||||
</View>
|
||||
|
||||
<LegendList
|
||||
<FlashList
|
||||
data={catalog.items}
|
||||
renderItem={renderContentItem}
|
||||
keyExtractor={keyExtractor}
|
||||
getItemType={getItemType}
|
||||
horizontal
|
||||
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}
|
||||
onEndReachedThreshold={0.7}
|
||||
onEndReached={() => {}}
|
||||
recycleItems={true}
|
||||
maintainVisibleContentPosition
|
||||
// FlashList v2 optimizations
|
||||
drawDistance={500}
|
||||
/>
|
||||
</Animated.View>
|
||||
);
|
||||
|
|
@ -126,7 +201,6 @@ const styles = StyleSheet.create({
|
|||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 16,
|
||||
marginBottom: 16,
|
||||
},
|
||||
titleContainer: {
|
||||
|
|
@ -135,7 +209,7 @@ const styles = StyleSheet.create({
|
|||
marginRight: 16,
|
||||
},
|
||||
catalogTitle: {
|
||||
fontSize: 24,
|
||||
fontSize: 24, // will be overridden responsively
|
||||
fontWeight: '800',
|
||||
letterSpacing: 0.5,
|
||||
marginBottom: 4,
|
||||
|
|
@ -144,26 +218,26 @@ const styles = StyleSheet.create({
|
|||
position: 'absolute',
|
||||
bottom: -2,
|
||||
left: 0,
|
||||
width: 40,
|
||||
height: 3,
|
||||
width: 40, // overridden responsively
|
||||
height: 3, // overridden responsively
|
||||
borderRadius: 2,
|
||||
opacity: 0.8,
|
||||
},
|
||||
viewAllButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 10,
|
||||
borderRadius: 20,
|
||||
paddingVertical: 8, // overridden responsively
|
||||
paddingHorizontal: 10, // overridden responsively
|
||||
borderRadius: 20, // overridden responsively
|
||||
backgroundColor: 'rgba(255,255,255,0.1)',
|
||||
},
|
||||
viewAllText: {
|
||||
fontSize: 14,
|
||||
fontSize: 14, // overridden responsively
|
||||
fontWeight: '600',
|
||||
marginRight: 4,
|
||||
marginRight: 4, // overridden responsively
|
||||
},
|
||||
catalogList: {
|
||||
paddingHorizontal: 16,
|
||||
// padding will be applied responsively in JSX
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -23,21 +23,39 @@ interface ContentItemProps {
|
|||
|
||||
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
|
||||
const calculatePosterLayout = (screenWidth: number) => {
|
||||
// Detect if device is a tablet (width >= 768px is common tablet breakpoint)
|
||||
const isTablet = screenWidth >= 768;
|
||||
|
||||
const MIN_POSTER_WIDTH = isTablet ? 140 : 100; // Bigger minimum for tablets
|
||||
const MAX_POSTER_WIDTH = isTablet ? 180 : 130; // Bigger maximum for tablets
|
||||
const LEFT_PADDING = 16; // Left padding
|
||||
const SPACING = 8; // Space between posters
|
||||
const deviceType = getDeviceType(screenWidth);
|
||||
|
||||
// Responsive sizing based on device type
|
||||
const MIN_POSTER_WIDTH = deviceType === 'tv' ? 180 : deviceType === 'largeTablet' ? 160 : deviceType === 'tablet' ? 140 : 100;
|
||||
const MAX_POSTER_WIDTH = deviceType === 'tv' ? 220 : deviceType === 'largeTablet' ? 200 : deviceType === 'tablet' ? 180 : 130;
|
||||
const LEFT_PADDING = deviceType === 'tv' ? 32 : deviceType === 'largeTablet' ? 28 : deviceType === 'tablet' ? 24 : 16;
|
||||
const SPACING = deviceType === 'tv' ? 12 : deviceType === 'largeTablet' ? 10 : deviceType === 'tablet' ? 8 : 8;
|
||||
|
||||
// Calculate available width for posters (reserve space for left padding)
|
||||
const availableWidth = screenWidth - LEFT_PADDING;
|
||||
|
||||
// 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++) {
|
||||
// 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;
|
||||
// Memoize poster width calculation to avoid recalculating on every render
|
||||
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) {
|
||||
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':
|
||||
return Math.min(POSTER_WIDTH + 20, POSTER_WIDTH + 30);
|
||||
return Math.max(130, POSTER_WIDTH + 25) * sizeMultiplier;
|
||||
default:
|
||||
return POSTER_WIDTH;
|
||||
return POSTER_WIDTH * sizeMultiplier;
|
||||
}
|
||||
}, [settings.posterSize]);
|
||||
}, [settings.posterSize, width]);
|
||||
|
||||
// Intersection observer simulation for lazy loading
|
||||
const itemRef = useRef<View>(null);
|
||||
|
|
@ -322,7 +345,16 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
|
|||
</View>
|
||||
</TouchableOpacity>
|
||||
{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}
|
||||
</Text>
|
||||
)}
|
||||
|
|
@ -409,7 +441,7 @@ const styles = StyleSheet.create({
|
|||
padding: 2,
|
||||
},
|
||||
title: {
|
||||
fontSize: 13,
|
||||
fontSize: 13, // Will be overridden responsively
|
||||
fontWeight: '500',
|
||||
marginTop: 4,
|
||||
textAlign: 'center',
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
|
|
@ -39,6 +39,14 @@ interface ContinueWatchingRef {
|
|||
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
|
||||
const calculatePosterLayout = (screenWidth: number) => {
|
||||
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 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
|
||||
const [alertVisible, setAlertVisible] = useState(false);
|
||||
const [alertTitle, setAlertTitle] = useState('');
|
||||
|
|
@ -632,18 +712,28 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
|||
// Memoized render function for continue watching items
|
||||
const renderContinueWatchingItem = useCallback(({ item }: { item: ContinueWatchingItem }) => (
|
||||
<TouchableOpacity
|
||||
style={[styles.wideContentItem, {
|
||||
backgroundColor: currentTheme.colors.elevation1,
|
||||
borderColor: currentTheme.colors.border,
|
||||
shadowColor: currentTheme.colors.black
|
||||
}]}
|
||||
style={[
|
||||
styles.wideContentItem,
|
||||
{
|
||||
backgroundColor: currentTheme.colors.elevation1,
|
||||
borderColor: currentTheme.colors.border,
|
||||
shadowColor: currentTheme.colors.black,
|
||||
width: computedItemWidth,
|
||||
height: computedItemHeight
|
||||
}
|
||||
]}
|
||||
activeOpacity={0.8}
|
||||
onPress={() => handleContentPress(item.id, item.type)}
|
||||
onLongPress={() => handleLongPress(item)}
|
||||
delayLongPress={800}
|
||||
>
|
||||
{/* Poster Image */}
|
||||
<View style={styles.posterContainer}>
|
||||
<View style={[
|
||||
styles.posterContainer,
|
||||
{
|
||||
width: isTV ? 100 : isLargeTablet ? 90 : isTablet ? 85 : 80
|
||||
}
|
||||
]}>
|
||||
<FastImage
|
||||
source={{
|
||||
uri: item.poster || 'https://via.placeholder.com/300x450',
|
||||
|
|
@ -663,21 +753,42 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
|||
</View>
|
||||
|
||||
{/* Content Details */}
|
||||
<View style={styles.contentDetails}>
|
||||
<View style={[
|
||||
styles.contentDetails,
|
||||
{
|
||||
padding: isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 12
|
||||
}
|
||||
]}>
|
||||
<View style={styles.titleRow}>
|
||||
{(() => {
|
||||
const isUpNext = item.type === 'series' && item.progress === 0;
|
||||
return (
|
||||
<View style={styles.titleRow}>
|
||||
<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}
|
||||
>
|
||||
{item.name}
|
||||
</Text>
|
||||
{isUpNext && (
|
||||
<View style={[styles.progressBadge, { backgroundColor: currentTheme.colors.primary }]}>
|
||||
<Text style={styles.progressText}>Up Next</Text>
|
||||
<View style={[
|
||||
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>
|
||||
|
|
@ -690,12 +801,24 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
|||
if (item.type === 'series' && item.season && item.episode) {
|
||||
return (
|
||||
<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}
|
||||
</Text>
|
||||
{item.episodeTitle && (
|
||||
<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}
|
||||
>
|
||||
{item.episodeTitle}
|
||||
|
|
@ -705,7 +828,13 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
|||
);
|
||||
} else {
|
||||
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'}
|
||||
</Text>
|
||||
);
|
||||
|
|
@ -715,7 +844,12 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
|||
{/* Progress Bar */}
|
||||
{item.progress > 0 && (
|
||||
<View style={styles.wideProgressContainer}>
|
||||
<View style={styles.wideProgressTrack}>
|
||||
<View style={[
|
||||
styles.wideProgressTrack,
|
||||
{
|
||||
height: isTV ? 6 : isLargeTablet ? 5 : isTablet ? 4 : 4
|
||||
}
|
||||
]}>
|
||||
<View
|
||||
style={[
|
||||
styles.wideProgressBar,
|
||||
|
|
@ -726,20 +860,26 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
|||
]}
|
||||
/>
|
||||
</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
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
), [currentTheme.colors, handleContentPress, handleLongPress, deletingItemId]);
|
||||
), [currentTheme.colors, handleContentPress, handleLongPress, deletingItemId, computedItemWidth, computedItemHeight, isTV, isLargeTablet, isTablet]);
|
||||
|
||||
// Memoized key extractor
|
||||
const keyExtractor = useCallback((item: ContinueWatchingItem) => `continue-${item.id}-${item.type}`, []);
|
||||
|
||||
// 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 (continueWatchingItems.length === 0) {
|
||||
|
|
@ -751,10 +891,23 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
|||
style={styles.container}
|
||||
entering={FadeIn.duration(350)}
|
||||
>
|
||||
<View style={styles.header}>
|
||||
<View style={[styles.header, { paddingHorizontal: horizontalPadding }]}>
|
||||
<View style={styles.titleContainer}>
|
||||
<Text style={[styles.title, { color: currentTheme.colors.text }]}>Continue Watching</Text>
|
||||
<View style={[styles.titleUnderline, { backgroundColor: currentTheme.colors.primary }]} />
|
||||
<Text style={[
|
||||
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>
|
||||
|
||||
|
|
@ -764,7 +917,13 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
|||
keyExtractor={keyExtractor}
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.wideList}
|
||||
contentContainerStyle={[
|
||||
styles.wideList,
|
||||
{
|
||||
paddingLeft: horizontalPadding,
|
||||
paddingRight: horizontalPadding
|
||||
}
|
||||
]}
|
||||
ItemSeparatorComponent={ItemSeparator}
|
||||
onEndReachedThreshold={0.7}
|
||||
onEndReached={() => {}}
|
||||
|
|
@ -792,7 +951,6 @@ const styles = StyleSheet.create({
|
|||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 16,
|
||||
marginBottom: 16,
|
||||
},
|
||||
titleContainer: {
|
||||
|
|
@ -814,7 +972,6 @@ const styles = StyleSheet.create({
|
|||
opacity: 0.8,
|
||||
},
|
||||
wideList: {
|
||||
paddingHorizontal: 16,
|
||||
paddingBottom: 8,
|
||||
paddingTop: 4,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -28,6 +28,14 @@ const { width } = Dimensions.get('window');
|
|||
const ITEM_WIDTH = width * 0.75; // phone default
|
||||
const ITEM_HEIGHT = 180; // phone default
|
||||
|
||||
// Enhanced responsive breakpoints
|
||||
const BREAKPOINTS = {
|
||||
phone: 0,
|
||||
tablet: 768,
|
||||
largeTablet: 1024,
|
||||
tv: 1440,
|
||||
};
|
||||
|
||||
interface ThisWeekEpisode {
|
||||
id: string;
|
||||
seriesId: string;
|
||||
|
|
@ -49,11 +57,77 @@ export const ThisWeekSection = React.memo(() => {
|
|||
const { currentTheme } = useTheme();
|
||||
const { calendarData, loading } = useCalendarData();
|
||||
|
||||
// Responsive sizing for tablets
|
||||
// Enhanced responsive sizing for tablets and TV screens
|
||||
const deviceWidth = Dimensions.get('window').width;
|
||||
const isTablet = deviceWidth >= 768;
|
||||
const computedItemWidth = useMemo(() => (isTablet ? Math.min(deviceWidth * 0.46, 560) : ITEM_WIDTH), [isTablet, deviceWidth]);
|
||||
const computedItemHeight = useMemo(() => (isTablet ? 220 : ITEM_HEIGHT), [isTablet]);
|
||||
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
|
||||
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
|
||||
const thisWeekEpisodes = useMemo(() => {
|
||||
|
|
@ -144,35 +218,70 @@ export const ThisWeekSection = React.memo(() => {
|
|||
'rgba(0,0,0,0.8)',
|
||||
'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]}
|
||||
>
|
||||
{/* Content area */}
|
||||
<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}
|
||||
</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}
|
||||
</Text>
|
||||
|
||||
{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}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<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} •
|
||||
</Text>
|
||||
<MaterialIcons
|
||||
name="event"
|
||||
size={isTablet ? 16 : 14}
|
||||
size={isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 14}
|
||||
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}
|
||||
</Text>
|
||||
</View>
|
||||
|
|
@ -189,14 +298,43 @@ export const ThisWeekSection = React.memo(() => {
|
|||
style={styles.container}
|
||||
entering={FadeIn.duration(350)}
|
||||
>
|
||||
<View style={styles.header}>
|
||||
<View style={[styles.header, { paddingHorizontal: horizontalPadding }]}>
|
||||
<View style={styles.titleContainer}>
|
||||
<Text style={[styles.title, { color: currentTheme.colors.text }]}>This Week</Text>
|
||||
<View style={[styles.titleUnderline, { backgroundColor: currentTheme.colors.primary }]} />
|
||||
<Text style={[
|
||||
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>
|
||||
<TouchableOpacity onPress={handleViewAll} style={styles.viewAllButton}>
|
||||
<Text style={[styles.viewAllText, { color: currentTheme.colors.textMuted }]}>View All</Text>
|
||||
<MaterialIcons name="chevron-right" size={20} color={currentTheme.colors.textMuted} />
|
||||
<TouchableOpacity onPress={handleViewAll} style={[
|
||||
styles.viewAllButton,
|
||||
{
|
||||
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>
|
||||
</View>
|
||||
|
||||
|
|
@ -206,20 +344,26 @@ export const ThisWeekSection = React.memo(() => {
|
|||
renderItem={renderEpisodeItem}
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={[styles.listContent, { paddingLeft: isTablet ? 24 : 16, paddingRight: isTablet ? 24 : 16 }]}
|
||||
snapToInterval={computedItemWidth + 16}
|
||||
contentContainerStyle={[
|
||||
styles.listContent,
|
||||
{
|
||||
paddingLeft: horizontalPadding,
|
||||
paddingRight: horizontalPadding
|
||||
}
|
||||
]}
|
||||
snapToInterval={computedItemWidth + itemSpacing}
|
||||
decelerationRate="fast"
|
||||
snapToAlignment="start"
|
||||
initialNumToRender={isTablet ? 4 : 3}
|
||||
windowSize={3}
|
||||
maxToRenderPerBatch={3}
|
||||
initialNumToRender={isTV ? 6 : isLargeTablet ? 5 : isTablet ? 4 : 3}
|
||||
windowSize={isTV ? 4 : isLargeTablet ? 4 : 3}
|
||||
maxToRenderPerBatch={isTV ? 4 : isLargeTablet ? 4 : 3}
|
||||
removeClippedSubviews
|
||||
getItemLayout={(data, index) => {
|
||||
const length = computedItemWidth + 16;
|
||||
const length = computedItemWidth + itemSpacing;
|
||||
const offset = length * index;
|
||||
return { length, offset, index };
|
||||
}}
|
||||
ItemSeparatorComponent={() => <View style={{ width: 16 }} />}
|
||||
ItemSeparatorComponent={() => <View style={{ width: itemSpacing }} />}
|
||||
/>
|
||||
</Animated.View>
|
||||
);
|
||||
|
|
@ -233,7 +377,6 @@ const styles = StyleSheet.create({
|
|||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 16,
|
||||
marginBottom: 16,
|
||||
},
|
||||
titleContainer: {
|
||||
|
|
@ -269,8 +412,6 @@ const styles = StyleSheet.create({
|
|||
marginRight: 4,
|
||||
},
|
||||
listContent: {
|
||||
paddingLeft: 16,
|
||||
paddingRight: 16,
|
||||
paddingBottom: 8,
|
||||
},
|
||||
loadingContainer: {
|
||||
|
|
@ -316,7 +457,7 @@ const styles = StyleSheet.create({
|
|||
padding: 12,
|
||||
borderRadius: 16,
|
||||
},
|
||||
contentArea: {
|
||||
contentArea: {
|
||||
width: '100%',
|
||||
},
|
||||
seriesName: {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React from 'react';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
|
|
@ -6,6 +6,7 @@ import {
|
|||
FlatList,
|
||||
TouchableOpacity,
|
||||
ActivityIndicator,
|
||||
Dimensions,
|
||||
} from 'react-native';
|
||||
import FastImage from '@d11/react-native-fast-image';
|
||||
import Animated, {
|
||||
|
|
@ -13,6 +14,14 @@ import Animated, {
|
|||
} from 'react-native-reanimated';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
|
||||
// Enhanced responsive breakpoints for Cast Section
|
||||
const BREAKPOINTS = {
|
||||
phone: 0,
|
||||
tablet: 768,
|
||||
largeTablet: 1024,
|
||||
tv: 1440,
|
||||
};
|
||||
|
||||
interface CastSectionProps {
|
||||
cast: any[];
|
||||
loadingCast: boolean;
|
||||
|
|
@ -28,6 +37,78 @@ export const CastSection: React.FC<CastSectionProps> = ({
|
|||
}) => {
|
||||
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) {
|
||||
return (
|
||||
<View style={styles.loadingContainer}>
|
||||
|
|
@ -45,25 +126,52 @@ export const CastSection: React.FC<CastSectionProps> = ({
|
|||
style={styles.castSection}
|
||||
entering={FadeIn.duration(300).delay(150)}
|
||||
>
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text style={[styles.sectionTitle, { color: currentTheme.colors.highEmphasis }]}>Cast</Text>
|
||||
<View style={[
|
||||
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>
|
||||
<FlatList
|
||||
horizontal
|
||||
data={cast}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.castList}
|
||||
contentContainerStyle={[
|
||||
styles.castList,
|
||||
{ paddingHorizontal: horizontalPadding }
|
||||
]}
|
||||
keyExtractor={(item) => item.id.toString()}
|
||||
renderItem={({ item, index }) => (
|
||||
<Animated.View
|
||||
entering={FadeIn.duration(300).delay(50 + index * 30)}
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={styles.castCard}
|
||||
style={[
|
||||
styles.castCard,
|
||||
{
|
||||
width: castCardWidth,
|
||||
marginRight: castCardSpacing
|
||||
}
|
||||
]}
|
||||
onPress={() => onSelectCastMember(item)}
|
||||
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 ? (
|
||||
<FastImage
|
||||
source={{
|
||||
|
|
@ -73,16 +181,43 @@ export const CastSection: React.FC<CastSectionProps> = ({
|
|||
resizeMode={FastImage.resizeMode.cover}
|
||||
/>
|
||||
) : (
|
||||
<View style={[styles.castImagePlaceholder, { backgroundColor: currentTheme.colors.darkBackground }]}>
|
||||
<Text style={[styles.placeholderText, { color: currentTheme.colors.textMuted }]}>
|
||||
<View style={[
|
||||
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)}
|
||||
</Text>
|
||||
</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 && (
|
||||
<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>
|
||||
</Animated.View>
|
||||
|
|
@ -107,14 +242,12 @@ const styles = StyleSheet.create({
|
|||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 12,
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
},
|
||||
castList: {
|
||||
paddingHorizontal: 16,
|
||||
paddingBottom: 4,
|
||||
},
|
||||
castCard: {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useCallback, useState, useRef } from 'react';
|
||||
import React, { useCallback, useState, useRef, useMemo } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
|
|
@ -21,7 +21,13 @@ import { useTraktComments } from '../../hooks/useTraktComments';
|
|||
import { useSettings } from '../../hooks/useSettings';
|
||||
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 {
|
||||
imdbId: string;
|
||||
|
|
@ -191,6 +197,64 @@ const CompactCommentCard: React.FC<{
|
|||
}).start();
|
||||
}, [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
|
||||
if (!comment || !comment.comment) {
|
||||
return null;
|
||||
|
|
@ -272,6 +336,11 @@ const CompactCommentCard: React.FC<{
|
|||
borderColor: theme.colors.border,
|
||||
opacity: fadeInOpacity,
|
||||
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 */}
|
||||
<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>
|
||||
|
||||
{/* 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}>
|
||||
<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}
|
||||
</Text>
|
||||
{user.vip && (
|
||||
<View style={styles.miniVipBadge}>
|
||||
<Text style={styles.miniVipText}>VIP</Text>
|
||||
<View style={[
|
||||
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>
|
||||
|
|
@ -306,48 +398,107 @@ const CompactCommentCard: React.FC<{
|
|||
|
||||
{/* Rating - Show stars */}
|
||||
{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)}
|
||||
<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
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 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 ? (
|
||||
<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
|
||||
text={comment.comment}
|
||||
theme={theme}
|
||||
numberOfLines={3}
|
||||
numberOfLines={isLargeScreen ? 4 : 3}
|
||||
revealedInlineSpoilers={isSpoilerRevealed}
|
||||
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>
|
||||
|
||||
{/* 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}>
|
||||
{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 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)}
|
||||
</Text>
|
||||
{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}
|
||||
</Text>
|
||||
)}
|
||||
{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}
|
||||
</Text>
|
||||
)}
|
||||
|
|
@ -578,6 +729,38 @@ export const CommentsSection: React.FC<CommentsSectionProps> = ({
|
|||
const { settings } = useSettings();
|
||||
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 {
|
||||
comments,
|
||||
loading,
|
||||
|
|
@ -654,41 +837,66 @@ export const CommentsSection: React.FC<CommentsSectionProps> = ({
|
|||
|
||||
const renderSkeletons = useCallback(() => {
|
||||
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 (
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.horizontalList}>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={[styles.horizontalList, { paddingRight: gap }]}>
|
||||
{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.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 style={styles.compactHeader}>
|
||||
<View style={[styles.skeletonLine, { width: 120 }]} />
|
||||
<View style={[styles.miniVipBadge, styles.skeletonBadge]} />
|
||||
<View style={[styles.compactHeader, { marginBottom: isTV ? 10 : isLargeTablet ? 8 : isTablet ? 8 : 8 }]}>
|
||||
<View style={[styles.skeletonLine, { width: headLineWidth, height: isTV ? 14 : 12 }]} />
|
||||
<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 style={styles.compactRating}>
|
||||
<View style={[styles.skeletonLine, { width: 80, height: 10 }]} />
|
||||
<View style={[styles.compactRating, { marginBottom: isTV ? 10 : isLargeTablet ? 8 : isTablet ? 8 : 8 }]}>
|
||||
<View style={[styles.skeletonLine, { width: ratingWidth, height: isTV ? 12 : 10 }]} />
|
||||
</View>
|
||||
|
||||
<View style={styles.commentContainer}>
|
||||
<View style={[styles.skeletonLine, { width: '95%' }]} />
|
||||
<View style={[styles.skeletonLine, { width: '90%', marginTop: 6 }]} />
|
||||
<View style={[styles.skeletonLine, { width: '70%', marginTop: 6 }]} />
|
||||
<View style={[styles.commentContainer, { marginBottom: isTV ? 10 : isLargeTablet ? 8 : isTablet ? 8 : 8 }]}>
|
||||
<View style={[styles.skeletonLine, { width: '95%', height: isTV ? 14 : 12 }]} />
|
||||
<View style={[styles.skeletonLine, { width: '90%', height: isTV ? 14 : 12, marginTop: 6 }]} />
|
||||
<View style={[styles.skeletonLine, { width: '70%', height: isTV ? 14 : 12, marginTop: 6 }]} />
|
||||
</View>
|
||||
|
||||
<View style={styles.compactMeta}>
|
||||
<View style={[styles.skeletonBadge, { width: 50, height: 12, borderRadius: 6 }]} />
|
||||
<View style={{ flexDirection: 'row', gap: 8 }}>
|
||||
<View style={[styles.skeletonLine, { width: 36, height: 10 }]} />
|
||||
<View style={[styles.skeletonLine, { width: 36, height: 10 }]} />
|
||||
<View style={[styles.compactMeta, { paddingTop: isTV ? 8 : isLargeTablet ? 6 : isTablet ? 6 : 6 }]}>
|
||||
<View style={[styles.skeletonBadge, { width: badgeW, height: badgeH, borderRadius: Math.min(6, badgeH / 2) }]} />
|
||||
<View style={{ flexDirection: 'row', gap }}>
|
||||
<View style={[styles.skeletonLine, { width: statWidth, height: isTV ? 12 : 10 }]} />
|
||||
<View style={[styles.skeletonLine, { width: statWidth, height: isTV ? 12 : 10 }]} />
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
);
|
||||
}, [currentTheme]);
|
||||
}, [currentTheme, isTV, isLargeTablet, isTablet]);
|
||||
|
||||
// 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
|
||||
|
|
@ -705,9 +913,23 @@ export const CommentsSection: React.FC<CommentsSectionProps> = ({
|
|||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.header}>
|
||||
<Text style={[styles.title, { color: currentTheme.colors.highEmphasis }]}>
|
||||
<View style={[
|
||||
styles.container,
|
||||
{ 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
|
||||
</Text>
|
||||
</View>
|
||||
|
|
@ -744,11 +966,14 @@ export const CommentsSection: React.FC<CommentsSectionProps> = ({
|
|||
renderItem={renderComment}
|
||||
contentContainerStyle={styles.horizontalList}
|
||||
removeClippedSubviews={false}
|
||||
getItemLayout={(data, index) => ({
|
||||
length: 292, // width + marginRight
|
||||
offset: 292 * index,
|
||||
index,
|
||||
})}
|
||||
getItemLayout={(data, index) => {
|
||||
const itemWidth = isTV ? 376 : isLargeTablet ? 334 : isTablet ? 312 : 292; // width + marginRight
|
||||
return {
|
||||
length: itemWidth,
|
||||
offset: itemWidth * index,
|
||||
index,
|
||||
};
|
||||
}}
|
||||
onEndReached={() => {
|
||||
if (hasMore && !loading) {
|
||||
loadMore();
|
||||
|
|
@ -991,7 +1216,6 @@ export const CommentBottomSheet: React.FC<{
|
|||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
padding: 16,
|
||||
marginBottom: 24,
|
||||
},
|
||||
header: {
|
||||
|
|
@ -1008,11 +1232,7 @@ const styles = StyleSheet.create({
|
|||
paddingRight: 16,
|
||||
},
|
||||
compactCard: {
|
||||
width: 280,
|
||||
height: 170,
|
||||
padding: 12,
|
||||
paddingBottom: 16,
|
||||
marginRight: 12,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
shadowColor: '#000',
|
||||
|
|
|
|||
|
|
@ -335,31 +335,192 @@ const ActionButtons = memo(({
|
|||
return isWatched ? 'Play' : playButtonText;
|
||||
}, [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 (
|
||||
<Animated.View style={[isTablet ? styles.tabletActionButtons : styles.actionButtons, animatedStyle]}>
|
||||
{/* 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>
|
||||
{shouldShowSingleRow ? (
|
||||
/* Single Row Layout - Play, Save, and one other button (3 total) */
|
||||
<View style={styles.singleRowLayout}>
|
||||
<TouchableOpacity
|
||||
style={[playButtonStyle, isTablet && styles.tabletPlayButton, styles.singleRowPlayButton]}
|
||||
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>
|
||||
|
||||
{/* Secondary Action Row - All other buttons */}
|
||||
<View style={styles.secondaryActionRow}>
|
||||
<TouchableOpacity
|
||||
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 */}
|
||||
<TouchableOpacity
|
||||
style={[styles.actionButton, styles.infoButton, isTablet && styles.tabletInfoButton]}
|
||||
|
|
@ -490,6 +651,8 @@ const ActionButtons = memo(({
|
|||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</Animated.View>
|
||||
);
|
||||
});
|
||||
|
|
@ -1679,7 +1842,7 @@ const HeroSection: React.FC<HeroSectionProps> = memo(({
|
|||
// When unmuting, hide action buttons, genre, title card, and watch progress
|
||||
actionButtonsOpacity.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 });
|
||||
} else {
|
||||
// When muting, show action buttons, genre, title card, and watch progress
|
||||
|
|
@ -1966,6 +2129,29 @@ const styles = StyleSheet.create({
|
|||
maxWidth: isTablet ? 600 : '100%',
|
||||
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: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
ActivityIndicator,
|
||||
Dimensions,
|
||||
} from 'react-native';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import FastImage from '@d11/react-native-fast-image';
|
||||
|
|
@ -20,6 +21,15 @@ import Animated, {
|
|||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
import { isMDBListEnabled } from '../../screens/MDBListSettingsScreen';
|
||||
import { getAgeRatingColor } from '../../utils/ageRatingColors';
|
||||
|
||||
// Enhanced responsive breakpoints for Metadata Details
|
||||
const BREAKPOINTS = {
|
||||
phone: 0,
|
||||
tablet: 768,
|
||||
largeTablet: 1024,
|
||||
tv: 1440,
|
||||
};
|
||||
|
||||
// MetadataSourceSelector removed
|
||||
|
||||
interface MetadataDetailsProps {
|
||||
|
|
@ -45,6 +55,38 @@ const MetadataDetails: React.FC<MetadataDetailsProps> = ({
|
|||
const [isMDBEnabled, setIsMDBEnabled] = 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
|
||||
const animatedHeight = useSharedValue(0);
|
||||
const [measuredHeights, setMeasuredHeights] = useState({ collapsed: 0, expanded: 0 });
|
||||
|
|
@ -144,12 +186,31 @@ function formatRuntime(runtime: string): string {
|
|||
)}
|
||||
|
||||
{/* 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 && (
|
||||
<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 && (
|
||||
<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)}
|
||||
</Text>
|
||||
)}
|
||||
|
|
@ -157,17 +218,32 @@ function formatRuntime(runtime: string): string {
|
|||
<Text style={[
|
||||
styles.metaText,
|
||||
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.imdbRating && !isMDBEnabled && (
|
||||
<View style={styles.ratingContainer}>
|
||||
<FastImage
|
||||
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}
|
||||
/>
|
||||
<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>
|
||||
|
|
@ -178,18 +254,62 @@ function formatRuntime(runtime: string): string {
|
|||
{/* Creator/Director Info */}
|
||||
<Animated.View
|
||||
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 && (
|
||||
<View style={styles.creatorSection}>
|
||||
<Text style={[styles.creatorLabel, { color: currentTheme.colors.white }]}>Director{metadata.directors.length > 1 ? 's' : ''}:</Text>
|
||||
<Text style={[styles.creatorText, { color: currentTheme.colors.mediumEmphasis }]}>{metadata.directors.join(', ')}</Text>
|
||||
<View style={[
|
||||
styles.creatorSection,
|
||||
{
|
||||
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>
|
||||
)}
|
||||
{metadata.creators && metadata.creators.length > 0 && (
|
||||
<View style={styles.creatorSection}>
|
||||
<Text style={[styles.creatorLabel, { color: currentTheme.colors.white }]}>Creator{metadata.creators.length > 1 ? 's' : ''}:</Text>
|
||||
<Text style={[styles.creatorText, { color: currentTheme.colors.mediumEmphasis }]}>{metadata.creators.join(', ')}</Text>
|
||||
<View style={[
|
||||
styles.creatorSection,
|
||||
{
|
||||
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>
|
||||
)}
|
||||
</Animated.View>
|
||||
|
|
@ -197,19 +317,41 @@ function formatRuntime(runtime: string): string {
|
|||
{/* Description */}
|
||||
{metadata.description && (
|
||||
<Animated.View
|
||||
style={[styles.descriptionContainer, loadingMetadata && styles.dimmed]}
|
||||
style={[
|
||||
styles.descriptionContainer,
|
||||
loadingMetadata && styles.dimmed,
|
||||
{ paddingHorizontal: horizontalPadding }
|
||||
]}
|
||||
entering={FadeIn.duration(300)}
|
||||
>
|
||||
{/* Hidden text elements to measure heights */}
|
||||
<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}
|
||||
onLayout={handleCollapsedTextLayout}
|
||||
>
|
||||
{metadata.description}
|
||||
</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}
|
||||
>
|
||||
{metadata.description}
|
||||
|
|
@ -222,7 +364,14 @@ function formatRuntime(runtime: string): string {
|
|||
>
|
||||
<Animated.View style={animatedDescriptionStyle}>
|
||||
<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}
|
||||
onTextLayout={handleTextLayout}
|
||||
>
|
||||
|
|
@ -230,13 +379,25 @@ function formatRuntime(runtime: string): string {
|
|||
</Text>
|
||||
</Animated.View>
|
||||
{(isTextTruncated || isFullDescriptionOpen) && (
|
||||
<View style={styles.showMoreButton}>
|
||||
<Text style={[styles.showMoreText, { color: currentTheme.colors.textMuted }]}>
|
||||
<View style={[
|
||||
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'}
|
||||
</Text>
|
||||
<MaterialIcons
|
||||
name={isFullDescriptionOpen ? "keyboard-arrow-up" : "keyboard-arrow-down"}
|
||||
size={18}
|
||||
size={isTV ? 22 : isLargeTablet ? 20 : isTablet ? 18 : 18}
|
||||
color={currentTheme.colors.textMuted}
|
||||
/>
|
||||
</View>
|
||||
|
|
@ -267,8 +428,6 @@ const styles = StyleSheet.create({
|
|||
metaInfo: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 18,
|
||||
paddingHorizontal: 16,
|
||||
marginBottom: 12,
|
||||
},
|
||||
metaText: {
|
||||
|
|
@ -303,7 +462,6 @@ const styles = StyleSheet.create({
|
|||
},
|
||||
creatorContainer: {
|
||||
marginBottom: 2,
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
creatorSection: {
|
||||
flexDirection: 'row',
|
||||
|
|
@ -324,7 +482,6 @@ const styles = StyleSheet.create({
|
|||
},
|
||||
descriptionContainer: {
|
||||
marginBottom: 16,
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
description: {
|
||||
fontSize: 15,
|
||||
|
|
|
|||
|
|
@ -20,32 +20,13 @@ import CustomAlert from '../../components/CustomAlert';
|
|||
|
||||
const { width } = Dimensions.get('window');
|
||||
|
||||
// Dynamic poster calculation based on screen width for More Like This section
|
||||
const calculatePosterLayout = (screenWidth: number) => {
|
||||
const MIN_POSTER_WIDTH = 100; // Slightly smaller for more items in this section
|
||||
const MAX_POSTER_WIDTH = 130; // Maximum poster width
|
||||
const HORIZONTAL_PADDING = 48; // Total horizontal padding/margins
|
||||
|
||||
// Calculate how many posters can fit (aim for slightly more items than main sections)
|
||||
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;
|
||||
// Breakpoints for responsive sizing
|
||||
const BREAKPOINTS = {
|
||||
phone: 0,
|
||||
tablet: 768,
|
||||
largeTablet: 1024,
|
||||
tv: 1440,
|
||||
} as const;
|
||||
|
||||
interface MoreLikeThisSectionProps {
|
||||
recommendations: StreamingContent[];
|
||||
|
|
@ -59,6 +40,48 @@ export const MoreLikeThisSection: React.FC<MoreLikeThisSectionProps> = ({
|
|||
const { currentTheme } = useTheme();
|
||||
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 [alertTitle, setAlertTitle] = React.useState('');
|
||||
const [alertMessage, setAlertMessage] = React.useState('');
|
||||
|
|
@ -94,15 +117,15 @@ export const MoreLikeThisSection: React.FC<MoreLikeThisSectionProps> = ({
|
|||
|
||||
const renderItem = ({ item }: { item: StreamingContent }) => (
|
||||
<TouchableOpacity
|
||||
style={styles.itemContainer}
|
||||
style={[styles.itemContainer, { width: posterWidth, marginRight: itemSpacing }]}
|
||||
onPress={() => handleItemPress(item)}
|
||||
>
|
||||
<FastImage
|
||||
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}
|
||||
/>
|
||||
<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}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
|
@ -121,15 +144,15 @@ export const MoreLikeThisSection: React.FC<MoreLikeThisSectionProps> = ({
|
|||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={[styles.sectionTitle, { color: currentTheme.colors.highEmphasis }]}>More Like This</Text>
|
||||
<View style={[styles.container, { paddingLeft: 0 }] }>
|
||||
<Text style={[styles.sectionTitle, { color: currentTheme.colors.highEmphasis, fontSize: isTV ? 24 : isLargeTablet ? 22 : isTablet ? 20 : 20, paddingHorizontal: horizontalPadding }]}>More Like This</Text>
|
||||
<FlatList
|
||||
data={recommendations}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={(item) => item.id}
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.listContentContainer}
|
||||
contentContainerStyle={[styles.listContentContainer, { paddingHorizontal: horizontalPadding, paddingRight: horizontalPadding + itemSpacing }]}
|
||||
/>
|
||||
<CustomAlert
|
||||
visible={alertVisible}
|
||||
|
|
@ -146,36 +169,30 @@ const styles = StyleSheet.create({
|
|||
container: {
|
||||
marginTop: 16,
|
||||
marginBottom: 16,
|
||||
paddingLeft: 0,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: '800',
|
||||
marginBottom: 12,
|
||||
marginTop: 8,
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
listContentContainer: {
|
||||
paddingHorizontal: 16,
|
||||
paddingRight: 32, // Ensure last item has padding
|
||||
paddingRight: 32, // Will be overridden responsively
|
||||
},
|
||||
itemContainer: {
|
||||
marginRight: 12,
|
||||
width: POSTER_WIDTH,
|
||||
marginRight: 12, // will be overridden responsively
|
||||
},
|
||||
poster: {
|
||||
width: POSTER_WIDTH,
|
||||
height: POSTER_HEIGHT,
|
||||
borderRadius: 8,
|
||||
borderRadius: 8, // overridden responsively
|
||||
marginBottom: 8,
|
||||
},
|
||||
title: {
|
||||
fontSize: 13,
|
||||
fontSize: 13, // overridden responsively
|
||||
fontWeight: '500',
|
||||
lineHeight: 18,
|
||||
lineHeight: 18, // overridden responsively
|
||||
},
|
||||
loadingContainer: {
|
||||
height: POSTER_HEIGHT + 40, // Approximate height to prevent layout shifts
|
||||
// Approximate height to prevent layout shifts; not used in responsive version
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { View, Text, StyleSheet, ActivityIndicator, Image, Animated } from 'react-native';
|
||||
import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react';
|
||||
import { View, Text, StyleSheet, ActivityIndicator, Image, Animated, Dimensions } from 'react-native';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
import { useMDBListRatings } from '../../hooks/useMDBListRatings';
|
||||
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 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 = {
|
||||
imdb: {
|
||||
name: 'IMDb',
|
||||
|
|
@ -56,6 +64,50 @@ export const RatingsSection: React.FC<RatingsSectionProps> = ({ imdbId, type })
|
|||
const fadeAnim = useRef(new Animated.Value(0)).current;
|
||||
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(() => {
|
||||
loadProviderSettings();
|
||||
checkMDBListEnabled();
|
||||
|
|
@ -164,6 +216,7 @@ export const RatingsSection: React.FC<RatingsSectionProps> = ({ imdbId, type })
|
|||
style={[
|
||||
styles.container,
|
||||
{
|
||||
paddingHorizontal: horizontalPadding,
|
||||
opacity: fadeAnim,
|
||||
transform: [{
|
||||
translateY: fadeAnim.interpolate({
|
||||
|
|
@ -180,22 +233,22 @@ export const RatingsSection: React.FC<RatingsSectionProps> = ({ imdbId, type })
|
|||
const displayValue = config.transform(parseFloat(value as string));
|
||||
|
||||
return (
|
||||
<View key={source} style={styles.compactRatingItem}>
|
||||
<View key={source} style={[styles.compactRatingItem, { marginRight: itemSpacing }]}>
|
||||
{config.isImage ? (
|
||||
<Image
|
||||
source={config.icon as any}
|
||||
style={styles.compactRatingIcon}
|
||||
style={[styles.compactRatingIcon, { width: iconSize, height: iconSize, marginRight: iconTextGap }]}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
) : (
|
||||
<View style={styles.compactSvgContainer}>
|
||||
<View style={[styles.compactSvgContainer, { marginRight: iconTextGap }]}>
|
||||
{React.createElement(config.icon as any, {
|
||||
width: 16,
|
||||
height: 16,
|
||||
width: iconSize,
|
||||
height: iconSize,
|
||||
})}
|
||||
</View>
|
||||
)}
|
||||
<Text style={[styles.compactRatingValue, { color: config.color }]}>
|
||||
<Text style={[styles.compactRatingValue, { color: config.color, fontSize: textSize }]}>
|
||||
{displayValue}
|
||||
</Text>
|
||||
</View>
|
||||
|
|
@ -210,7 +263,6 @@ const styles = StyleSheet.create({
|
|||
container: {
|
||||
marginTop: 2,
|
||||
marginBottom: 8,
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
loadingContainer: {
|
||||
height: 40,
|
||||
|
|
|
|||
|
|
@ -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 FastImage from '@d11/react-native-fast-image';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
|
|
@ -15,6 +15,14 @@ import { TraktService } from '../../services/traktService';
|
|||
import { logger } from '../../utils/logger';
|
||||
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 {
|
||||
episodes: Episode[];
|
||||
selectedSeason: number;
|
||||
|
|
@ -42,8 +50,120 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
|
|||
const { currentTheme } = useTheme();
|
||||
const { settings } = useSettings();
|
||||
const { width } = useWindowDimensions();
|
||||
const isTablet = width > 768;
|
||||
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 } }>({});
|
||||
// Delay item entering animations to avoid FlashList initial layout glitches
|
||||
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);
|
||||
|
||||
return (
|
||||
<View style={[styles.seasonSelectorWrapper, isTablet && styles.seasonSelectorWrapperTablet]}>
|
||||
<View style={styles.seasonSelectorHeader}>
|
||||
<View style={[
|
||||
styles.seasonSelectorWrapper,
|
||||
{ paddingHorizontal: horizontalPadding }
|
||||
]}>
|
||||
<View style={[
|
||||
styles.seasonSelectorHeader,
|
||||
{
|
||||
marginBottom: isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 12
|
||||
}
|
||||
]}>
|
||||
<Text style={[
|
||||
styles.seasonSelectorTitle,
|
||||
isTablet && styles.seasonSelectorTitleTablet,
|
||||
{ color: currentTheme.colors.highEmphasis }
|
||||
{
|
||||
color: currentTheme.colors.highEmphasis,
|
||||
fontSize: isTV ? 28 : isLargeTablet ? 26 : isTablet ? 24 : 18
|
||||
}
|
||||
]}>Seasons</Text>
|
||||
|
||||
{/* Dropdown Toggle Button */}
|
||||
|
|
@ -360,7 +490,10 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
|
|||
: currentTheme.colors.elevation3,
|
||||
borderColor: seasonViewMode === 'posters'
|
||||
? '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={() => {
|
||||
|
|
@ -375,7 +508,8 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
|
|||
{
|
||||
color: seasonViewMode === 'posters'
|
||||
? currentTheme.colors.mediumEmphasis
|
||||
: currentTheme.colors.highEmphasis
|
||||
: currentTheme.colors.highEmphasis,
|
||||
fontSize: isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 12
|
||||
}
|
||||
]}>
|
||||
{seasonViewMode === 'posters' ? 'Posters' : 'Text'}
|
||||
|
|
@ -389,7 +523,12 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
|
|||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={styles.seasonSelectorContainer}
|
||||
contentContainerStyle={[styles.seasonSelectorContent, isTablet && styles.seasonSelectorContentTablet]}
|
||||
contentContainerStyle={[
|
||||
styles.seasonSelectorContent,
|
||||
{
|
||||
paddingBottom: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8
|
||||
}
|
||||
]}
|
||||
initialNumToRender={5}
|
||||
maxToRenderPerBatch={5}
|
||||
windowSize={3}
|
||||
|
|
@ -416,7 +555,13 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
|
|||
<TouchableOpacity
|
||||
style={[
|
||||
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
|
||||
]}
|
||||
onPress={() => onSeasonChange(season)}
|
||||
|
|
@ -448,12 +593,23 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
|
|||
<TouchableOpacity
|
||||
style={[
|
||||
styles.seasonButton,
|
||||
isTablet && styles.seasonButtonTablet,
|
||||
{
|
||||
marginRight: seasonButtonSpacing,
|
||||
width: seasonPosterWidth
|
||||
},
|
||||
selectedSeason === season && [styles.selectedSeasonButton, { borderColor: currentTheme.colors.primary }]
|
||||
]}
|
||||
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
|
||||
source={{ uri: seasonPoster }}
|
||||
style={styles.seasonPoster}
|
||||
|
|
@ -462,8 +618,10 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
|
|||
{selectedSeason === season && (
|
||||
<View style={[
|
||||
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
|
||||
style={[
|
||||
styles.seasonButtonText,
|
||||
isTablet && styles.seasonButtonTextTablet,
|
||||
{ color: currentTheme.colors.mediumEmphasis },
|
||||
{
|
||||
color: currentTheme.colors.mediumEmphasis,
|
||||
fontSize: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 14
|
||||
},
|
||||
selectedSeason === season && [
|
||||
styles.selectedSeasonButtonText,
|
||||
isTablet && styles.selectedSeasonButtonTextTablet,
|
||||
{ color: currentTheme.colors.primary }
|
||||
]
|
||||
]}
|
||||
>
|
||||
Season {season}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}}
|
||||
|
|
@ -550,22 +709,43 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
|
|||
key={episode.id}
|
||||
style={[
|
||||
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)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={[
|
||||
styles.episodeImageContainer,
|
||||
isTablet && styles.episodeImageContainerTablet
|
||||
{
|
||||
width: isTV ? 200 : isLargeTablet ? 180 : isTablet ? 160 : 120,
|
||||
height: isTV ? 200 : isLargeTablet ? 180 : isTablet ? 160 : 120
|
||||
}
|
||||
]}>
|
||||
<FastImage
|
||||
source={{ uri: episodeImage }}
|
||||
style={styles.episodeImage}
|
||||
resizeMode={FastImage.resizeMode.cover}
|
||||
/>
|
||||
<View style={styles.episodeNumberBadge}>
|
||||
<Text style={styles.episodeNumberText}>{episodeString}</Text>
|
||||
<View style={[
|
||||
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>
|
||||
{showProgress && (
|
||||
<View style={styles.progressBarContainer}>
|
||||
|
|
@ -578,53 +758,112 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
|
|||
</View>
|
||||
)}
|
||||
{progressPercent >= 85 && (
|
||||
<View style={[styles.completedBadge, { backgroundColor: currentTheme.colors.primary }]}>
|
||||
<MaterialIcons name="check" size={12} color={currentTheme.colors.white} />
|
||||
<View style={[
|
||||
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>
|
||||
)}
|
||||
{(!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 style={[
|
||||
styles.episodeInfo,
|
||||
isTablet && styles.episodeInfoTablet
|
||||
{
|
||||
paddingLeft: isTV ? 20 : isLargeTablet ? 18 : isTablet ? 16 : 12,
|
||||
flex: 1,
|
||||
justifyContent: 'center'
|
||||
}
|
||||
]}>
|
||||
<View style={[
|
||||
styles.episodeHeader,
|
||||
isTablet && styles.episodeHeaderTablet
|
||||
{
|
||||
marginBottom: isTV ? 8 : isLargeTablet ? 6 : isTablet ? 6 : 4
|
||||
}
|
||||
]}>
|
||||
<Text style={[
|
||||
styles.episodeTitle,
|
||||
isTablet && styles.episodeTitleTablet,
|
||||
{ color: currentTheme.colors.text }
|
||||
]} numberOfLines={2}>
|
||||
{
|
||||
color: currentTheme.colors.text,
|
||||
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}
|
||||
</Text>
|
||||
<View style={[
|
||||
styles.episodeMetadata,
|
||||
isTablet && styles.episodeMetadataTablet
|
||||
{
|
||||
gap: isTV ? 8 : isLargeTablet ? 7 : isTablet ? 6 : 4,
|
||||
flexWrap: 'wrap'
|
||||
}
|
||||
]}>
|
||||
{effectiveVote > 0 && (
|
||||
<View style={styles.ratingContainer}>
|
||||
<FastImage
|
||||
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}
|
||||
/>
|
||||
<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)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{effectiveRuntime && (
|
||||
<View style={styles.runtimeContainer}>
|
||||
<MaterialIcons name="schedule" size={14} color={currentTheme.colors.textMuted} />
|
||||
<Text style={[styles.runtimeText, { 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,
|
||||
fontSize: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 13 : 13
|
||||
}
|
||||
]}>
|
||||
{formatRuntime(effectiveRuntime)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{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)}
|
||||
</Text>
|
||||
)}
|
||||
|
|
@ -632,9 +871,12 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
|
|||
</View>
|
||||
<Text style={[
|
||||
styles.episodeOverview,
|
||||
isTablet && styles.episodeOverviewTablet,
|
||||
{ color: currentTheme.colors.mediumEmphasis }
|
||||
]} numberOfLines={isTablet ? 3 : 2}>
|
||||
{
|
||||
color: currentTheme.colors.mediumEmphasis,
|
||||
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'}
|
||||
</Text>
|
||||
</View>
|
||||
|
|
@ -684,47 +926,25 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
|
|||
key={episode.id}
|
||||
style={[
|
||||
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
|
||||
{
|
||||
borderWidth: 1,
|
||||
borderColor: 'transparent',
|
||||
borderColor: 'rgba(255,255,255,0.12)',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 8,
|
||||
elevation: 12,
|
||||
}
|
||||
]}
|
||||
onPress={() => onSelectEpisode(episode)}
|
||||
activeOpacity={0.85}
|
||||
>
|
||||
{/* Gradient Border Container */}
|
||||
<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>
|
||||
{/* Solid outline replaces gradient border */}
|
||||
|
||||
{/* Background Image */}
|
||||
<FastImage
|
||||
|
|
@ -746,35 +966,88 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
|
|||
style={styles.episodeGradient}
|
||||
>
|
||||
{/* 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 */}
|
||||
<View style={[styles.episodeNumberBadgeHorizontal, isTablet && styles.episodeNumberBadgeHorizontalTablet]}>
|
||||
<Text style={[styles.episodeNumberHorizontal, isTablet && styles.episodeNumberHorizontalTablet]}>{episodeString}</Text>
|
||||
<View style={[
|
||||
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>
|
||||
|
||||
{/* 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}
|
||||
</Text>
|
||||
|
||||
{/* 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'}
|
||||
</Text>
|
||||
|
||||
{/* Metadata Row */}
|
||||
<View style={styles.episodeMetadataRowHorizontal}>
|
||||
<View style={[
|
||||
styles.episodeMetadataRowHorizontal,
|
||||
{
|
||||
gap: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8
|
||||
}
|
||||
]}>
|
||||
{episode.runtime && (
|
||||
<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)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{episode.vote_average > 0 && (
|
||||
<View style={styles.ratingContainerHorizontal}>
|
||||
<MaterialIcons name="star" size={14} color="#FFD700" />
|
||||
<Text style={styles.ratingTextHorizontal}>
|
||||
<MaterialIcons name="star" size={isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 14} color="#FFD700" />
|
||||
<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)}
|
||||
</Text>
|
||||
</View>
|
||||
|
|
@ -799,12 +1072,34 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
|
|||
|
||||
{/* Completed Badge */}
|
||||
{progressPercent >= 85 && (
|
||||
<View style={[styles.completedBadgeHorizontal, {
|
||||
backgroundColor: currentTheme.colors.primary,
|
||||
}]}>
|
||||
<MaterialIcons name="check" size={16} color="#fff" />
|
||||
<View style={[
|
||||
styles.completedBadgeHorizontal,
|
||||
{
|
||||
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>
|
||||
)}
|
||||
{(!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>
|
||||
</TouchableOpacity>
|
||||
|
|
@ -824,7 +1119,15 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
|
|||
<Animated.View
|
||||
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'}
|
||||
</Text>
|
||||
|
||||
|
|
@ -854,7 +1157,10 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
|
|||
entering={enableItemAnimations ? FadeIn.duration(300).delay(100 + index * 30) : undefined as any}
|
||||
style={[
|
||||
styles.episodeCardWrapperHorizontal,
|
||||
isTablet && styles.episodeCardWrapperHorizontalTablet
|
||||
{
|
||||
width: horizontalCardWidth,
|
||||
marginRight: horizontalItemSpacing
|
||||
}
|
||||
]}
|
||||
>
|
||||
{renderHorizontalEpisodeCard(episode)}
|
||||
|
|
@ -863,17 +1169,22 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
|
|||
keyExtractor={episode => episode.id.toString()}
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={isTablet ? styles.episodeListContentHorizontalTablet : styles.episodeListContentHorizontal}
|
||||
contentContainerStyle={[
|
||||
styles.episodeListContentHorizontal,
|
||||
{
|
||||
paddingLeft: horizontalPadding,
|
||||
paddingRight: horizontalPadding
|
||||
}
|
||||
]}
|
||||
removeClippedSubviews
|
||||
initialNumToRender={3}
|
||||
maxToRenderPerBatch={5}
|
||||
windowSize={5}
|
||||
getItemLayout={(data, index) => {
|
||||
const cardWidth = isTablet ? width * 0.4 : width * 0.75;
|
||||
const margin = isTablet ? 20 : 16;
|
||||
const length = horizontalCardWidth + horizontalItemSpacing;
|
||||
return {
|
||||
length: cardWidth + margin,
|
||||
offset: (cardWidth + margin) * index,
|
||||
length,
|
||||
offset: length * index,
|
||||
index,
|
||||
};
|
||||
}}
|
||||
|
|
@ -892,7 +1203,13 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
|
|||
</Animated.View>
|
||||
)}
|
||||
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
|
||||
/>
|
||||
)
|
||||
|
|
@ -937,11 +1254,6 @@ const styles = StyleSheet.create({
|
|||
// Vertical Layout Styles
|
||||
episodeListContentVertical: {
|
||||
paddingBottom: 8,
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
episodeListContentVerticalTablet: {
|
||||
paddingHorizontal: 16,
|
||||
paddingBottom: 8,
|
||||
},
|
||||
episodeGridVertical: {
|
||||
flexDirection: 'row',
|
||||
|
|
@ -1098,20 +1410,10 @@ const styles = StyleSheet.create({
|
|||
|
||||
// Horizontal Layout Styles
|
||||
episodeListContentHorizontal: {
|
||||
paddingLeft: 16,
|
||||
paddingRight: 16,
|
||||
},
|
||||
episodeListContentHorizontalTablet: {
|
||||
paddingLeft: 24,
|
||||
paddingRight: 24,
|
||||
// Padding will be added responsively
|
||||
},
|
||||
episodeCardWrapperHorizontal: {
|
||||
width: Dimensions.get('window').width * 0.75,
|
||||
marginRight: 16,
|
||||
},
|
||||
episodeCardWrapperHorizontalTablet: {
|
||||
width: Dimensions.get('window').width * 0.4,
|
||||
marginRight: 20,
|
||||
// Dimensions will be set responsively
|
||||
},
|
||||
episodeCardHorizontal: {
|
||||
borderRadius: 16,
|
||||
|
|
@ -1128,13 +1430,6 @@ const styles = StyleSheet.create({
|
|||
width: '100%',
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
episodeCardHorizontalTablet: {
|
||||
height: 260,
|
||||
borderRadius: 20,
|
||||
elevation: 12,
|
||||
shadowOpacity: 0.4,
|
||||
shadowRadius: 16,
|
||||
},
|
||||
episodeBackgroundImage: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
|
|
@ -1273,11 +1568,6 @@ const styles = StyleSheet.create({
|
|||
// Season Selector Styles
|
||||
seasonSelectorWrapper: {
|
||||
marginBottom: 20,
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
seasonSelectorWrapperTablet: {
|
||||
marginBottom: 24,
|
||||
paddingHorizontal: 24,
|
||||
},
|
||||
seasonSelectorHeader: {
|
||||
flexDirection: 'row',
|
||||
|
|
@ -1306,32 +1596,14 @@ const styles = StyleSheet.create({
|
|||
},
|
||||
seasonButton: {
|
||||
alignItems: 'center',
|
||||
marginRight: 16,
|
||||
width: 100,
|
||||
},
|
||||
seasonButtonTablet: {
|
||||
alignItems: 'center',
|
||||
marginRight: 20,
|
||||
width: 120,
|
||||
},
|
||||
selectedSeasonButton: {
|
||||
opacity: 1,
|
||||
},
|
||||
seasonPosterContainer: {
|
||||
position: 'relative',
|
||||
width: 100,
|
||||
height: 150,
|
||||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
marginBottom: 8,
|
||||
},
|
||||
seasonPosterContainerTablet: {
|
||||
position: 'relative',
|
||||
width: 120,
|
||||
height: 180,
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
marginBottom: 12,
|
||||
},
|
||||
seasonPoster: {
|
||||
width: '100%',
|
||||
|
|
@ -1382,22 +1654,7 @@ const styles = StyleSheet.create({
|
|||
},
|
||||
seasonTextButton: {
|
||||
alignItems: 'center',
|
||||
marginRight: 16,
|
||||
width: 110,
|
||||
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',
|
||||
},
|
||||
selectedSeasonTextButton: {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useState, useEffect, useCallback, memo, useRef } from 'react';
|
||||
import React, { useState, useEffect, useCallback, memo, useRef, useMemo } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
|
|
@ -21,8 +21,13 @@ import TrailerService from '../../services/trailerService';
|
|||
import TrailerModal from './TrailerModal';
|
||||
import Animated, { useSharedValue, withTiming, withDelay, useAnimatedStyle } from 'react-native-reanimated';
|
||||
|
||||
const { width } = Dimensions.get('window');
|
||||
const isTablet = width >= 768;
|
||||
// Enhanced responsive breakpoints for Trailers Section
|
||||
const BREAKPOINTS = {
|
||||
phone: 0,
|
||||
tablet: 768,
|
||||
largeTablet: 1024,
|
||||
tv: 1440,
|
||||
};
|
||||
|
||||
interface TrailerVideo {
|
||||
id: string;
|
||||
|
|
@ -66,6 +71,65 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
|
|||
const [dropdownVisible, setDropdownVisible] = useState(false);
|
||||
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
|
||||
const sectionOpacitySV = useSharedValue(0);
|
||||
const sectionTranslateYSV = useSharedValue(8);
|
||||
|
|
@ -462,22 +526,48 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
|
|||
}
|
||||
|
||||
return (
|
||||
<Animated.View style={[styles.container, sectionAnimatedStyle]}>
|
||||
<Animated.View style={[
|
||||
styles.container,
|
||||
sectionAnimatedStyle,
|
||||
{ paddingHorizontal: horizontalPadding }
|
||||
]}>
|
||||
{/* Enhanced Header with Category Selector */}
|
||||
<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
|
||||
</Text>
|
||||
|
||||
{/* Category Selector - Right Aligned */}
|
||||
{trailerCategories.length > 0 && selectedCategory && (
|
||||
<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}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<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}
|
||||
ellipsizeMode="tail"
|
||||
>
|
||||
|
|
@ -485,7 +575,7 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
|
|||
</Text>
|
||||
<MaterialIcons
|
||||
name={dropdownVisible ? "expand-less" : "expand-more"}
|
||||
size={18}
|
||||
size={isTV ? 22 : isLargeTablet ? 20 : isTablet ? 18 : 18}
|
||||
color="rgba(255,255,255,0.7)"
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
|
@ -506,32 +596,58 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
|
|||
>
|
||||
<View style={[styles.dropdownContainer, {
|
||||
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 => (
|
||||
<TouchableOpacity
|
||||
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)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={styles.dropdownItemContent}>
|
||||
<View style={[styles.categoryIconContainer, {
|
||||
backgroundColor: currentTheme.colors.primary + '15'
|
||||
}]}>
|
||||
<View style={[
|
||||
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
|
||||
name={getTrailerTypeIcon(category) as any}
|
||||
size={14}
|
||||
size={isTV ? 18 : isLargeTablet ? 16 : isTablet ? 14 : 14}
|
||||
color={currentTheme.colors.primary}
|
||||
/>
|
||||
</View>
|
||||
<Text style={[
|
||||
styles.dropdownItemText,
|
||||
{ color: currentTheme.colors.highEmphasis }
|
||||
{
|
||||
color: currentTheme.colors.highEmphasis,
|
||||
fontSize: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 16
|
||||
}
|
||||
]}>
|
||||
{formatTrailerType(category)}
|
||||
</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}
|
||||
</Text>
|
||||
</View>
|
||||
|
|
@ -548,16 +664,25 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
|
|||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.trailersScrollContent}
|
||||
contentContainerStyle={[
|
||||
styles.trailersScrollContent,
|
||||
{ gap: trailerCardSpacing }
|
||||
]}
|
||||
style={styles.trailersScrollView}
|
||||
decelerationRate="fast"
|
||||
snapToInterval={isTablet ? 212 : 182} // card width + gap for smooth scrolling
|
||||
snapToInterval={trailerCardWidth + trailerCardSpacing} // card width + gap for smooth scrolling
|
||||
snapToAlignment="start"
|
||||
>
|
||||
{trailers[selectedCategory].map((trailer, index) => (
|
||||
<TouchableOpacity
|
||||
key={trailer.id}
|
||||
style={styles.trailerCard}
|
||||
style={[
|
||||
styles.trailerCard,
|
||||
{
|
||||
width: trailerCardWidth,
|
||||
borderRadius: isTV ? 20 : isLargeTablet ? 18 : isTablet ? 16 : 16
|
||||
}
|
||||
]}
|
||||
onPress={() => handleTrailerPress(trailer)}
|
||||
activeOpacity={0.9}
|
||||
>
|
||||
|
|
@ -565,33 +690,71 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
|
|||
<View style={styles.thumbnailWrapper}>
|
||||
<FastImage
|
||||
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}
|
||||
/>
|
||||
{/* 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>
|
||||
|
||||
{/* Trailer Info */}
|
||||
<View style={styles.trailerInfo}>
|
||||
<View style={[
|
||||
styles.trailerInfo,
|
||||
{
|
||||
padding: isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 12
|
||||
}
|
||||
]}>
|
||||
<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}
|
||||
>
|
||||
{trailer.displayName || trailer.name}
|
||||
</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()}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
{/* Scroll Indicator - shows when there are more items to scroll */}
|
||||
{trailers[selectedCategory].length > (isTablet ? 4 : 3) && (
|
||||
<View style={styles.scrollIndicator}>
|
||||
{trailers[selectedCategory].length > (isTV ? 5 : isLargeTablet ? 4 : isTablet ? 4 : 3) && (
|
||||
<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
|
||||
name="chevron-right"
|
||||
size={20}
|
||||
size={isTV ? 24 : isLargeTablet ? 22 : isTablet ? 20 : 20}
|
||||
color={currentTheme.colors.textMuted}
|
||||
style={{ opacity: 0.6 }}
|
||||
/>
|
||||
|
|
@ -614,7 +777,6 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
|
|||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
paddingHorizontal: 16,
|
||||
marginTop: 24,
|
||||
marginBottom: 16,
|
||||
},
|
||||
|
|
@ -749,13 +911,11 @@ const styles = StyleSheet.create({
|
|||
},
|
||||
trailersScrollContent: {
|
||||
paddingHorizontal: 4, // Restore padding for first/last items
|
||||
gap: 12,
|
||||
paddingRight: 20, // Extra padding at end for scroll indicator
|
||||
},
|
||||
|
||||
// Enhanced Trailer Card Styles
|
||||
trailerCard: {
|
||||
width: isTablet ? 200 : 170,
|
||||
backgroundColor: 'rgba(255,255,255,0.03)',
|
||||
borderRadius: 16,
|
||||
borderWidth: 1,
|
||||
|
|
|
|||
|
|
@ -1588,6 +1588,11 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
}
|
||||
|
||||
controlsTimeout.current = setTimeout(hideControls, 5000);
|
||||
|
||||
// Auto-fetch and load English external subtitles if available
|
||||
if (imdbId) {
|
||||
fetchAvailableSubtitles(undefined, true);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('[AndroidVideoPlayer] Error in onLoad:', error);
|
||||
// Set fallback values to prevent crashes
|
||||
|
|
|
|||
|
|
@ -12,6 +12,11 @@ interface KSPlayerViewProps {
|
|||
volume?: number;
|
||||
audioTrack?: number;
|
||||
textTrack?: number;
|
||||
allowsExternalPlayback?: boolean;
|
||||
usesExternalPlaybackWhileExternalScreenIsActive?: boolean;
|
||||
subtitleBottomOffset?: number;
|
||||
subtitleFontSize?: number;
|
||||
resizeMode?: 'contain' | 'cover' | 'stretch';
|
||||
onLoad?: (data: any) => void;
|
||||
onProgress?: (data: any) => void;
|
||||
onBuffering?: (data: any) => void;
|
||||
|
|
@ -32,6 +37,10 @@ export interface KSPlayerRef {
|
|||
setAudioTrack: (trackId: number) => void;
|
||||
setTextTrack: (trackId: number) => void;
|
||||
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 {
|
||||
|
|
@ -40,6 +49,11 @@ export interface KSPlayerProps {
|
|||
volume?: number;
|
||||
audioTrack?: number;
|
||||
textTrack?: number;
|
||||
allowsExternalPlayback?: boolean;
|
||||
usesExternalPlaybackWhileExternalScreenIsActive?: boolean;
|
||||
subtitleBottomOffset?: number;
|
||||
subtitleFontSize?: number;
|
||||
resizeMode?: 'contain' | 'cover' | 'stretch';
|
||||
onLoad?: (data: any) => void;
|
||||
onProgress?: (data: any) => void;
|
||||
onBuffering?: (data: any) => void;
|
||||
|
|
@ -109,6 +123,38 @@ const KSPlayer = forwardRef<KSPlayerRef, KSPlayerProps>((props, ref) => {
|
|||
}
|
||||
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
|
||||
|
|
@ -129,6 +175,11 @@ const KSPlayer = forwardRef<KSPlayerRef, KSPlayerProps>((props, ref) => {
|
|||
volume={props.volume}
|
||||
audioTrack={props.audioTrack}
|
||||
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)}
|
||||
onProgress={(e: any) => props.onProgress?.(e?.nativeEvent ?? e)}
|
||||
onBuffering={(e: any) => props.onBuffering?.(e?.nativeEvent ?? e)}
|
||||
|
|
|
|||
|
|
@ -94,13 +94,20 @@ const KSPlayerCore: React.FC = () => {
|
|||
const screenData = Dimensions.get('screen');
|
||||
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 shouldUseFullscreen = isIPad;
|
||||
const isMacOS = Platform.OS === 'ios' && Platform.isPad === true;
|
||||
const shouldUseFullscreen = isIPad || isMacOS;
|
||||
|
||||
// Use window dimensions for iPad instead of screen dimensions
|
||||
const windowData = Dimensions.get('window');
|
||||
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 [currentTime, setCurrentTime] = useState(0);
|
||||
|
|
@ -111,6 +118,7 @@ const KSPlayerCore: React.FC = () => {
|
|||
const [textTracks, setTextTracks] = useState<TextTrack[]>([]);
|
||||
const [selectedTextTrack, setSelectedTextTrack] = useState<number>(-1);
|
||||
const [resizeMode, setResizeMode] = useState<ResizeModeType>('contain');
|
||||
const [playerBackend, setPlayerBackend] = useState<string>('');
|
||||
const [buffered, setBuffered] = useState(0);
|
||||
const [seekPosition, setSeekPosition] = useState<number | null>(null);
|
||||
const ksPlayerRef = useRef<KSPlayerRef>(null);
|
||||
|
|
@ -253,6 +261,10 @@ const KSPlayerCore: React.FC = () => {
|
|||
const controlsTimeout = useRef<NodeJS.Timeout | null>(null);
|
||||
const [isSyncingBeforeClose, setIsSyncingBeforeClose] = useState(false);
|
||||
|
||||
// AirPlay state
|
||||
const [isAirPlayActive, setIsAirPlayActive] = useState<boolean>(false);
|
||||
const [allowsAirPlay, setAllowsAirPlay] = useState<boolean>(true);
|
||||
|
||||
// Silent startup-timeout retry state
|
||||
const startupRetryCountRef = useRef(0);
|
||||
const startupRetryTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
|
@ -949,6 +961,18 @@ const KSPlayerCore: React.FC = () => {
|
|||
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
|
||||
if (!isOpeningAnimationComplete) {
|
||||
setIsVideoLoaded(true);
|
||||
|
|
@ -1032,6 +1056,24 @@ const KSPlayerCore: React.FC = () => {
|
|||
logger.error('[VideoPlayer] onLoad called with null/undefined data');
|
||||
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
|
||||
const videoDuration = data.duration;
|
||||
if (DEBUG_MODE) {
|
||||
|
|
@ -1242,6 +1284,11 @@ const KSPlayerCore: React.FC = () => {
|
|||
}
|
||||
|
||||
controlsTimeout.current = setTimeout(hideControls, 5000);
|
||||
|
||||
// Auto-fetch and load English external subtitles if available
|
||||
if (imdbId) {
|
||||
fetchAvailableSubtitles(undefined, true);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('[VideoPlayer] Error in onLoad:', error);
|
||||
// Set fallback values to prevent crashes
|
||||
|
|
@ -1268,6 +1315,12 @@ const KSPlayerCore: React.FC = () => {
|
|||
};
|
||||
|
||||
const cycleAspectRatio = () => {
|
||||
// iOS KSPlayer: toggle native resize mode so subtitles remain independent
|
||||
if (Platform.OS === 'ios') {
|
||||
setResizeMode((prev) => (prev === 'cover' ? 'contain' : 'cover'));
|
||||
return;
|
||||
}
|
||||
// Fallback (non‑iOS paths): keep legacy zoom behavior
|
||||
const newZoom = zoomScale === 1.1 ? 1 : 1.1;
|
||||
setZoomScale(newZoom);
|
||||
setZoomTranslateX(0);
|
||||
|
|
@ -2204,7 +2257,7 @@ const KSPlayerCore: React.FC = () => {
|
|||
if (typeof saved.subtitleOutlineColor === 'string') setSubtitleOutlineColor(saved.subtitleOutlineColor);
|
||||
if (typeof saved.subtitleOutlineWidth === 'number') setSubtitleOutlineWidth(saved.subtitleOutlineWidth);
|
||||
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.subtitleLineHeightMultiplier === 'number') setSubtitleLineHeightMultiplier(saved.subtitleLineHeightMultiplier);
|
||||
if (typeof saved.subtitleOffsetSec === 'number') setSubtitleOffsetSec(saved.subtitleOffsetSec);
|
||||
|
|
@ -2244,7 +2297,7 @@ const KSPlayerCore: React.FC = () => {
|
|||
subtitleOutlineColor,
|
||||
subtitleOutlineWidth,
|
||||
subtitleAlign,
|
||||
subtitleBottomOffset,
|
||||
subtitleBottomOffset,
|
||||
subtitleLetterSpacing,
|
||||
subtitleLineHeightMultiplier,
|
||||
subtitleOffsetSec,
|
||||
|
|
@ -2326,6 +2379,27 @@ const KSPlayerCore: React.FC = () => {
|
|||
}
|
||||
}, [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) => {
|
||||
if (newStream.url === currentStreamUrl) {
|
||||
setShowSourcesModal(false);
|
||||
|
|
@ -2419,7 +2493,7 @@ const KSPlayerCore: React.FC = () => {
|
|||
<View style={[
|
||||
styles.container,
|
||||
shouldUseFullscreen ? {
|
||||
// iPad fullscreen: use flex layout instead of absolute positioning
|
||||
// iPad/macOS fullscreen: use flex layout instead of absolute positioning
|
||||
flex: 1,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
|
|
@ -2438,8 +2512,8 @@ const KSPlayerCore: React.FC = () => {
|
|||
{
|
||||
opacity: backgroundFadeAnim,
|
||||
zIndex: shouldHideOpeningOverlay ? -1 : 3000,
|
||||
width: screenDimensions.width,
|
||||
height: screenDimensions.height,
|
||||
width: shouldUseFullscreen ? '100%' : screenDimensions.width,
|
||||
height: shouldUseFullscreen ? '100%' : screenDimensions.height,
|
||||
}
|
||||
]}
|
||||
pointerEvents={shouldHideOpeningOverlay ? 'none' : 'auto'}
|
||||
|
|
@ -2448,8 +2522,8 @@ const KSPlayerCore: React.FC = () => {
|
|||
<Animated.View style={[
|
||||
StyleSheet.absoluteFill,
|
||||
{
|
||||
width: screenDimensions.width,
|
||||
height: screenDimensions.height,
|
||||
width: shouldUseFullscreen ? '100%' : screenDimensions.width,
|
||||
height: shouldUseFullscreen ? '100%' : screenDimensions.height,
|
||||
opacity: backdropImageOpacityAnim
|
||||
}
|
||||
]}>
|
||||
|
|
@ -2514,8 +2588,8 @@ const KSPlayerCore: React.FC = () => {
|
|||
style={[
|
||||
styles.sourceChangeOverlay,
|
||||
{
|
||||
width: screenDimensions.width,
|
||||
height: screenDimensions.height,
|
||||
width: shouldUseFullscreen ? '100%' : screenDimensions.width,
|
||||
height: shouldUseFullscreen ? '100%' : screenDimensions.height,
|
||||
opacity: fadeAnim,
|
||||
}
|
||||
]}
|
||||
|
|
@ -2535,8 +2609,8 @@ const KSPlayerCore: React.FC = () => {
|
|||
{
|
||||
opacity: DISABLE_OPENING_OVERLAY ? 1 : openingFadeAnim,
|
||||
transform: DISABLE_OPENING_OVERLAY ? [] : [{ scale: openingScaleAnim }],
|
||||
width: screenDimensions.width,
|
||||
height: screenDimensions.height,
|
||||
width: shouldUseFullscreen ? '100%' : screenDimensions.width,
|
||||
height: shouldUseFullscreen ? '100%' : screenDimensions.height,
|
||||
}
|
||||
]}
|
||||
>
|
||||
|
|
@ -2556,10 +2630,10 @@ const KSPlayerCore: React.FC = () => {
|
|||
>
|
||||
<View style={{
|
||||
position: 'absolute',
|
||||
top: screenDimensions.height * 0.15, // Back to original margin
|
||||
top: getDimensions().height * 0.15, // Back to original margin
|
||||
left: 0,
|
||||
width: screenDimensions.width * 0.4, // Back to larger area (40% of screen)
|
||||
height: screenDimensions.height * 0.7, // Back to larger middle portion (70% of screen)
|
||||
width: getDimensions().width * 0.4, // Back to larger area (40% of screen)
|
||||
height: getDimensions().height * 0.7, // Back to larger middle portion (70% of screen)
|
||||
zIndex: 10, // Higher z-index to capture gestures
|
||||
}} />
|
||||
</TapGestureHandler>
|
||||
|
|
@ -2581,10 +2655,10 @@ const KSPlayerCore: React.FC = () => {
|
|||
>
|
||||
<View style={{
|
||||
position: 'absolute',
|
||||
top: screenDimensions.height * 0.15, // Back to original margin
|
||||
top: getDimensions().height * 0.15, // Back to original margin
|
||||
right: 0,
|
||||
width: screenDimensions.width * 0.4, // Back to larger area (40% of screen)
|
||||
height: screenDimensions.height * 0.7, // Back to larger middle portion (70% of screen)
|
||||
width: getDimensions().width * 0.4, // Back to larger area (40% of screen)
|
||||
height: getDimensions().height * 0.7, // Back to larger middle portion (70% of screen)
|
||||
zIndex: 10, // Higher z-index to capture gestures
|
||||
}} />
|
||||
</TapGestureHandler>
|
||||
|
|
@ -2613,18 +2687,18 @@ const KSPlayerCore: React.FC = () => {
|
|||
>
|
||||
<View style={{
|
||||
position: 'absolute',
|
||||
top: screenDimensions.height * 0.15,
|
||||
left: screenDimensions.width * 0.4, // Start after left gesture area
|
||||
width: screenDimensions.width * 0.2, // Center area (20% of screen)
|
||||
height: screenDimensions.height * 0.7,
|
||||
top: getDimensions().height * 0.15,
|
||||
left: getDimensions().width * 0.4, // Start after left gesture area
|
||||
width: getDimensions().width * 0.2, // Center area (20% of screen)
|
||||
height: getDimensions().height * 0.7,
|
||||
zIndex: 5, // Lower z-index, controls use box-none to allow touches through
|
||||
}} />
|
||||
</TapGestureHandler>
|
||||
|
||||
<View
|
||||
style={[styles.videoContainer, {
|
||||
width: screenDimensions.width,
|
||||
height: screenDimensions.height,
|
||||
width: shouldUseFullscreen ? '100%' : screenDimensions.width,
|
||||
height: shouldUseFullscreen ? '100%' : screenDimensions.height,
|
||||
}]}
|
||||
>
|
||||
|
||||
|
|
@ -2637,8 +2711,8 @@ const KSPlayerCore: React.FC = () => {
|
|||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: screenDimensions.width,
|
||||
height: screenDimensions.height,
|
||||
width: getDimensions().width,
|
||||
height: getDimensions().height,
|
||||
}}>
|
||||
<TouchableOpacity
|
||||
style={{ flex: 1 }}
|
||||
|
|
@ -2649,7 +2723,7 @@ const KSPlayerCore: React.FC = () => {
|
|||
>
|
||||
<KSPlayerComponent
|
||||
ref={ksPlayerRef}
|
||||
style={[styles.video, customVideoStyles, { transform: [{ scale: zoomScale }] }]}
|
||||
style={styles.video}
|
||||
source={{
|
||||
uri: currentStreamUrl,
|
||||
headers: headers && Object.keys(headers).length > 0 ? headers : undefined
|
||||
|
|
@ -2658,6 +2732,11 @@ const KSPlayerCore: React.FC = () => {
|
|||
volume={volume / 100}
|
||||
audioTrack={selectedAudioTrack ?? undefined}
|
||||
textTrack={useCustomSubtitles ? -1 : selectedTextTrack}
|
||||
allowsExternalPlayback={allowsAirPlay}
|
||||
usesExternalPlaybackWhileExternalScreenIsActive={true}
|
||||
subtitleBottomOffset={subtitleBottomOffset}
|
||||
subtitleFontSize={subtitleSize}
|
||||
resizeMode={resizeMode === 'none' ? 'contain' : resizeMode}
|
||||
onProgress={handleProgress}
|
||||
onLoad={onLoad}
|
||||
onEnd={onEnd}
|
||||
|
|
@ -2690,6 +2769,7 @@ const KSPlayerCore: React.FC = () => {
|
|||
skip={skip}
|
||||
handleClose={handleClose}
|
||||
cycleAspectRatio={cycleAspectRatio}
|
||||
currentResizeMode={resizeMode}
|
||||
setShowAudioModal={setShowAudioModal}
|
||||
setShowSubtitleModal={setShowSubtitleModal}
|
||||
isSubtitleModalOpen={showSubtitleModal}
|
||||
|
|
@ -2699,8 +2779,12 @@ const KSPlayerCore: React.FC = () => {
|
|||
onSlidingComplete={handleSlidingComplete}
|
||||
buffered={buffered}
|
||||
formatTime={formatTime}
|
||||
playerBackend={playerBackend}
|
||||
cyclePlaybackSpeed={cyclePlaybackSpeed}
|
||||
currentPlaybackSpeed={playbackSpeed}
|
||||
isAirPlayActive={isAirPlayActive}
|
||||
allowsAirPlay={allowsAirPlay}
|
||||
onAirPlayPress={handleAirPlayPress}
|
||||
/>
|
||||
|
||||
{showPauseOverlay && (
|
||||
|
|
@ -2727,7 +2811,7 @@ const KSPlayerCore: React.FC = () => {
|
|||
}}
|
||||
>
|
||||
{/* 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
|
||||
start={{ x: 0, y: 0.5 }}
|
||||
end={{ x: 1, y: 0.5 }}
|
||||
|
|
@ -3085,8 +3169,8 @@ const KSPlayerCore: React.FC = () => {
|
|||
<Animated.View
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: screenDimensions.width / 2 - 60,
|
||||
top: screenDimensions.height / 2 - 60,
|
||||
left: getDimensions().width / 2 - 60,
|
||||
top: getDimensions().height / 2 - 60,
|
||||
opacity: volumeOverlayOpacity,
|
||||
zIndex: 1000,
|
||||
}}
|
||||
|
|
@ -3182,8 +3266,8 @@ const KSPlayerCore: React.FC = () => {
|
|||
<Animated.View
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: screenDimensions.width / 2 - 60,
|
||||
top: screenDimensions.height / 2 - 60,
|
||||
left: getDimensions().width / 2 - 60,
|
||||
top: getDimensions().height / 2 - 60,
|
||||
opacity: brightnessOverlayOpacity,
|
||||
zIndex: 1000,
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import React from 'react';
|
||||
import { View, Text, TouchableOpacity, Animated, StyleSheet, Platform, Dimensions } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import Feather from 'react-native-vector-icons/Feather';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import Slider from '@react-native-community/slider';
|
||||
import { styles } from '../utils/playerStyles';
|
||||
|
|
@ -43,6 +44,10 @@ interface PlayerControlsProps {
|
|||
buffered: number;
|
||||
formatTime: (seconds: number) => string;
|
||||
playerBackend?: string;
|
||||
// AirPlay props
|
||||
isAirPlayActive?: boolean;
|
||||
allowsAirPlay?: boolean;
|
||||
onAirPlayPress?: () => void;
|
||||
}
|
||||
|
||||
export const PlayerControls: React.FC<PlayerControlsProps> = ({
|
||||
|
|
@ -80,6 +85,9 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
|
|||
buffered,
|
||||
formatTime,
|
||||
playerBackend,
|
||||
isAirPlayActive,
|
||||
allowsAirPlay,
|
||||
onAirPlayPress,
|
||||
}) => {
|
||||
const { currentTheme } = useTheme();
|
||||
|
||||
|
|
@ -232,6 +240,22 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
|
|||
|
||||
|
||||
|
||||
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';
|
||||
|
||||
const closeIconSize = isTV ? 28 : isLargeTablet ? 26 : isTablet ? 24 : 24;
|
||||
const skipIconSize = isTV ? 28 : isLargeTablet ? 26 : isTablet ? 24 : 24;
|
||||
const playIconSize = isTV ? 56 : isLargeTablet ? 48 : isTablet ? 44 : 40;
|
||||
return (
|
||||
<Animated.View
|
||||
style={[StyleSheet.absoluteFill, { opacity: fadeAnim, zIndex: 20 }]}
|
||||
|
|
@ -291,7 +315,7 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
|
|||
)}
|
||||
</View>
|
||||
<TouchableOpacity style={styles.closeButton} onPress={handleClose}>
|
||||
<Ionicons name="close" size={24} color="white" />
|
||||
<Ionicons name="close" size={closeIconSize} color="white" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</LinearGradient>
|
||||
|
|
@ -469,6 +493,18 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
|
|||
]} />
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
{/* Center Controls (Play/Pause, Skip) */}
|
||||
<View style={styles.controls}>
|
||||
<TouchableOpacity onPress={() => skip(-10)} style={styles.skipButton}>
|
||||
<Ionicons name="play-back" size={skipIconSize} color="white" />
|
||||
<Text style={styles.skipText}>10</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={togglePlayback} style={styles.playButton}>
|
||||
<Ionicons name={paused ? "play" : "pause"} size={playIconSize} color="white" />
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={() => skip(10)} style={styles.skipButton}>
|
||||
<Ionicons name="play-forward" size={skipIconSize} color="white" />
|
||||
<Text style={styles.skipText}>10</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
|
|
@ -548,6 +584,26 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
|
|||
</Text>
|
||||
</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>
|
||||
</LinearGradient>
|
||||
|
|
|
|||
|
|
@ -306,47 +306,75 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
|
|||
Built-in Subtitles
|
||||
</Text>
|
||||
|
||||
{/* Notice about built-in subtitle limitations - only when KSPlayer active on iOS */}
|
||||
{isIos && isKsPlayerActive && (
|
||||
{/* Built-in subtitles now enabled for KSPlayer */}
|
||||
{isKsPlayerActive && (
|
||||
<View style={{
|
||||
backgroundColor: 'rgba(255, 193, 7, 0.1)',
|
||||
backgroundColor: 'rgba(34, 197, 94, 0.1)',
|
||||
borderRadius: 12,
|
||||
padding: sectionPad,
|
||||
marginBottom: 15,
|
||||
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 }}>
|
||||
<MaterialIcons name="info" size={18} color="#FFC107" />
|
||||
<MaterialIcons name="check-circle" size={18} color="#22C55E" />
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={{
|
||||
color: '#FFC107',
|
||||
color: '#22C55E',
|
||||
fontSize: isCompact ? 12 : 13,
|
||||
fontWeight: '600',
|
||||
marginBottom: 4,
|
||||
}}>
|
||||
Built-in subtitles temporarily disabled
|
||||
Built-in subtitles enabled for KSPlayer
|
||||
</Text>
|
||||
<Text style={{
|
||||
color: 'rgba(255, 255, 255, 0.8)',
|
||||
fontSize: isCompact ? 11 : 12,
|
||||
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>
|
||||
</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 }}>
|
||||
{ksTextTracks.map((track) => {
|
||||
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 (
|
||||
<TouchableOpacity
|
||||
key={track.id}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
container: {
|
||||
|
|
@ -37,14 +71,14 @@ export const styles = StyleSheet.create({
|
|||
padding: 0,
|
||||
},
|
||||
topGradient: {
|
||||
paddingTop: 20,
|
||||
paddingHorizontal: 20,
|
||||
paddingBottom: 10,
|
||||
paddingTop: padV,
|
||||
paddingHorizontal: padH,
|
||||
paddingBottom: Math.max(10, Math.round(padV * 0.6)),
|
||||
},
|
||||
bottomGradient: {
|
||||
paddingBottom: 20,
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: 20,
|
||||
paddingBottom: padV,
|
||||
paddingHorizontal: padH,
|
||||
paddingTop: padV,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
|
|
@ -57,12 +91,12 @@ export const styles = StyleSheet.create({
|
|||
},
|
||||
title: {
|
||||
color: 'white',
|
||||
fontSize: 18,
|
||||
fontSize: titleFont,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
episodeInfo: {
|
||||
color: 'rgba(255, 255, 255, 0.9)',
|
||||
fontSize: 14,
|
||||
fontSize: episodeInfoFont,
|
||||
marginTop: 3,
|
||||
},
|
||||
metadataRow: {
|
||||
|
|
@ -73,20 +107,20 @@ export const styles = StyleSheet.create({
|
|||
},
|
||||
metadataText: {
|
||||
color: 'rgba(255, 255, 255, 0.7)',
|
||||
fontSize: 12,
|
||||
fontSize: metadataFont,
|
||||
marginRight: 8,
|
||||
},
|
||||
qualityBadge: {
|
||||
backgroundColor: 'rgba(229, 9, 20, 0.2)',
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 4,
|
||||
paddingHorizontal: qualityPadH,
|
||||
paddingVertical: qualityPadV,
|
||||
borderRadius: qualityRadius,
|
||||
marginRight: 8,
|
||||
marginBottom: 4,
|
||||
},
|
||||
qualityText: {
|
||||
color: '#E50914',
|
||||
fontSize: 11,
|
||||
fontSize: qualityTextFont,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
providerText: {
|
||||
|
|
@ -110,6 +144,11 @@ export const styles = StyleSheet.create({
|
|||
top: '50%',
|
||||
transform: [{ translateY: -50 }],
|
||||
paddingHorizontal: 20,
|
||||
gap: controlsGap,
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: '50%',
|
||||
transform: [{ translateY: controlsTranslateY }],
|
||||
zIndex: 1000,
|
||||
},
|
||||
|
||||
|
|
@ -176,6 +215,10 @@ export const styles = StyleSheet.create({
|
|||
borderRightColor: 'transparent',
|
||||
borderBottomColor: 'transparent',
|
||||
position: 'absolute',
|
||||
skipText: {
|
||||
color: 'white',
|
||||
fontSize: skipTextFont,
|
||||
marginTop: 2,
|
||||
},
|
||||
arcRight: {
|
||||
borderWidth: 4,
|
||||
|
|
@ -198,19 +241,19 @@ export const styles = StyleSheet.create({
|
|||
},
|
||||
sliderContainer: {
|
||||
position: 'absolute',
|
||||
bottom: 55,
|
||||
bottom: sliderBottom,
|
||||
left: 0,
|
||||
right: 0,
|
||||
paddingHorizontal: 20,
|
||||
paddingHorizontal: padH,
|
||||
zIndex: 1000,
|
||||
},
|
||||
progressTouchArea: {
|
||||
height: 40, // Increased from 30 to give more space for the thumb
|
||||
height: progressTouchHeight, // Increased touch area for larger displays
|
||||
justifyContent: 'center',
|
||||
width: '100%',
|
||||
},
|
||||
progressBarContainer: {
|
||||
height: 4,
|
||||
height: progressBarHeight,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.2)',
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
|
|
@ -234,12 +277,12 @@ export const styles = StyleSheet.create({
|
|||
},
|
||||
progressThumb: {
|
||||
position: 'absolute',
|
||||
width: 16,
|
||||
height: 16,
|
||||
borderRadius: 8,
|
||||
width: progressThumbSize,
|
||||
height: progressThumbSize,
|
||||
borderRadius: progressThumbSize / 2,
|
||||
backgroundColor: '#E50914',
|
||||
top: -6, // Position to center on the progress bar
|
||||
marginLeft: -8, // Center the thumb horizontally
|
||||
top: progressThumbTop, // Position to center on the progress bar
|
||||
marginLeft: -(progressThumbSize / 2), // Center the thumb horizontally
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.3,
|
||||
|
|
@ -257,7 +300,7 @@ export const styles = StyleSheet.create({
|
|||
},
|
||||
duration: {
|
||||
color: 'white',
|
||||
fontSize: 12,
|
||||
fontSize: durationFont,
|
||||
fontWeight: '500',
|
||||
},
|
||||
bottomButtons: {
|
||||
|
|
@ -272,7 +315,7 @@ export const styles = StyleSheet.create({
|
|||
},
|
||||
bottomButtonText: {
|
||||
color: 'white',
|
||||
fontSize: 12,
|
||||
fontSize: bottomButtonTextFont,
|
||||
},
|
||||
modalOverlay: {
|
||||
flex: 1,
|
||||
|
|
|
|||
|
|
@ -33,3 +33,4 @@ const styles = StyleSheet.create({
|
|||
});
|
||||
|
||||
export default ToastManager;
|
||||
|
||||
|
|
|
|||
|
|
@ -69,3 +69,4 @@ export const ToastProvider: React.FC<ToastProviderProps> = ({ children }) => {
|
|||
</ToastContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -58,12 +58,13 @@ const SPACING = {
|
|||
|
||||
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 MIN_ITEM_WIDTH = 120;
|
||||
const MAX_ITEM_WIDTH = 180; // Increased for tablets
|
||||
const HORIZONTAL_PADDING = SPACING.lg * 2;
|
||||
const ITEM_SPACING = SPACING.sm;
|
||||
// Increase padding and spacing on larger screens for proper breathing room
|
||||
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
|
||||
const availableWidth = screenWidth - HORIZONTAL_PADDING;
|
||||
|
|
@ -80,9 +81,12 @@ const calculateCatalogLayout = (screenWidth: number) => {
|
|||
} else if (screenWidth < 1200) {
|
||||
// Large tablet: 4-6 columns
|
||||
numColumns = Math.min(Math.max(maxColumns, 4), 6);
|
||||
} else {
|
||||
// Very large screens: 5-8 columns
|
||||
} else if (screenWidth < 1600) {
|
||||
// Desktop-ish: 5-8 columns
|
||||
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
|
||||
|
|
@ -90,11 +94,13 @@ const calculateCatalogLayout = (screenWidth: number) => {
|
|||
const itemWidth = (availableWidth - totalSpacing) / numColumns;
|
||||
|
||||
// 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 {
|
||||
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',
|
||||
paddingHorizontal: 16,
|
||||
paddingTop: Platform.OS === 'android' ? ANDROID_STATUSBAR_HEIGHT + 8 : 8,
|
||||
// Center header on very wide screens
|
||||
alignSelf: 'center',
|
||||
maxWidth: 1400,
|
||||
width: '100%',
|
||||
},
|
||||
backButton: {
|
||||
|
|
@ -131,17 +134,11 @@ const createStyles = (colors: any) => StyleSheet.create({
|
|||
paddingHorizontal: 16,
|
||||
paddingBottom: 16,
|
||||
paddingTop: 8,
|
||||
// Center title on very wide screens
|
||||
alignSelf: 'center',
|
||||
maxWidth: 1400,
|
||||
width: '100%',
|
||||
},
|
||||
list: {
|
||||
padding: SPACING.lg,
|
||||
paddingTop: SPACING.sm,
|
||||
// Center content on very wide screens
|
||||
alignSelf: 'center',
|
||||
maxWidth: 1400, // Prevent content from being too wide on large screens
|
||||
width: '100%',
|
||||
},
|
||||
item: {
|
||||
|
|
@ -653,11 +650,12 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
|
|||
const effectiveItemWidth = React.useMemo(() => {
|
||||
if (effectiveNumColumns === screenData.numColumns) return screenData.itemWidth;
|
||||
// recompute width for custom columns on mobile to maintain spacing roughly similar
|
||||
const HORIZONTAL_PADDING = 16 * 2; // SPACING.lg * 2
|
||||
const ITEM_SPACING = 8; // SPACING.sm
|
||||
const HORIZONTAL_PADDING = (screenData as any).containerPadding ? (screenData as any).containerPadding * 2 : 16 * 2;
|
||||
const ITEM_SPACING = (screenData as any).itemSpacing ?? 8;
|
||||
const availableWidth = screenData.width - HORIZONTAL_PADDING;
|
||||
const totalSpacing = ITEM_SPACING * (effectiveNumColumns - 1);
|
||||
return (availableWidth - totalSpacing) / effectiveNumColumns;
|
||||
const width = (availableWidth - totalSpacing) / effectiveNumColumns;
|
||||
return Math.floor(width);
|
||||
}, [effectiveNumColumns, screenData.width, screenData.itemWidth]);
|
||||
|
||||
// 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
|
||||
const isLastInRow = (index + 1) % effectiveNumColumns === 0;
|
||||
// For proper spacing
|
||||
const rightMargin = isLastInRow ? 0 : SPACING.sm;
|
||||
const rightMargin = isLastInRow ? 0 : ((screenData as any).itemSpacing ?? SPACING.sm);
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
|
|
@ -841,6 +839,7 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
|
|||
keyExtractor={(item) => `${item.id}-${item.type}`}
|
||||
numColumns={effectiveNumColumns}
|
||||
key={effectiveNumColumns}
|
||||
ItemSeparatorComponent={() => <View style={{ height: ((screenData as any).itemSpacing ?? SPACING.sm) }} />}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
|
|
@ -849,7 +848,7 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
|
|||
tintColor={colors.primary}
|
||||
/>
|
||||
}
|
||||
contentContainerStyle={styles.list}
|
||||
contentContainerStyle={[styles.list, { paddingHorizontal: (screenData as any).containerPadding ?? SPACING.lg, paddingTop: SPACING.sm, paddingBottom: SPACING.lg }]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
removeClippedSubviews={true}
|
||||
getItemType={() => 'item'}
|
||||
|
|
|
|||
|
|
@ -67,6 +67,14 @@ const MemoizedCastSection = memo(CastSection);
|
|||
const MemoizedSeriesContent = memo(SeriesContent);
|
||||
const MemoizedMovieContent = memo(MovieContent);
|
||||
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 MemoizedCommentsSection = memo(CommentsSection);
|
||||
const MemoizedCastDetailsModal = memo(CastDetailsModal);
|
||||
|
|
@ -90,6 +98,38 @@ const MetadataScreen: React.FC = () => {
|
|||
// Trakt integration
|
||||
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
|
||||
const [isContentReady, setIsContentReady] = 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 */}
|
||||
{shouldLoadSecondaryData && Object.keys(groupedEpisodes).length > 0 && metadata?.networks && metadata.networks.length > 0 && metadata?.description && (
|
||||
<Animated.View style={[styles.productionContainer, networkSectionAnimatedStyle]}>
|
||||
<Text style={styles.productionHeader}>Network</Text>
|
||||
<View style={styles.productionRow}>
|
||||
<Animated.View style={[
|
||||
styles.productionContainer,
|
||||
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) => (
|
||||
<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 ? (
|
||||
<FastImage
|
||||
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}
|
||||
/>
|
||||
) : (
|
||||
<Text style={styles.productionText}>{net.name}</Text>
|
||||
<Text style={[
|
||||
styles.productionText,
|
||||
{
|
||||
fontSize: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 12 : 12
|
||||
}
|
||||
]}>{net.name}</Text>
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
|
|
@ -1001,17 +1075,46 @@ const MetadataScreen: React.FC = () => {
|
|||
metadata?.networks && Array.isArray(metadata.networks) &&
|
||||
metadata.networks.some((n: any) => !!n?.logo) &&
|
||||
metadata?.description && (
|
||||
<Animated.View style={[styles.productionContainer, productionSectionAnimatedStyle]}>
|
||||
<Text style={styles.productionHeader}>Production</Text>
|
||||
<View style={styles.productionRow}>
|
||||
<Animated.View style={[
|
||||
styles.productionContainer,
|
||||
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
|
||||
.filter((net: any) => !!net?.logo)
|
||||
.slice(0, 6)
|
||||
.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
|
||||
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}
|
||||
/>
|
||||
</View>
|
||||
|
|
@ -1041,29 +1144,38 @@ const MetadataScreen: React.FC = () => {
|
|||
|
||||
{/* Movie Details section - shown above recommendations for movies when TMDB enrichment is ON */}
|
||||
{shouldLoadSecondaryData && Object.keys(groupedEpisodes).length === 0 && metadata?.movieDetails && (
|
||||
<View style={styles.tvDetailsContainer}>
|
||||
<Text style={styles.tvDetailsHeader}>Movie Details</Text>
|
||||
<View style={[
|
||||
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 && (
|
||||
<View style={styles.tvDetailRow}>
|
||||
<Text style={styles.tvDetailLabel}>Tagline</Text>
|
||||
<Text style={[styles.tvDetailValue, { fontStyle: 'italic' }]}>
|
||||
<View style={[styles.tvDetailRow, { paddingVertical: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8 }]}>
|
||||
<Text style={[styles.tvDetailLabel, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>Tagline</Text>
|
||||
<Text style={[styles.tvDetailValue, { fontStyle: 'italic', fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>
|
||||
"{metadata.movieDetails.tagline}"
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{metadata.movieDetails.status && (
|
||||
<View style={styles.tvDetailRow}>
|
||||
<Text style={styles.tvDetailLabel}>Status</Text>
|
||||
<Text style={styles.tvDetailValue}>{metadata.movieDetails.status}</Text>
|
||||
<View style={[styles.tvDetailRow, { paddingVertical: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8 }]}>
|
||||
<Text style={[styles.tvDetailLabel, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>Status</Text>
|
||||
<Text style={[styles.tvDetailValue, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>{metadata.movieDetails.status}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{metadata.movieDetails.releaseDate && (
|
||||
<View style={styles.tvDetailRow}>
|
||||
<Text style={styles.tvDetailLabel}>Release Date</Text>
|
||||
<Text style={styles.tvDetailValue}>
|
||||
<View style={[styles.tvDetailRow, { paddingVertical: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8 }]}>
|
||||
<Text style={[styles.tvDetailLabel, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>Release Date</Text>
|
||||
<Text style={[styles.tvDetailValue, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>
|
||||
{new Date(metadata.movieDetails.releaseDate).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
|
|
@ -1074,43 +1186,43 @@ const MetadataScreen: React.FC = () => {
|
|||
)}
|
||||
|
||||
{metadata.movieDetails.runtime && (
|
||||
<View style={styles.tvDetailRow}>
|
||||
<Text style={styles.tvDetailLabel}>Runtime</Text>
|
||||
<Text style={styles.tvDetailValue}>
|
||||
<View style={[styles.tvDetailRow, { paddingVertical: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8 }]}>
|
||||
<Text style={[styles.tvDetailLabel, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>Runtime</Text>
|
||||
<Text style={[styles.tvDetailValue, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>
|
||||
{Math.floor(metadata.movieDetails.runtime / 60)}h {metadata.movieDetails.runtime % 60}m
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{metadata.movieDetails.budget && metadata.movieDetails.budget > 0 && (
|
||||
<View style={styles.tvDetailRow}>
|
||||
<Text style={styles.tvDetailLabel}>Budget</Text>
|
||||
<Text style={styles.tvDetailValue}>
|
||||
<View style={[styles.tvDetailRow, { paddingVertical: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8 }]}>
|
||||
<Text style={[styles.tvDetailLabel, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>Budget</Text>
|
||||
<Text style={[styles.tvDetailValue, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>
|
||||
${metadata.movieDetails.budget.toLocaleString()}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{metadata.movieDetails.revenue && metadata.movieDetails.revenue > 0 && (
|
||||
<View style={styles.tvDetailRow}>
|
||||
<Text style={styles.tvDetailLabel}>Revenue</Text>
|
||||
<Text style={styles.tvDetailValue}>
|
||||
<View style={[styles.tvDetailRow, { paddingVertical: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8 }]}>
|
||||
<Text style={[styles.tvDetailLabel, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>Revenue</Text>
|
||||
<Text style={[styles.tvDetailValue, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>
|
||||
${metadata.movieDetails.revenue.toLocaleString()}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{metadata.movieDetails.originCountry && metadata.movieDetails.originCountry.length > 0 && (
|
||||
<View style={styles.tvDetailRow}>
|
||||
<Text style={styles.tvDetailLabel}>Origin Country</Text>
|
||||
<Text style={styles.tvDetailValue}>{metadata.movieDetails.originCountry.join(', ')}</Text>
|
||||
<View style={[styles.tvDetailRow, { paddingVertical: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8 }]}>
|
||||
<Text style={[styles.tvDetailLabel, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>Origin Country</Text>
|
||||
<Text style={[styles.tvDetailValue, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>{metadata.movieDetails.originCountry.join(', ')}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{metadata.movieDetails.originalLanguage && (
|
||||
<View style={styles.tvDetailRow}>
|
||||
<Text style={styles.tvDetailLabel}>Original Language</Text>
|
||||
<Text style={styles.tvDetailValue}>{metadata.movieDetails.originalLanguage.toUpperCase()}</Text>
|
||||
<View style={[styles.tvDetailRow, { paddingVertical: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8 }]}>
|
||||
<Text style={[styles.tvDetailLabel, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>Original Language</Text>
|
||||
<Text style={[styles.tvDetailValue, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>{metadata.movieDetails.originalLanguage.toUpperCase()}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
|
@ -1158,20 +1270,29 @@ const MetadataScreen: React.FC = () => {
|
|||
|
||||
{/* TV Details section - shown after episodes for series when TMDB enrichment is ON */}
|
||||
{shouldLoadSecondaryData && Object.keys(groupedEpisodes).length > 0 && metadata?.tvDetails && (
|
||||
<View style={styles.tvDetailsContainer}>
|
||||
<Text style={styles.tvDetailsHeader}>Show Details</Text>
|
||||
<View style={[
|
||||
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 && (
|
||||
<View style={styles.tvDetailRow}>
|
||||
<Text style={styles.tvDetailLabel}>Status</Text>
|
||||
<Text style={styles.tvDetailValue}>{metadata.tvDetails.status}</Text>
|
||||
<View style={[styles.tvDetailRow, { paddingVertical: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8 }]}>
|
||||
<Text style={[styles.tvDetailLabel, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>Status</Text>
|
||||
<Text style={[styles.tvDetailValue, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>{metadata.tvDetails.status}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{metadata.tvDetails.firstAirDate && (
|
||||
<View style={styles.tvDetailRow}>
|
||||
<Text style={styles.tvDetailLabel}>First Air Date</Text>
|
||||
<Text style={styles.tvDetailValue}>
|
||||
<View style={[styles.tvDetailRow, { paddingVertical: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8 }]}>
|
||||
<Text style={[styles.tvDetailLabel, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>First Air Date</Text>
|
||||
<Text style={[styles.tvDetailValue, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>
|
||||
{new Date(metadata.tvDetails.firstAirDate).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
|
|
@ -1182,9 +1303,9 @@ const MetadataScreen: React.FC = () => {
|
|||
)}
|
||||
|
||||
{metadata.tvDetails.lastAirDate && (
|
||||
<View style={styles.tvDetailRow}>
|
||||
<Text style={styles.tvDetailLabel}>Last Air Date</Text>
|
||||
<Text style={styles.tvDetailValue}>
|
||||
<View style={[styles.tvDetailRow, { paddingVertical: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8 }]}>
|
||||
<Text style={[styles.tvDetailLabel, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>Last Air Date</Text>
|
||||
<Text style={[styles.tvDetailValue, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>
|
||||
{new Date(metadata.tvDetails.lastAirDate).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
|
|
@ -1195,46 +1316,46 @@ const MetadataScreen: React.FC = () => {
|
|||
)}
|
||||
|
||||
{metadata.tvDetails.numberOfSeasons && (
|
||||
<View style={styles.tvDetailRow}>
|
||||
<Text style={styles.tvDetailLabel}>Seasons</Text>
|
||||
<Text style={styles.tvDetailValue}>{metadata.tvDetails.numberOfSeasons}</Text>
|
||||
<View style={[styles.tvDetailRow, { paddingVertical: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8 }]}>
|
||||
<Text style={[styles.tvDetailLabel, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>Seasons</Text>
|
||||
<Text style={[styles.tvDetailValue, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>{metadata.tvDetails.numberOfSeasons}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{metadata.tvDetails.numberOfEpisodes && (
|
||||
<View style={styles.tvDetailRow}>
|
||||
<Text style={styles.tvDetailLabel}>Total Episodes</Text>
|
||||
<Text style={styles.tvDetailValue}>{metadata.tvDetails.numberOfEpisodes}</Text>
|
||||
<View style={[styles.tvDetailRow, { paddingVertical: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8 }]}>
|
||||
<Text style={[styles.tvDetailLabel, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>Total Episodes</Text>
|
||||
<Text style={[styles.tvDetailValue, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>{metadata.tvDetails.numberOfEpisodes}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{metadata.tvDetails.episodeRunTime && metadata.tvDetails.episodeRunTime.length > 0 && (
|
||||
<View style={styles.tvDetailRow}>
|
||||
<Text style={styles.tvDetailLabel}>Episode Runtime</Text>
|
||||
<Text style={styles.tvDetailValue}>
|
||||
<View style={[styles.tvDetailRow, { paddingVertical: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8 }]}>
|
||||
<Text style={[styles.tvDetailLabel, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>Episode Runtime</Text>
|
||||
<Text style={[styles.tvDetailValue, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>
|
||||
{metadata.tvDetails.episodeRunTime.join(' - ')} min
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{metadata.tvDetails.originCountry && metadata.tvDetails.originCountry.length > 0 && (
|
||||
<View style={styles.tvDetailRow}>
|
||||
<Text style={styles.tvDetailLabel}>Origin Country</Text>
|
||||
<Text style={styles.tvDetailValue}>{metadata.tvDetails.originCountry.join(', ')}</Text>
|
||||
<View style={[styles.tvDetailRow, { paddingVertical: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8 }]}>
|
||||
<Text style={[styles.tvDetailLabel, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>Origin Country</Text>
|
||||
<Text style={[styles.tvDetailValue, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>{metadata.tvDetails.originCountry.join(', ')}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{metadata.tvDetails.originalLanguage && (
|
||||
<View style={styles.tvDetailRow}>
|
||||
<Text style={styles.tvDetailLabel}>Original Language</Text>
|
||||
<Text style={styles.tvDetailValue}>{metadata.tvDetails.originalLanguage.toUpperCase()}</Text>
|
||||
<View style={[styles.tvDetailRow, { paddingVertical: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8 }]}>
|
||||
<Text style={[styles.tvDetailLabel, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>Original Language</Text>
|
||||
<Text style={[styles.tvDetailValue, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>{metadata.tvDetails.originalLanguage.toUpperCase()}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{metadata.tvDetails.createdBy && metadata.tvDetails.createdBy.length > 0 && (
|
||||
<View style={styles.tvDetailRow}>
|
||||
<Text style={styles.tvDetailLabel}>Created By</Text>
|
||||
<Text style={styles.tvDetailValue}>
|
||||
<View style={[styles.tvDetailRow, { paddingVertical: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8 }]}>
|
||||
<Text style={[styles.tvDetailLabel, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>Created By</Text>
|
||||
<Text style={[styles.tvDetailValue, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>
|
||||
{metadata.tvDetails.createdBy.map(creator => creator.name).join(', ')}
|
||||
</Text>
|
||||
</View>
|
||||
|
|
@ -1400,7 +1521,6 @@ const styles = StyleSheet.create({
|
|||
marginBottom: 8,
|
||||
},
|
||||
productionContainer: {
|
||||
paddingHorizontal: 16,
|
||||
marginTop: 0,
|
||||
marginBottom: 20,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -45,14 +45,33 @@ import { useTheme } from '../contexts/ThemeContext';
|
|||
import LoadingSpinner from '../components/common/LoadingSpinner';
|
||||
|
||||
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;
|
||||
|
||||
// Tablet-optimized poster sizes
|
||||
const HORIZONTAL_ITEM_WIDTH = isTablet ? width * 0.18 : width * 0.3;
|
||||
// Responsive poster sizes
|
||||
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 POSTER_WIDTH = isTablet ? 70 : 90;
|
||||
const POSTER_HEIGHT = isTablet ? 105 : 135;
|
||||
const POSTER_WIDTH = isTV ? 90 : isLargeTablet ? 80 : isTablet ? 70 : 90;
|
||||
const POSTER_HEIGHT = POSTER_WIDTH * 1.5;
|
||||
const RECENT_SEARCHES_KEY = 'recent_searches';
|
||||
const MAX_RECENT_SEARCHES = 10;
|
||||
|
||||
|
|
@ -597,13 +616,20 @@ const SearchScreen = () => {
|
|||
)}
|
||||
</View>
|
||||
<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}
|
||||
>
|
||||
{item.name}
|
||||
</Text>
|
||||
{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}
|
||||
</Text>
|
||||
)}
|
||||
|
|
@ -652,8 +678,16 @@ const SearchScreen = () => {
|
|||
|
||||
{/* Movies */}
|
||||
{movieResults.length > 0 && (
|
||||
<Animated.View style={styles.carouselContainer} entering={FadeIn.duration(300)}>
|
||||
<Text style={[styles.carouselSubtitle, { color: currentTheme.colors.lightGray }]}>
|
||||
<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,
|
||||
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})
|
||||
</Text>
|
||||
<FlatList
|
||||
|
|
@ -678,8 +712,16 @@ const SearchScreen = () => {
|
|||
|
||||
{/* TV Shows */}
|
||||
{seriesResults.length > 0 && (
|
||||
<Animated.View style={styles.carouselContainer} entering={FadeIn.duration(300)}>
|
||||
<Text style={[styles.carouselSubtitle, { color: currentTheme.colors.lightGray }]}>
|
||||
<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,
|
||||
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})
|
||||
</Text>
|
||||
<FlatList
|
||||
|
|
@ -704,8 +746,16 @@ const SearchScreen = () => {
|
|||
|
||||
{/* Other types */}
|
||||
{otherResults.length > 0 && (
|
||||
<Animated.View style={styles.carouselContainer} entering={FadeIn.duration(300)}>
|
||||
<Text style={[styles.carouselSubtitle, { color: currentTheme.colors.lightGray }]}>
|
||||
<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,
|
||||
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})
|
||||
</Text>
|
||||
<FlatList
|
||||
|
|
@ -736,7 +786,7 @@ const SearchScreen = () => {
|
|||
|
||||
const headerBaseHeight = Platform.OS === 'android' ? 80 : 60;
|
||||
// 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 headerHeight = headerBaseHeight + topSpacing + 60;
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
if (!this.installedAddons.has(cinemetaId)) {
|
||||
const hasUserRemovedCinemeta = await this.hasUserRemovedAddon(cinemetaId);
|
||||
|
||||
if (!this.installedAddons.has(cinemetaId) && !hasUserRemovedCinemeta) {
|
||||
try {
|
||||
const cinemetaManifest = await this.getManifest('https://v3-cinemeta.strem.io/manifest.json');
|
||||
this.installedAddons.set(cinemetaId, cinemetaManifest);
|
||||
|
|
@ -432,8 +434,9 @@ class StremioService {
|
|||
this.addonOrder = this.addonOrder.filter(id => this.installedAddons.has(id));
|
||||
}
|
||||
|
||||
// Ensure required pre-installed addons are present without forcing their position
|
||||
if (!this.addonOrder.includes(cinemetaId) && this.installedAddons.has(cinemetaId)) {
|
||||
// Add Cinemeta to order only if user hasn't removed it
|
||||
const hasUserRemovedCinemetaOrder = await this.hasUserRemovedAddon(cinemetaId);
|
||||
if (!this.addonOrder.includes(cinemetaId) && this.installedAddons.has(cinemetaId) && !hasUserRemovedCinemetaOrder) {
|
||||
this.addonOrder.push(cinemetaId);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -150,3 +150,4 @@ class ToastService {
|
|||
|
||||
export const toastService = ToastService.getInstance();
|
||||
export default toastService;
|
||||
|
||||
|
|
|
|||
514
trakt/docs.md
|
|
@ -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"
|
||||
}
|
||||