diff --git a/.idea/gradle.xml b/.idea/gradle.xml
index 18a266ce..c030d62f 100644
--- a/.idea/gradle.xml
+++ b/.idea/gradle.xml
@@ -4,8 +4,9 @@
-
+
\ No newline at end of file
diff --git a/NRMediaTailorTracker/build.gradle b/NRMediaTailorTracker/build.gradle
new file mode 100644
index 00000000..23f23ce1
--- /dev/null
+++ b/NRMediaTailorTracker/build.gradle
@@ -0,0 +1,60 @@
+plugins {
+ id 'com.android.library'
+ id 'maven-publish'
+}
+android {
+ compileSdkVersion 33
+ defaultConfig {
+ minSdkVersion 16
+ targetSdkVersion 33
+ versionCode 1
+ versionName "1.0.0"
+ testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
+ consumerProguardFiles "consumer-rules.pro"
+ }
+ buildTypes {
+ debug {
+ minifyEnabled false
+ proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
+ buildConfigField("long", "VERSION_CODE", "${defaultConfig.versionCode}")
+ buildConfigField("String","VERSION_NAME","\"${defaultConfig.versionName}\"")
+ }
+ release {
+ minifyEnabled false
+ proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
+ buildConfigField("long", "VERSION_CODE", "${defaultConfig.versionCode}")
+ buildConfigField("String","VERSION_NAME","\"${defaultConfig.versionName}\"")
+ }
+ }
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_1_8
+ targetCompatibility JavaVersion.VERSION_1_8
+ }
+ namespace 'com.newrelic.videoagent.mediatailor'
+}
+dependencies {
+ implementation 'androidx.appcompat:appcompat:1.2.0'
+ implementation 'com.google.android.material:material:1.2.1'
+ implementation project(path: ':NewRelicVideoCore')
+ implementation 'androidx.media3:media3-exoplayer:1.1.0'
+ implementation 'androidx.media3:media3-exoplayer-hls:1.1.0'
+ testImplementation 'junit:junit:4.13.2'
+ testImplementation 'org.robolectric:robolectric:4.10.3'
+ androidTestImplementation 'androidx.test.ext:junit:1.1.5'
+ androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
+ androidTestImplementation 'androidx.test:runner:1.5.2'
+ androidTestImplementation 'androidx.test:rules:1.5.0'
+}
+afterEvaluate {
+ publishing {
+ publications {
+ release(MavenPublication) {
+ from components.release
+ groupId = 'com.github.newrelic'
+ artifactId = 'NRMediaTailorTracker'
+ version = "${android.defaultConfig.versionName}"
+ }
+ }
+ }
+
+}
diff --git a/NRMediaTailorTracker/src/main/AndroidManifest.xml b/NRMediaTailorTracker/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..3cb3262d
--- /dev/null
+++ b/NRMediaTailorTracker/src/main/AndroidManifest.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/NRMediaTailorTracker/src/main/java/com/newrelic/videoagent/mediatailor/tracker/NRTrackerMediaTailor.java b/NRMediaTailorTracker/src/main/java/com/newrelic/videoagent/mediatailor/tracker/NRTrackerMediaTailor.java
new file mode 100644
index 00000000..88cea89a
--- /dev/null
+++ b/NRMediaTailorTracker/src/main/java/com/newrelic/videoagent/mediatailor/tracker/NRTrackerMediaTailor.java
@@ -0,0 +1,903 @@
+package com.newrelic.videoagent.mediatailor.tracker;
+
+import android.os.Handler;
+import android.os.Looper;
+import android.util.Log;
+
+import androidx.media3.common.Player;
+import androidx.media3.exoplayer.ExoPlayer;
+import androidx.media3.exoplayer.hls.HlsManifest;
+import androidx.media3.exoplayer.hls.playlist.HlsMediaPlaylist;
+
+import com.newrelic.videoagent.core.tracker.NRVideoTracker;
+import com.newrelic.videoagent.mediatailor.utils.ManifestParser;
+import com.newrelic.videoagent.mediatailor.utils.MediaTailorConstants;
+import com.newrelic.videoagent.mediatailor.utils.NetworkUtils;
+
+import org.json.JSONArray;
+import org.json.JSONObject;
+
+import java.io.BufferedReader;
+import java.io.InputStreamReader;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * AWS MediaTailor Ad Tracker for ExoPlayer
+ *
+ * Tracks ads from AWS MediaTailor SSAI streams (HLS/DASH)
+ *
+ * Features:
+ * - Client-side ad detection from manifest markers (CUE-OUT/CUE-IN)
+ * - Pod-level tracking (multiple ads within one break)
+ * - VOD and Live stream support
+ * - Tracking API metadata enrichment
+ */
+public class NRTrackerMediaTailor extends NRVideoTracker implements Player.Listener {
+
+ private static final String TAG = "NRMediaTailorTracker";
+
+ // Import constants from utility class
+ private static final String STREAM_TYPE_VOD = MediaTailorConstants.STREAM_TYPE_VOD;
+ private static final String STREAM_TYPE_LIVE = MediaTailorConstants.STREAM_TYPE_LIVE;
+ private static final String MANIFEST_TYPE_HLS = MediaTailorConstants.MANIFEST_TYPE_HLS;
+ private static final String MANIFEST_TYPE_DASH = MediaTailorConstants.MANIFEST_TYPE_DASH;
+ private static final long LIVE_POLL_INTERVAL_MS = MediaTailorConstants.DEFAULT_LIVE_TRACKING_POLL_INTERVAL_MS;
+ private static final int TRACKING_TIMEOUT_MS = MediaTailorConstants.DEFAULT_TRACKING_API_TIMEOUT_MS;
+
+ protected ExoPlayer player;
+
+ // Stream properties
+ private String streamType;
+ private String manifestType;
+ private String mediaTailorEndpoint;
+ private String trackingUrl;
+ private String sessionId;
+
+ // Ad tracking state
+ private List adSchedule = new ArrayList<>();
+ private AdBreak currentAdBreak;
+ private AdPod currentAdPod;
+ private boolean hasEndedContent = false;
+
+ // Configuration
+ private boolean enableManifestParsing = true;
+ private boolean enableTrackingAPI = true;
+
+ // Handlers and timers
+ private Handler handler;
+ private Runnable pollManifestRunnable;
+ private Runnable pollTrackingRunnable;
+ private Runnable timeUpdateRunnable;
+
+ // Time update monitoring (equivalent to videoJS timeupdate event)
+ private static final long TIME_UPDATE_INTERVAL_MS = MediaTailorConstants.DEFAULT_TIME_UPDATE_INTERVAL_MS;
+
+ /**
+ * Checks if tracker should be used for this player source
+ */
+ public static boolean isUsing(ExoPlayer player) {
+ if (player == null || player.getCurrentMediaItem() == null) {
+ return false;
+ }
+ String uri = player.getCurrentMediaItem().localConfiguration.uri.toString();
+ return uri != null && uri.contains(".mediatailor.");
+ }
+
+ /**
+ * Constructor
+ */
+ public NRTrackerMediaTailor() {
+ handler = new Handler(Looper.getMainLooper());
+ }
+
+ /**
+ * Constructor with player
+ */
+ public NRTrackerMediaTailor(ExoPlayer player) {
+ this();
+ setPlayer(player);
+ }
+
+ /**
+ * Set player and initialize tracking
+ */
+ @Override
+ public void setPlayer(Object player) {
+ Log.d(TAG, "setPlayer() called");
+ this.player = (ExoPlayer) player;
+
+ if (this.player.getCurrentMediaItem() != null) {
+ this.mediaTailorEndpoint = this.player.getCurrentMediaItem().localConfiguration.uri.toString();
+ this.manifestType = detectManifestType(this.mediaTailorEndpoint);
+ this.trackingUrl = extractTrackingUrl(this.mediaTailorEndpoint);
+ this.sessionId = extractSessionId(this.mediaTailorEndpoint);
+
+ Log.d(TAG, "MediaTailor tracker initialized");
+ Log.d(TAG, "Manifest type: " + manifestType);
+ Log.d(TAG, "Session ID: " + sessionId);
+ Log.d(TAG, "Tracking URL: " + trackingUrl);
+ Log.d(TAG, "Current player state: " + this.player.getPlaybackState());
+
+ registerListeners();
+
+ // If player is already ready, initialize tracking immediately
+ if (this.player.getPlaybackState() == Player.STATE_READY && streamType == null) {
+ Log.d(TAG, "Player already in READY state, initializing tracking now");
+ long duration = this.player.getDuration();
+ streamType = (duration == androidx.media3.common.C.TIME_UNSET) ? STREAM_TYPE_LIVE : STREAM_TYPE_VOD;
+ Log.d(TAG, "Stream type detected: " + streamType);
+ initializeTracking();
+ }
+ } else {
+ Log.w(TAG, "No current media item available when setPlayer() called");
+ }
+
+ super.setPlayer(player);
+ }
+
+ /**
+ * Register player event listeners
+ */
+ @Override
+ public void registerListeners() {
+ if (player != null) {
+ player.addListener(this);
+ Log.d(TAG, "Event listeners registered");
+
+ // Start time update monitoring (equivalent to videoJS timeupdate event)
+ startTimeUpdateMonitoring();
+ }
+ }
+
+ /**
+ * Unregister player event listeners
+ */
+ @Override
+ public void unregisterListeners() {
+ if (player != null) {
+ player.removeListener(this);
+ stopTimeUpdateMonitoring();
+ stopLivePolling();
+ }
+ }
+
+ /**
+ * Detect manifest type from URL
+ */
+ private String detectManifestType(String url) {
+ if (url.contains(".m3u8")) {
+ return MANIFEST_TYPE_HLS;
+ } else if (url.contains(".mpd") || url.contains("/dash")) {
+ return MANIFEST_TYPE_DASH;
+ }
+ return MANIFEST_TYPE_HLS; // Default to HLS
+ }
+
+ /**
+ * Extract tracking URL from sessionized MediaTailor URL
+ */
+ private String extractTrackingUrl(String url) {
+ // Extract sessionId from URL
+ Pattern pattern = Pattern.compile("aws\\.sessionId=([^&]+)");
+ Matcher matcher = pattern.matcher(url);
+
+ if (matcher.find()) {
+ String sessionId = matcher.group(1);
+
+ // Extract base URL and construct tracking URL
+ // Format: https://{cloudfront-id}.mediatailor.{region}.amazonaws.com/v1/tracking/{account-id}/{config}/{sessionId}
+ Pattern urlPattern = Pattern.compile("(https://[^/]+)/v1/(?:master|dash)/([^/]+)/([^/]+)/");
+ Matcher urlMatcher = urlPattern.matcher(url);
+
+ if (urlMatcher.find()) {
+ String baseUrl = urlMatcher.group(1);
+ String accountId = urlMatcher.group(2);
+ String config = urlMatcher.group(3);
+
+ return baseUrl + "/v1/tracking/" + accountId + "/" + config + "/" + sessionId;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Extract sessionId from URL
+ */
+ private String extractSessionId(String url) {
+ Pattern pattern = Pattern.compile("aws\\.sessionId=([^&]+)");
+ Matcher matcher = pattern.matcher(url);
+
+ if (matcher.find()) {
+ return matcher.group(1);
+ }
+
+ return null;
+ }
+
+ /**
+ * Player listener: On playback state changed
+ */
+ @Override
+ public void onPlaybackStateChanged(int playbackState) {
+ Log.d(TAG, "onPlaybackStateChanged: " + playbackState + " (streamType=" + streamType + ")");
+
+ if (playbackState == Player.STATE_READY && streamType == null) {
+ // Detect stream type based on duration
+ long duration = player.getDuration();
+ streamType = (duration == androidx.media3.common.C.TIME_UNSET) ? STREAM_TYPE_LIVE : STREAM_TYPE_VOD;
+
+ Log.d(TAG, "Stream type detected: " + streamType + " (duration=" + duration + ")");
+
+ // Initialize tracking based on stream type
+ initializeTracking();
+ } else if (playbackState == Player.STATE_ENDED) {
+ // Player reached end - ad break should have already been exited in onTimeUpdate
+ // This is a safety fallback in case the timing check missed it
+ if (currentAdBreak != null) {
+ Log.d(TAG, "Player ended with active ad break (fallback) - exiting now");
+ exitAdBreak();
+ }
+ }
+ }
+
+ /**
+ * Initialize tracking based on stream type
+ */
+ private void initializeTracking() {
+ Log.d(TAG, "Initializing " + manifestType.toUpperCase() + " " + streamType.toUpperCase() + " tracking");
+
+ if (streamType.equals(STREAM_TYPE_VOD)) {
+ setupVODTracking();
+ } else {
+ setupLiveTracking();
+ }
+ }
+
+ /**
+ * Setup VOD tracking (single parse, no polling)
+ */
+ private void setupVODTracking() {
+ Log.d(TAG, "VOD mode: Single manifest parse");
+
+ // Parse manifest for ad breaks
+ parseManifestForAds();
+
+ // Fetch tracking metadata if available
+ if (trackingUrl != null && enableTrackingAPI) {
+ fetchTrackingMetadata();
+ }
+ }
+
+ /**
+ * Setup Live tracking (continuous polling)
+ */
+ private void setupLiveTracking() {
+ Log.d(TAG, "Live mode: Continuous polling");
+
+ // Initial parse
+ parseManifestForAds();
+
+ // Start polling for new ads
+ pollManifestRunnable = new Runnable() {
+ @Override
+ public void run() {
+ parseManifestForAds();
+ handler.postDelayed(this, LIVE_POLL_INTERVAL_MS);
+ }
+ };
+ handler.postDelayed(pollManifestRunnable, LIVE_POLL_INTERVAL_MS);
+
+ // Poll tracking API if available
+ if (trackingUrl != null && enableTrackingAPI) {
+ pollTrackingRunnable = new Runnable() {
+ @Override
+ public void run() {
+ fetchTrackingMetadata();
+ handler.postDelayed(this, LIVE_POLL_INTERVAL_MS);
+ }
+ };
+ handler.postDelayed(pollTrackingRunnable, LIVE_POLL_INTERVAL_MS);
+ }
+ }
+
+ /**
+ * Stop live polling timers
+ */
+ private void stopLivePolling() {
+ if (pollManifestRunnable != null) {
+ handler.removeCallbacks(pollManifestRunnable);
+ pollManifestRunnable = null;
+ }
+
+ if (pollTrackingRunnable != null) {
+ handler.removeCallbacks(pollTrackingRunnable);
+ pollTrackingRunnable = null;
+ }
+ }
+
+ /**
+ * Start time update monitoring (equivalent to videoJS timeupdate event)
+ * This is the PRIMARY ad detection mechanism - checks currentTime vs ad schedule
+ */
+ private void startTimeUpdateMonitoring() {
+ if (timeUpdateRunnable != null) {
+ return; // Already started
+ }
+
+ Log.d(TAG, "Starting time update monitoring (checking every " + TIME_UPDATE_INTERVAL_MS + "ms)");
+
+ timeUpdateRunnable = new Runnable() {
+ @Override
+ public void run() {
+ if (player != null && player.isPlaying()) {
+ onTimeUpdate();
+ }
+ handler.postDelayed(this, TIME_UPDATE_INTERVAL_MS);
+ }
+ };
+
+ handler.postDelayed(timeUpdateRunnable, TIME_UPDATE_INTERVAL_MS);
+ }
+
+ /**
+ * Stop time update monitoring
+ */
+ private void stopTimeUpdateMonitoring() {
+ if (timeUpdateRunnable != null) {
+ handler.removeCallbacks(timeUpdateRunnable);
+ timeUpdateRunnable = null;
+ Log.d(TAG, "Stopped time update monitoring");
+ }
+ }
+
+ /**
+ * Called periodically to check for ad break transitions (like videoJS timeupdate)
+ * This is the CORE ad detection logic
+ */
+ private void onTimeUpdate() {
+ double currentTime = player.getCurrentPosition() / 1000.0;
+ AdBreak activeBreak = findActiveAdBreak(currentTime);
+
+ // Debug logging every 5 seconds
+ if (adSchedule.size() > 0 && Math.floor(currentTime) % 5 == 0 && Math.floor(currentTime * 10) % 10 == 0) {
+ Log.d(TAG, String.format("TimeUpdate: %.2fs, Active break: %s, Schedule count: %d",
+ currentTime,
+ activeBreak != null ? activeBreak.id : "none",
+ adSchedule.size()));
+ }
+
+ // Check if we're near the end of the stream and in an ad break
+ // Exit ad break BEFORE content ends to maintain correct viewId
+ if (currentAdBreak != null && streamType.equals(STREAM_TYPE_VOD)) {
+ long duration = player.getDuration();
+ long currentPosition = player.getCurrentPosition();
+ // If we're within 100ms of the end, exit the ad break now
+ if (duration != androidx.media3.common.C.TIME_UNSET &&
+ currentPosition >= duration - 100) {
+ Log.d(TAG, "Near end of stream - exiting ad break before CONTENT_END");
+ exitAdBreak();
+ return;
+ }
+ }
+
+ if (activeBreak != null) {
+ // === INSIDE AD BREAK ===
+ if (!activeBreak.hasFiredStart) {
+ enterAdBreak(activeBreak);
+ }
+
+ // Check for pod transitions within break
+ if (currentAdBreak != null) {
+ checkPodTransition(activeBreak, currentTime);
+ }
+ } else if (currentAdBreak != null) {
+ // === EXITING AD BREAK ===
+ exitAdBreak();
+ }
+ }
+
+ /**
+ * Parse HLS manifest for ad breaks using SCTE-35 markers
+ * Delegates to ManifestParser utility class
+ */
+ private void parseManifestForAds() {
+ if (!enableManifestParsing || manifestType == null || !manifestType.equals(MANIFEST_TYPE_HLS)) {
+ Log.d(TAG, "Manifest parsing disabled or not HLS");
+ return;
+ }
+
+ if (mediaTailorEndpoint == null) {
+ Log.w(TAG, "No manifest URL available for parsing");
+ return;
+ }
+
+ new Thread(() -> {
+ try {
+ Log.d(TAG, "Fetching and parsing HLS manifest: " + mediaTailorEndpoint);
+
+ // Fetch manifest using NetworkUtils
+ String manifestText = NetworkUtils.fetchText(mediaTailorEndpoint, TRACKING_TIMEOUT_MS);
+
+ if (manifestText != null) {
+ // Check if this is a master playlist (contains variant streams)
+ if (ManifestParser.isMasterPlaylist(manifestText)) {
+ Log.d(TAG, "Master playlist detected - fetching variant stream for SCTE-35 markers");
+
+ // Extract first variant stream URL
+ String variantUrl = ManifestParser.extractFirstVariantUrl(manifestText, mediaTailorEndpoint);
+
+ if (variantUrl != null) {
+ // Fetch the variant playlist (contains SCTE-35 markers)
+ String variantText = NetworkUtils.fetchText(variantUrl, TRACKING_TIMEOUT_MS);
+
+ if (variantText != null) {
+ // Parse the variant playlist for ads
+ parseMediaPlaylist(variantText);
+ } else {
+ Log.w(TAG, "Failed to fetch variant playlist");
+ }
+ } else {
+ Log.w(TAG, "Could not extract variant URL from master playlist");
+ }
+ } else {
+ // This is already a media playlist - parse directly
+ Log.d(TAG, "Media playlist detected - parsing for SCTE-35 markers");
+ parseMediaPlaylist(manifestText);
+ }
+ } else {
+ Log.w(TAG, "Failed to fetch manifest");
+ }
+
+ } catch (Exception e) {
+ Log.e(TAG, "Error parsing manifest", e);
+ }
+ }).start();
+ }
+
+ /**
+ * Parse media playlist for SCTE-35 markers
+ */
+ private void parseMediaPlaylist(String manifestText) {
+ // Parse using ManifestParser
+ List manifestAdBreaks = ManifestParser.parseHLSManifest(manifestText);
+
+ if (manifestAdBreaks.size() > 0) {
+ Log.d(TAG, "Parsed " + manifestAdBreaks.size() + " ad break(s) from manifest");
+
+ // Merge with existing schedule (from tracking API)
+ adSchedule = ManifestParser.mergeAdSchedules(adSchedule, manifestAdBreaks);
+ } else {
+ Log.d(TAG, "No ad breaks found in manifest");
+ }
+ }
+
+ /**
+ * Fetch tracking metadata from MediaTailor Tracking API
+ */
+ private void fetchTrackingMetadata() {
+ if (trackingUrl == null) {
+ Log.d(TAG, "No tracking URL available");
+ return;
+ }
+
+ new Thread(() -> {
+ try {
+ Log.d(TAG, "Fetching tracking metadata from: " + trackingUrl);
+
+ URL url = new URL(trackingUrl);
+ HttpURLConnection connection = (HttpURLConnection) url.openConnection();
+ connection.setRequestMethod("GET");
+ connection.setConnectTimeout(TRACKING_TIMEOUT_MS);
+ connection.setReadTimeout(TRACKING_TIMEOUT_MS);
+
+ int responseCode = connection.getResponseCode();
+
+ if (responseCode == HttpURLConnection.HTTP_OK) {
+ BufferedReader reader = new BufferedReader(new InputStreamReader(connection.getInputStream()));
+ StringBuilder response = new StringBuilder();
+ String line;
+
+ while ((line = reader.readLine()) != null) {
+ response.append(line);
+ }
+ reader.close();
+
+ // Parse tracking data
+ processTrackingMetadata(response.toString());
+ } else {
+ Log.w(TAG, "Tracking API returned code: " + responseCode);
+ }
+
+ connection.disconnect();
+
+ } catch (Exception e) {
+ Log.e(TAG, "Error fetching tracking metadata", e);
+ }
+ }).start();
+ }
+
+ /**
+ * Process tracking metadata from API
+ */
+ private void processTrackingMetadata(String jsonData) {
+ try {
+ JSONObject data = new JSONObject(jsonData);
+
+ if (data.has("avails")) {
+ JSONArray avails = data.getJSONArray("avails");
+ Log.d(TAG, "Received " + avails.length() + " avail(s) from tracking API");
+
+ for (int i = 0; i < avails.length(); i++) {
+ JSONObject avail = avails.getJSONObject(i);
+
+ // Parse avail data
+ double startTime = avail.optDouble("startTimeInSeconds", 0);
+ double duration = avail.optDouble("durationInSeconds", 0);
+ String availId = avail.optString("availId", "");
+
+ Log.d(TAG, String.format("Parsing avail %d: startTime=%.2fs, duration=%.2fs",
+ i, startTime, duration));
+
+ // Create ad break
+ AdBreak adBreak = new AdBreak();
+ adBreak.id = availId;
+ adBreak.startTime = startTime;
+ adBreak.duration = duration;
+ adBreak.endTime = startTime + duration;
+ adBreak.source = "tracking-api";
+
+ // Parse ads within avail
+ if (avail.has("ads")) {
+ JSONArray ads = avail.getJSONArray("ads");
+
+ for (int j = 0; j < ads.length(); j++) {
+ JSONObject ad = ads.getJSONObject(j);
+
+ double adStartTime = ad.optDouble("startTimeInSeconds", 0); // Absolute time from stream start
+ double adDuration = ad.optDouble("durationInSeconds", 0);
+
+ Log.d(TAG, String.format(" Parsing ad %d: startTime=%.2fs, duration=%.2fs, title=%s",
+ j, adStartTime, adDuration, ad.optString("adTitle", "")));
+
+ AdPod pod = new AdPod();
+ pod.title = ad.optString("adTitle", "");
+ pod.creativeId = ad.optString("creativeId", "");
+ pod.startTime = adStartTime; // Use absolute time directly (not offset!)
+ pod.duration = adDuration;
+ pod.endTime = pod.startTime + pod.duration;
+
+ adBreak.pods.add(pod);
+ }
+ }
+
+ // Add to schedule
+ adSchedule.add(adBreak);
+ }
+
+ // Sort by start time (using Collections.sort for API 16+ compatibility)
+ java.util.Collections.sort(adSchedule, new java.util.Comparator() {
+ @Override
+ public int compare(AdBreak a, AdBreak b) {
+ return Double.compare(a.startTime, b.startTime);
+ }
+ });
+
+ Log.d(TAG, "Ad schedule updated: " + adSchedule.size() + " ad break(s)");
+
+ // Log detailed schedule for debugging
+ for (int i = 0; i < adSchedule.size(); i++) {
+ AdBreak ab = adSchedule.get(i);
+ Log.d(TAG, String.format(" Break %d: %.2fs-%.2fs (%.2fs) - %d pods",
+ i, ab.startTime, ab.endTime, ab.duration, ab.pods.size()));
+ for (int j = 0; j < ab.pods.size(); j++) {
+ AdPod pod = ab.pods.get(j);
+ Log.d(TAG, String.format(" Pod %d: '%s' %.2fs-%.2fs (%.2fs)",
+ j, pod.title, pod.startTime, pod.endTime, pod.duration));
+ }
+ }
+ }
+
+ } catch (Exception e) {
+ Log.e(TAG, "Error processing tracking metadata", e);
+ }
+ }
+
+ /**
+ * Find active ad break for current time
+ */
+ private AdBreak findActiveAdBreak(double currentTime) {
+ for (AdBreak adBreak : adSchedule) {
+ if (currentTime >= adBreak.startTime && currentTime < adBreak.endTime) {
+ return adBreak;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Enter ad break
+ */
+ private void enterAdBreak(AdBreak adBreak) {
+ currentAdBreak = adBreak;
+ getState().isAd = true;
+
+ // Determine ad position
+ int breakIndex = adSchedule.indexOf(adBreak);
+ String adPosition = determineAdPosition(breakIndex, adSchedule.size());
+ currentAdBreak.adPosition = adPosition;
+
+ Log.d(TAG, "Entering ad break: " + adBreak.id + " at position " + adPosition);
+
+ // Send AD_BREAK_START
+ sendAdBreakStart();
+
+ adBreak.hasFiredStart = true;
+ }
+
+ /**
+ * Exit ad break
+ */
+ private void exitAdBreak() {
+ if (currentAdPod != null) {
+ sendEnd();
+ currentAdPod = null;
+ }
+
+ Log.d(TAG, "Exiting ad break: " + currentAdBreak.id);
+
+ // Send AD_BREAK_END
+ sendAdBreakEnd();
+ currentAdBreak.hasFiredEnd = true;
+ currentAdBreak = null;
+
+ getState().isAd = false;
+ }
+
+ /**
+ * Check for pod transitions within ad break and track quartiles
+ */
+ private void checkPodTransition(AdBreak adBreak, double currentTime) {
+ // Debug logging
+ if (Math.floor(currentTime * 4) % 4 == 0) { // Log every 250ms aligned
+ Log.d(TAG, String.format("checkPodTransition: time=%.2fs, break=%s (%.2fs-%.2fs), %d pods, currentPod=%s",
+ currentTime, adBreak.id, adBreak.startTime, adBreak.endTime,
+ adBreak.pods.size(), currentAdPod != null ? currentAdPod.title : "null"));
+ }
+
+ if (adBreak.pods.isEmpty()) {
+ // No pods - treat entire break as single ad
+ if (!adBreak.hasFiredAdStart) {
+ Log.d(TAG, "→ AD_START (no pods)");
+ sendRequest();
+ sendStart();
+ adBreak.hasFiredAdStart = true;
+ }
+
+ // Track quartiles for entire break
+ double adProgress = currentTime - adBreak.startTime;
+ trackQuartiles(adBreak, adProgress);
+ return;
+ }
+
+ // Find active pod
+ AdPod activePod = null;
+ for (AdPod pod : adBreak.pods) {
+ if (currentTime >= pod.startTime && currentTime < pod.endTime) {
+ activePod = pod;
+ break;
+ }
+ }
+
+ if (activePod != null && currentAdPod != activePod) {
+ // Transition to new pod
+ if (currentAdPod != null) {
+ Log.d(TAG, "→ AD_END (pod transition)");
+ sendEnd();
+ }
+
+ currentAdPod = activePod;
+ Log.d(TAG, "→ AD_START (new pod): " + activePod.title);
+
+ sendRequest();
+ sendStart();
+ activePod.hasFiredStart = true;
+ }
+
+ // Track quartiles for active pod
+ if (currentAdPod != null) {
+ double podProgress = currentTime - currentAdPod.startTime;
+ trackQuartiles(currentAdPod, podProgress);
+ }
+ }
+
+ /**
+ * Track quartile events for active ad/pod (like videoJS)
+ */
+ private void trackQuartiles(Object adObject, double progress) {
+ double duration;
+ boolean[] fired = new boolean[3];
+
+ if (adObject instanceof AdPod) {
+ AdPod pod = (AdPod) adObject;
+ duration = pod.duration;
+ fired[0] = pod.hasFiredQ1;
+ fired[1] = pod.hasFiredQ2;
+ fired[2] = pod.hasFiredQ3;
+ } else {
+ AdBreak adBreak = (AdBreak) adObject;
+ duration = adBreak.duration;
+ fired[0] = adBreak.hasFiredQ1;
+ fired[1] = adBreak.hasFiredQ2;
+ fired[2] = adBreak.hasFiredQ3;
+ }
+
+ if (duration <= 0) return;
+
+ // Q1 - 25%
+ if (!fired[0] && progress >= duration * 0.25) {
+ Log.d(TAG, "→ AD_QUARTILE 25%");
+ sendAdQuartile();
+ if (adObject instanceof AdPod) {
+ ((AdPod) adObject).hasFiredQ1 = true;
+ } else {
+ ((AdBreak) adObject).hasFiredQ1 = true;
+ }
+ }
+
+ // Q2 - 50%
+ if (!fired[1] && progress >= duration * 0.50) {
+ Log.d(TAG, "→ AD_QUARTILE 50%");
+ sendAdQuartile();
+ if (adObject instanceof AdPod) {
+ ((AdPod) adObject).hasFiredQ2 = true;
+ } else {
+ ((AdBreak) adObject).hasFiredQ2 = true;
+ }
+ }
+
+ // Q3 - 75%
+ if (!fired[2] && progress >= duration * 0.75) {
+ Log.d(TAG, "→ AD_QUARTILE 75%");
+ sendAdQuartile();
+ if (adObject instanceof AdPod) {
+ ((AdPod) adObject).hasFiredQ3 = true;
+ } else {
+ ((AdBreak) adObject).hasFiredQ3 = true;
+ }
+ }
+ }
+
+ /**
+ * Determine ad position (pre-roll, mid-roll, post-roll)
+ */
+ private String determineAdPosition(int index, int total) {
+ if (index == 0) {
+ return "pre-roll";
+ } else if (index == total - 1 && streamType.equals(STREAM_TYPE_VOD)) {
+ return "post-roll";
+ } else {
+ return "mid-roll";
+ }
+ }
+
+ // Tracker metadata overrides
+
+ @Override
+ public String getTrackerName() {
+ return "aws-media-tailor";
+ }
+
+ @Override
+ public String getTrackerVersion() {
+ return "1.0.0";
+ }
+
+ @Override
+ public String getPlayerName() {
+ return "ExoPlayer";
+ }
+
+ @Override
+ public String getPlayerVersion() {
+ return androidx.media3.common.MediaLibraryInfo.VERSION;
+ }
+
+ @Override
+ public String getTitle() {
+ if (currentAdPod != null && currentAdPod.title != null) {
+ return currentAdPod.title;
+ }
+ if (currentAdBreak != null && currentAdBreak.id != null) {
+ return currentAdBreak.id;
+ }
+ return null;
+ }
+
+ @Override
+ public String getSrc() {
+ return mediaTailorEndpoint;
+ }
+
+ @Override
+ public Long getDuration() {
+ if (currentAdPod != null) {
+ return (long) (currentAdPod.duration * 1000);
+ }
+ if (currentAdBreak != null) {
+ return (long) (currentAdBreak.duration * 1000);
+ }
+ return null;
+ }
+
+ @Override
+ public Map getAttributes(String action, Map attributes) {
+ Map attr = super.getAttributes(action, attributes);
+
+ // Add MediaTailor-specific attributes
+ if (sessionId != null) {
+ attr.put("sessionId", sessionId);
+ }
+
+ if (currentAdBreak != null && currentAdBreak.adPosition != null) {
+ attr.put("adPosition", currentAdBreak.adPosition);
+ }
+
+ attr.put("adIntegration", "AWS MediaTailor");
+ attr.put("streamType", streamType);
+
+ return attr;
+ }
+
+ /**
+ * Cleanup
+ */
+ public void dispose() {
+ Log.d(TAG, "Disposing MediaTailorAdsTracker");
+ stopLivePolling();
+ unregisterListeners();
+ }
+
+ // Inner classes for ad scheduling
+
+ /**
+ * Represents an ad break (avail)
+ */
+ public static class AdBreak {
+ public String id;
+ public double startTime;
+ public double duration;
+ public double endTime;
+ public String source;
+ public String adPosition;
+ public boolean hasFiredStart = false;
+ public boolean hasFiredEnd = false;
+ public boolean hasFiredAdStart = false;
+ public boolean hasFiredQ1 = false;
+ public boolean hasFiredQ2 = false;
+ public boolean hasFiredQ3 = false;
+ public List pods = new ArrayList<>();
+ }
+
+ /**
+ * Represents an individual ad within a break (pod)
+ */
+ public static class AdPod {
+ public String title;
+ public String creativeId;
+ public double startTime;
+ public double duration;
+ public double endTime;
+ public boolean hasFiredStart = false;
+ public boolean hasFiredQ1 = false;
+ public boolean hasFiredQ2 = false;
+ public boolean hasFiredQ3 = false;
+ }
+}
diff --git a/NRMediaTailorTracker/src/main/java/com/newrelic/videoagent/mediatailor/utils/ManifestParser.java b/NRMediaTailorTracker/src/main/java/com/newrelic/videoagent/mediatailor/utils/ManifestParser.java
new file mode 100644
index 00000000..9ba31b43
--- /dev/null
+++ b/NRMediaTailorTracker/src/main/java/com/newrelic/videoagent/mediatailor/utils/ManifestParser.java
@@ -0,0 +1,339 @@
+package com.newrelic.videoagent.mediatailor.utils;
+
+import android.util.Log;
+
+import com.newrelic.videoagent.mediatailor.tracker.NRTrackerMediaTailor.AdBreak;
+import com.newrelic.videoagent.mediatailor.tracker.NRTrackerMediaTailor.AdPod;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.regex.Matcher;
+
+/**
+ * Manifest Parser Utilities for AWS MediaTailor
+ * Parses HLS and DASH manifests to detect SCTE-35 ad markers
+ * Based on VideoJS mt.js implementation
+ */
+public class ManifestParser {
+
+ private static final String TAG = "MTManifestParser";
+
+ /**
+ * Parse HLS manifest text for SCTE-35 markers (CUE-OUT/CUE-IN)
+ * Based on videoJS implementation in mt.js:205-331
+ *
+ * @param manifestText The HLS manifest content (.m3u8)
+ * @return List of detected ad breaks with pod information
+ */
+ public static List parseHLSManifest(String manifestText) {
+ List adBreaks = new ArrayList<>();
+
+ if (manifestText == null || manifestText.isEmpty()) {
+ Log.w(TAG, "Empty manifest text provided");
+ return adBreaks;
+ }
+
+ String[] lines = manifestText.split("\n");
+
+ double currentTime = 0;
+ AdBreak currentAdBreak = null;
+ List adPods = new ArrayList<>();
+ Double currentPodStartTime = null;
+ String lastMapUrl = null;
+ boolean isInAdBreak = false;
+
+ for (String line : lines) {
+ line = line.trim();
+
+ // Detect CUE-OUT (ad break start)
+ if (line.startsWith("#EXT-X-CUE-OUT")) {
+ Matcher matcher = MediaTailorConstants.REGEX_CUE_OUT.matcher(line);
+ Double duration = null;
+
+ if (matcher.find()) {
+ duration = Double.parseDouble(matcher.group(1));
+ }
+
+ isInAdBreak = true;
+ adPods = new ArrayList<>();
+ currentPodStartTime = currentTime;
+ lastMapUrl = null;
+
+ currentAdBreak = new AdBreak();
+ currentAdBreak.id = "avail-manifest-" + currentTime;
+ currentAdBreak.startTime = currentTime;
+ currentAdBreak.duration = duration != null ? duration : 0;
+ currentAdBreak.endTime = 0; // Will be set when CUE-IN is found
+ currentAdBreak.source = MediaTailorConstants.AD_SOURCE_MANIFEST_CUE;
+
+ Log.d(TAG, String.format("Found CUE-OUT at %.2fs, duration=%.2fs",
+ currentTime, currentAdBreak.duration));
+ }
+
+ // Detect CUE-IN (ad break end)
+ else if (line.startsWith("#EXT-X-CUE-IN")) {
+ if (currentAdBreak != null) {
+ // Close final pod
+ if (currentPodStartTime != null) {
+ double podDuration = currentTime - currentPodStartTime;
+ AdPod pod = new AdPod();
+ pod.startTime = currentPodStartTime;
+ pod.duration = podDuration;
+ pod.endTime = currentTime;
+ adPods.add(pod);
+
+ Log.d(TAG, String.format(" Final pod %.2fs-%.2fs (%.2fs)",
+ pod.startTime, pod.endTime, pod.duration));
+ }
+
+ // Calculate actual duration
+ double actualDuration = currentTime - currentAdBreak.startTime;
+
+ // Filter false positives (< MIN_AD_DURATION)
+ if (actualDuration >= MediaTailorConstants.MIN_AD_DURATION) {
+ currentAdBreak.duration = actualDuration;
+ currentAdBreak.endTime = currentTime;
+ currentAdBreak.pods = adPods;
+ adBreaks.add(currentAdBreak);
+
+ Log.d(TAG, String.format("CUE-IN at %.2fs, total duration=%.2fs, %d pod(s)",
+ currentTime, actualDuration, adPods.size()));
+ } else {
+ Log.d(TAG, String.format("Ignoring short ad break (%.2fs)", actualDuration));
+ }
+
+ // Reset state
+ currentAdBreak = null;
+ isInAdBreak = false;
+ currentPodStartTime = null;
+ lastMapUrl = null;
+ adPods = new ArrayList<>();
+ }
+ }
+
+ // Detect MAP URL changes (pod boundaries)
+ else if (isInAdBreak && line.startsWith("#EXT-X-MAP")) {
+ Matcher matcher = MediaTailorConstants.REGEX_MAP.matcher(line);
+ if (matcher.find()) {
+ String mapUrl = matcher.group(1);
+
+ if (mapUrl != null && !mapUrl.equals(lastMapUrl)) {
+ // New MAP = new pod boundary
+ if (currentPodStartTime != null && lastMapUrl != null) {
+ double podDuration = currentTime - currentPodStartTime;
+ AdPod pod = new AdPod();
+ pod.startTime = currentPodStartTime;
+ pod.duration = podDuration;
+ pod.endTime = currentTime;
+ adPods.add(pod);
+
+ Log.d(TAG, String.format(" Pod boundary (MAP change) %.2fs-%.2fs (%.2fs)",
+ pod.startTime, pod.endTime, pod.duration));
+ }
+ currentPodStartTime = currentTime;
+ lastMapUrl = mapUrl;
+ }
+ }
+ }
+
+ // Track time via EXTINF (segment duration)
+ else if (line.startsWith("#EXTINF:")) {
+ Matcher matcher = MediaTailorConstants.REGEX_EXTINF.matcher(line);
+ if (matcher.find()) {
+ double segmentDuration = Double.parseDouble(matcher.group(1));
+ currentTime += segmentDuration;
+ }
+ }
+ }
+
+ // Handle unclosed ad break (shouldn't happen with proper manifests)
+ if (currentAdBreak != null && currentAdBreak.duration > 0) {
+ currentAdBreak.endTime = currentAdBreak.startTime + currentAdBreak.duration;
+ currentAdBreak.pods = adPods;
+ adBreaks.add(currentAdBreak);
+ Log.d(TAG, "Added unclosed ad break");
+ }
+
+ return adBreaks;
+ }
+
+ /**
+ * Extract target duration from HLS manifest
+ * Used to determine optimal polling interval for LIVE streams
+ *
+ * @param manifestText The HLS manifest content
+ * @return Target duration in seconds, or 0 if not found
+ */
+ public static int extractTargetDuration(String manifestText) {
+ if (manifestText == null || manifestText.isEmpty()) {
+ return 0;
+ }
+
+ Matcher matcher = MediaTailorConstants.REGEX_TARGET_DURATION.matcher(manifestText);
+ if (matcher.find()) {
+ int targetDuration = Integer.parseInt(matcher.group(1));
+ Log.d(TAG, "Found target duration: " + targetDuration + "s");
+ return targetDuration;
+ }
+
+ return 0;
+ }
+
+ /**
+ * Extract first variant stream URL from master playlist
+ * Master playlists don't contain SCTE-35 markers - need to fetch variant playlist
+ *
+ * @param masterPlaylistText The master playlist content
+ * @param baseUrl The base URL for resolving relative paths
+ * @return First variant stream URL, or null if not found
+ */
+ public static String extractFirstVariantUrl(String masterPlaylistText, String baseUrl) {
+ if (masterPlaylistText == null || masterPlaylistText.isEmpty()) {
+ return null;
+ }
+
+ String[] lines = masterPlaylistText.split("\n");
+ String lastStreamInfo = null;
+
+ for (String line : lines) {
+ line = line.trim();
+
+ // Look for #EXT-X-STREAM-INF (variant stream definition)
+ if (line.startsWith("#EXT-X-STREAM-INF")) {
+ lastStreamInfo = line;
+ }
+ // The next non-comment line after STREAM-INF is the variant URL
+ else if (lastStreamInfo != null && !line.startsWith("#") && !line.isEmpty()) {
+ String variantUrl = line;
+
+ // If relative URL, make it absolute
+ if (!variantUrl.startsWith("http")) {
+ // Extract base URL (remove master.m3u8 part)
+ int lastSlash = baseUrl.lastIndexOf('/');
+ if (lastSlash > 0) {
+ String base = baseUrl.substring(0, lastSlash + 1);
+ variantUrl = base + variantUrl;
+ }
+ }
+
+ Log.d(TAG, "Found variant stream URL: " + variantUrl);
+ return variantUrl;
+ }
+ }
+
+ Log.w(TAG, "No variant stream found in master playlist");
+ return null;
+ }
+
+ /**
+ * Check if manifest is a master playlist (contains #EXT-X-STREAM-INF)
+ *
+ * @param manifestText The manifest content
+ * @return true if master playlist, false if media playlist
+ */
+ public static boolean isMasterPlaylist(String manifestText) {
+ return manifestText != null && manifestText.contains("#EXT-X-STREAM-INF");
+ }
+
+ /**
+ * Parse DASH manifest for SCTE-35 EventStream markers
+ * TODO: Implement DASH manifest parsing
+ * Based on videoJS mt.js:573-685
+ *
+ * @param xmlText The DASH manifest content (.mpd)
+ * @return List of detected ad breaks
+ */
+ public static List parseDASHManifest(String xmlText) {
+ List adBreaks = new ArrayList<>();
+
+ // TODO: Implement DASH parsing
+ // 1. Parse XML using Android XML parser
+ // 2. Find elements with SCTE-35 schemeIdUri
+ // 3. Extract elements with presentationTime, duration, id
+ // 4. Convert timescale to seconds
+ // 5. Create AdBreak objects from events
+
+ Log.w(TAG, "DASH manifest parsing not yet implemented");
+ return adBreaks;
+ }
+
+ /**
+ * Merge manifest-detected ads with existing schedule
+ * Deduplicates and enriches ads from both sources
+ *
+ * @param adSchedule Existing ad schedule (may contain tracking API data)
+ * @param manifestAdBreaks Ad breaks detected from manifest parsing
+ * @return Merged and deduplicated ad schedule
+ */
+ public static List mergeAdSchedules(List adSchedule, List manifestAdBreaks) {
+ if (manifestAdBreaks == null || manifestAdBreaks.isEmpty()) {
+ return adSchedule;
+ }
+
+ // If no existing schedule, use manifest ads directly
+ if (adSchedule.isEmpty()) {
+ Log.d(TAG, "Using manifest ads as primary schedule");
+ return new ArrayList<>(manifestAdBreaks);
+ }
+
+ List mergedSchedule = new ArrayList<>(adSchedule);
+
+ // Merge: Check for overlaps with tracking API data
+ for (AdBreak manifestBreak : manifestAdBreaks) {
+ boolean found = false;
+
+ // Check if this break already exists (from tracking API)
+ for (AdBreak existingBreak : mergedSchedule) {
+ // Consider it the same break if start times are within AD_TIMING_TOLERANCE
+ if (Math.abs(existingBreak.startTime - manifestBreak.startTime)
+ < MediaTailorConstants.AD_TIMING_TOLERANCE) {
+ found = true;
+
+ // Enrich tracking API break with manifest data
+ if (existingBreak.pods.isEmpty() && !manifestBreak.pods.isEmpty()) {
+ existingBreak.pods = manifestBreak.pods;
+ Log.d(TAG, String.format("Enriched break at %.2fs with %d manifest pod(s)",
+ existingBreak.startTime, manifestBreak.pods.size()));
+ }
+ existingBreak.source = MediaTailorConstants.AD_SOURCE_MANIFEST_AND_TRACKING;
+ break;
+ }
+ }
+
+ // If not found, add manifest break to schedule
+ if (!found) {
+ mergedSchedule.add(manifestBreak);
+ Log.d(TAG, String.format("Added new manifest-only break at %.2fs", manifestBreak.startTime));
+ }
+ }
+
+ // Sort by start time (using Collections.sort for API 16+ compatibility)
+ java.util.Collections.sort(mergedSchedule, new java.util.Comparator() {
+ @Override
+ public int compare(AdBreak a, AdBreak b) {
+ return Double.compare(a.startTime, b.startTime);
+ }
+ });
+
+ Log.d(TAG, "Merged ad schedule now has " + mergedSchedule.size() + " break(s)");
+ return mergedSchedule;
+ }
+
+ /**
+ * Calculate ad position (pre/mid/post) for VOD streams
+ *
+ * @param adStartTime Ad break start time in seconds
+ * @param contentDuration Total content duration in seconds
+ * @param tolerance Time tolerance for pre/post detection
+ * @return Ad position: "pre", "mid", or "post"
+ */
+ public static String calculateAdPosition(double adStartTime, double contentDuration, double tolerance) {
+ if (adStartTime <= tolerance) {
+ return MediaTailorConstants.AD_POSITION_PRE;
+ } else if (adStartTime >= contentDuration - tolerance) {
+ return MediaTailorConstants.AD_POSITION_POST;
+ } else {
+ return MediaTailorConstants.AD_POSITION_MID;
+ }
+ }
+}
diff --git a/NRMediaTailorTracker/src/main/java/com/newrelic/videoagent/mediatailor/utils/MediaTailorConstants.java b/NRMediaTailorTracker/src/main/java/com/newrelic/videoagent/mediatailor/utils/MediaTailorConstants.java
new file mode 100644
index 00000000..ed21c4ca
--- /dev/null
+++ b/NRMediaTailorTracker/src/main/java/com/newrelic/videoagent/mediatailor/utils/MediaTailorConstants.java
@@ -0,0 +1,72 @@
+package com.newrelic.videoagent.mediatailor.utils;
+
+import java.util.regex.Pattern;
+
+/**
+ * MediaTailor Constants
+ * Contains all regex patterns, config defaults, and constant values for AWS MediaTailor ad tracking
+ * Based on VideoJS mt-constants.js
+ */
+public class MediaTailorConstants {
+
+ // HLS Manifest Regex Patterns
+ public static final Pattern REGEX_CUE_OUT = Pattern.compile("#EXT-X-CUE-OUT:DURATION=([\\d.]+)");
+ public static final Pattern REGEX_CUE_IN = Pattern.compile("#EXT-X-CUE-IN");
+ public static final Pattern REGEX_DISCONTINUITY = Pattern.compile("#EXT-X-DISCONTINUITY");
+ public static final Pattern REGEX_MAP = Pattern.compile("#EXT-X-MAP:URI=\"([^\"]+)\"");
+ public static final Pattern REGEX_EXTINF = Pattern.compile("#EXTINF:([\\d.]+)");
+ public static final Pattern REGEX_TARGET_DURATION = Pattern.compile("#EXT-X-TARGETDURATION:(\\d+)");
+
+ // MediaTailor URL Patterns
+ public static final String MT_SEGMENT_PATTERN = "segments.mediatailor";
+ public static final String MT_URL_PATTERN = ".mediatailor.";
+
+ // Default Configuration
+ public static final boolean DEFAULT_ENABLE_MANIFEST_PARSING = true;
+ public static final boolean DEFAULT_ENABLE_TRACKING_API = true;
+ public static final long DEFAULT_LIVE_MANIFEST_POLL_INTERVAL_MS = 5000; // 5s
+ public static final long DEFAULT_LIVE_TRACKING_POLL_INTERVAL_MS = 10000; // 10s
+ public static final int DEFAULT_TRACKING_API_TIMEOUT_MS = 5000; // 5s
+ public static final long DEFAULT_TIME_UPDATE_INTERVAL_MS = 250; // 250ms
+
+ // Timing Thresholds
+ public static final double MIN_AD_DURATION = 0.5; // Minimum ad duration in seconds (filter false positives)
+ public static final double AD_TIMING_TOLERANCE = 0.5; // Tolerance for matching ad times in seconds
+ public static final long POST_AD_PAUSE_THRESHOLD_MS = 500; // Ignore pause events within 500ms after ad break
+
+ // Stream Types
+ public static final String STREAM_TYPE_VOD = "vod";
+ public static final String STREAM_TYPE_LIVE = "live";
+
+ // Manifest Types
+ public static final String MANIFEST_TYPE_HLS = "hls";
+ public static final String MANIFEST_TYPE_DASH = "dash";
+
+ // Ad Position Types (for VOD only)
+ public static final String AD_POSITION_PRE = "pre";
+ public static final String AD_POSITION_MID = "mid";
+ public static final String AD_POSITION_POST = "post";
+
+ // Ad Detection Sources
+ public static final String AD_SOURCE_MANIFEST_CUE = "manifest-cue";
+ public static final String AD_SOURCE_TRACKING_API = "tracking-api";
+ public static final String AD_SOURCE_MANIFEST_AND_TRACKING = "manifest+tracking";
+ public static final String AD_SOURCE_VHS_DISCONTINUITY = "vhs-discontinuity";
+ public static final String AD_SOURCE_DASH_EMSG = "dash-emsg";
+ public static final String AD_SOURCE_DASH_EVENT_STREAM = "dash-event-stream";
+
+ // Quartile Percentages
+ public static final double QUARTILE_Q1 = 0.25; // 25%
+ public static final double QUARTILE_Q2 = 0.50; // 50%
+ public static final double QUARTILE_Q3 = 0.75; // 75%
+
+ // Ad Partner
+ public static final String AD_PARTNER = "aws-mediatailor";
+
+ // Heartbeat Interval
+ public static final long AD_HEARTBEAT_INTERVAL_MS = 30000; // 30 seconds
+
+ private MediaTailorConstants() {
+ // Prevent instantiation
+ }
+}
diff --git a/NRMediaTailorTracker/src/main/java/com/newrelic/videoagent/mediatailor/utils/NetworkUtils.java b/NRMediaTailorTracker/src/main/java/com/newrelic/videoagent/mediatailor/utils/NetworkUtils.java
new file mode 100644
index 00000000..95a431d2
--- /dev/null
+++ b/NRMediaTailorTracker/src/main/java/com/newrelic/videoagent/mediatailor/utils/NetworkUtils.java
@@ -0,0 +1,185 @@
+package com.newrelic.videoagent.mediatailor.utils;
+
+import android.util.Log;
+
+import java.io.BufferedReader;
+import java.io.InputStreamReader;
+import java.net.HttpURLConnection;
+import java.net.URL;
+
+/**
+ * Network Utilities for MediaTailor
+ * Handles HTTP requests for manifest and tracking API fetching
+ */
+public class NetworkUtils {
+
+ private static final String TAG = "MTNetworkUtils";
+
+ /**
+ * Callback interface for async fetch operations
+ */
+ public interface FetchCallback {
+ void onSuccess(String response);
+ void onError(Exception error);
+ }
+
+ /**
+ * Fetch text content from a URL (synchronous)
+ *
+ * @param urlString The URL to fetch
+ * @param timeoutMs Connection and read timeout in milliseconds
+ * @return Response text, or null on error
+ */
+ public static String fetchText(String urlString, int timeoutMs) {
+ try {
+ URL url = new URL(urlString);
+ HttpURLConnection connection = (HttpURLConnection) url.openConnection();
+ connection.setRequestMethod("GET");
+ connection.setConnectTimeout(timeoutMs);
+ connection.setReadTimeout(timeoutMs);
+
+ int responseCode = connection.getResponseCode();
+
+ if (responseCode == HttpURLConnection.HTTP_OK) {
+ BufferedReader reader = new BufferedReader(
+ new InputStreamReader(connection.getInputStream())
+ );
+ StringBuilder response = new StringBuilder();
+ String line;
+
+ while ((line = reader.readLine()) != null) {
+ response.append(line).append("\n");
+ }
+ reader.close();
+ connection.disconnect();
+
+ return response.toString();
+ } else {
+ Log.w(TAG, "HTTP request failed with code: " + responseCode + " for URL: " + urlString);
+ connection.disconnect();
+ return null;
+ }
+
+ } catch (Exception e) {
+ Log.e(TAG, "Error fetching URL: " + urlString, e);
+ return null;
+ }
+ }
+
+ /**
+ * Fetch text content from a URL (asynchronous)
+ *
+ * @param urlString The URL to fetch
+ * @param timeoutMs Connection and read timeout in milliseconds
+ * @param callback Callback to receive result
+ */
+ public static void fetchTextAsync(final String urlString, final int timeoutMs, final FetchCallback callback) {
+ new Thread(new Runnable() {
+ @Override
+ public void run() {
+ try {
+ String response = fetchText(urlString, timeoutMs);
+ if (response != null) {
+ callback.onSuccess(response);
+ } else {
+ callback.onError(new Exception("HTTP request failed"));
+ }
+ } catch (Exception e) {
+ callback.onError(e);
+ }
+ }
+ }).start();
+ }
+
+ /**
+ * Extract session ID from MediaTailor URL
+ * Pattern: ?aws.sessionId=SESSION_ID or /v1/session/SESSION_ID/
+ *
+ * @param url The MediaTailor URL
+ * @return Session ID, or null if not found
+ */
+ public static String extractSessionId(String url) {
+ if (url == null || url.isEmpty()) {
+ return null;
+ }
+
+ // Try query parameter pattern: ?aws.sessionId=...
+ if (url.contains("aws.sessionId=")) {
+ int start = url.indexOf("aws.sessionId=") + "aws.sessionId=".length();
+ int end = url.indexOf("&", start);
+ if (end == -1) {
+ end = url.length();
+ }
+ return url.substring(start, end);
+ }
+
+ // Try path pattern: /v1/session/SESSION_ID/
+ if (url.contains("/v1/session/")) {
+ int start = url.indexOf("/v1/session/") + "/v1/session/".length();
+ int end = url.indexOf("/", start);
+ if (end == -1) {
+ end = url.length();
+ }
+ return url.substring(start, end);
+ }
+
+ return null;
+ }
+
+ /**
+ * Build tracking API URL from MediaTailor session
+ * Format: https://{domain}/v1/tracking/{sessionId}
+ *
+ * @param manifestUrl The MediaTailor manifest URL
+ * @param sessionId The session ID
+ * @return Tracking API URL, or null if cannot construct
+ */
+ public static String buildTrackingUrl(String manifestUrl, String sessionId) {
+ if (manifestUrl == null || sessionId == null) {
+ return null;
+ }
+
+ try {
+ URL url = new URL(manifestUrl);
+ String protocol = url.getProtocol();
+ String host = url.getHost();
+
+ return protocol + "://" + host + "/v1/tracking/" + sessionId;
+ } catch (Exception e) {
+ Log.e(TAG, "Error building tracking URL", e);
+ return null;
+ }
+ }
+
+ /**
+ * Detect manifest type from URL
+ *
+ * @param url The manifest URL
+ * @return "hls" for .m3u8, "dash" for .mpd, or null for unknown
+ */
+ public static String detectManifestType(String url) {
+ if (url == null || url.isEmpty()) {
+ return null;
+ }
+
+ url = url.toLowerCase();
+
+ if (url.contains(".m3u8")) {
+ return MediaTailorConstants.MANIFEST_TYPE_HLS;
+ } else if (url.contains(".mpd")) {
+ return MediaTailorConstants.MANIFEST_TYPE_DASH;
+ }
+
+ return null;
+ }
+
+ /**
+ * Check if URL is a MediaTailor URL
+ *
+ * @param url The URL to check
+ * @return true if URL contains ".mediatailor."
+ */
+ public static boolean isMediaTailorUrl(String url) {
+ return url != null && url.contains(MediaTailorConstants.MT_URL_PATTERN);
+ }
+}
diff --git a/NewRelicVideoCore/src/main/java/com/newrelic/videoagent/core/NRVideo.java b/NewRelicVideoCore/src/main/java/com/newrelic/videoagent/core/NRVideo.java
index fbc71466..c8e38194 100644
--- a/NewRelicVideoCore/src/main/java/com/newrelic/videoagent/core/NRVideo.java
+++ b/NewRelicVideoCore/src/main/java/com/newrelic/videoagent/core/NRVideo.java
@@ -48,17 +48,33 @@ public static Integer addPlayer(NRVideoPlayerConfiguration config) {
throw new IllegalStateException("NRVideo is not initialized. Call NRVideo.newBuilder(context).withConfiguration(config).build() first.");
}
- // Create content tracker with ExoPlayer instance
+ // Detect MediaTailor stream
+ boolean isMediaTailor = isMediaTailorStream(config.getPlayer());
+
+ // Create content tracker (always ExoPlayer)
NRTracker contentTracker = createContentTracker();
NRTracker adsTracker = null;
- if (config.isAdEnabled()) {
- adsTracker = createAdTracker();
- NRLog.d("add tracker is added");
+
+ // Create appropriate ads tracker based on stream type
+ if (isMediaTailor) {
+ // MediaTailor tracker for SSAI ad detection
+ adsTracker = createMediaTailorTracker();
+ NRLog.d("MediaTailor tracker added");
+ } else if (config.isAdEnabled()) {
+ // IMA tracker for client-side ads
+ NRLog.d("IMA ads tracker added");
}
// Now start the tracker system
Integer trackerId = NewRelicVideoAgent.getInstance().start(contentTracker, adsTracker);
((NRVideoTracker) contentTracker).setPlayer(config.getPlayer());
+
+ // MediaTailor tracker needs player reference for event listening and ad detection
+ // IMA tracker doesn't need it as it uses its own AdsManager
+ if (isMediaTailor && adsTracker != null) {
+ ((NRVideoTracker) adsTracker).setPlayer(config.getPlayer());
+ }
+
NRLog.i("NRVideo initialization completed successfully with tracker ID: " + trackerId + " and player name:" + config.getPlayerName());
if (config.getCustomAttributes() != null && !config.getCustomAttributes().isEmpty()) {
for (Map.Entry entry : config.getCustomAttributes().entrySet()) {
@@ -267,6 +283,34 @@ private static NRTracker createAdTracker() {
}
}
+ private static NRTracker createMediaTailorTracker() {
+ try {
+ // Create MediaTailor tracker
+ Class> mtTrackerClass = Class.forName("com.newrelic.videoagent.mediatailor.tracker.NRTrackerMediaTailor");
+ return (NRTracker) mtTrackerClass.newInstance();
+ } catch (Exception e) {
+ NRLog.w("Failed to create MediaTailor tracker, falling back to ExoPlayer tracker: " + e.getMessage());
+ // Fallback to ExoPlayer tracker
+ return createContentTracker();
+ }
+ }
+
+ private static boolean isMediaTailorStream(Object player) {
+ try {
+ // Use reflection to call NRTrackerMediaTailor.isUsing()
+ Class> mtTrackerClass = Class.forName("com.newrelic.videoagent.mediatailor.tracker.NRTrackerMediaTailor");
+ java.lang.reflect.Method isUsingMethod = mtTrackerClass.getMethod("isUsing", androidx.media3.exoplayer.ExoPlayer.class);
+ Boolean result = (Boolean) isUsingMethod.invoke(null, player);
+ NRLog.d("MediaTailor detection result: " + result);
+ return result;
+ } catch (Exception e) {
+ // If MediaTailor tracker not available or detection fails, assume not MediaTailor
+ NRLog.w("MediaTailor detection failed: " + e.getMessage());
+ e.printStackTrace();
+ return false;
+ }
+ }
+
/**
* Sets the user ID.
*
diff --git a/app/build.gradle b/app/build.gradle
index 4da34dc9..d85b4771 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -56,6 +56,7 @@ dependencies {
implementation project(path: ':NewRelicVideoCore')
implementation project(path: ':NRExoPlayerTracker')
implementation project(path: ':NRIMATracker')
+ implementation project(path: ':NRMediaTailorTracker')
testImplementation 'junit:junit:4.13.2'
// Updated AndroidX Test dependencies that are compatible with Android 12+
@@ -68,6 +69,7 @@ dependencies {
implementation 'androidx.media3:media3-ui:1.1.0'
implementation 'androidx.media3:media3-exoplayer-ima:1.1.0'
implementation 'androidx.media3:media3-exoplayer-dash:1.1.0'
+ implementation 'androidx.media3:media3-exoplayer-hls:1.1.0'
implementation 'com.android.support:multidex:1.0.3'
}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index ae9a0c9a..0abd31fc 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -13,6 +13,11 @@
android:label="@string/title_activity_video_player_ads"
android:theme="@style/Theme.NRVideoProject.NoActionBar"
android:exported="false">
+
diff --git a/app/src/main/java/com/newrelic/nrvideoproject/MainActivity.java b/app/src/main/java/com/newrelic/nrvideoproject/MainActivity.java
index 6d448c0a..28ce2808 100644
--- a/app/src/main/java/com/newrelic/nrvideoproject/MainActivity.java
+++ b/app/src/main/java/com/newrelic/nrvideoproject/MainActivity.java
@@ -34,6 +34,17 @@ protected void onCreate(Bundle savedInstanceState) {
findViewById(R.id.video1).setOnClickListener(this);
findViewById(R.id.video2).setOnClickListener(this);
findViewById(R.id.video3).setOnClickListener(this);
+
+ // MediaTailor button - launches MediaTailor player with auto-start
+ findViewById(R.id.mediatailor_button).setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ Intent intent = new Intent(MainActivity.this, VideoPlayerMediaTailor.class);
+ intent.putExtra("autoplay", true);
+ startActivity(intent);
+ }
+ });
+
findViewById(R.id.video4).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
diff --git a/app/src/main/java/com/newrelic/nrvideoproject/VideoPlayerMediaTailor.java b/app/src/main/java/com/newrelic/nrvideoproject/VideoPlayerMediaTailor.java
new file mode 100644
index 00000000..2306e71f
--- /dev/null
+++ b/app/src/main/java/com/newrelic/nrvideoproject/VideoPlayerMediaTailor.java
@@ -0,0 +1,287 @@
+package com.newrelic.nrvideoproject;
+
+import androidx.appcompat.app.AppCompatActivity;
+import androidx.media3.common.MediaItem;
+import androidx.media3.common.Player;
+import androidx.media3.datasource.DataSource;
+import androidx.media3.datasource.DefaultDataSource;
+import androidx.media3.exoplayer.ExoPlayer;
+import androidx.media3.exoplayer.source.DefaultMediaSourceFactory;
+import androidx.media3.ui.PlayerView;
+import android.graphics.Color;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Looper;
+import android.util.Log;
+import android.view.View;
+import android.widget.Button;
+import android.widget.RadioButton;
+import android.widget.RadioGroup;
+import android.widget.TextView;
+import com.newrelic.videoagent.core.NRVideo;
+import com.newrelic.videoagent.core.NRVideoPlayerConfiguration;
+
+import org.json.JSONObject;
+
+import java.io.BufferedReader;
+import java.io.InputStreamReader;
+import java.io.OutputStream;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+public class VideoPlayerMediaTailor extends AppCompatActivity {
+
+ private ExoPlayer player;
+ private Integer trackerId;
+
+ private RadioGroup initModeGroup;
+ private RadioButton radioAuto;
+ private RadioButton radioManual;
+ private RadioButton radioAll;
+ private Button loadStreamButton;
+ private TextView statusText;
+
+ private ExecutorService executorService;
+ private Handler mainHandler;
+
+ // MediaTailor configuration
+ // TODO: Replace with your actual AWS MediaTailor credentials
+ private static final String CLOUDFRONT_ID = "YOUR_CLOUDFRONT_ID_HERE";
+ private static final String REGION = "YOUR_AWS_REGION_HERE"; // e.g., us-east-1, ap-southeast-2
+ private static final String ACCOUNT_ID = "YOUR_ACCOUNT_ID_HERE";
+ private static final String PLAYBACK_CONFIG = "YOUR_PLAYBACK_CONFIG_HERE";
+ private static final String BASE_URL = "https://" + CLOUDFRONT_ID + ".mediatailor." + REGION + ".amazonaws.com";
+
+ private enum InitMode {
+ AUTO, MANUAL, ALL
+ }
+
+ private InitMode currentMode = InitMode.AUTO;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_video_player_mediatailor);
+
+ executorService = Executors.newSingleThreadExecutor();
+ mainHandler = new Handler(Looper.getMainLooper());
+
+ // Check if launched from MainActivity with autoplay
+ boolean autoplay = getIntent().getBooleanExtra("autoplay", false);
+
+ String video = getIntent().getStringExtra("video");
+ Log.v("VideoPlayerMediaTailor", "Play video with AWS MediaTailor: " + video);
+
+ // Initialize UI components
+ initModeGroup = findViewById(R.id.init_mode_group);
+ radioAuto = findViewById(R.id.radio_auto);
+ radioManual = findViewById(R.id.radio_manual);
+ radioAll = findViewById(R.id.radio_all);
+ loadStreamButton = findViewById(R.id.load_stream_button);
+ statusText = findViewById(R.id.status_text);
+
+ // If launched with autoplay flag, hide controls and auto-start
+ if (autoplay) {
+ findViewById(R.id.controls_container).setVisibility(View.GONE);
+ updateStatus("Initializing MediaTailor session...");
+ initializeSessionAndPlay();
+ } else {
+ // Setup radio button listeners for manual mode selection
+ setupModeListeners();
+
+ // Auto mode is default - load immediately
+ if (currentMode == InitMode.AUTO) {
+ updateStatus("Auto mode: Loading stream...");
+ initializeSessionAndPlay();
+ }
+ }
+ }
+
+ private void setupModeListeners() {
+ initModeGroup.setOnCheckedChangeListener((group, checkedId) -> {
+ if (checkedId == R.id.radio_auto) {
+ currentMode = InitMode.AUTO;
+ updateStatus("Auto mode: Stream will load automatically");
+ loadStreamButton.setVisibility(View.GONE);
+ initializeSessionAndPlay();
+ } else if (checkedId == R.id.radio_manual) {
+ currentMode = InitMode.MANUAL;
+ updateStatus("Manual mode: Click button to load stream");
+ loadStreamButton.setVisibility(View.VISIBLE);
+ if (player != null) {
+ player.stop();
+ }
+ } else if (checkedId == R.id.radio_all) {
+ currentMode = InitMode.ALL;
+ updateStatus("All mode: Supports both auto and manual initialization. Button available for manual control.");
+ loadStreamButton.setVisibility(View.VISIBLE);
+ initializeSessionAndPlay();
+ }
+ });
+
+ loadStreamButton.setOnClickListener(v -> {
+ updateStatus("Loading stream...");
+ initializeSessionAndPlay();
+ });
+ }
+
+ @Override
+ protected void onDestroy() {
+ super.onDestroy();
+ if (trackerId != null) {
+ NRVideo.releaseTracker(trackerId);
+ }
+ if (player != null) {
+ player.stop();
+ player.release();
+ }
+ if (executorService != null) {
+ executorService.shutdown();
+ }
+ }
+
+ private void updateStatus(String message) {
+ updateStatus(message, false);
+ }
+
+ private void updateStatus(String message, boolean isError) {
+ mainHandler.post(() -> {
+ statusText.setText(message);
+ if (isError) {
+ statusText.setBackgroundColor(Color.parseColor("#F8D7DA"));
+ statusText.setTextColor(Color.parseColor("#721C24"));
+ } else {
+ statusText.setBackgroundColor(Color.parseColor("#F8F9FA"));
+ statusText.setTextColor(Color.parseColor("#495057"));
+ }
+ });
+ Log.d("VideoPlayerMediaTailor", message);
+ }
+
+ /**
+ * Initialize MediaTailor session and play video with sessionId
+ * This follows the pattern from the videojs sample
+ */
+ private void initializeSessionAndPlay() {
+ executorService.execute(() -> {
+ try {
+ updateStatus("Initializing session...");
+
+ // Call MediaTailor session initialization endpoint (using HLS since origin is HLS)
+ String sessionEndpoint = BASE_URL + "/v1/session/" + ACCOUNT_ID + "/" + PLAYBACK_CONFIG + "/hls";
+ Log.d("VideoPlayerMediaTailor", "Session endpoint: " + sessionEndpoint);
+
+ URL url = new URL(sessionEndpoint);
+ HttpURLConnection connection = (HttpURLConnection) url.openConnection();
+ connection.setRequestMethod("POST");
+ connection.setRequestProperty("Content-Type", "application/json");
+ connection.setDoOutput(true);
+
+ // Send empty JSON body
+ OutputStream os = connection.getOutputStream();
+ os.write("{}".getBytes());
+ os.flush();
+ os.close();
+
+ int responseCode = connection.getResponseCode();
+ Log.d("VideoPlayerMediaTailor", "Session init response code: " + responseCode);
+
+ if (responseCode == HttpURLConnection.HTTP_OK) {
+ BufferedReader in = new BufferedReader(new InputStreamReader(connection.getInputStream()));
+ StringBuilder response = new StringBuilder();
+ String line;
+ while ((line = in.readLine()) != null) {
+ response.append(line);
+ }
+ in.close();
+
+ JSONObject jsonResponse = new JSONObject(response.toString());
+ String manifestUrl = jsonResponse.getString("manifestUrl");
+ Log.d("VideoPlayerMediaTailor", "Manifest URL: " + manifestUrl);
+
+ // Extract sessionId from manifestUrl
+ Pattern pattern = Pattern.compile("aws\\.sessionId=([^&]+)");
+ Matcher matcher = pattern.matcher(manifestUrl);
+
+ if (matcher.find()) {
+ String sessionId = matcher.group(1);
+ Log.d("VideoPlayerMediaTailor", "Extracted sessionId: " + sessionId);
+
+ // Construct HLS URL with sessionId (following videoJS pattern)
+ // Use master.m3u8 path and append sessionId as query parameter
+ String hlsUrl = BASE_URL + "/v1/master/" + ACCOUNT_ID + "/" + PLAYBACK_CONFIG + "/master.m3u8?aws.sessionId=" + sessionId;
+ Log.d("VideoPlayerMediaTailor", "HLS URL with sessionId: " + hlsUrl);
+
+ updateStatus("Session initialized! Loading stream...");
+
+ // Play video on main thread
+ mainHandler.post(() -> playVideo(hlsUrl, sessionId));
+ } else {
+ throw new Exception("Failed to extract sessionId from manifest URL");
+ }
+ } else {
+ throw new Exception("Session init failed with response code: " + responseCode);
+ }
+
+ connection.disconnect();
+
+ } catch (Exception e) {
+ Log.e("VideoPlayerMediaTailor", "Session initialization error", e);
+ updateStatus("Session init error: " + e.getMessage(), true);
+ }
+ });
+ }
+
+ /**
+ * Play video with the given URL and sessionId
+ */
+ private void playVideo(String videoUrl, String sessionId) {
+ updateStatus("Initializing player...");
+
+ // Clean up existing player if any
+ if (player != null) {
+ if (trackerId != null) {
+ NRVideo.releaseTracker(trackerId);
+ trackerId = null;
+ }
+ player.stop();
+ player.release();
+ player = null;
+ }
+
+ // Create a DataSource.Factory for network access
+ DataSource.Factory dataSourceFactory = new DefaultDataSource.Factory(this);
+
+ // Create a MediaSource factory with DASH and HLS support
+ DefaultMediaSourceFactory mediaSourceFactory = new DefaultMediaSourceFactory(this);
+
+ // Build ExoPlayer with media source factory
+ player = new ExoPlayer.Builder(this)
+ .setMediaSourceFactory(mediaSourceFactory)
+ .build();
+
+ // IMPORTANT: Set media item BEFORE NRVideo.addPlayer() so MediaTailor detection can work
+ // The detection checks player.getCurrentMediaItem().uri for ".mediatailor." pattern
+ player.setMediaItem(MediaItem.fromUri(videoUrl));
+
+ NRVideoPlayerConfiguration playerConfiguration = new NRVideoPlayerConfiguration("mediatailor-player", player, false, null);
+ trackerId = NRVideo.addPlayer(playerConfiguration);
+
+ Log.d("VideoPlayerMediaTailor", "Player configured with dual trackers (ExoPlayer + MediaTailor) and sessionId: " + sessionId);
+
+ PlayerView playerView = findViewById(R.id.player);
+ playerView.setPlayer(player);
+
+ updateStatus("Loading HLS stream...");
+
+ // MediaTailor provides an HLS manifest with ads stitched in
+ player.setPlayWhenReady(true); // Autoplay
+ player.prepare();
+ }
+}
diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
index 65a5f605..54e16aa4 100644
--- a/app/src/main/res/layout/activity_main.xml
+++ b/app/src/main/res/layout/activity_main.xml
@@ -6,6 +6,19 @@
android:layout_height="match_parent"
tools:context=".MainActivity">
+
+
+ app:layout_constraintTop_toBottomOf="@+id/mediatailor_button" />