sub changes

This commit is contained in:
tapframe 2025-12-27 22:29:27 +05:30
parent 91af3a4021
commit a89c7f5c5c
8 changed files with 554 additions and 402 deletions

View file

@ -5,11 +5,11 @@
// Created by KSPlayer integration
//
#import <React/RCTViewManager.h>
#import <React/RCTBridgeModule.h>
#import <React/RCTEventEmitter.h>
#import <React/RCTViewManager.h>
@interface RCT_EXTERN_MODULE(KSPlayerViewManager, RCTViewManager)
@interface RCT_EXTERN_MODULE (KSPlayerViewManager, RCTViewManager)
RCT_EXPORT_VIEW_PROPERTY(source, NSDictionary)
RCT_EXPORT_VIEW_PROPERTY(paused, BOOL)
@ -21,6 +21,8 @@ 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(subtitleTextColor, NSString)
RCT_EXPORT_VIEW_PROPERTY(subtitleBackgroundColor, NSString)
RCT_EXPORT_VIEW_PROPERTY(resizeMode, NSString)
// Event properties
@ -31,25 +33,37 @@ RCT_EXPORT_VIEW_PROPERTY(onEnd, RCTDirectEventBlock)
RCT_EXPORT_VIEW_PROPERTY(onError, RCTDirectEventBlock)
RCT_EXPORT_VIEW_PROPERTY(onBufferingProgress, RCTDirectEventBlock)
RCT_EXTERN_METHOD(seek:(nonnull NSNumber *)node toTime:(nonnull NSNumber *)time)
RCT_EXTERN_METHOD(setSource:(nonnull NSNumber *)node source:(nonnull NSDictionary *)source)
RCT_EXTERN_METHOD(setPaused:(nonnull NSNumber *)node paused:(BOOL)paused)
RCT_EXTERN_METHOD(setVolume:(nonnull NSNumber *)node volume:(nonnull NSNumber *)volume)
RCT_EXTERN_METHOD(setPlaybackRate:(nonnull NSNumber *)node rate:(nonnull NSNumber *)rate)
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)
RCT_EXTERN_METHOD(seek : (nonnull NSNumber *)node toTime : (nonnull NSNumber *)
time)
RCT_EXTERN_METHOD(setSource : (nonnull NSNumber *)
node source : (nonnull NSDictionary *)source)
RCT_EXTERN_METHOD(setPaused : (nonnull NSNumber *)node paused : (BOOL)paused)
RCT_EXTERN_METHOD(setVolume : (nonnull NSNumber *)
node volume : (nonnull NSNumber *)volume)
RCT_EXTERN_METHOD(setPlaybackRate : (nonnull NSNumber *)
node rate : (nonnull NSNumber *)rate)
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)
@interface RCT_EXTERN_MODULE (KSPlayerModule, RCTEventEmitter)
RCT_EXTERN_METHOD(getTracks:(NSNumber *)nodeTag resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject)
RCT_EXTERN_METHOD(getAirPlayState:(NSNumber *)nodeTag resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject)
RCT_EXTERN_METHOD(showAirPlayPicker:(NSNumber *)nodeTag)
RCT_EXTERN_METHOD(getTracks : (NSNumber *)nodeTag resolve : (
RCTPromiseResolveBlock)resolve reject : (RCTPromiseRejectBlock)reject)
RCT_EXTERN_METHOD(getAirPlayState : (NSNumber *)nodeTag resolve : (
RCTPromiseResolveBlock)resolve reject : (RCTPromiseRejectBlock)reject)
RCT_EXTERN_METHOD(showAirPlayPicker : (NSNumber *)nodeTag)
@end

View file

@ -9,6 +9,7 @@ import Foundation
import KSPlayer
import React
import AVKit
import SwiftUI
@objc(KSPlayerView)
class KSPlayerView: UIView {
@ -98,6 +99,20 @@ class KSPlayerView: UIView {
}
}
@objc var subtitleTextColor: NSString = "#FFFFFF" {
didSet {
print("KSPlayerView: [PROP SETTER] subtitleTextColor setter called with value: \(subtitleTextColor)")
updateSubtitleColors()
}
}
@objc var subtitleBackgroundColor: NSString = "rgba(0,0,0,0.7)" {
didSet {
print("KSPlayerView: [PROP SETTER] subtitleBackgroundColor setter called with value: \(subtitleBackgroundColor)")
updateSubtitleColors()
}
}
@objc var resizeMode: NSString = "contain" {
didSet {
print("KSPlayerView: [PROP SETTER] resizeMode setter called with value: \(resizeMode)")
@ -269,6 +284,99 @@ class KSPlayerView: UIView {
}
print("KSPlayerView: [FONT UPDATE] Applied subtitle font size: \(size)")
}
private func updateSubtitleColors() {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
// Parse and apply text color
let textColor = self.parseColor(self.subtitleTextColor as String) ?? .white
self.playerView.subtitleLabel.textColor = textColor
SubtitleModel.textColor = SwiftUI.Color(textColor)
// Parse and apply background color
let bgColor = self.parseColor(self.subtitleBackgroundColor as String) ?? UIColor.black.withAlphaComponent(0.7)
self.playerView.subtitleBackView.backgroundColor = bgColor
SubtitleModel.textBackgroundColor = SwiftUI.Color(bgColor)
print("KSPlayerView: [COLOR UPDATE] Applied textColor: \(self.subtitleTextColor), bgColor: \(self.subtitleBackgroundColor)")
}
}
private func parseColor(_ colorString: String) -> UIColor? {
let trimmed = colorString.trimmingCharacters(in: .whitespaces)
// Handle hex colors (#RGB, #RRGGBB, #RRGGBBAA)
if trimmed.hasPrefix("#") {
return parseHexColor(trimmed)
}
// Handle rgba(r, g, b, a) format
if trimmed.lowercased().hasPrefix("rgba") {
return parseRgbaColor(trimmed)
}
// Handle rgb(r, g, b) format
if trimmed.lowercased().hasPrefix("rgb") {
return parseRgbColor(trimmed)
}
return nil
}
private func parseHexColor(_ hex: String) -> UIColor? {
var hexString = hex.trimmingCharacters(in: .whitespaces).uppercased()
if hexString.hasPrefix("#") {
hexString.remove(at: hexString.startIndex)
}
var rgbValue: UInt64 = 0
Scanner(string: hexString).scanHexInt64(&rgbValue)
switch hexString.count {
case 3: // RGB (12-bit)
let r = CGFloat((rgbValue & 0xF00) >> 8) / 15.0
let g = CGFloat((rgbValue & 0x0F0) >> 4) / 15.0
let b = CGFloat(rgbValue & 0x00F) / 15.0
return UIColor(red: r, green: g, blue: b, alpha: 1.0)
case 6: // RRGGBB (24-bit)
let r = CGFloat((rgbValue & 0xFF0000) >> 16) / 255.0
let g = CGFloat((rgbValue & 0x00FF00) >> 8) / 255.0
let b = CGFloat(rgbValue & 0x0000FF) / 255.0
return UIColor(red: r, green: g, blue: b, alpha: 1.0)
case 8: // RRGGBBAA (32-bit)
let r = CGFloat((rgbValue & 0xFF000000) >> 24) / 255.0
let g = CGFloat((rgbValue & 0x00FF0000) >> 16) / 255.0
let b = CGFloat((rgbValue & 0x0000FF00) >> 8) / 255.0
let a = CGFloat(rgbValue & 0x000000FF) / 255.0
return UIColor(red: r, green: g, blue: b, alpha: a)
default:
return nil
}
}
private func parseRgbaColor(_ rgba: String) -> UIColor? {
let pattern = "rgba?\\s*\\(\\s*([0-9.]+)\\s*,\\s*([0-9.]+)\\s*,\\s*([0-9.]+)\\s*(?:,\\s*([0-9.]+))?\\s*\\)"
guard let regex = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive),
let match = regex.firstMatch(in: rgba, range: NSRange(rgba.startIndex..., in: rgba)),
match.numberOfRanges >= 4 else {
return nil
}
let r = CGFloat((Double((rgba as NSString).substring(with: match.range(at: 1))) ?? 0) / 255.0)
let g = CGFloat((Double((rgba as NSString).substring(with: match.range(at: 2))) ?? 0) / 255.0)
let b = CGFloat((Double((rgba as NSString).substring(with: match.range(at: 3))) ?? 0) / 255.0)
var a: CGFloat = 1.0
if match.numberOfRanges >= 5, match.range(at: 4).location != NSNotFound {
a = CGFloat(Double((rgba as NSString).substring(with: match.range(at: 4))) ?? 1.0)
}
return UIColor(red: r, green: g, blue: b, alpha: a)
}
private func parseRgbColor(_ rgb: String) -> UIColor? {
return parseRgbaColor(rgb) // Same parsing works for rgb
}
func setSource(_ source: NSDictionary) {
currentSource = source
@ -883,10 +991,13 @@ extension KSPlayerView: KSPlayerLayerDelegate {
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"
// Determine player backend type from actual player instance
let playerBackend: String
if let _ = layer.player as? KSMEPlayer {
playerBackend = "KSMEPlayer"
} else {
playerBackend = "KSAVPlayer"
}
// Send onLoad event to React Native with track information
let p = layer.player

File diff suppressed because it is too large Load diff

View file

@ -204,15 +204,15 @@ export const RatingsSection: React.FC<RatingsSectionProps> = ({ imdbId, type })
// Priority: IMDB, TMDB, Tomatoes, Metacritic
const priorityOrder = ['imdb', 'tmdb', 'tomatoes', 'metacritic', 'trakt', 'letterboxd', 'audience'];
const displayRatings = priorityOrder
.filter(source =>
source in ratings &&
.filter(source =>
source in ratings &&
ratings[source as keyof typeof ratings] !== undefined &&
(enabledProviders[source] ?? true) // Show by default if setting not found
)
.map(source => [source, ratings[source as keyof typeof ratings]!]);
return (
<Animated.View
<Animated.View
style={[
styles.container,
{
@ -231,11 +231,11 @@ export const RatingsSection: React.FC<RatingsSectionProps> = ({ imdbId, type })
{displayRatings.map(([source, value]) => {
const config = ratingConfig[source as keyof typeof ratingConfig];
const displayValue = config.transform(parseFloat(value as string));
return (
<View key={source} style={[styles.compactRatingItem, { marginRight: itemSpacing }]}>
{config.isImage ? (
<Image
<Image
source={config.icon as any}
style={[styles.compactRatingIcon, { width: iconSize, height: iconSize, marginRight: iconTextGap }]}
resizeMode="contain"

View file

@ -17,6 +17,8 @@ interface KSPlayerViewProps {
usesExternalPlaybackWhileExternalScreenIsActive?: boolean;
subtitleBottomOffset?: number;
subtitleFontSize?: number;
subtitleTextColor?: string;
subtitleBackgroundColor?: string;
resizeMode?: 'contain' | 'cover' | 'stretch';
onLoad?: (data: any) => void;
onProgress?: (data: any) => void;
@ -56,6 +58,8 @@ export interface KSPlayerProps {
usesExternalPlaybackWhileExternalScreenIsActive?: boolean;
subtitleBottomOffset?: number;
subtitleFontSize?: number;
subtitleTextColor?: string;
subtitleBackgroundColor?: string;
resizeMode?: 'contain' | 'cover' | 'stretch';
onLoad?: (data: any) => void;
onProgress?: (data: any) => void;
@ -204,6 +208,8 @@ const KSPlayer = forwardRef<KSPlayerRef, KSPlayerProps>((props, ref) => {
usesExternalPlaybackWhileExternalScreenIsActive={props.usesExternalPlaybackWhileExternalScreenIsActive}
subtitleBottomOffset={props.subtitleBottomOffset}
subtitleFontSize={props.subtitleFontSize}
subtitleTextColor={props.subtitleTextColor}
subtitleBackgroundColor={props.subtitleBackgroundColor}
resizeMode={props.resizeMode}
onLoad={(e: any) => props.onLoad?.(e?.nativeEvent ?? e)}
onProgress={(e: any) => props.onProgress?.(e?.nativeEvent ?? e)}

View file

@ -546,6 +546,10 @@ const KSPlayerCore: React.FC = () => {
screenWidth={screenDimensions.width}
screenHeight={screenDimensions.height}
customVideoStyles={{ width: '100%', height: '100%' }}
subtitleTextColor={customSubs.subtitleTextColor}
subtitleBackgroundColor={customSubs.subtitleBackground ? `rgba(0,0,0,${customSubs.subtitleBgOpacity})` : 'transparent'}
subtitleFontSize={customSubs.subtitleSize}
subtitleBottomOffset={customSubs.subtitleBottomOffset}
/>
{/* Custom Subtitles Overlay */}

View file

@ -36,6 +36,12 @@ interface KSPlayerSurfaceProps {
screenWidth: number;
screenHeight: number;
customVideoStyles: any;
// Subtitle styling
subtitleTextColor?: string;
subtitleBackgroundColor?: string;
subtitleFontSize?: number;
subtitleBottomOffset?: number;
}
export const KSPlayerSurface: React.FC<KSPlayerSurfaceProps> = ({
@ -64,7 +70,11 @@ export const KSPlayerSurface: React.FC<KSPlayerSurfaceProps> = ({
onPlaybackResume,
screenWidth,
screenHeight,
customVideoStyles
customVideoStyles,
subtitleTextColor,
subtitleBackgroundColor,
subtitleFontSize,
subtitleBottomOffset
}) => {
const pinchRef = useRef<PinchGestureHandler>(null);
@ -132,6 +142,10 @@ export const KSPlayerSurface: React.FC<KSPlayerSurfaceProps> = ({
resizeMode={resizeMode}
audioTrack={audioTrack}
textTrack={textTrack}
subtitleTextColor={subtitleTextColor}
subtitleBackgroundColor={subtitleBackgroundColor}
subtitleFontSize={subtitleFontSize}
subtitleBottomOffset={subtitleBottomOffset}
onLoad={handleLoad}
onProgress={onProgress}
onBuffering={handleBuffering}

View file

@ -94,6 +94,8 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
const [activeTab, setActiveTab] = React.useState<'built-in' | 'addon' | 'appearance'>('built-in');
const isCompact = width < 360 || height < 640;
// Internal subtitle is active when a built-in track is selected AND not using custom/addon subtitles
const isUsingInternalSubtitle = selectedTextTrack >= 0 && !useCustomSubtitles;
const sectionPad = isCompact ? 12 : 16;
const chipPadH = isCompact ? 8 : 12;
const chipPadV = isCompact ? 6 : 8;
@ -363,64 +365,72 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
</TouchableOpacity>
</View>
</View>
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<Text style={{ color: 'white', fontWeight: '600' }}>Text Shadow</Text>
<TouchableOpacity onPress={() => setSubtitleTextShadow(!subtitleTextShadow)} style={{ paddingHorizontal: 10, paddingVertical: 8, borderRadius: 10, backgroundColor: subtitleTextShadow ? 'rgba(255,255,255,0.18)' : 'rgba(255,255,255,0.08)', borderWidth: 1, borderColor: 'rgba(255,255,255,0.15)', alignItems: 'center' }}>
<Text style={{ color: '#fff', fontWeight: '700' }}>{subtitleTextShadow ? 'On' : 'Off'}</Text>
</TouchableOpacity>
</View>
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<Text style={{ color: 'white' }}>Outline Color</Text>
<View style={{ flexDirection: 'row', gap: 8 }}>
{['#000000', '#FFFFFF', '#00E5FF', '#FF5C5C'].map(c => (
<TouchableOpacity key={c} onPress={() => setSubtitleOutlineColor(c)} style={{ width: 22, height: 22, borderRadius: 11, backgroundColor: c, borderWidth: 2, borderColor: subtitleOutlineColor === c ? '#fff' : 'rgba(255,255,255,0.3)' }} />
))}
</View>
</View>
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<Text style={{ color: 'white' }}>Outline Width</Text>
<View style={{ flexDirection: 'row', gap: 8, alignItems: 'center' }}>
<TouchableOpacity onPress={() => setSubtitleOutlineWidth(Math.max(0, subtitleOutlineWidth - 1))} style={{ width: controlBtn.size, height: controlBtn.size, borderRadius: controlBtn.radius, backgroundColor: 'rgba(255,255,255,0.18)', alignItems: 'center', justifyContent: 'center' }}>
<MaterialIcons name="remove" color="#fff" size={18} />
</TouchableOpacity>
<View style={{ minWidth: 42, paddingHorizontal: 6, paddingVertical: 4, borderRadius: 10, backgroundColor: 'rgba(255,255,255,0.12)' }}>
<Text style={{ color: 'white', textAlign: 'center', fontWeight: '700' }}>{subtitleOutlineWidth}</Text>
</View>
<TouchableOpacity onPress={() => setSubtitleOutlineWidth(subtitleOutlineWidth + 1)} style={{ width: controlBtn.size, height: controlBtn.size, borderRadius: controlBtn.radius, backgroundColor: 'rgba(255,255,255,0.18)', alignItems: 'center', justifyContent: 'center' }}>
<MaterialIcons name="add" color="#fff" size={18} />
{!isUsingInternalSubtitle && (
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<Text style={{ color: 'white', fontWeight: '600' }}>Text Shadow</Text>
<TouchableOpacity onPress={() => setSubtitleTextShadow(!subtitleTextShadow)} style={{ paddingHorizontal: 10, paddingVertical: 8, borderRadius: 10, backgroundColor: subtitleTextShadow ? 'rgba(255,255,255,0.18)' : 'rgba(255,255,255,0.08)', borderWidth: 1, borderColor: 'rgba(255,255,255,0.15)', alignItems: 'center' }}>
<Text style={{ color: '#fff', fontWeight: '700' }}>{subtitleTextShadow ? 'On' : 'Off'}</Text>
</TouchableOpacity>
</View>
</View>
<View style={{ flexDirection: isCompact ? 'column' : 'row', justifyContent: 'space-between', gap: 12 }}>
<View style={{ flex: 1 }}>
<Text style={{ color: 'white', fontWeight: '600' }}>Letter Spacing</Text>
<View style={{ flexDirection: 'row', gap: 8, alignItems: 'center', justifyContent: 'space-between' }}>
<TouchableOpacity onPress={() => setSubtitleLetterSpacing(Math.max(0, +(subtitleLetterSpacing - 0.5).toFixed(1)))} style={{ width: controlBtn.size, height: controlBtn.size, borderRadius: controlBtn.radius, backgroundColor: 'rgba(255,255,255,0.18)', alignItems: 'center', justifyContent: 'center' }}>
<MaterialIcons name="remove" color="#fff" size={18} />
</TouchableOpacity>
<View style={{ minWidth: 48, paddingHorizontal: 6, paddingVertical: 4, borderRadius: 10, backgroundColor: 'rgba(255,255,255,0.12)' }}>
<Text style={{ color: 'white', textAlign: 'center', fontWeight: '700' }}>{subtitleLetterSpacing.toFixed(1)}</Text>
)}
{!isUsingInternalSubtitle && (
<>
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<Text style={{ color: 'white' }}>Outline Color</Text>
<View style={{ flexDirection: 'row', gap: 8 }}>
{['#000000', '#FFFFFF', '#00E5FF', '#FF5C5C'].map(c => (
<TouchableOpacity key={c} onPress={() => setSubtitleOutlineColor(c)} style={{ width: 22, height: 22, borderRadius: 11, backgroundColor: c, borderWidth: 2, borderColor: subtitleOutlineColor === c ? '#fff' : 'rgba(255,255,255,0.3)' }} />
))}
</View>
</View>
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<Text style={{ color: 'white' }}>Outline Width</Text>
<View style={{ flexDirection: 'row', gap: 8, alignItems: 'center' }}>
<TouchableOpacity onPress={() => setSubtitleOutlineWidth(Math.max(0, subtitleOutlineWidth - 1))} style={{ width: controlBtn.size, height: controlBtn.size, borderRadius: controlBtn.radius, backgroundColor: 'rgba(255,255,255,0.18)', alignItems: 'center', justifyContent: 'center' }}>
<MaterialIcons name="remove" color="#fff" size={18} />
</TouchableOpacity>
<View style={{ minWidth: 42, paddingHorizontal: 6, paddingVertical: 4, borderRadius: 10, backgroundColor: 'rgba(255,255,255,0.12)' }}>
<Text style={{ color: 'white', textAlign: 'center', fontWeight: '700' }}>{subtitleOutlineWidth}</Text>
</View>
<TouchableOpacity onPress={() => setSubtitleOutlineWidth(subtitleOutlineWidth + 1)} style={{ width: controlBtn.size, height: controlBtn.size, borderRadius: controlBtn.radius, backgroundColor: 'rgba(255,255,255,0.18)', alignItems: 'center', justifyContent: 'center' }}>
<MaterialIcons name="add" color="#fff" size={18} />
</TouchableOpacity>
</View>
</View>
</>
)}
{!isUsingInternalSubtitle && (
<View style={{ flexDirection: isCompact ? 'column' : 'row', justifyContent: 'space-between', gap: 12 }}>
<View style={{ flex: 1 }}>
<Text style={{ color: 'white', fontWeight: '600' }}>Letter Spacing</Text>
<View style={{ flexDirection: 'row', gap: 8, alignItems: 'center', justifyContent: 'space-between' }}>
<TouchableOpacity onPress={() => setSubtitleLetterSpacing(Math.max(0, +(subtitleLetterSpacing - 0.5).toFixed(1)))} style={{ width: controlBtn.size, height: controlBtn.size, borderRadius: controlBtn.radius, backgroundColor: 'rgba(255,255,255,0.18)', alignItems: 'center', justifyContent: 'center' }}>
<MaterialIcons name="remove" color="#fff" size={18} />
</TouchableOpacity>
<View style={{ minWidth: 48, paddingHorizontal: 6, paddingVertical: 4, borderRadius: 10, backgroundColor: 'rgba(255,255,255,0.12)' }}>
<Text style={{ color: 'white', textAlign: 'center', fontWeight: '700' }}>{subtitleLetterSpacing.toFixed(1)}</Text>
</View>
<TouchableOpacity onPress={() => setSubtitleLetterSpacing(+(subtitleLetterSpacing + 0.5).toFixed(1))} style={{ width: controlBtn.size, height: controlBtn.size, borderRadius: controlBtn.radius, backgroundColor: 'rgba(255,255,255,0.18)', alignItems: 'center', justifyContent: 'center' }}>
<MaterialIcons name="add" color="#fff" size={18} />
</TouchableOpacity>
</View>
</View>
<View style={{ flex: 1 }}>
<Text style={{ color: 'white', fontWeight: '600' }}>Line Height</Text>
<View style={{ flexDirection: 'row', gap: 8, alignItems: 'center', justifyContent: 'space-between' }}>
<TouchableOpacity onPress={() => setSubtitleLineHeightMultiplier(Math.max(1, +(subtitleLineHeightMultiplier - 0.1).toFixed(1)))} style={{ width: controlBtn.size, height: controlBtn.size, borderRadius: controlBtn.radius, backgroundColor: 'rgba(255,255,255,0.18)', alignItems: 'center', justifyContent: 'center' }}>
<MaterialIcons name="remove" color="#fff" size={18} />
</TouchableOpacity>
<View style={{ minWidth: 48, paddingHorizontal: 6, paddingVertical: 4, borderRadius: 10, backgroundColor: 'rgba(255,255,255,0.12)' }}>
<Text style={{ color: 'white', textAlign: 'center', fontWeight: '700' }}>{subtitleLineHeightMultiplier.toFixed(1)}</Text>
</View>
<TouchableOpacity onPress={() => setSubtitleLineHeightMultiplier(+(subtitleLineHeightMultiplier + 0.1).toFixed(1))} style={{ width: controlBtn.size, height: controlBtn.size, borderRadius: controlBtn.radius, backgroundColor: 'rgba(255,255,255,0.18)', alignItems: 'center', justifyContent: 'center' }}>
<MaterialIcons name="add" color="#fff" size={18} />
</TouchableOpacity>
</View>
<TouchableOpacity onPress={() => setSubtitleLetterSpacing(+(subtitleLetterSpacing + 0.5).toFixed(1))} style={{ width: controlBtn.size, height: controlBtn.size, borderRadius: controlBtn.radius, backgroundColor: 'rgba(255,255,255,0.18)', alignItems: 'center', justifyContent: 'center' }}>
<MaterialIcons name="add" color="#fff" size={18} />
</TouchableOpacity>
</View>
</View>
<View style={{ flex: 1 }}>
<Text style={{ color: 'white', fontWeight: '600' }}>Line Height</Text>
<View style={{ flexDirection: 'row', gap: 8, alignItems: 'center', justifyContent: 'space-between' }}>
<TouchableOpacity onPress={() => setSubtitleLineHeightMultiplier(Math.max(1, +(subtitleLineHeightMultiplier - 0.1).toFixed(1)))} style={{ width: controlBtn.size, height: controlBtn.size, borderRadius: controlBtn.radius, backgroundColor: 'rgba(255,255,255,0.18)', alignItems: 'center', justifyContent: 'center' }}>
<MaterialIcons name="remove" color="#fff" size={18} />
</TouchableOpacity>
<View style={{ minWidth: 48, paddingHorizontal: 6, paddingVertical: 4, borderRadius: 10, backgroundColor: 'rgba(255,255,255,0.12)' }}>
<Text style={{ color: 'white', textAlign: 'center', fontWeight: '700' }}>{subtitleLineHeightMultiplier.toFixed(1)}</Text>
</View>
<TouchableOpacity onPress={() => setSubtitleLineHeightMultiplier(+(subtitleLineHeightMultiplier + 0.1).toFixed(1))} style={{ width: controlBtn.size, height: controlBtn.size, borderRadius: controlBtn.radius, backgroundColor: 'rgba(255,255,255,0.18)', alignItems: 'center', justifyContent: 'center' }}>
<MaterialIcons name="add" color="#fff" size={18} />
</TouchableOpacity>
</View>
</View>
</View>
)}
<View style={{ marginTop: 4 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<Text style={{ color: 'white', fontWeight: '600' }}>Timing Offset (s)</Text>