diff --git a/android/app/src/main/res/drawable-hdpi/splashscreen_logo.png b/android/app/src/main/res/drawable-hdpi/splashscreen_logo.png
index 4438ec3..29ed05b 100644
Binary files a/android/app/src/main/res/drawable-hdpi/splashscreen_logo.png and b/android/app/src/main/res/drawable-hdpi/splashscreen_logo.png differ
diff --git a/android/app/src/main/res/drawable-mdpi/splashscreen_logo.png b/android/app/src/main/res/drawable-mdpi/splashscreen_logo.png
index 1ed3f86..1e3a378 100644
Binary files a/android/app/src/main/res/drawable-mdpi/splashscreen_logo.png and b/android/app/src/main/res/drawable-mdpi/splashscreen_logo.png differ
diff --git a/android/app/src/main/res/drawable-xhdpi/splashscreen_logo.png b/android/app/src/main/res/drawable-xhdpi/splashscreen_logo.png
index 72b74fd..6b7ddf9 100644
Binary files a/android/app/src/main/res/drawable-xhdpi/splashscreen_logo.png and b/android/app/src/main/res/drawable-xhdpi/splashscreen_logo.png differ
diff --git a/android/app/src/main/res/drawable-xxhdpi/splashscreen_logo.png b/android/app/src/main/res/drawable-xxhdpi/splashscreen_logo.png
index f2b2882..b977b3f 100644
Binary files a/android/app/src/main/res/drawable-xxhdpi/splashscreen_logo.png and b/android/app/src/main/res/drawable-xxhdpi/splashscreen_logo.png differ
diff --git a/android/app/src/main/res/drawable-xxxhdpi/splashscreen_logo.png b/android/app/src/main/res/drawable-xxxhdpi/splashscreen_logo.png
index dda7ae6..a79fe8b 100644
Binary files a/android/app/src/main/res/drawable-xxxhdpi/splashscreen_logo.png and b/android/app/src/main/res/drawable-xxxhdpi/splashscreen_logo.png differ
diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp
index 90eae32..bf53e9c 100644
Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ
diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp
index 13f0a54..13e14ea 100644
Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp differ
diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
index 739fbfc..934bf05 100644
Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ
diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp
index 925188e..52cdd30 100644
Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ
diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp
index 8f03753..83217a9 100644
Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp differ
diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
index 2476542..5fbb1f3 100644
Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ
diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp
index 0303110..b1e5362 100644
Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ
diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp
index f32047c..5133d3d 100644
Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp differ
diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
index bc419a2..0488026 100644
Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ
diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
index 0bb4297..04093c9 100644
Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ
diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp
index 1bf77f3..63d0078 100644
Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp differ
diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
index 92407f1..1c6776e 100644
Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ
diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
index 578225f..e61bef4 100644
Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ
diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp
index cf53f52..3e9f36e 100644
Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp differ
diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
index 7f31cf8..d049451 100644
Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ
diff --git a/app.json b/app.json
index 8662c1f..5a069f4 100644
--- a/app.json
+++ b/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"
},
diff --git a/assets/android/ic_launcher-web.png b/assets/android/ic_launcher-web.png
index 70a03a8..9c3a1fb 100644
Binary files a/assets/android/ic_launcher-web.png and b/assets/android/ic_launcher-web.png differ
diff --git a/assets/android/mipmap-hdpi/ic_launcher.png b/assets/android/mipmap-hdpi/ic_launcher.png
index 38ab336..63653c1 100644
Binary files a/assets/android/mipmap-hdpi/ic_launcher.png and b/assets/android/mipmap-hdpi/ic_launcher.png differ
diff --git a/assets/android/mipmap-hdpi/ic_launcher_foreground.png b/assets/android/mipmap-hdpi/ic_launcher_foreground.png
index 940dccd..46d5c74 100644
Binary files a/assets/android/mipmap-hdpi/ic_launcher_foreground.png and b/assets/android/mipmap-hdpi/ic_launcher_foreground.png differ
diff --git a/assets/android/mipmap-hdpi/ic_launcher_round.png b/assets/android/mipmap-hdpi/ic_launcher_round.png
index 66437aa..a67afe1 100644
Binary files a/assets/android/mipmap-hdpi/ic_launcher_round.png and b/assets/android/mipmap-hdpi/ic_launcher_round.png differ
diff --git a/assets/android/mipmap-ldpi/ic_launcher.png b/assets/android/mipmap-ldpi/ic_launcher.png
index e55a992..4f72f37 100644
Binary files a/assets/android/mipmap-ldpi/ic_launcher.png and b/assets/android/mipmap-ldpi/ic_launcher.png differ
diff --git a/assets/android/mipmap-mdpi/ic_launcher.png b/assets/android/mipmap-mdpi/ic_launcher.png
index 761b690..12d0832 100644
Binary files a/assets/android/mipmap-mdpi/ic_launcher.png and b/assets/android/mipmap-mdpi/ic_launcher.png differ
diff --git a/assets/android/mipmap-mdpi/ic_launcher_foreground.png b/assets/android/mipmap-mdpi/ic_launcher_foreground.png
index e0b31c6..eedf360 100644
Binary files a/assets/android/mipmap-mdpi/ic_launcher_foreground.png and b/assets/android/mipmap-mdpi/ic_launcher_foreground.png differ
diff --git a/assets/android/mipmap-mdpi/ic_launcher_round.png b/assets/android/mipmap-mdpi/ic_launcher_round.png
index a627ea0..ee64b08 100644
Binary files a/assets/android/mipmap-mdpi/ic_launcher_round.png and b/assets/android/mipmap-mdpi/ic_launcher_round.png differ
diff --git a/assets/android/mipmap-xhdpi/ic_launcher.png b/assets/android/mipmap-xhdpi/ic_launcher.png
index 9007654..c71d35f 100644
Binary files a/assets/android/mipmap-xhdpi/ic_launcher.png and b/assets/android/mipmap-xhdpi/ic_launcher.png differ
diff --git a/assets/android/mipmap-xhdpi/ic_launcher_foreground.png b/assets/android/mipmap-xhdpi/ic_launcher_foreground.png
index b41d433..9c303ef 100644
Binary files a/assets/android/mipmap-xhdpi/ic_launcher_foreground.png and b/assets/android/mipmap-xhdpi/ic_launcher_foreground.png differ
diff --git a/assets/android/mipmap-xhdpi/ic_launcher_round.png b/assets/android/mipmap-xhdpi/ic_launcher_round.png
index 16e1023..0d9d1de 100644
Binary files a/assets/android/mipmap-xhdpi/ic_launcher_round.png and b/assets/android/mipmap-xhdpi/ic_launcher_round.png differ
diff --git a/assets/android/mipmap-xxhdpi/ic_launcher.png b/assets/android/mipmap-xxhdpi/ic_launcher.png
index 93d17d5..5cda623 100644
Binary files a/assets/android/mipmap-xxhdpi/ic_launcher.png and b/assets/android/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/assets/android/mipmap-xxhdpi/ic_launcher_foreground.png b/assets/android/mipmap-xxhdpi/ic_launcher_foreground.png
index 176e8e6..f510bbf 100644
Binary files a/assets/android/mipmap-xxhdpi/ic_launcher_foreground.png and b/assets/android/mipmap-xxhdpi/ic_launcher_foreground.png differ
diff --git a/assets/android/mipmap-xxhdpi/ic_launcher_round.png b/assets/android/mipmap-xxhdpi/ic_launcher_round.png
index 3e19591..9ec97ab 100644
Binary files a/assets/android/mipmap-xxhdpi/ic_launcher_round.png and b/assets/android/mipmap-xxhdpi/ic_launcher_round.png differ
diff --git a/assets/android/mipmap-xxxhdpi/ic_launcher.png b/assets/android/mipmap-xxxhdpi/ic_launcher.png
index cbdc61f..ad2c371 100644
Binary files a/assets/android/mipmap-xxxhdpi/ic_launcher.png and b/assets/android/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/assets/android/mipmap-xxxhdpi/ic_launcher_foreground.png b/assets/android/mipmap-xxxhdpi/ic_launcher_foreground.png
index a194108..7cabe37 100644
Binary files a/assets/android/mipmap-xxxhdpi/ic_launcher_foreground.png and b/assets/android/mipmap-xxxhdpi/ic_launcher_foreground.png differ
diff --git a/assets/android/mipmap-xxxhdpi/ic_launcher_round.png b/assets/android/mipmap-xxxhdpi/ic_launcher_round.png
index 0f726c4..71e9834 100644
Binary files a/assets/android/mipmap-xxxhdpi/ic_launcher_round.png and b/assets/android/mipmap-xxxhdpi/ic_launcher_round.png differ
diff --git a/assets/android/playstore-icon.png b/assets/android/playstore-icon.png
index f97a895..4d1e685 100644
Binary files a/assets/android/playstore-icon.png and b/assets/android/playstore-icon.png differ
diff --git a/assets/android/values/ic_launcher_background.xml b/assets/android/values/ic_launcher_background.xml
index cb9e160..58403da 100644
--- a/assets/android/values/ic_launcher_background.xml
+++ b/assets/android/values/ic_launcher_background.xml
@@ -1,4 +1,4 @@
- #d1d1d2
+ #2f2f2f
\ No newline at end of file
diff --git a/assets/ios/AppIcon.appiconset/Icon-App-20x20@1x.png b/assets/ios/AppIcon.appiconset/Icon-App-20x20@1x.png
index 5837ee4..4e725a1 100644
Binary files a/assets/ios/AppIcon.appiconset/Icon-App-20x20@1x.png and b/assets/ios/AppIcon.appiconset/Icon-App-20x20@1x.png differ
diff --git a/assets/ios/AppIcon.appiconset/Icon-App-20x20@2x.png b/assets/ios/AppIcon.appiconset/Icon-App-20x20@2x.png
index 8fa15ef..3445ca7 100644
Binary files a/assets/ios/AppIcon.appiconset/Icon-App-20x20@2x.png and b/assets/ios/AppIcon.appiconset/Icon-App-20x20@2x.png differ
diff --git a/assets/ios/AppIcon.appiconset/Icon-App-20x20@3x.png b/assets/ios/AppIcon.appiconset/Icon-App-20x20@3x.png
index 004058f..23336a0 100644
Binary files a/assets/ios/AppIcon.appiconset/Icon-App-20x20@3x.png and b/assets/ios/AppIcon.appiconset/Icon-App-20x20@3x.png differ
diff --git a/assets/ios/AppIcon.appiconset/Icon-App-29x29@1x.png b/assets/ios/AppIcon.appiconset/Icon-App-29x29@1x.png
index 4a853b6..82e12de 100644
Binary files a/assets/ios/AppIcon.appiconset/Icon-App-29x29@1x.png and b/assets/ios/AppIcon.appiconset/Icon-App-29x29@1x.png differ
diff --git a/assets/ios/AppIcon.appiconset/Icon-App-29x29@2x.png b/assets/ios/AppIcon.appiconset/Icon-App-29x29@2x.png
index a3e794a..2fe5d46 100644
Binary files a/assets/ios/AppIcon.appiconset/Icon-App-29x29@2x.png and b/assets/ios/AppIcon.appiconset/Icon-App-29x29@2x.png differ
diff --git a/assets/ios/AppIcon.appiconset/Icon-App-29x29@3x.png b/assets/ios/AppIcon.appiconset/Icon-App-29x29@3x.png
index 3e317c1..b969aa7 100644
Binary files a/assets/ios/AppIcon.appiconset/Icon-App-29x29@3x.png and b/assets/ios/AppIcon.appiconset/Icon-App-29x29@3x.png differ
diff --git a/assets/ios/AppIcon.appiconset/Icon-App-40x40@1x.png b/assets/ios/AppIcon.appiconset/Icon-App-40x40@1x.png
index 8fa15ef..3445ca7 100644
Binary files a/assets/ios/AppIcon.appiconset/Icon-App-40x40@1x.png and b/assets/ios/AppIcon.appiconset/Icon-App-40x40@1x.png differ
diff --git a/assets/ios/AppIcon.appiconset/Icon-App-40x40@2x.png b/assets/ios/AppIcon.appiconset/Icon-App-40x40@2x.png
index 718e4ca..dd5ac1a 100644
Binary files a/assets/ios/AppIcon.appiconset/Icon-App-40x40@2x.png and b/assets/ios/AppIcon.appiconset/Icon-App-40x40@2x.png differ
diff --git a/assets/ios/AppIcon.appiconset/Icon-App-40x40@3x.png b/assets/ios/AppIcon.appiconset/Icon-App-40x40@3x.png
index 147ca19..0128c2a 100644
Binary files a/assets/ios/AppIcon.appiconset/Icon-App-40x40@3x.png and b/assets/ios/AppIcon.appiconset/Icon-App-40x40@3x.png differ
diff --git a/assets/ios/AppIcon.appiconset/Icon-App-60x60@2x.png b/assets/ios/AppIcon.appiconset/Icon-App-60x60@2x.png
index 147ca19..0128c2a 100644
Binary files a/assets/ios/AppIcon.appiconset/Icon-App-60x60@2x.png and b/assets/ios/AppIcon.appiconset/Icon-App-60x60@2x.png differ
diff --git a/assets/ios/AppIcon.appiconset/Icon-App-60x60@3x.png b/assets/ios/AppIcon.appiconset/Icon-App-60x60@3x.png
index 2faf8a0..aea993c 100644
Binary files a/assets/ios/AppIcon.appiconset/Icon-App-60x60@3x.png and b/assets/ios/AppIcon.appiconset/Icon-App-60x60@3x.png differ
diff --git a/assets/ios/AppIcon.appiconset/Icon-App-76x76@1x.png b/assets/ios/AppIcon.appiconset/Icon-App-76x76@1x.png
index 556ef58..2698ce9 100644
Binary files a/assets/ios/AppIcon.appiconset/Icon-App-76x76@1x.png and b/assets/ios/AppIcon.appiconset/Icon-App-76x76@1x.png differ
diff --git a/assets/ios/AppIcon.appiconset/Icon-App-76x76@2x.png b/assets/ios/AppIcon.appiconset/Icon-App-76x76@2x.png
index 1eba090..b59c049 100644
Binary files a/assets/ios/AppIcon.appiconset/Icon-App-76x76@2x.png and b/assets/ios/AppIcon.appiconset/Icon-App-76x76@2x.png differ
diff --git a/assets/ios/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/assets/ios/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png
index 47de330..a08fb02 100644
Binary files a/assets/ios/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png and b/assets/ios/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ
diff --git a/assets/ios/AppIcon.appiconset/ItunesArtwork@2x.png b/assets/ios/AppIcon.appiconset/ItunesArtwork@2x.png
index 5f69402..38237e1 100644
Binary files a/assets/ios/AppIcon.appiconset/ItunesArtwork@2x.png and b/assets/ios/AppIcon.appiconset/ItunesArtwork@2x.png differ
diff --git a/assets/ios/iTunesArtwork@1x.png b/assets/ios/iTunesArtwork@1x.png
index f97a895..4d1e685 100644
Binary files a/assets/ios/iTunesArtwork@1x.png and b/assets/ios/iTunesArtwork@1x.png differ
diff --git a/assets/ios/iTunesArtwork@2x.png b/assets/ios/iTunesArtwork@2x.png
index 5f69402..38237e1 100644
Binary files a/assets/ios/iTunesArtwork@2x.png and b/assets/ios/iTunesArtwork@2x.png differ
diff --git a/assets/ios/iTunesArtwork@3x.png b/assets/ios/iTunesArtwork@3x.png
index 938011a..b367f5a 100644
Binary files a/assets/ios/iTunesArtwork@3x.png and b/assets/ios/iTunesArtwork@3x.png differ
diff --git a/ios/KSPlayerManager.m b/ios/KSPlayerManager.m
index f521027..d61dc40 100644
--- a/ios/KSPlayerManager.m
+++ b/ios/KSPlayerManager.m
@@ -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
diff --git a/ios/KSPlayerModule.swift b/ios/KSPlayerModule.swift
index ee487c1..58ace7c 100644
--- a/ios/KSPlayerModule.swift
+++ b/ios/KSPlayerModule.swift
@@ -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")
+ }
+ }
+ }
}
diff --git a/ios/KSPlayerView.swift b/ios/KSPlayerView.swift
index 7ef84c2..fd31269 100644
--- a/ios/KSPlayerView.swift
+++ b/ios/KSPlayerView.swift
@@ -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()
])
}
}
diff --git a/ios/KSPlayerViewManager.swift b/ios/KSPlayerViewManager.swift
index ce9e3f0..733a842 100644
--- a/ios/KSPlayerViewManager.swift
+++ b/ios/KSPlayerViewManager.swift
@@ -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)")
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/ios/Nuvio.xcodeproj/project.pbxproj b/ios/Nuvio.xcodeproj/project.pbxproj
index a1ef76a..b562e96 100644
--- a/ios/Nuvio.xcodeproj/project.pbxproj
+++ b/ios/Nuvio.xcodeproj/project.pbxproj
@@ -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;
diff --git a/ios/Nuvio.xcodeproj/xcshareddata/xcschemes/Nuvio.xcscheme b/ios/Nuvio.xcodeproj/xcshareddata/xcschemes/Nuvio.xcscheme
index 60f9eb0..d56adf8 100644
--- a/ios/Nuvio.xcodeproj/xcshareddata/xcschemes/Nuvio.xcscheme
+++ b/ios/Nuvio.xcodeproj/xcshareddata/xcschemes/Nuvio.xcscheme
@@ -82,7 +82,7 @@
buildConfiguration = "Debug">
diff --git a/ios/Nuvio/Images.xcassets/AppIcon.appiconset/App-Icon-1024x1024@1x.png b/ios/Nuvio/Images.xcassets/AppIcon.appiconset/App-Icon-1024x1024@1x.png
index 4e9f344..288b100 100644
Binary files a/ios/Nuvio/Images.xcassets/AppIcon.appiconset/App-Icon-1024x1024@1x.png and b/ios/Nuvio/Images.xcassets/AppIcon.appiconset/App-Icon-1024x1024@1x.png differ
diff --git a/ios/Nuvio/Images.xcassets/SplashScreenLegacy.imageset/image.png b/ios/Nuvio/Images.xcassets/SplashScreenLegacy.imageset/image.png
index 83330c3..efcdf22 100644
Binary files a/ios/Nuvio/Images.xcassets/SplashScreenLegacy.imageset/image.png and b/ios/Nuvio/Images.xcassets/SplashScreenLegacy.imageset/image.png differ
diff --git a/ios/Nuvio/Images.xcassets/SplashScreenLegacy.imageset/image@2x.png b/ios/Nuvio/Images.xcassets/SplashScreenLegacy.imageset/image@2x.png
index 83330c3..efcdf22 100644
Binary files a/ios/Nuvio/Images.xcassets/SplashScreenLegacy.imageset/image@2x.png and b/ios/Nuvio/Images.xcassets/SplashScreenLegacy.imageset/image@2x.png differ
diff --git a/ios/Nuvio/Images.xcassets/SplashScreenLegacy.imageset/image@3x.png b/ios/Nuvio/Images.xcassets/SplashScreenLegacy.imageset/image@3x.png
index 83330c3..efcdf22 100644
Binary files a/ios/Nuvio/Images.xcassets/SplashScreenLegacy.imageset/image@3x.png and b/ios/Nuvio/Images.xcassets/SplashScreenLegacy.imageset/image@3x.png differ
diff --git a/package-lock.json b/package-lock.json
index 8a6cbf5..e9619c5 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
diff --git a/package.json b/package.json
index 267e782..8ce6708 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/src/assets/splash-icon-new.png b/src/assets/splash-icon-new.png
new file mode 100644
index 0000000..07445d3
Binary files /dev/null and b/src/assets/splash-icon-new.png differ
diff --git a/src/components/SplashScreen.tsx b/src/components/SplashScreen.tsx
index e465740..6e1950a 100644
--- a/src/components/SplashScreen.tsx
+++ b/src/components/SplashScreen.tsx
@@ -29,7 +29,7 @@ const SplashScreen = ({ onFinish }: SplashScreenProps) => {
return (
diff --git a/src/components/home/CatalogSection.tsx b/src/components/home/CatalogSection.tsx
index 1f344fb..1d8bd4c 100644
--- a/src/components/home/CatalogSection.tsx
+++ b/src/components/home/CatalogSection.tsx
@@ -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(() => , []);
+ // Memoize the ItemSeparatorComponent to prevent re-creation (responsive spacing)
+ const separatorWidth = isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8;
+ const ItemSeparator = useCallback(() => , [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 (
-
+
- {catalog.name}
-
+
+ {catalog.name}
+
+
@@ -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,
+ }
+ ]}
>
- View All
-
+ View All
+
- {}}
- recycleItems={true}
- maintainVisibleContentPosition
+ // FlashList v2 optimizations
+ drawDistance={500}
/>
);
@@ -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
},
});
diff --git a/src/components/home/ContentItem.tsx b/src/components/home/ContentItem.tsx
index e9e161b..d8466f6 100644
--- a/src/components/home/ContentItem.tsx
+++ b/src/components/home/ContentItem.tsx
@@ -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(null);
@@ -322,7 +345,16 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
{settings.showPosterTitles && (
-
+
{item.name}
)}
@@ -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',
diff --git a/src/components/home/ContinueWatchingSection.tsx b/src/components/home/ContinueWatchingSection.tsx
index ce21382..5ed400d 100644
--- a/src/components/home/ContinueWatchingSection.tsx
+++ b/src/components/home/ContinueWatchingSection.tsx
@@ -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;
}
+// 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((props, re
const [deletingItemId, setDeletingItemId] = useState(null);
const longPressTimeoutRef = useRef(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((props, re
// Memoized render function for continue watching items
const renderContinueWatchingItem = useCallback(({ item }: { item: ContinueWatchingItem }) => (
handleContentPress(item.id, item.type)}
onLongPress={() => handleLongPress(item)}
delayLongPress={800}
>
{/* Poster Image */}
-
+
((props, re
{/* Content Details */}
-
+
{(() => {
const isUpNext = item.type === 'series' && item.progress === 0;
return (
{item.name}
{isUpNext && (
-
- Up Next
+
+ Up Next
)}
@@ -690,12 +801,24 @@ const ContinueWatchingSection = React.forwardRef((props, re
if (item.type === 'series' && item.season && item.episode) {
return (
-
+
Season {item.season}
{item.episodeTitle && (
{item.episodeTitle}
@@ -705,7 +828,13 @@ const ContinueWatchingSection = React.forwardRef((props, re
);
} else {
return (
-
+
{item.year} • {item.type === 'movie' ? 'Movie' : 'Series'}
);
@@ -715,7 +844,12 @@ const ContinueWatchingSection = React.forwardRef((props, re
{/* Progress Bar */}
{item.progress > 0 && (
-
+
((props, re
]}
/>
-
+
{Math.round(item.progress)}% watched
)}
- ), [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(() => , []);
+ const ItemSeparator = useCallback(() => , [itemSpacing]);
// If no continue watching items, don't render anything
if (continueWatchingItems.length === 0) {
@@ -751,10 +891,23 @@ const ContinueWatchingSection = React.forwardRef((props, re
style={styles.container}
entering={FadeIn.duration(350)}
>
-
+
- Continue Watching
-
+ Continue Watching
+
@@ -764,7 +917,13 @@ const ContinueWatchingSection = React.forwardRef((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,
},
diff --git a/src/components/home/ThisWeekSection.tsx b/src/components/home/ThisWeekSection.tsx
index b58d5b9..f9a53a6 100644
--- a/src/components/home/ThisWeekSection.tsx
+++ b/src/components/home/ThisWeekSection.tsx
@@ -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 */}
-
+
{item.seriesName}
-
+
{item.title}
{item.overview && (
-
+
{item.overview}
)}
-
+
S{item.season}:E{item.episode} •
-
+
{formattedDate}
@@ -189,14 +298,43 @@ export const ThisWeekSection = React.memo(() => {
style={styles.container}
entering={FadeIn.duration(350)}
>
-
+
- This Week
-
+ This Week
+
-
- View All
-
+
+ View All
+
@@ -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={() => }
+ ItemSeparatorComponent={() => }
/>
);
@@ -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: {
diff --git a/src/components/metadata/CastSection.tsx b/src/components/metadata/CastSection.tsx
index 00f4c19..3f24812 100644
--- a/src/components/metadata/CastSection.tsx
+++ b/src/components/metadata/CastSection.tsx
@@ -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 = ({
}) => {
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 (
@@ -45,25 +126,52 @@ export const CastSection: React.FC = ({
style={styles.castSection}
entering={FadeIn.duration(300).delay(150)}
>
-
- Cast
+
+ Cast
item.id.toString()}
renderItem={({ item, index }) => (
onSelectCastMember(item)}
activeOpacity={0.7}
>
-
+
{item.profile_path ? (
= ({
resizeMode={FastImage.resizeMode.cover}
/>
) : (
-
-
+
+
{item.name.split(' ').reduce((prev: string, current: string) => prev + current[0], '').substring(0, 2)}
)}
- {item.name}
+ {item.name}
{isTmdbEnrichmentEnabled && item.character && (
- {item.character}
+ {item.character}
)}
@@ -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: {
diff --git a/src/components/metadata/CommentsSection.tsx b/src/components/metadata/CommentsSection.tsx
index 60b99c6..db1cf2a 100644
--- a/src/components/metadata/CommentsSection.tsx
+++ b/src/components/metadata/CommentsSection.tsx
@@ -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 */}
-
+
{/* Header Section - Fixed at top */}
-
+
-
+
{username}
{user.vip && (
-
- VIP
+
+ VIP
)}
@@ -306,48 +398,107 @@ const CompactCommentCard: React.FC<{
{/* Rating - Show stars */}
{comment.user_stats?.rating && (
-
+
{renderCompactStars(comment.user_stats.rating)}
-
+
{comment.user_stats.rating}/10
)}
{/* Comment Preview - Flexible area that fills space */}
-
+
{shouldBlurContent ? (
- ⚠️ This comment contains spoilers. Tap to reveal.
+ ⚠️ This comment contains spoilers. Tap to reveal.
) : (
)}
{/* Meta Info - Fixed at bottom */}
-
+
{comment.spoiler && (
- Spoiler
+ Spoiler
)}
-
+
{formatRelativeTime(comment.created_at)}
{comment.likes > 0 && (
-
+
👍 {comment.likes}
)}
{comment.replies > 0 && (
-
+
💬 {comment.replies}
)}
@@ -578,6 +729,38 @@ export const CommentsSection: React.FC = ({
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 = ({
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 (
-
+
{placeholders.map((i) => (
-
+
-
+
-
-
-
+
+
+
-
-
+
+
-
-
-
-
+
+
+
+
-
-
-
-
-
+
+
+
+
+
))}
);
- }, [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 = ({
}
return (
-
-
-
+
+
+
Trakt Comments
@@ -744,11 +966,14 @@ export const CommentsSection: React.FC = ({
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',
diff --git a/src/components/metadata/HeroSection.tsx b/src/components/metadata/HeroSection.tsx
index a619538..45a84b6 100644
--- a/src/components/metadata/HeroSection.tsx
+++ b/src/components/metadata/HeroSection.tsx
@@ -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 (
- {/* Play Button Row - Only Play button */}
-
-
- {
- 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"}
- />
- {finalPlayButtonText}
-
-
+ {shouldShowSingleRow ? (
+ /* Single Row Layout - Play, Save, and one other button (3 total) */
+
+
+ {
+ 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"}
+ />
+ {finalPlayButtonText}
+
- {/* Secondary Action Row - All other buttons */}
-
+
+ {Platform.OS === 'ios' ? (
+ GlassViewComp && liquidGlassAvailable ? (
+
+ ) : (
+
+ )
+ ) : (
+
+ )}
+
+
+ {inLibrary ? 'Saved' : 'Save'}
+
+
+
+ {/* Third Button - AI Chat, Trakt Collection, or Ratings */}
+ {hasAiChat && (
+ {
+ // 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 ? (
+
+ ) : (
+
+ )
+ ) : (
+
+ )}
+
+
+ )}
+
+ {hasTraktCollection && !hasAiChat && (
+
+ {Platform.OS === 'ios' ? (
+ GlassViewComp && liquidGlassAvailable ? (
+
+ ) : (
+
+ )
+ ) : (
+
+ )}
+
+
+ )}
+
+ {hasRatings && !hasAiChat && !hasTraktCollection && (
+
+ {Platform.OS === 'ios' ? (
+ GlassViewComp && liquidGlassAvailable ? (
+
+ ) : (
+
+ )
+ ) : (
+
+ )}
+
+
+ )}
+
+ ) : (
+ <>
+ {/* Play Button Row - Only Play button */}
+
+
+ {
+ 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"}
+ />
+ {finalPlayButtonText}
+
+
+
+ {/* Secondary Action Row - All other buttons */}
+
{/* Save Button */}
)}
+ >
+ )}
);
});
@@ -1679,7 +1842,7 @@ const HeroSection: React.FC = 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,
diff --git a/src/components/metadata/MetadataDetails.tsx b/src/components/metadata/MetadataDetails.tsx
index 14463d7..3c7c0fe 100644
--- a/src/components/metadata/MetadataDetails.tsx
+++ b/src/components/metadata/MetadataDetails.tsx
@@ -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 = ({
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 */}
-
+
{metadata.year && (
- {metadata.year}
+ {metadata.year}
)}
{metadata.runtime && (
-
+
{formatRuntime(metadata.runtime)}
)}
@@ -157,17 +218,32 @@ function formatRuntime(runtime: string): string {
{metadata.certification}
)}
{metadata.imdbRating && !isMDBEnabled && (
- {metadata.imdbRating}
+ {metadata.imdbRating}
)}
@@ -178,18 +254,62 @@ function formatRuntime(runtime: string): string {
{/* Creator/Director Info */}
{metadata.directors && metadata.directors.length > 0 && (
-
- Director{metadata.directors.length > 1 ? 's' : ''}:
- {metadata.directors.join(', ')}
+
+ Director{metadata.directors.length > 1 ? 's' : ''}:
+ {metadata.directors.join(', ')}
)}
{metadata.creators && metadata.creators.length > 0 && (
-
- Creator{metadata.creators.length > 1 ? 's' : ''}:
- {metadata.creators.join(', ')}
+
+ Creator{metadata.creators.length > 1 ? 's' : ''}:
+ {metadata.creators.join(', ')}
)}
@@ -197,19 +317,41 @@ function formatRuntime(runtime: string): string {
{/* Description */}
{metadata.description && (
{/* Hidden text elements to measure heights */}
{metadata.description}
{metadata.description}
@@ -222,7 +364,14 @@ function formatRuntime(runtime: string): string {
>
@@ -230,13 +379,25 @@ function formatRuntime(runtime: string): string {
{(isTextTruncated || isFullDescriptionOpen) && (
-
-
+
+
{isFullDescriptionOpen ? 'Show Less' : 'Show More'}
@@ -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,
diff --git a/src/components/metadata/MoreLikeThisSection.tsx b/src/components/metadata/MoreLikeThisSection.tsx
index 64d97b9..13ae71c 100644
--- a/src/components/metadata/MoreLikeThisSection.tsx
+++ b/src/components/metadata/MoreLikeThisSection.tsx
@@ -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 = ({
const { currentTheme } = useTheme();
const navigation = useNavigation>();
+ // 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 = ({
const renderItem = ({ item }: { item: StreamingContent }) => (
handleItemPress(item)}
>
-
+
{item.name}
@@ -121,15 +144,15 @@ export const MoreLikeThisSection: React.FC = ({
}
return (
-
- More Like This
+
+ More Like This
item.id}
horizontal
showsHorizontalScrollIndicator={false}
- contentContainerStyle={styles.listContentContainer}
+ contentContainerStyle={[styles.listContentContainer, { paddingHorizontal: horizontalPadding, paddingRight: horizontalPadding + itemSpacing }]}
/>
= ({ 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 = ({ imdbId, type })
style={[
styles.container,
{
+ paddingHorizontal: horizontalPadding,
opacity: fadeAnim,
transform: [{
translateY: fadeAnim.interpolate({
@@ -180,22 +233,22 @@ export const RatingsSection: React.FC = ({ imdbId, type })
const displayValue = config.transform(parseFloat(value as string));
return (
-
+
{config.isImage ? (
) : (
-
+
{React.createElement(config.icon as any, {
- width: 16,
- height: 16,
+ width: iconSize,
+ height: iconSize,
})}
)}
-
+
{displayValue}
@@ -210,7 +263,6 @@ const styles = StyleSheet.create({
container: {
marginTop: 2,
marginBottom: 8,
- paddingHorizontal: 16,
},
loadingContainer: {
height: 40,
diff --git a/src/components/metadata/SeriesContent.tsx b/src/components/metadata/SeriesContent.tsx
index 9d20443..44e8e9d 100644
--- a/src/components/metadata/SeriesContent.tsx
+++ b/src/components/metadata/SeriesContent.tsx
@@ -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 = ({
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 = ({
const seasons = Object.keys(groupedEpisodes).map(Number).sort((a, b) => a - b);
return (
-
-
+
+
Seasons
{/* Dropdown Toggle Button */}
@@ -360,7 +490,10 @@ export const SeriesContent: React.FC = ({
: 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 = ({
{
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 = ({
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 = ({
onSeasonChange(season)}
@@ -448,12 +593,23 @@ export const SeriesContent: React.FC = ({
onSeasonChange(season)}
>
-
+
= ({
{selectedSeason === season && (
)}
@@ -471,18 +629,19 @@ export const SeriesContent: React.FC = ({
Season {season}
-
+
);
}}
@@ -550,22 +709,43 @@ export const SeriesContent: React.FC = ({
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}
>
-
- {episodeString}
+
+ {episodeString}
{showProgress && (
@@ -578,53 +758,112 @@ export const SeriesContent: React.FC = ({
)}
{progressPercent >= 85 && (
-
-
+
+
)}
+ {(!progress || progressPercent === 0) && (
+
+ )}
+ {
+ 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}
{effectiveVote > 0 && (
-
+
{effectiveVote.toFixed(1)}
)}
{effectiveRuntime && (
-
-
+
+
{formatRuntime(effectiveRuntime)}
)}
{episode.air_date && (
-
+
{formatDate(episode.air_date)}
)}
@@ -632,9 +871,12 @@ export const SeriesContent: React.FC = ({
+ {
+ 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'}
@@ -684,47 +926,25 @@ export const SeriesContent: React.FC = ({
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 */}
-
-
-
+ {/* Solid outline replaces gradient border */}
{/* Background Image */}
= ({
style={styles.episodeGradient}
>
{/* Content Container */}
-
+
{/* Episode Number Badge */}
-
- {episodeString}
+
+ {episodeString}
{/* Episode Title */}
-
+
{episode.name}
{/* Episode Description */}
-
+
{episode.overview || 'No description available'}
{/* Metadata Row */}
-
+
{episode.runtime && (
-
+
{formatRuntime(episode.runtime)}
)}
{episode.vote_average > 0 && (
-
-
+
+
{episode.vote_average.toFixed(1)}
@@ -799,12 +1072,34 @@ export const SeriesContent: React.FC = ({
{/* Completed Badge */}
{progressPercent >= 85 && (
-
-
+
+
)}
+ {(!progress || progressPercent === 0) && (
+
+ )}
@@ -824,7 +1119,15 @@ export const SeriesContent: React.FC = ({
-
+
{currentSeasonEpisodes.length} {currentSeasonEpisodes.length === 1 ? 'Episode' : 'Episodes'}
@@ -854,7 +1157,10 @@ export const SeriesContent: React.FC = ({
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 = ({
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 = ({
)}
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: {
diff --git a/src/components/metadata/TrailersSection.tsx b/src/components/metadata/TrailersSection.tsx
index c059a59..df1edd4 100644
--- a/src/components/metadata/TrailersSection.tsx
+++ b/src/components/metadata/TrailersSection.tsx
@@ -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 = memo(({
const [dropdownVisible, setDropdownVisible] = useState(false);
const [backendAvailable, setBackendAvailable] = useState(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 = memo(({
}
return (
-
+
{/* Enhanced Header with Category Selector */}
-
+
Trailers & Videos
{/* Category Selector - Right Aligned */}
{trailerCategories.length > 0 && selectedCategory && (
@@ -485,7 +575,7 @@ const TrailersSection: React.FC = memo(({
@@ -506,32 +596,58 @@ const TrailersSection: React.FC = memo(({
>
{trailerCategories.map(category => (
handleCategorySelect(category)}
activeOpacity={0.7}
>
-
+
{formatTrailerType(category)}
-
+
{trailers[category].length}
@@ -548,16 +664,25 @@ const TrailersSection: React.FC = memo(({
{trailers[selectedCategory].map((trailer, index) => (
handleTrailerPress(trailer)}
activeOpacity={0.9}
>
@@ -565,33 +690,71 @@ const TrailersSection: React.FC = memo(({
{/* Subtle Gradient Overlay */}
-
+
{/* Trailer Info */}
-
+
{trailer.displayName || trailer.name}
-
+
{new Date(trailer.published_at).getFullYear()}
))}
{/* Scroll Indicator - shows when there are more items to scroll */}
- {trailers[selectedCategory].length > (isTablet ? 4 : 3) && (
-
+ {trailers[selectedCategory].length > (isTV ? 5 : isLargeTablet ? 4 : isTablet ? 4 : 3) && (
+
@@ -614,7 +777,6 @@ const TrailersSection: React.FC = 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,
diff --git a/src/components/player/AndroidVideoPlayer.tsx b/src/components/player/AndroidVideoPlayer.tsx
index 40d1d1f..6a190a2 100644
--- a/src/components/player/AndroidVideoPlayer.tsx
+++ b/src/components/player/AndroidVideoPlayer.tsx
@@ -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
diff --git a/src/components/player/KSPlayerComponent.tsx b/src/components/player/KSPlayerComponent.tsx
index aa9d8fc..5bf3710 100644
--- a/src/components/player/KSPlayerComponent.tsx
+++ b/src/components/player/KSPlayerComponent.tsx
@@ -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((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((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)}
diff --git a/src/components/player/KSPlayerCore.tsx b/src/components/player/KSPlayerCore.tsx
index ae9db99..60cb9e4 100644
--- a/src/components/player/KSPlayerCore.tsx
+++ b/src/components/player/KSPlayerCore.tsx
@@ -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([]);
const [selectedTextTrack, setSelectedTextTrack] = useState(-1);
const [resizeMode, setResizeMode] = useState('contain');
+ const [playerBackend, setPlayerBackend] = useState('');
const [buffered, setBuffered] = useState(0);
const [seekPosition, setSeekPosition] = useState(null);
const ksPlayerRef = useRef(null);
@@ -253,6 +261,10 @@ const KSPlayerCore: React.FC = () => {
const controlsTimeout = useRef(null);
const [isSyncingBeforeClose, setIsSyncingBeforeClose] = useState(false);
+ // AirPlay state
+ const [isAirPlayActive, setIsAirPlayActive] = useState(false);
+ const [allowsAirPlay, setAllowsAirPlay] = useState(true);
+
// Silent startup-timeout retry state
const startupRetryCountRef = useRef(0);
const startupRetryTimerRef = useRef(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 = () => {
{
{
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 = () => {
@@ -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 = () => {
>
@@ -2581,10 +2655,10 @@ const KSPlayerCore: React.FC = () => {
>
@@ -2613,18 +2687,18 @@ const KSPlayerCore: React.FC = () => {
>
@@ -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,
}}>
{
>
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 */}
-
+
{
{
string;
playerBackend?: string;
+ // AirPlay props
+ isAirPlayActive?: boolean;
+ allowsAirPlay?: boolean;
+ onAirPlayPress?: () => void;
}
export const PlayerControls: React.FC = ({
@@ -80,20 +85,42 @@ export const PlayerControls: React.FC = ({
buffered,
formatTime,
playerBackend,
+ isAirPlayActive,
+ allowsAirPlay,
+ onAirPlayPress,
}) => {
const { currentTheme } = useTheme();
-
- /* Responsive Spacing */
+ /* Responsive Spacing - Merged with tablet support */
const screenWidth = Dimensions.get('window').width;
- const buttonSpacing = screenWidth * 0.15;
+ const BREAKPOINTS = { phone: 0, tablet: 768, largeTablet: 1024, tv: 1440 } as const;
+ const getDeviceType = (w: number) => {
+ if (w >= BREAKPOINTS.tv) return 'tv';
+ if (w >= BREAKPOINTS.largeTablet) return 'largeTablet';
+ if (w >= BREAKPOINTS.tablet) return 'tablet';
+ return 'phone';
+ };
+ const deviceType = getDeviceType(screenWidth);
+ const isTablet = deviceType === 'tablet';
+ const isLargeTablet = deviceType === 'largeTablet';
+ const isTV = deviceType === 'tv';
- const playButtonSize = screenWidth * 0.12; // 12% of screen width
- const playIconSize = playButtonSize * 0.6; // 60% of button size
- const seekButtonSize = screenWidth * 0.11; // 11% of screen width
- const seekIconSize = seekButtonSize * 0.75; // 75% of button size
- const seekNumberSize = seekButtonSize * 0.25; // 25% of button size
- const arcBorderWidth = seekButtonSize * 0.05; // 5% of button size
+ // Responsive button sizing - combines percentage-based with breakpoint scaling
+ const baseButtonSpacing = screenWidth * 0.15;
+ const buttonSpacing = isTV ? baseButtonSpacing * 1.2 : isLargeTablet ? baseButtonSpacing * 1.1 : isTablet ? baseButtonSpacing : baseButtonSpacing * 0.9;
+
+ const basePlayButtonSize = screenWidth * 0.12;
+ const playButtonSize = isTV ? basePlayButtonSize * 1.2 : isLargeTablet ? basePlayButtonSize * 1.1 : isTablet ? basePlayButtonSize : basePlayButtonSize * 0.9;
+ const playIconSize = playButtonSize * 0.6;
+
+ const baseSeekButtonSize = screenWidth * 0.11;
+ const seekButtonSize = isTV ? baseSeekButtonSize * 1.2 : isLargeTablet ? baseSeekButtonSize * 1.1 : isTablet ? baseSeekButtonSize : baseSeekButtonSize * 0.9;
+ const seekIconSize = seekButtonSize * 0.75;
+ const seekNumberSize = seekButtonSize * 0.25;
+ const arcBorderWidth = seekButtonSize * 0.05;
+
+ // Icon sizes for other controls
+ const closeIconSize = isTV ? 28 : isLargeTablet ? 26 : isTablet ? 24 : 24;
/* Animations - State & Refs */
const [showBackwardSign, setShowBackwardSign] = React.useState(false);
@@ -228,10 +255,6 @@ export const PlayerControls: React.FC = ({
togglePlayback();
};
-
-
-
-
return (
= ({
)}
-
+
-
- {/* Center Controls - CloudStream Style */}
+ {/* Center Controls - CloudStream Style with Responsive Sizing */}
@@ -548,6 +570,26 @@ export const PlayerControls: React.FC = ({
)}
+
+ {/* AirPlay Button - iOS only, KSAVPlayer only */}
+ {Platform.OS === 'ios' && onAirPlayPress && playerBackend === 'KSAVPlayer' && (
+
+
+
+ {allowsAirPlay ? 'AirPlay' : 'AirPlay Off'}
+
+
+ )}
diff --git a/src/components/player/modals/SubtitleModals.tsx b/src/components/player/modals/SubtitleModals.tsx
index d3269b8..b6713d4 100644
--- a/src/components/player/modals/SubtitleModals.tsx
+++ b/src/components/player/modals/SubtitleModals.tsx
@@ -306,47 +306,75 @@ export const SubtitleModals: React.FC = ({
Built-in Subtitles
- {/* Notice about built-in subtitle limitations - only when KSPlayer active on iOS */}
- {isIos && isKsPlayerActive && (
+ {/* Built-in subtitles now enabled for KSPlayer */}
+ {isKsPlayerActive && (
-
+
- Built-in subtitles temporarily disabled
+ Built-in subtitles enabled for KSPlayer
- 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.
)}
-
- {(!isIos || (isIos && !isKsPlayerActive)) && (
+
+ {/* Disable Subtitles Button */}
+ {
+ selectTextTrack(-1);
+ setSelectedOnlineSubtitleId(null);
+ }}
+ activeOpacity={0.7}
+ >
+
+
+ Disable All Subtitles
+
+ {selectedTextTrack === -1 && (
+
+ )}
+
+
+
+ {/* Always show built-in subtitles */}
+ {ksTextTracks.length > 0 && (
{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 (
{
+ 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: {
@@ -154,6 +188,11 @@ export const styles = StyleSheet.create({
justifyContent: 'center',
position: 'relative',
},
+ skipText: {
+ color: 'white',
+ fontSize: skipTextFont,
+ marginTop: 2,
+ },
playIcon: {
color: '#FFFFFF',
opacity: 1,
@@ -198,19 +237,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 +273,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 +296,7 @@ export const styles = StyleSheet.create({
},
duration: {
color: 'white',
- fontSize: 12,
+ fontSize: durationFont,
fontWeight: '500',
},
bottomButtons: {
@@ -272,7 +311,7 @@ export const styles = StyleSheet.create({
},
bottomButtonText: {
color: 'white',
- fontSize: 12,
+ fontSize: bottomButtonTextFont,
},
modalOverlay: {
flex: 1,
diff --git a/src/components/ui/ToastManager.tsx b/src/components/ui/ToastManager.tsx
index b87164e..3089bc9 100644
--- a/src/components/ui/ToastManager.tsx
+++ b/src/components/ui/ToastManager.tsx
@@ -33,3 +33,4 @@ const styles = StyleSheet.create({
});
export default ToastManager;
+
diff --git a/src/contexts/ToastContext.tsx b/src/contexts/ToastContext.tsx
index e1bf58d..ca6d739 100644
--- a/src/contexts/ToastContext.tsx
+++ b/src/contexts/ToastContext.tsx
@@ -69,3 +69,4 @@ export const ToastProvider: React.FC = ({ children }) => {
);
};
+
diff --git a/src/screens/CatalogScreen.tsx b/src/screens/CatalogScreen.tsx
index ce72e9c..2bdec21 100644
--- a/src/screens/CatalogScreen.tsx
+++ b/src/screens/CatalogScreen.tsx
@@ -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 = ({ 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 = ({ 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 (
= ({ route, navigation }) => {
keyExtractor={(item) => `${item.id}-${item.type}`}
numColumns={effectiveNumColumns}
key={effectiveNumColumns}
+ ItemSeparatorComponent={() => }
refreshControl={
= ({ 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'}
diff --git a/src/screens/MetadataScreen.tsx b/src/screens/MetadataScreen.tsx
index 7e94b3e..d347925 100644
--- a/src/screens/MetadataScreen.tsx
+++ b/src/screens/MetadataScreen.tsx
@@ -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 && (
-
- Network
-
+
+ Network
+
{metadata.networks.slice(0, 6).map((net) => (
-
+
{net.logo ? (
) : (
- {net.name}
+ {net.name}
)}
))}
@@ -1001,17 +1075,46 @@ const MetadataScreen: React.FC = () => {
metadata?.networks && Array.isArray(metadata.networks) &&
metadata.networks.some((n: any) => !!n?.logo) &&
metadata?.description && (
-
- Production
-
+
+ Production
+
{metadata.networks
.filter((net: any) => !!net?.logo)
.slice(0, 6)
.map((net: any) => (
-
+
@@ -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 && (
-
- Movie Details
+
+ Movie Details
{metadata.movieDetails.tagline && (
-
- Tagline
-
+
+ Tagline
+
"{metadata.movieDetails.tagline}"
)}
{metadata.movieDetails.status && (
-
- Status
- {metadata.movieDetails.status}
+
+ Status
+ {metadata.movieDetails.status}
)}
{metadata.movieDetails.releaseDate && (
-
- Release Date
-
+
+ Release Date
+
{new Date(metadata.movieDetails.releaseDate).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
@@ -1074,43 +1186,43 @@ const MetadataScreen: React.FC = () => {
)}
{metadata.movieDetails.runtime && (
-
- Runtime
-
+
+ Runtime
+
{Math.floor(metadata.movieDetails.runtime / 60)}h {metadata.movieDetails.runtime % 60}m
)}
{metadata.movieDetails.budget && metadata.movieDetails.budget > 0 && (
-
- Budget
-
+
+ Budget
+
${metadata.movieDetails.budget.toLocaleString()}
)}
{metadata.movieDetails.revenue && metadata.movieDetails.revenue > 0 && (
-
- Revenue
-
+
+ Revenue
+
${metadata.movieDetails.revenue.toLocaleString()}
)}
{metadata.movieDetails.originCountry && metadata.movieDetails.originCountry.length > 0 && (
-
- Origin Country
- {metadata.movieDetails.originCountry.join(', ')}
+
+ Origin Country
+ {metadata.movieDetails.originCountry.join(', ')}
)}
{metadata.movieDetails.originalLanguage && (
-
- Original Language
- {metadata.movieDetails.originalLanguage.toUpperCase()}
+
+ Original Language
+ {metadata.movieDetails.originalLanguage.toUpperCase()}
)}
@@ -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 && (
-
- Show Details
+
+ Show Details
{metadata.tvDetails.status && (
-
- Status
- {metadata.tvDetails.status}
+
+ Status
+ {metadata.tvDetails.status}
)}
{metadata.tvDetails.firstAirDate && (
-
- First Air Date
-
+
+ First Air Date
+
{new Date(metadata.tvDetails.firstAirDate).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
@@ -1182,9 +1303,9 @@ const MetadataScreen: React.FC = () => {
)}
{metadata.tvDetails.lastAirDate && (
-
- Last Air Date
-
+
+ Last Air Date
+
{new Date(metadata.tvDetails.lastAirDate).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
@@ -1195,46 +1316,46 @@ const MetadataScreen: React.FC = () => {
)}
{metadata.tvDetails.numberOfSeasons && (
-
- Seasons
- {metadata.tvDetails.numberOfSeasons}
+
+ Seasons
+ {metadata.tvDetails.numberOfSeasons}
)}
{metadata.tvDetails.numberOfEpisodes && (
-
- Total Episodes
- {metadata.tvDetails.numberOfEpisodes}
+
+ Total Episodes
+ {metadata.tvDetails.numberOfEpisodes}
)}
{metadata.tvDetails.episodeRunTime && metadata.tvDetails.episodeRunTime.length > 0 && (
-
- Episode Runtime
-
+
+ Episode Runtime
+
{metadata.tvDetails.episodeRunTime.join(' - ')} min
)}
{metadata.tvDetails.originCountry && metadata.tvDetails.originCountry.length > 0 && (
-
- Origin Country
- {metadata.tvDetails.originCountry.join(', ')}
+
+ Origin Country
+ {metadata.tvDetails.originCountry.join(', ')}
)}
{metadata.tvDetails.originalLanguage && (
-
- Original Language
- {metadata.tvDetails.originalLanguage.toUpperCase()}
+
+ Original Language
+ {metadata.tvDetails.originalLanguage.toUpperCase()}
)}
{metadata.tvDetails.createdBy && metadata.tvDetails.createdBy.length > 0 && (
-
- Created By
-
+
+ Created By
+
{metadata.tvDetails.createdBy.map(creator => creator.name).join(', ')}
@@ -1400,7 +1521,6 @@ const styles = StyleSheet.create({
marginBottom: 8,
},
productionContainer: {
- paddingHorizontal: 16,
marginTop: 0,
marginBottom: 20,
},
diff --git a/src/screens/SearchScreen.tsx b/src/screens/SearchScreen.tsx
index 22f1555..c680fe9 100644
--- a/src/screens/SearchScreen.tsx
+++ b/src/screens/SearchScreen.tsx
@@ -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 = () => {
)}
{item.name}
{item.year && (
-
+
{item.year}
)}
@@ -652,8 +678,16 @@ const SearchScreen = () => {
{/* Movies */}
{movieResults.length > 0 && (
-
-
+
+
Movies ({movieResults.length})
{
{/* TV Shows */}
{seriesResults.length > 0 && (
-
-
+
+
TV Shows ({seriesResults.length})
{
{/* Other types */}
{otherResults.length > 0 && (
-
-
+
+
{otherResults[0].type.charAt(0).toUpperCase() + otherResults[0].type.slice(1)} ({otherResults.length})
{
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;
diff --git a/src/services/stremioService.ts b/src/services/stremioService.ts
index d5a1957..bf3cf27 100644
--- a/src/services/stremioService.ts
+++ b/src/services/stremioService.ts
@@ -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);
}
diff --git a/src/services/toastService.ts b/src/services/toastService.ts
index 2ceb987..dc81093 100644
--- a/src/services/toastService.ts
+++ b/src/services/toastService.ts
@@ -150,3 +150,4 @@ class ToastService {
export const toastService = ToastService.getInstance();
export default toastService;
+
diff --git a/trakt/docs.md b/trakt/docs.md
deleted file mode 100644
index b9f7d3c..0000000
--- a/trakt/docs.md
+++ /dev/null
@@ -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"
-}
\ No newline at end of file