From fd0aa58cbca3410bbff6611ab200ea9f954169fd Mon Sep 17 00:00:00 2001 From: Steve Mayhew Date: Wed, 4 Oct 2023 16:01:39 -0700 Subject: [PATCH 1/2] Enables HLS Live Offset Synchronization Across Multiple Devices This commit addresses the issue of inaccurate live position measurement in HLS by introducing NTP time synchronization. Unlike DASH, HLS lacks origin time synchronization logic, resulting in potential discrepancies in wall-clock time when using `System.currentTimeMillis()`. The commit utilizes the `SntpClient` in ExoPlayer to determine the clock offset with the default NTP server (`time.android.com`) and applies this information to the `HlsMediaSource`. With the implementation of this change, multiple devices can now synchronize playback to a common time source, as long as the origin server also synchronizes to an NTP time source. --- .../media3/exoplayer/hls/HlsMediaSource.java | 3 +- .../playlist/DefaultHlsPlaylistTracker.java | 45 +++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/libraries/exoplayer_hls/src/main/java/androidx/media3/exoplayer/hls/HlsMediaSource.java b/libraries/exoplayer_hls/src/main/java/androidx/media3/exoplayer/hls/HlsMediaSource.java index ccad73c1f7f..843e319bfc5 100644 --- a/libraries/exoplayer_hls/src/main/java/androidx/media3/exoplayer/hls/HlsMediaSource.java +++ b/libraries/exoplayer_hls/src/main/java/androidx/media3/exoplayer/hls/HlsMediaSource.java @@ -57,6 +57,7 @@ import androidx.media3.exoplayer.upstream.CmcdConfiguration; import androidx.media3.exoplayer.upstream.DefaultLoadErrorHandlingPolicy; import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy; +import androidx.media3.exoplayer.util.SntpClient; import androidx.media3.extractor.Extractor; import androidx.media3.extractor.text.SubtitleParser; import com.google.errorprone.annotations.CanIgnoreReturnValue; @@ -598,7 +599,7 @@ private SinglePeriodTimeline createTimelineForLive( return new SinglePeriodTimeline( presentationStartTimeMs, windowStartTimeMs, - /* elapsedRealtimeEpochOffsetMs= */ C.TIME_UNSET, + SntpClient.getElapsedRealtimeOffsetMs(), periodDurationUs, /* windowDurationUs= */ playlist.durationUs, /* windowPositionInPeriodUs= */ offsetFromInitialStartTimeUs, diff --git a/libraries/exoplayer_hls/src/main/java/androidx/media3/exoplayer/hls/playlist/DefaultHlsPlaylistTracker.java b/libraries/exoplayer_hls/src/main/java/androidx/media3/exoplayer/hls/playlist/DefaultHlsPlaylistTracker.java index 9e2d786d186..8bde386136d 100644 --- a/libraries/exoplayer_hls/src/main/java/androidx/media3/exoplayer/hls/playlist/DefaultHlsPlaylistTracker.java +++ b/libraries/exoplayer_hls/src/main/java/androidx/media3/exoplayer/hls/playlist/DefaultHlsPlaylistTracker.java @@ -19,6 +19,7 @@ import static androidx.media3.common.util.Util.castNonNull; import static java.lang.Math.max; +import android.annotation.SuppressLint; import android.net.Uri; import android.os.Handler; import android.os.SystemClock; @@ -26,6 +27,7 @@ import androidx.media3.common.C; import androidx.media3.common.ParserException; import androidx.media3.common.util.Assertions; +import androidx.media3.common.util.Log; import androidx.media3.common.util.UnstableApi; import androidx.media3.common.util.Util; import androidx.media3.datasource.DataSource; @@ -43,6 +45,7 @@ import androidx.media3.exoplayer.upstream.Loader; import androidx.media3.exoplayer.upstream.Loader.LoadErrorAction; import androidx.media3.exoplayer.upstream.ParsingLoadable; +import androidx.media3.exoplayer.util.SntpClient; import com.google.common.collect.Iterables; import java.io.IOException; import java.util.HashMap; @@ -54,6 +57,8 @@ public final class DefaultHlsPlaylistTracker implements HlsPlaylistTracker, Loader.Callback> { + public static final String TAG = "DefaultHlsPlaylistTracker"; + /** Factory for {@link DefaultHlsPlaylistTracker} instances. */ public static final Factory FACTORY = DefaultHlsPlaylistTracker::new; @@ -141,6 +146,25 @@ public void start( playlistParserFactory.createPlaylistParser()); Assertions.checkState(initialPlaylistLoader == null); initialPlaylistLoader = new Loader("DefaultHlsPlaylistTracker:MultivariantPlaylist"); + SntpClient.InitializationCallback callback = + new SntpClient.InitializationCallback() { + @Override + public void onInitialized() { + requestMasterPlaylist(eventDispatcher, multivariantPlaylistLoadable); + } + + @SuppressLint("Range") + @Override + public void onInitializationFailed(IOException error) { + Log.w(TAG, "NTP time init failed, use default system time", error); + requestMasterPlaylist(eventDispatcher, multivariantPlaylistLoadable); + } + }; + SntpClient.initialize(initialPlaylistLoader, callback); + } + + private void requestMasterPlaylist( + EventDispatcher eventDispatcher, ParsingLoadable multivariantPlaylistLoadable) { long elapsedRealtime = initialPlaylistLoader.startLoading( multivariantPlaylistLoadable, @@ -728,6 +752,27 @@ private void loadPlaylistInternal(Uri playlistRequestUri) { } private void loadPlaylistImmediately(Uri playlistRequestUri) { + if (primaryMediaPlaylistUrl == null || playlistRequestUri.equals(primaryMediaPlaylistUrl)) { + SntpClient.InitializationCallback callback = + new SntpClient.InitializationCallback() { + @Override + public void onInitialized() { + loadAfterTimeSync(playlistRequestUri); + } + + @Override + public void onInitializationFailed(IOException error) { + Log.w(TAG, "NTP time init failed, use default system time", error); + loadAfterTimeSync(playlistRequestUri); + } + }; + SntpClient.initialize(mediaPlaylistLoader, callback); + } else { + loadAfterTimeSync(playlistRequestUri); + } + } + + private void loadAfterTimeSync(Uri playlistRequestUri) { ParsingLoadable.Parser mediaPlaylistParser = playlistParserFactory.createPlaylistParser(multivariantPlaylist, playlistSnapshot); ParsingLoadable mediaPlaylistLoadable = From a4979a8667e36e1cfc527e3462a6071abb17abc7 Mon Sep 17 00:00:00 2001 From: Steve Mayhew Date: Thu, 5 Oct 2023 10:53:50 -0700 Subject: [PATCH 2/2] Include test method for Live Offset in main demo Enables testing the Live Offset settings with the main demo PlayerActivity. Using an intent like: ``` adb shell am start -n androidx.media3.demo.main/.PlayerActivity -a androidx.media3.demo.main.action.VIEW --ei live_offset_target 30 --ef live_offset_adjust_speed 35.0 ``` Will start playback synced to a 30 second offset with agressive speed adjustment to achive it, note this requires audio codec that supports speed changes (AAC for example) --- .../androidx/media3/demo/main/IntentUtil.java | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/demos/main/src/main/java/androidx/media3/demo/main/IntentUtil.java b/demos/main/src/main/java/androidx/media3/demo/main/IntentUtil.java index c2710f42a08..2fe1ae08e3b 100644 --- a/demos/main/src/main/java/androidx/media3/demo/main/IntentUtil.java +++ b/demos/main/src/main/java/androidx/media3/demo/main/IntentUtil.java @@ -26,6 +26,7 @@ import androidx.media3.common.C; import androidx.media3.common.MediaItem; import androidx.media3.common.MediaItem.ClippingConfiguration; +import androidx.media3.common.MediaItem.LiveConfiguration; import androidx.media3.common.MediaItem.SubtitleConfiguration; import androidx.media3.common.MediaMetadata; import androidx.media3.common.Player; @@ -86,6 +87,9 @@ public class IntentUtil { } } + public static final String LIVE_OFFSET_TARGET_VALUE = "live_offset_target"; + public static final String LIVE_OFFSET_ADJUSTMENT_SPEED = "live_offset_adjust_speed"; + /** Creates a list of {@link MediaItem media items} from an {@link Intent}. */ public static List createMediaItemsFromIntent(Intent intent) { List mediaItems = new ArrayList<>(); @@ -143,6 +147,9 @@ private static MediaItem createMediaItemFromIntent( SubtitleConfiguration subtitleConfiguration = createSubtitleConfiguration(intent, extrasKeySuffix); long imageDurationMs = intent.getLongExtra(IMAGE_DURATION_MS + extrasKeySuffix, C.TIME_UNSET); + @Nullable + LiveConfiguration liveConfiguration = createLiveConfiguration(intent, extrasKeySuffix); + MediaItem.Builder builder = new MediaItem.Builder() .setUri(uri) @@ -165,9 +172,37 @@ private static MediaItem createMediaItemFromIntent( builder.setSubtitleConfigurations(ImmutableList.of(subtitleConfiguration)); } + if (liveConfiguration != null) { + builder.setLiveConfiguration(liveConfiguration); + } + return populateDrmPropertiesFromIntent(builder, intent, extrasKeySuffix).build(); } + @Nullable + private static LiveConfiguration createLiveConfiguration(Intent intent, String extrasKeySuffix) { + LiveConfiguration.Builder builder = null; + boolean fast_resync = intent.hasExtra(LIVE_OFFSET_ADJUSTMENT_SPEED + extrasKeySuffix); + if (fast_resync) { + float resyncPercentChange = intent.getFloatExtra(LIVE_OFFSET_ADJUSTMENT_SPEED, 0.0f) / 100.0f; + builder = new LiveConfiguration.Builder(); + builder + .setMaxPlaybackSpeed(1.0f + resyncPercentChange) + .setMinPlaybackSpeed(1.0f - resyncPercentChange); + } + + if (intent.hasExtra(LIVE_OFFSET_TARGET_VALUE)) { + int liveTargetOffsetMs = intent.getIntExtra(LIVE_OFFSET_TARGET_VALUE, 0) * 1000; + builder = builder == null ? new LiveConfiguration.Builder() : builder; + + builder.setTargetOffsetMs(liveTargetOffsetMs); + if (fast_resync) { + builder.setMinOffsetMs(liveTargetOffsetMs).setMinOffsetMs(liveTargetOffsetMs); + } + } + return builder == null ? null : builder.build(); + } + @Nullable private static MediaItem.SubtitleConfiguration createSubtitleConfiguration( Intent intent, String extrasKeySuffix) {