unified-video-framework 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/.github/workflows/ci.yml +253 -0
  2. package/ANDROID_TV_IMPLEMENTATION.md +313 -0
  3. package/COMPLETION_STATUS.md +165 -0
  4. package/CONTRIBUTING.md +376 -0
  5. package/FINAL_STATUS_REPORT.md +170 -0
  6. package/FRAMEWORK_REVIEW.md +247 -0
  7. package/IMPROVEMENTS_SUMMARY.md +168 -0
  8. package/LICENSE +21 -0
  9. package/NATIVE_APP_INTEGRATION_GUIDE.md +903 -0
  10. package/PAYWALL_RENTAL_FLOW.md +499 -0
  11. package/PLATFORM_SETUP_GUIDE.md +1636 -0
  12. package/README.md +315 -0
  13. package/RUN_LOCALLY.md +151 -0
  14. package/apps/demo/cast-sender-min.html +173 -0
  15. package/apps/demo/custom-player.html +883 -0
  16. package/apps/demo/demo.html +990 -0
  17. package/apps/demo/enhanced-player.html +3556 -0
  18. package/apps/demo/index.html +159 -0
  19. package/apps/rental-api/.env.example +24 -0
  20. package/apps/rental-api/README.md +23 -0
  21. package/apps/rental-api/migrations/001_init.sql +35 -0
  22. package/apps/rental-api/migrations/002_videos.sql +10 -0
  23. package/apps/rental-api/migrations/003_add_gateway_subref.sql +4 -0
  24. package/apps/rental-api/migrations/004_update_gateways.sql +4 -0
  25. package/apps/rental-api/migrations/005_seed_demo_video.sql +5 -0
  26. package/apps/rental-api/package-lock.json +2045 -0
  27. package/apps/rental-api/package.json +33 -0
  28. package/apps/rental-api/scripts/run-migration.js +42 -0
  29. package/apps/rental-api/scripts/update-video-currency.js +21 -0
  30. package/apps/rental-api/scripts/update-video-price.js +19 -0
  31. package/apps/rental-api/src/config.ts +14 -0
  32. package/apps/rental-api/src/db.ts +10 -0
  33. package/apps/rental-api/src/routes/cashfree.ts +167 -0
  34. package/apps/rental-api/src/routes/pesapal.ts +92 -0
  35. package/apps/rental-api/src/routes/rentals.ts +242 -0
  36. package/apps/rental-api/src/routes/webhooks.ts +73 -0
  37. package/apps/rental-api/src/server.ts +41 -0
  38. package/apps/rental-api/src/services/entitlements.ts +45 -0
  39. package/apps/rental-api/src/services/payments.ts +22 -0
  40. package/apps/rental-api/tsconfig.json +17 -0
  41. package/check-urls.ps1 +74 -0
  42. package/comparison-report.md +181 -0
  43. package/docs/PAYWALL.md +95 -0
  44. package/docs/PLAYER_UI_VISIBILITY.md +431 -0
  45. package/docs/README.md +7 -0
  46. package/docs/SYSTEM_ARCHITECTURE.md +612 -0
  47. package/docs/VDOCIPHER_CLONE_REQUIREMENTS.md +403 -0
  48. package/examples/android/JavaSampleApp/MainActivity.java +641 -0
  49. package/examples/android/JavaSampleApp/activity_main.xml +226 -0
  50. package/examples/android/SampleApp/MainActivity.kt +430 -0
  51. package/examples/ios/SampleApp/ViewController.swift +337 -0
  52. package/examples/ios/SwiftUISampleApp/ContentView.swift +304 -0
  53. package/iOS_IMPLEMENTATION_OPTIONS.md +470 -0
  54. package/ios/UnifiedVideoPlayer/UnifiedVideoPlayer.podspec +33 -0
  55. package/jest.config.js +33 -0
  56. package/jitpack.yml +5 -0
  57. package/lerna.json +35 -0
  58. package/package.json +69 -0
  59. package/packages/PLATFORM_STATUS.md +163 -0
  60. package/packages/android/build.gradle +135 -0
  61. package/packages/android/src/main/AndroidManifest.xml +36 -0
  62. package/packages/android/src/main/java/com/unifiedvideo/player/PlayerConfiguration.java +221 -0
  63. package/packages/android/src/main/java/com/unifiedvideo/player/UnifiedVideoPlayer.java +1037 -0
  64. package/packages/android/src/main/java/com/unifiedvideo/player/UnifiedVideoPlayer.kt +707 -0
  65. package/packages/android/src/main/java/com/unifiedvideo/player/analytics/AnalyticsProvider.java +9 -0
  66. package/packages/android/src/main/java/com/unifiedvideo/player/cast/CastManager.java +141 -0
  67. package/packages/android/src/main/java/com/unifiedvideo/player/cast/CastOptionsProvider.java +29 -0
  68. package/packages/android/src/main/java/com/unifiedvideo/player/overlay/WatermarkOverlayView.java +88 -0
  69. package/packages/android/src/main/java/com/unifiedvideo/player/pip/PipActionReceiver.java +33 -0
  70. package/packages/android/src/main/java/com/unifiedvideo/player/services/PlaybackService.java +110 -0
  71. package/packages/android/src/main/java/com/unifiedvideo/player/services/PlayerHolder.java +19 -0
  72. package/packages/core/package.json +34 -0
  73. package/packages/core/src/BasePlayer.ts +250 -0
  74. package/packages/core/src/VideoPlayer.ts +237 -0
  75. package/packages/core/src/VideoPlayerFactory.ts +145 -0
  76. package/packages/core/src/index.ts +20 -0
  77. package/packages/core/src/interfaces/IVideoPlayer.ts +184 -0
  78. package/packages/core/src/interfaces.ts +240 -0
  79. package/packages/core/src/utils/EventEmitter.ts +66 -0
  80. package/packages/core/src/utils/PlatformDetector.ts +300 -0
  81. package/packages/core/tsconfig.json +20 -0
  82. package/packages/enact/package.json +51 -0
  83. package/packages/enact/src/VideoPlayer.js +365 -0
  84. package/packages/enact/src/adapters/TizenAdapter.js +354 -0
  85. package/packages/enact/src/index.js +82 -0
  86. package/packages/ios/BUILD_INSTRUCTIONS.md +108 -0
  87. package/packages/ios/FIX_EMBED_ISSUE.md +142 -0
  88. package/packages/ios/GETTING_STARTED.md +100 -0
  89. package/packages/ios/Package.swift +35 -0
  90. package/packages/ios/README.md +84 -0
  91. package/packages/ios/Sources/UnifiedVideoPlayer/Analytics/AnalyticsEmitter.swift +26 -0
  92. package/packages/ios/Sources/UnifiedVideoPlayer/DRM/FairPlayDRMManager.swift +102 -0
  93. package/packages/ios/Sources/UnifiedVideoPlayer/Info.plist +24 -0
  94. package/packages/ios/Sources/UnifiedVideoPlayer/Remote/RemoteCommandCenter.swift +109 -0
  95. package/packages/ios/Sources/UnifiedVideoPlayer/UnifiedVideoPlayer.swift +811 -0
  96. package/packages/ios/Sources/UnifiedVideoPlayer/UnifiedVideoPlayerView.swift +640 -0
  97. package/packages/ios/Sources/UnifiedVideoPlayer/Utilities/Color+Hex.swift +36 -0
  98. package/packages/ios/UnifiedVideoPlayer.podspec +27 -0
  99. package/packages/ios/UnifiedVideoPlayer.xcodeproj/project.pbxproj +385 -0
  100. package/packages/ios/build_framework.sh +55 -0
  101. package/packages/react-native/android/src/main/java/com/unifiedvideo/UnifiedVideoPlayerModule.kt +482 -0
  102. package/packages/react-native/ios/UnifiedVideoPlayer.swift +436 -0
  103. package/packages/react-native/package.json +51 -0
  104. package/packages/react-native/src/ReactNativePlayer.tsx +423 -0
  105. package/packages/react-native/src/VideoPlayer.tsx +224 -0
  106. package/packages/react-native/src/index.ts +28 -0
  107. package/packages/react-native/src/utils/EventEmitter.ts +66 -0
  108. package/packages/react-native/tsconfig.json +31 -0
  109. package/packages/roku/components/UnifiedVideoPlayer.brs +400 -0
  110. package/packages/roku/package.json +44 -0
  111. package/packages/roku/source/VideoPlayer.brs +231 -0
  112. package/packages/roku/source/main.brs +28 -0
  113. package/packages/web/GETTING_STARTED.md +292 -0
  114. package/packages/web/jest.config.js +28 -0
  115. package/packages/web/jest.setup.ts +110 -0
  116. package/packages/web/package.json +50 -0
  117. package/packages/web/src/SecureVideoPlayer.ts +1164 -0
  118. package/packages/web/src/WebPlayer.ts +3110 -0
  119. package/packages/web/src/__tests__/WebPlayer.test.ts +314 -0
  120. package/packages/web/src/index.ts +14 -0
  121. package/packages/web/src/paywall/PaywallController.ts +215 -0
  122. package/packages/web/src/react/WebPlayerView.tsx +177 -0
  123. package/packages/web/tsconfig.json +23 -0
  124. package/packages/web/webpack.config.js +45 -0
  125. package/server.js +131 -0
  126. package/server.py +84 -0
  127. package/test-urls.ps1 +97 -0
  128. package/test-video-urls.ps1 +87 -0
  129. package/tsconfig.json +39 -0
@@ -0,0 +1,1037 @@
1
+ /**
2
+ * UnifiedVideoPlayer.java
3
+ * Unified Video Framework - Android Native SDK (Java Version)
4
+ */
5
+
6
+ package com.unifiedvideo.player;
7
+
8
+ import android.content.Context;
9
+ import android.net.Uri;
10
+ import android.os.Handler;
11
+ import android.os.Looper;
12
+ import android.util.Log;
13
+ import android.view.Surface;
14
+ import android.view.View;
15
+ import android.view.ViewGroup;
16
+ import android.widget.FrameLayout;
17
+
18
+ import com.unifiedvideo.player.services.PlayerHolder;
19
+ import com.unifiedvideo.player.services.PlaybackService;
20
+
21
+ import androidx.annotation.NonNull;
22
+ import androidx.annotation.Nullable;
23
+
24
+ import com.google.android.exoplayer2.C;
25
+ import com.google.android.exoplayer2.ExoPlayer;
26
+ import com.google.android.exoplayer2.LoadControl;
27
+ import com.google.android.exoplayer2.MediaItem;
28
+ import com.google.android.exoplayer2.PlaybackException;
29
+ import com.google.android.exoplayer2.PlaybackParameters;
30
+ import com.google.android.exoplayer2.Player;
31
+ import com.google.android.exoplayer2.Timeline;
32
+ import com.google.android.exoplayer2.analytics.AnalyticsListener;
33
+ import com.google.android.exoplayer2.drm.DefaultDrmSessionManager;
34
+ import com.google.android.exoplayer2.drm.DrmSessionManager;
35
+ import com.google.android.exoplayer2.drm.DrmSessionManagerProvider;
36
+ import com.google.android.exoplayer2.drm.FrameworkMediaDrm;
37
+ import com.google.android.exoplayer2.drm.HttpMediaDrmCallback;
38
+ import com.google.android.exoplayer2.source.LoadEventInfo;
39
+ import com.google.android.exoplayer2.source.MediaLoadData;
40
+ import com.google.android.exoplayer2.source.MediaSource;
41
+ import com.google.android.exoplayer2.source.ProgressiveMediaSource;
42
+ import com.google.android.exoplayer2.source.dash.DashMediaSource;
43
+ import com.google.android.exoplayer2.source.hls.HlsMediaSource;
44
+ import com.google.android.exoplayer2.source.smoothstreaming.SsMediaSource;
45
+ import com.google.android.exoplayer2.trackselection.DefaultTrackSelector;
46
+ import com.google.android.exoplayer2.ui.PlayerView;
47
+ import com.google.android.exoplayer2.ui.StyledPlayerView;
48
+ import com.google.android.exoplayer2.upstream.DefaultDataSource;
49
+ import com.google.android.exoplayer2.upstream.DefaultHttpDataSource;
50
+ import com.google.android.exoplayer2.util.MimeTypes;
51
+ import com.google.android.exoplayer2.util.Util;
52
+ import com.google.android.exoplayer2.video.VideoSize;
53
+
54
+ import java.util.ArrayList;
55
+ import java.util.HashMap;
56
+ import java.util.List;
57
+ import java.util.Map;
58
+ import java.util.UUID;
59
+ import java.util.concurrent.TimeUnit;
60
+
61
+ // New imports
62
+ import android.app.Activity;
63
+ import android.app.PictureInPictureParams;
64
+ import android.app.RemoteAction;
65
+ import android.os.Build;
66
+ import android.util.Rational;
67
+ import android.app.PendingIntent;
68
+ import android.content.Intent;
69
+ import android.graphics.drawable.Icon;
70
+
71
+ import com.unifiedvideo.player.analytics.AnalyticsProvider;
72
+ import com.unifiedvideo.player.cast.CastManager;
73
+ import com.unifiedvideo.player.cast.CastManager.SubtitleItem;
74
+ import com.unifiedvideo.player.overlay.WatermarkOverlayView;
75
+
76
+ import androidx.appcompat.widget.AppCompatButton;
77
+ import androidx.appcompat.widget.AppCompatTextView;
78
+ import androidx.mediarouter.app.MediaRouteButton;
79
+ import com.google.android.gms.cast.framework.CastButtonFactory;
80
+ import com.google.android.gms.cast.framework.CastSession;
81
+ import com.google.android.gms.cast.framework.SessionManagerListener;
82
+
83
+ import android.view.Gravity;
84
+
85
+ /**
86
+ * Main Unified Video Player class for Android (Java implementation)
87
+ */
88
+ public class UnifiedVideoPlayer {
89
+
90
+ private static final String TAG = "UnifiedVideoPlayer";
91
+
92
+ // Player components
93
+ private ExoPlayer exoPlayer;
94
+ private View playerView;
95
+ private ViewGroup container;
96
+ private PlayerConfiguration configuration;
97
+ private MediaSourceInfo currentSource;
98
+ private DefaultTrackSelector trackSelector;
99
+
100
+ // Overlay
101
+ private WatermarkOverlayView watermarkOverlay;
102
+
103
+ // Analytics
104
+ private final List<AnalyticsProvider> analyticsProviders = new ArrayList<>();
105
+
106
+ // Cast
107
+ private CastManager castManager;
108
+ private MediaRouteButton castButton;
109
+ private AppCompatButton stopCastBtn;
110
+ private AppCompatTextView subtitleBtn;
111
+ private SessionManagerListener<CastSession> castSessionListener;
112
+
113
+ // Context
114
+ private final Context context;
115
+
116
+ // Handler for progress updates
117
+ private final Handler mainHandler = new Handler(Looper.getMainLooper());
118
+ private Runnable updateProgressHandler;
119
+ private Runnable watermarkRandomizer;
120
+
121
+ // State properties
122
+ private PlayerState state = PlayerState.IDLE;
123
+ private boolean isPlaying = false;
124
+ private long duration = 0;
125
+ private long currentPosition = 0;
126
+ private long bufferedPosition = 0;
127
+ private float volume = 1.0f;
128
+
129
+ // Event listeners
130
+ private PlayerEventListener eventListener;
131
+
132
+ /**
133
+ * Player states
134
+ */
135
+ public enum PlayerState {
136
+ IDLE,
137
+ LOADING,
138
+ READY,
139
+ PLAYING,
140
+ PAUSED,
141
+ BUFFERING,
142
+ SEEKING,
143
+ ENDED,
144
+ ERROR
145
+ }
146
+
147
+ /**
148
+ * Event listener interface
149
+ */
150
+ public interface PlayerEventListener {
151
+ void onReady();
152
+ void onPlay();
153
+ void onPause();
154
+ void onTimeUpdate(long currentTime);
155
+ void onBuffering(boolean isBuffering);
156
+ void onSeek(long position);
157
+ void onEnded();
158
+ void onError(Exception error);
159
+ void onLoadedMetadata(Map<String, Object> metadata);
160
+ void onVolumeChange(float volume);
161
+ void onStateChange(PlayerState state);
162
+ void onProgress(long bufferedPosition);
163
+ void onVideoSizeChanged(int width, int height);
164
+ }
165
+
166
+ /**
167
+ * Constructor
168
+ * @param context Android context
169
+ */
170
+ public UnifiedVideoPlayer(@NonNull Context context) {
171
+ this.context = context.getApplicationContext();
172
+ }
173
+
174
+ /**
175
+ * Initialize the player with container and configuration
176
+ * @param container ViewGroup to hold the player view
177
+ * @param configuration Player configuration (optional)
178
+ */
179
+ public void initialize(@NonNull ViewGroup container, @Nullable PlayerConfiguration configuration) {
180
+ this.container = container;
181
+ this.configuration = configuration != null ? configuration : new PlayerConfiguration.Builder().build();
182
+
183
+ setupPlayer();
184
+ applyConfiguration();
185
+ }
186
+
187
+ /**
188
+ * Set event listener
189
+ * @param listener Event listener implementation
190
+ */
191
+ public void setEventListener(PlayerEventListener listener) {
192
+ this.eventListener = listener;
193
+ }
194
+
195
+ /**
196
+ * Setup the player
197
+ */
198
+ private void setupPlayer() {
199
+ // Create track selector for adaptive streaming
200
+ trackSelector = new DefaultTrackSelector(context);
201
+ trackSelector.setParameters(
202
+ trackSelector.buildUponParameters()
203
+ .setMaxVideoSizeSd()
204
+ .build()
205
+ );
206
+
207
+ // Create player
208
+ exoPlayer = new ExoPlayer.Builder(context)
209
+ .setTrackSelector(trackSelector)
210
+ .build();
211
+ // Expose player to services (background & PiP actions)
212
+ PlayerHolder.setPlayer(exoPlayer);
213
+
214
+ // Add listeners
215
+ exoPlayer.addListener(playerEventListener);
216
+ exoPlayer.addAnalyticsListener(analyticsListener);
217
+
218
+ // Create player view
219
+ if (configuration.useStyledControls) {
220
+ StyledPlayerView styledPlayerView = new StyledPlayerView(context);
221
+ styledPlayerView.setPlayer(exoPlayer);
222
+ styledPlayerView.setUseController(configuration.controls);
223
+ styledPlayerView.setControllerShowTimeoutMs(3000);
224
+ styledPlayerView.setControllerHideOnTouch(true);
225
+ styledPlayerView.setShowBuffering(StyledPlayerView.SHOW_BUFFERING_WHEN_PLAYING);
226
+ playerView = styledPlayerView;
227
+ } else {
228
+ PlayerView simplePlayerView = new PlayerView(context);
229
+ simplePlayerView.setPlayer(exoPlayer);
230
+ simplePlayerView.setUseController(configuration.controls);
231
+ simplePlayerView.setControllerShowTimeoutMs(3000);
232
+ simplePlayerView.setControllerHideOnTouch(true);
233
+ playerView = simplePlayerView;
234
+ }
235
+
236
+ // Add to container
237
+ FrameLayout.LayoutParams layoutParams = new FrameLayout.LayoutParams(
238
+ FrameLayout.LayoutParams.MATCH_PARENT,
239
+ FrameLayout.LayoutParams.MATCH_PARENT
240
+ );
241
+ playerView.setLayoutParams(layoutParams);
242
+ container.addView(playerView);
243
+
244
+ // Add watermark overlay on top (optional by default)
245
+ watermarkOverlay = new WatermarkOverlayView(context);
246
+ watermarkOverlay.setLayoutParams(new FrameLayout.LayoutParams(
247
+ FrameLayout.LayoutParams.MATCH_PARENT,
248
+ FrameLayout.LayoutParams.MATCH_PARENT
249
+ ));
250
+ watermarkOverlay.setAlphaFactor(0.3f);
251
+ container.addView(watermarkOverlay);
252
+
253
+ // Add Cast button (top-right)
254
+ castButton = new MediaRouteButton(context);
255
+ FrameLayout.LayoutParams castLp = new FrameLayout.LayoutParams(
256
+ FrameLayout.LayoutParams.WRAP_CONTENT,
257
+ FrameLayout.LayoutParams.WRAP_CONTENT
258
+ );
259
+ castLp.gravity = Gravity.TOP | Gravity.END;
260
+ castLp.topMargin = 16; castLp.rightMargin = 16;
261
+ castButton.setLayoutParams(castLp);
262
+ try { CastButtonFactory.setUpMediaRouteButton(context, castButton); } catch (Exception ignored) {}
263
+ container.addView(castButton);
264
+
265
+ // Stop Cast pill button (hidden until session active)
266
+ stopCastBtn = new AppCompatButton(context);
267
+ stopCastBtn.setText("Stop Casting");
268
+ FrameLayout.LayoutParams stopLp = new FrameLayout.LayoutParams(
269
+ FrameLayout.LayoutParams.WRAP_CONTENT,
270
+ FrameLayout.LayoutParams.WRAP_CONTENT
271
+ );
272
+ stopLp.gravity = Gravity.TOP | Gravity.END;
273
+ stopLp.topMargin = 72; stopLp.rightMargin = 16;
274
+ stopCastBtn.setLayoutParams(stopLp);
275
+ stopCastBtn.setVisibility(View.GONE);
276
+ stopCastBtn.setOnClickListener(v -> { if (castManager != null) castManager.stopCasting(); });
277
+ container.addView(stopCastBtn);
278
+
279
+ // Subtitle toggle button (cycles through tracks)
280
+ subtitleBtn = new AppCompatTextView(context);
281
+ subtitleBtn.setText("CC");
282
+ subtitleBtn.setPadding(20, 10, 20, 10);
283
+ subtitleBtn.setBackgroundColor(0x66000000);
284
+ subtitleBtn.setTextColor(0xFFFFFFFF);
285
+ FrameLayout.LayoutParams subLp = new FrameLayout.LayoutParams(
286
+ FrameLayout.LayoutParams.WRAP_CONTENT,
287
+ FrameLayout.LayoutParams.WRAP_CONTENT
288
+ );
289
+ subLp.gravity = Gravity.TOP | Gravity.END;
290
+ subLp.topMargin = 128; subLp.rightMargin = 16;
291
+ subtitleBtn.setLayoutParams(subLp);
292
+ subtitleBtn.setOnClickListener(v -> cycleSubtitle());
293
+ container.addView(subtitleBtn);
294
+
295
+ // Start progress updates
296
+ startProgressUpdates();
297
+
298
+ // Schedule watermark movement
299
+ scheduleWatermark();
300
+
301
+ updateState(PlayerState.IDLE);
302
+ }
303
+
304
+ /**
305
+ * Apply configuration to the player
306
+ */
307
+ private void applyConfiguration() {
308
+ if (configuration == null) return;
309
+
310
+ exoPlayer.setVolume(configuration.volume);
311
+ exoPlayer.setPlaybackParameters(new PlaybackParameters(configuration.playbackSpeed));
312
+
313
+ if (configuration.loop) {
314
+ exoPlayer.setRepeatMode(Player.REPEAT_MODE_ALL);
315
+ } else {
316
+ exoPlayer.setRepeatMode(Player.REPEAT_MODE_OFF);
317
+ }
318
+
319
+ if (configuration.muted) {
320
+ exoPlayer.setVolume(0f);
321
+ }
322
+
323
+ if (configuration.debug) {
324
+ enableDebugLogging();
325
+ }
326
+ }
327
+
328
+ /**
329
+ * Load media source
330
+ * @param source Media source information
331
+ */
332
+ public void load(@NonNull MediaSourceInfo source) {
333
+ currentSource = source;
334
+ updateState(PlayerState.LOADING);
335
+
336
+ Uri uri = Uri.parse(source.url);
337
+ MediaSource mediaSource = createMediaSource(uri, source);
338
+
339
+ exoPlayer.setMediaSource(mediaSource);
340
+ exoPlayer.prepare();
341
+
342
+ // Apply start time if configured
343
+ if (configuration.startTime > 0) {
344
+ seekTo(configuration.startTime);
345
+ }
346
+
347
+ // Auto-play if configured
348
+ if (configuration.autoPlay) {
349
+ play();
350
+ }
351
+
352
+ // Load subtitles if provided
353
+ if (source.subtitles != null && !source.subtitles.isEmpty()) {
354
+ loadSubtitles(source.subtitles);
355
+ }
356
+ }
357
+
358
+ /**
359
+ * Load media from URL
360
+ * @param url Media URL
361
+ */
362
+ public void load(@NonNull String url) {
363
+ MediaSourceInfo source = new MediaSourceInfo(url);
364
+ load(source);
365
+ }
366
+
367
+ /**
368
+ * Create ExoPlayer MediaSource
369
+ */
370
+ private MediaSource createMediaSource(Uri uri, MediaSourceInfo source) {
371
+ DefaultDataSource.Factory dataSourceFactory = new DefaultDataSource.Factory(context);
372
+
373
+ // Configure DRM if needed
374
+ DrmSessionManagerProvider drmSessionManagerProvider = null;
375
+ if (source.drm != null) {
376
+ drmSessionManagerProvider = createDrmSessionManagerProvider(source.drm);
377
+ }
378
+
379
+ MediaItem.Builder mediaItemBuilder = new MediaItem.Builder().setUri(uri);
380
+
381
+ // Add DRM configuration
382
+ if (source.drm != null) {
383
+ MediaItem.DrmConfiguration drmConfig = new MediaItem.DrmConfiguration.Builder(getDrmUuid(source.drm.type))
384
+ .setLicenseUri(source.drm.licenseUrl)
385
+ .setMultiSession(source.drm.multiSession)
386
+ .setForceDefaultLicenseUri(source.drm.forceDefaultLicenseUri)
387
+ .setLicenseRequestHeaders(source.drm.headers != null ? source.drm.headers : new HashMap<>())
388
+ .build();
389
+ mediaItemBuilder.setDrmConfiguration(drmConfig);
390
+ }
391
+
392
+ // Attach side-loaded subtitles if provided
393
+ if (source.subtitles != null && !source.subtitles.isEmpty()) {
394
+ List<MediaItem.SubtitleConfiguration> subs = new ArrayList<>();
395
+ for (SubtitleTrack st : source.subtitles) {
396
+ MediaItem.SubtitleConfiguration subCfg = new MediaItem.SubtitleConfiguration.Builder(Uri.parse(st.url))
397
+ .setMimeType(st.mimeType != null ? st.mimeType : MimeTypes.TEXT_VTT)
398
+ .setLanguage(st.language)
399
+ .setLabel(st.label)
400
+ .build();
401
+ subs.add(subCfg);
402
+ }
403
+ mediaItemBuilder.setSubtitleConfigurations(subs);
404
+ }
405
+
406
+ MediaItem mediaItem = mediaItemBuilder.build();
407
+
408
+ // Create appropriate media source based on type
409
+ String type = source.type != null ? source.type : MediaSourceInfo.detectType(source.url);
410
+
411
+ switch (type) {
412
+ case "hls":
413
+ HlsMediaSource.Factory hlsFactory = new HlsMediaSource.Factory(dataSourceFactory);
414
+ if (drmSessionManagerProvider != null) {
415
+ hlsFactory.setDrmSessionManagerProvider(drmSessionManagerProvider);
416
+ }
417
+ return hlsFactory.createMediaSource(mediaItem);
418
+
419
+ case "dash":
420
+ DashMediaSource.Factory dashFactory = new DashMediaSource.Factory(dataSourceFactory);
421
+ if (drmSessionManagerProvider != null) {
422
+ dashFactory.setDrmSessionManagerProvider(drmSessionManagerProvider);
423
+ }
424
+ return dashFactory.createMediaSource(mediaItem);
425
+
426
+ case "smoothstreaming":
427
+ SsMediaSource.Factory ssFactory = new SsMediaSource.Factory(dataSourceFactory);
428
+ if (drmSessionManagerProvider != null) {
429
+ ssFactory.setDrmSessionManagerProvider(drmSessionManagerProvider);
430
+ }
431
+ return ssFactory.createMediaSource(mediaItem);
432
+
433
+ default:
434
+ ProgressiveMediaSource.Factory progressiveFactory = new ProgressiveMediaSource.Factory(dataSourceFactory);
435
+ if (drmSessionManagerProvider != null) {
436
+ progressiveFactory.setDrmSessionManagerProvider(drmSessionManagerProvider);
437
+ }
438
+ return progressiveFactory.createMediaSource(mediaItem);
439
+ }
440
+ }
441
+
442
+ /**
443
+ * Create DRM Session Manager Provider
444
+ */
445
+ private DrmSessionManagerProvider createDrmSessionManagerProvider(DRMConfiguration drm) {
446
+ HttpMediaDrmCallback drmCallback = new HttpMediaDrmCallback(
447
+ drm.licenseUrl,
448
+ new DefaultHttpDataSource.Factory()
449
+ );
450
+
451
+ if (drm.headers != null) {
452
+ for (Map.Entry<String, String> entry : drm.headers.entrySet()) {
453
+ drmCallback.setKeyRequestProperty(entry.getKey(), entry.getValue());
454
+ }
455
+ }
456
+
457
+ return new DrmSessionManagerProvider() {
458
+ @Override
459
+ public DrmSessionManager get(MediaItem mediaItem) {
460
+ DefaultDrmSessionManager drmSessionManager = new DefaultDrmSessionManager.Builder()
461
+ .setUuidAndExoMediaDrmProvider(
462
+ getDrmUuid(drm.type),
463
+ FrameworkMediaDrm.DEFAULT_PROVIDER
464
+ )
465
+ .build(drmCallback);
466
+
467
+ drmSessionManager.setMode(DefaultDrmSessionManager.MODE_PLAYBACK, new byte[0]);
468
+ return drmSessionManager;
469
+ }
470
+ };
471
+ }
472
+
473
+ /**
474
+ * Get DRM UUID from type
475
+ */
476
+ private UUID getDrmUuid(String drmType) {
477
+ switch (drmType.toLowerCase()) {
478
+ case "widevine":
479
+ return C.WIDEVINE_UUID;
480
+ case "playready":
481
+ return C.PLAYREADY_UUID;
482
+ case "clearkey":
483
+ return C.CLEARKEY_UUID;
484
+ default:
485
+ return C.WIDEVINE_UUID;
486
+ }
487
+ }
488
+
489
+ /**
490
+ * Load subtitles
491
+ */
492
+ private void loadSubtitles(List<SubtitleTrack> subtitles) {
493
+ // Subtitles are attached via MediaItem subtitle configurations in createMediaSource.
494
+ for (SubtitleTrack subtitle : subtitles) {
495
+ Log.d(TAG, "Subtitle configured: " + subtitle.label + " (" + subtitle.language + ")");
496
+ }
497
+ }
498
+
499
+ // Playback Control Methods
500
+
501
+ public void play() {
502
+ exoPlayer.play();
503
+ isPlaying = true;
504
+ updateState(PlayerState.PLAYING);
505
+ if (configuration != null && configuration.allowBackgroundPlayback) {
506
+ try { PlaybackService.start(context); } catch (Exception ignored) {}
507
+ }
508
+ if (eventListener != null) eventListener.onPlay();
509
+ }
510
+
511
+ public void pause() {
512
+ exoPlayer.pause();
513
+ isPlaying = false;
514
+ updateState(PlayerState.PAUSED);
515
+ if (configuration != null && configuration.allowBackgroundPlayback) {
516
+ try { PlaybackService.start(context); } catch (Exception ignored) {}
517
+ }
518
+ if (eventListener != null) eventListener.onPause();
519
+ }
520
+
521
+ public void stop() {
522
+ exoPlayer.stop();
523
+ exoPlayer.seekTo(0);
524
+ isPlaying = false;
525
+ updateState(PlayerState.IDLE);
526
+ }
527
+
528
+ public void togglePlayPause() {
529
+ if (isPlaying) {
530
+ pause();
531
+ } else {
532
+ play();
533
+ }
534
+ }
535
+
536
+ public void seekTo(long position) {
537
+ updateState(PlayerState.SEEKING);
538
+ exoPlayer.seekTo(position);
539
+ if (eventListener != null) eventListener.onSeek(position);
540
+ }
541
+
542
+ public void seekForward(int seconds) {
543
+ long newPosition = currentPosition + (seconds * 1000);
544
+ seekTo(Math.min(newPosition, duration));
545
+ }
546
+
547
+ public void seekBackward(int seconds) {
548
+ long newPosition = currentPosition - (seconds * 1000);
549
+ seekTo(Math.max(newPosition, 0));
550
+ }
551
+
552
+ // Volume Control
553
+
554
+ public void setVolume(float volume) {
555
+ float clampedVolume = Math.max(0f, Math.min(1f, volume));
556
+ this.volume = clampedVolume;
557
+ exoPlayer.setVolume(clampedVolume);
558
+ if (eventListener != null) eventListener.onVolumeChange(clampedVolume);
559
+ }
560
+
561
+ public void mute() {
562
+ exoPlayer.setVolume(0f);
563
+ }
564
+
565
+ public void unmute() {
566
+ exoPlayer.setVolume(volume);
567
+ }
568
+
569
+ public void toggleMute() {
570
+ if (exoPlayer.getVolume() == 0f) {
571
+ unmute();
572
+ } else {
573
+ mute();
574
+ }
575
+ }
576
+
577
+ // Playback Speed
578
+
579
+ public void setPlaybackSpeed(float speed) {
580
+ exoPlayer.setPlaybackParameters(new PlaybackParameters(speed));
581
+ }
582
+
583
+ public float getPlaybackSpeed() {
584
+ return exoPlayer.getPlaybackParameters().speed;
585
+ }
586
+
587
+ // Quality Selection
588
+
589
+ public void setVideoQuality(String quality) {
590
+ DefaultTrackSelector.Parameters.Builder parametersBuilder = trackSelector.buildUponParameters();
591
+
592
+ switch (quality) {
593
+ case "auto":
594
+ trackSelector.setParameters(parametersBuilder.clearVideoSizeConstraints().build());
595
+ break;
596
+ case "hd":
597
+ trackSelector.setParameters(
598
+ parametersBuilder
599
+ .setMaxVideoSize(1920, 1080)
600
+ .setMinVideoSize(1280, 720)
601
+ .build()
602
+ );
603
+ break;
604
+ case "sd":
605
+ trackSelector.setParameters(parametersBuilder.setMaxVideoSizeSd().build());
606
+ break;
607
+ case "low":
608
+ trackSelector.setParameters(
609
+ parametersBuilder.setMaxVideoSize(854, 480).build()
610
+ );
611
+ break;
612
+ }
613
+ }
614
+
615
+ // State Management
616
+
617
+ private void updateState(PlayerState newState) {
618
+ state = newState;
619
+ if (eventListener != null) eventListener.onStateChange(newState);
620
+ trackAnalytics("statechange", new HashMap<String, Object>() {{ put("state", newState.name()); }});
621
+
622
+ if (configuration.debug) {
623
+ Log.d(TAG, "State changed to: " + newState);
624
+ }
625
+ }
626
+
627
+ // Progress Updates
628
+
629
+ private void startProgressUpdates() {
630
+ updateProgressHandler = new Runnable() {
631
+ @Override
632
+ public void run() {
633
+ if (exoPlayer != null) {
634
+ currentPosition = exoPlayer.getCurrentPosition();
635
+ bufferedPosition = exoPlayer.getBufferedPosition();
636
+ duration = exoPlayer.getDuration();
637
+
638
+ if (eventListener != null) {
639
+ eventListener.onTimeUpdate(currentPosition);
640
+ eventListener.onProgress(bufferedPosition);
641
+ }
642
+ }
643
+ mainHandler.postDelayed(this, 100);
644
+ }
645
+ };
646
+ mainHandler.post(updateProgressHandler);
647
+ }
648
+
649
+ private void stopProgressUpdates() {
650
+ if (updateProgressHandler != null) {
651
+ mainHandler.removeCallbacks(updateProgressHandler);
652
+ }
653
+ if (watermarkRandomizer != null) {
654
+ mainHandler.removeCallbacks(watermarkRandomizer);
655
+ }
656
+ }
657
+
658
+ private void scheduleWatermark() {
659
+ if (watermarkOverlay == null) return;
660
+ watermarkRandomizer = new Runnable() {
661
+ @Override
662
+ public void run() {
663
+ try {
664
+ watermarkOverlay.randomize();
665
+ } catch (Exception ignored) {}
666
+ mainHandler.postDelayed(this, 5000);
667
+ }
668
+ };
669
+ mainHandler.postDelayed(watermarkRandomizer, 5000);
670
+ }
671
+
672
+ // Player Event Listener
673
+
674
+ private final Player.Listener playerEventListener = new Player.Listener() {
675
+ @Override
676
+ public void onPlaybackStateChanged(int playbackState) {
677
+ switch (playbackState) {
678
+ case Player.STATE_IDLE:
679
+ updateState(PlayerState.IDLE);
680
+ break;
681
+ case Player.STATE_BUFFERING:
682
+ updateState(PlayerState.BUFFERING);
683
+ if (eventListener != null) eventListener.onBuffering(true);
684
+ break;
685
+ case Player.STATE_READY:
686
+ if (state == PlayerState.LOADING || state == PlayerState.BUFFERING) {
687
+ updateState(PlayerState.READY);
688
+ if (eventListener != null) {
689
+ eventListener.onReady();
690
+ emitLoadedMetadata();
691
+ }
692
+ }
693
+ if (state == PlayerState.BUFFERING && eventListener != null) {
694
+ eventListener.onBuffering(false);
695
+ }
696
+ if (exoPlayer.isPlaying()) {
697
+ updateState(PlayerState.PLAYING);
698
+ }
699
+ break;
700
+ case Player.STATE_ENDED:
701
+ updateState(PlayerState.ENDED);
702
+ if (eventListener != null) eventListener.onEnded();
703
+
704
+ if (configuration.loop) {
705
+ seekTo(0);
706
+ play();
707
+ }
708
+ break;
709
+ }
710
+ }
711
+
712
+ @Override
713
+ public void onIsPlayingChanged(boolean isPlaying) {
714
+ UnifiedVideoPlayer.this.isPlaying = isPlaying;
715
+ if (isPlaying) {
716
+ updateState(PlayerState.PLAYING);
717
+ trackAnalytics("play", null);
718
+ } else if (state == PlayerState.PLAYING) {
719
+ updateState(PlayerState.PAUSED);
720
+ trackAnalytics("pause", null);
721
+ }
722
+ }
723
+
724
+ @Override
725
+ public void onPlayerError(PlaybackException error) {
726
+ updateState(PlayerState.ERROR);
727
+ if (eventListener != null) eventListener.onError(error);
728
+ trackAnalytics("error", new HashMap<String, Object>() {{ put("message", error.getMessage()); }});
729
+
730
+ if (configuration.debug) {
731
+ Log.e(TAG, "Player error: " + error.getMessage(), error);
732
+ }
733
+ }
734
+
735
+ @Override
736
+ public void onVideoSizeChanged(VideoSize videoSize) {
737
+ if (eventListener != null) {
738
+ eventListener.onVideoSizeChanged(videoSize.width, videoSize.height);
739
+ }
740
+ }
741
+
742
+ @Override
743
+ public void onRenderedFirstFrame() {
744
+ Log.d(TAG, "First frame rendered");
745
+ }
746
+ };
747
+
748
+ // Analytics Listener
749
+
750
+ private final AnalyticsListener analyticsListener = new AnalyticsListener() {
751
+ @Override
752
+ public void onLoadCompleted(AnalyticsListener.EventTime eventTime, LoadEventInfo loadEventInfo, MediaLoadData mediaLoadData) {
753
+ Log.d(TAG, "Load completed: " + loadEventInfo.dataSpec.uri);
754
+ }
755
+
756
+ @Override
757
+ public void onBandwidthEstimate(AnalyticsListener.EventTime eventTime, int totalLoadTimeMs, long totalBytesLoaded, long bitrateEstimate) {
758
+ if (configuration.debug) {
759
+ Log.d(TAG, "Bandwidth estimate: " + bitrateEstimate + " bps");
760
+ }
761
+ }
762
+ };
763
+
764
+ private void emitLoadedMetadata() {
765
+ Map<String, Object> metadata = new HashMap<>();
766
+
767
+ if (exoPlayer != null) {
768
+ metadata.put("duration", exoPlayer.getDuration());
769
+
770
+ if (exoPlayer.getVideoFormat() != null) {
771
+ metadata.put("width", exoPlayer.getVideoFormat().width);
772
+ metadata.put("height", exoPlayer.getVideoFormat().height);
773
+ metadata.put("frameRate", exoPlayer.getVideoFormat().frameRate);
774
+ metadata.put("bitrate", exoPlayer.getVideoFormat().bitrate);
775
+ metadata.put("codec", exoPlayer.getVideoFormat().codecs != null ? exoPlayer.getVideoFormat().codecs : "");
776
+ }
777
+
778
+ if (exoPlayer.getAudioFormat() != null) {
779
+ metadata.put("audioChannels", exoPlayer.getAudioFormat().channelCount);
780
+ metadata.put("audioSampleRate", exoPlayer.getAudioFormat().sampleRate);
781
+ metadata.put("audioCodec", exoPlayer.getAudioFormat().codecs != null ? exoPlayer.getAudioFormat().codecs : "");
782
+ }
783
+ }
784
+
785
+ if (eventListener != null) {
786
+ eventListener.onLoadedMetadata(metadata);
787
+ }
788
+ trackAnalytics("loadedmetadata", metadata);
789
+ }
790
+
791
+ // Surface Control (for advanced use cases)
792
+
793
+ public void setVideoSurface(Surface surface) {
794
+ exoPlayer.setVideoSurface(surface);
795
+ }
796
+
797
+ public void clearVideoSurface() {
798
+ exoPlayer.clearVideoSurface();
799
+ }
800
+
801
+ // Debug
802
+
803
+ private void enableDebugLogging() {
804
+ Log.d(TAG, "Debug mode enabled");
805
+ Log.d(TAG, "Configuration: " + configuration);
806
+ }
807
+
808
+ // Lifecycle Management
809
+
810
+ public void onResume() {
811
+ if (exoPlayer != null && state == PlayerState.PLAYING) {
812
+ exoPlayer.play();
813
+ }
814
+ }
815
+
816
+ /** Enter Picture-in-Picture mode (requires host Activity with android:supportsPictureInPicture="true"). */
817
+ public boolean enterPictureInPicture(@NonNull Activity activity) {
818
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
819
+ try {
820
+ PendingIntent playPi = PendingIntent.getBroadcast(activity, 200,
821
+ new Intent("com.unifiedvideo.player.ACTION_PLAY"), Build.VERSION.SDK_INT >= 31 ? PendingIntent.FLAG_MUTABLE : 0);
822
+ PendingIntent pausePi = PendingIntent.getBroadcast(activity, 201,
823
+ new Intent("com.unifiedvideo.player.ACTION_PAUSE"), Build.VERSION.SDK_INT >= 31 ? PendingIntent.FLAG_MUTABLE : 0);
824
+
825
+ RemoteAction playAction = new RemoteAction(
826
+ Icon.createWithResource(activity, android.R.drawable.ic_media_play),
827
+ "Play",
828
+ "Play",
829
+ playPi
830
+ );
831
+ RemoteAction pauseAction = new RemoteAction(
832
+ Icon.createWithResource(activity, android.R.drawable.ic_media_pause),
833
+ "Pause",
834
+ "Pause",
835
+ pausePi
836
+ );
837
+
838
+ PictureInPictureParams params = new PictureInPictureParams.Builder()
839
+ .setAspectRatio(new Rational(16, 9))
840
+ .setActions(java.util.Arrays.asList(playAction, pauseAction))
841
+ .build();
842
+ activity.enterPictureInPictureMode(params);
843
+ return true;
844
+ } catch (Exception e) {
845
+ Log.w(TAG, "PiP entry failed", e);
846
+ }
847
+ }
848
+ return false;
849
+ }
850
+
851
+ public void onPause() {
852
+ if (!configuration.allowBackgroundPlayback && exoPlayer != null) {
853
+ exoPlayer.pause();
854
+ }
855
+ }
856
+
857
+ public void onStop() {
858
+ if (!configuration.allowBackgroundPlayback && exoPlayer != null) {
859
+ exoPlayer.stop();
860
+ }
861
+ }
862
+
863
+ // Cleanup
864
+
865
+ public void release() {
866
+ stopProgressUpdates();
867
+ try { PlaybackService.stop(context); } catch (Exception ignored) {}
868
+
869
+ if (exoPlayer != null) {
870
+ exoPlayer.removeListener(playerEventListener);
871
+ exoPlayer.removeAnalyticsListener(analyticsListener);
872
+ exoPlayer.release();
873
+ }
874
+
875
+ if (playerView != null && container != null) {
876
+ container.removeView(playerView);
877
+ }
878
+
879
+ exoPlayer = null;
880
+ playerView = null;
881
+ container = null;
882
+
883
+ updateState(PlayerState.IDLE);
884
+ }
885
+
886
+ // Getters
887
+
888
+ public PlayerState getState() {
889
+ return state;
890
+ }
891
+
892
+ public boolean isPlaying() {
893
+ return isPlaying;
894
+ }
895
+
896
+ public long getDuration() {
897
+ return duration;
898
+ }
899
+
900
+ public long getCurrentPosition() {
901
+ return currentPosition;
902
+ }
903
+
904
+ public long getBufferedPosition() {
905
+ return bufferedPosition;
906
+ }
907
+
908
+ public float getVolume() {
909
+ return volume;
910
+ }
911
+
912
+ // Theming & Watermark
913
+ public void setThemeColors(int accentStart, int accentEnd) {
914
+ if (watermarkOverlay != null) {
915
+ watermarkOverlay.setAccentColors(accentStart, accentEnd);
916
+ }
917
+ }
918
+
919
+ public void setWatermarkEnabled(boolean enabled) {
920
+ if (watermarkOverlay != null) {
921
+ watermarkOverlay.setVisibility(enabled ? View.VISIBLE : View.GONE);
922
+ }
923
+ }
924
+
925
+ // Analytics wiring
926
+ public void addAnalyticsProvider(AnalyticsProvider provider) {
927
+ if (provider != null) analyticsProviders.add(provider);
928
+ }
929
+
930
+ public void setAnalyticsProviders(List<AnalyticsProvider> providers) {
931
+ analyticsProviders.clear();
932
+ if (providers != null) analyticsProviders.addAll(providers);
933
+ }
934
+
935
+ private void trackAnalytics(String event, Map<String, Object> data) {
936
+ if (analyticsProviders.isEmpty()) return;
937
+ Map<String, Object> payload = new HashMap<>();
938
+ if (data != null) payload.putAll(data);
939
+ payload.put("timestamp", System.currentTimeMillis());
940
+ payload.put("position", currentPosition);
941
+ payload.put("duration", duration);
942
+ for (AnalyticsProvider p : analyticsProviders) {
943
+ try {
944
+ p.track(event, payload);
945
+ } catch (Exception ignored) {}
946
+ }
947
+ }
948
+
949
+ // Casting API
950
+ public void enableCasting() {
951
+ if (castManager == null) castManager = new CastManager();
952
+ if (castSessionListener == null) {
953
+ castSessionListener = new SessionManagerListener<CastSession>() {
954
+ @Override public void onSessionStarted(CastSession session, String s) { updateCastUi(true); }
955
+ @Override public void onSessionResumed(CastSession session, boolean b) { updateCastUi(true); }
956
+ @Override public void onSessionEnded(CastSession session, int i) { updateCastUi(false); }
957
+ @Override public void onSessionSuspended(CastSession session, int i) { updateCastUi(false); }
958
+ @Override public void onSessionStarting(CastSession session) {}
959
+ @Override public void onSessionResuming(CastSession session, String s) {}
960
+ @Override public void onSessionEnding(CastSession session) {}
961
+ @Override public void onSessionResumeFailed(CastSession session, int i) {}
962
+ @Override public void onSessionStartFailed(CastSession session, int i) { updateCastUi(false); }
963
+ };
964
+ }
965
+ castManager.addSessionManagerListener(castSessionListener);
966
+ }
967
+
968
+ private void updateCastUi(boolean connected) {
969
+ if (stopCastBtn != null) {
970
+ stopCastBtn.post(() -> stopCastBtn.setVisibility(connected ? View.VISIBLE : View.GONE));
971
+ }
972
+ }
973
+
974
+ public boolean isCastingAvailable() {
975
+ if (castManager == null) return false;
976
+ return castManager.hasSession();
977
+ }
978
+
979
+ public void castCurrentMedia() {
980
+ if (castManager == null || currentSource == null) return;
981
+ String ct = inferContentType(currentSource);
982
+ List<SubtitleItem> subs = null;
983
+ if (currentSource.subtitles != null && !currentSource.subtitles.isEmpty()) {
984
+ subs = new ArrayList<>();
985
+ for (SubtitleTrack st : currentSource.subtitles) {
986
+ subs.add(new SubtitleItem(st.url, st.language, st.label, st.mimeType));
987
+ }
988
+ }
989
+ castManager.startCasting(currentSource.url, ct, currentSource.metadata != null ? String.valueOf(currentSource.metadata.get("title")) : null, subs, null);
990
+ }
991
+
992
+ private String inferContentType(MediaSourceInfo src) {
993
+ String u = src.url != null ? src.url.toLowerCase() : "";
994
+ if (u.endsWith(".m3u8")) return "application/x-mpegURL";
995
+ if (u.endsWith(".mpd")) return "application/dash+xml";
996
+ if (u.endsWith(".webm")) return "video/webm";
997
+ return "video/mp4";
998
+ }
999
+ // Subtitle selection helpers
1000
+ private int subtitleIndex = -1; // -1 off
1001
+ private void cycleSubtitle() {
1002
+ if (currentSource == null || currentSource.subtitles == null || currentSource.subtitles.isEmpty()) {
1003
+ // toggle off/on no-op
1004
+ disableSubtitles();
1005
+ return;
1006
+ }
1007
+ subtitleIndex++;
1008
+ if (subtitleIndex >= currentSource.subtitles.size()) subtitleIndex = -1; // off
1009
+ if (subtitleIndex == -1) {
1010
+ disableSubtitles();
1011
+ } else {
1012
+ SubtitleTrack st = currentSource.subtitles.get(subtitleIndex);
1013
+ setSubtitleLanguage(st.language);
1014
+ }
1015
+ }
1016
+
1017
+ public void setSubtitleLanguage(String language) {
1018
+ try {
1019
+ exoPlayer.setTrackSelectionParameters(
1020
+ exoPlayer.getTrackSelectionParameters().buildUpon()
1021
+ .setPreferredTextLanguage(language)
1022
+ .setSelectUndeterminedTextLanguage(true)
1023
+ .build()
1024
+ );
1025
+ } catch (Exception ignored) {}
1026
+ }
1027
+
1028
+ public void disableSubtitles() {
1029
+ try {
1030
+ exoPlayer.setTrackSelectionParameters(
1031
+ exoPlayer.getTrackSelectionParameters().buildUpon()
1032
+ .setPreferredTextLanguage(null)
1033
+ .build()
1034
+ );
1035
+ } catch (Exception ignored) {}
1036
+ }
1037
+ }