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