diff --git a/app/build.gradle b/app/build.gradle index 652eeb79212..afc50065e9a 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -125,7 +125,7 @@ ext { androidxWorkVersion = '2.8.1' stateSaverVersion = '1.4.1' - exoPlayerVersion = '2.18.7' + exoPlayerVersion = '2.19.1' googleAutoServiceVersion = '1.1.1' groupieVersion = '2.10.1' markwonVersion = '4.6.2' @@ -214,7 +214,7 @@ dependencies { // the corresponding commit hash, since JitPack sometimes deletes artifacts. // If there’s already a git hash, just add more of it to the end (or remove a letter) // to cause jitpack to regenerate the artifact. - implementation 'com.github.TeamNewPipe:NewPipeExtractor:68b4c9acbae2d167e7b1209bb6bf0ae086dd427e' + implementation 'com.github.davidasunmo.NewPipeExtractor:NewPipeExtractor:45df3cdb1013370a5ef9d1f9ca0df8a7cf71ebf2' implementation 'com.github.TeamNewPipe:NoNonsense-FilePicker:5.0.0' /** Checkstyle **/ diff --git a/app/src/main/java/org/schabi/newpipe/MainActivity.java b/app/src/main/java/org/schabi/newpipe/MainActivity.java index b709c110727..a5537fab73b 100644 --- a/app/src/main/java/org/schabi/newpipe/MainActivity.java +++ b/app/src/main/java/org/schabi/newpipe/MainActivity.java @@ -70,6 +70,8 @@ import org.schabi.newpipe.extractor.comments.CommentsInfoItem; import org.schabi.newpipe.extractor.exceptions.ExtractionException; import org.schabi.newpipe.extractor.services.peertube.PeertubeInstance; +import org.schabi.newpipe.extractor.utils.ExtractorLogger; +import org.schabi.newpipe.extractor.utils.Logger; import org.schabi.newpipe.fragments.BackPressable; import org.schabi.newpipe.fragments.MainFragment; import org.schabi.newpipe.fragments.detail.VideoDetailFragment; @@ -137,6 +139,29 @@ public class MainActivity extends AppCompatActivity { @Override protected void onCreate(final Bundle savedInstanceState) { if (DEBUG) { + // Override Extractor to print to Logcat + ExtractorLogger.setLogger(new Logger() { + @Override + public void debug(final String tag, final String message) { + Log.d(tag, message); + } + + @Override + public void warn(final String tag, final String message) { + Log.w(tag, message); + } + + @Override + public void error(final String tag, final String message) { + Log.e(tag, message); + } + + @Override + public void error(final String tag, final String message, final Throwable t) { + Log.e(tag, message, t); + } + + }); Log.d(TAG, "onCreate() called with: " + "savedInstanceState = [" + savedInstanceState + "]"); } diff --git a/app/src/main/java/org/schabi/newpipe/player/Player.java b/app/src/main/java/org/schabi/newpipe/player/Player.java index 040f0dc99dc..b8cdc898606 100644 --- a/app/src/main/java/org/schabi/newpipe/player/Player.java +++ b/app/src/main/java/org/schabi/newpipe/player/Player.java @@ -226,8 +226,8 @@ public final class Player implements PlaybackListener, Listener { // audio only mode does not mean that player type is background, but that the player was // minimized to background but will resume automatically to the original player type - private boolean isAudioOnly = false; - private boolean isPrepared = false; + private boolean isAudioOnly; + private boolean isPrepared; /*////////////////////////////////////////////////////////////////////////// // UIs, listeners and disposables @@ -239,9 +239,9 @@ public final class Player implements PlaybackListener, Listener { private BroadcastReceiver broadcastReceiver; private IntentFilter intentFilter; @Nullable - private PlayerServiceEventListener fragmentListener = null; + private PlayerServiceEventListener fragmentListener; @Nullable - private PlayerEventListener activityListener = null; + private PlayerEventListener activityListener; @NonNull private final SerialDisposable progressUpdateDisposable = new SerialDisposable(); @@ -386,7 +386,7 @@ public void handleIntent(@NonNull final Intent intent) { final boolean playbackSkipSilence = getPrefs().getBoolean(getContext().getString( R.string.playback_skip_silence_key), getPlaybackSkipSilence()); - final boolean samePlayQueue = playQueue != null && playQueue.equalStreamsAndIndex(newQueue); + final boolean samePlayQueue = newQueue.equalStreamsAndIndex(playQueue); final int repeatMode = intent.getIntExtra(REPEAT_MODE, getRepeatMode()); final boolean playWhenReady = intent.getBooleanExtra(PLAY_WHEN_READY, true); final boolean isMuted = intent.getBooleanExtra(IS_MUTED, isMuted()); @@ -636,7 +636,9 @@ private void setRecovery(final int queuePos, final long windowPos) { } if (DEBUG) { - Log.d(TAG, "Setting recovery, queue: " + queuePos + ", pos: " + windowPos); + final var currentTitle = currentItem != null ? currentItem.getTitle() : ""; + Log.d(TAG, "Setting recovery, queue: " + + queuePos + "[" + currentTitle + "], pos: " + windowPos); } playQueue.setRecovery(queuePos, windowPos); } @@ -951,6 +953,34 @@ private Disposable getProgressUpdateDisposable() { //endregion + public static String exoplayerStateToString(final int playbackState) { + return switch (playbackState) { + case com.google.android.exoplayer2.Player.STATE_IDLE -> // 1 + "STATE_IDLE"; + case com.google.android.exoplayer2.Player.STATE_BUFFERING -> // 2 + "STATE_BUFFERING"; + case com.google.android.exoplayer2.Player.STATE_READY -> //3 + "STATE_READY"; + case com.google.android.exoplayer2.Player.STATE_ENDED -> // 4 + "STATE_ENDED"; + default -> + throw new IllegalArgumentException("Unknown playback state " + playbackState); + }; + } + + public static String stateToString(final int state) { + return switch (state) { + case STATE_PREFLIGHT -> "STATE_PREFLIGHT"; + case STATE_BLOCKED -> "STATE_BLOCKED"; + case STATE_PLAYING -> "STATE_PLAYING"; + case STATE_BUFFERING -> "STATE_BUFFERING"; + case STATE_PAUSED -> "STATE_PAUSED"; + case STATE_PAUSED_SEEK -> "STATE_PAUSED_SEEK"; + case STATE_COMPLETED -> "STATE_COMPLETED"; + default -> throw new IllegalArgumentException("Unknown playback state " + state); + }; + } + /*////////////////////////////////////////////////////////////////////////// // Playback states @@ -973,7 +1003,7 @@ public void onPlayWhenReadyChanged(final boolean playWhenReady, final int reason public void onPlaybackStateChanged(final int playbackState) { if (DEBUG) { Log.d(TAG, "ExoPlayer - onPlaybackStateChanged() called with: " - + "playbackState = [" + playbackState + "]"); + + "playbackState = [" + exoplayerStateToString(playbackState) + "]"); } updatePlaybackState(getPlayWhenReady(), playbackState); } @@ -982,7 +1012,7 @@ private void updatePlaybackState(final boolean playWhenReady, final int playback if (DEBUG) { Log.d(TAG, "ExoPlayer - updatePlaybackState() called with: " + "playWhenReady = [" + playWhenReady + "], " - + "playbackState = [" + playbackState + "]"); + + "playbackState = [" + exoplayerStateToString(playbackState) + "]"); } if (currentState == STATE_PAUSED_SEEK) { @@ -1060,7 +1090,8 @@ public void onPlaybackUnblock(final MediaSource mediaSource) { public void changeState(final int state) { if (DEBUG) { - Log.d(TAG, "changeState() called with: state = [" + state + "]"); + Log.d(TAG, + "changeState() called with: state = [" + stateToString(state) + "]"); } currentState = state; switch (state) { @@ -1770,7 +1801,7 @@ private void saveStreamProgressState(final long progressMillis) { .observeOn(AndroidSchedulers.mainThread()) .doOnError(e -> { if (DEBUG) { - e.printStackTrace(); + Log.e(TAG, "Error saving stream state", e); } }) .onErrorComplete() diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/LoggingHttpDataSource.java b/app/src/main/java/org/schabi/newpipe/player/datasource/LoggingHttpDataSource.java new file mode 100644 index 00000000000..c5ffe87330a --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/LoggingHttpDataSource.java @@ -0,0 +1,124 @@ +package org.schabi.newpipe.player.datasource; + +import static org.schabi.newpipe.MainActivity.DEBUG; + +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.android.exoplayer2.upstream.DataSpec; +import com.google.android.exoplayer2.upstream.DefaultHttpDataSource; +import com.google.android.exoplayer2.upstream.HttpDataSource; +import com.google.android.exoplayer2.upstream.TransferListener; + +import org.schabi.newpipe.DownloaderImpl; + +import java.nio.charset.StandardCharsets; +import java.util.Map; + +public class LoggingHttpDataSource extends DefaultHttpDataSource { + + public final String TAG = getClass().getSimpleName() + "@" + hashCode(); + + public LoggingHttpDataSource() { } + + public LoggingHttpDataSource(@Nullable final String userAgent, + final int connectTimeoutMillis, + final int readTimeoutMillis, + final boolean allowCrossProtocolRedirects, + @Nullable final RequestProperties defaultRequestProperties) { + super(userAgent, + connectTimeoutMillis, + readTimeoutMillis, + allowCrossProtocolRedirects, + defaultRequestProperties); + } + + + @Override + public long open(final DataSpec dataSpec) throws HttpDataSourceException { + if (!DEBUG) { + return super.open(dataSpec); + } + + Log.d(TAG, "Request URL: " + dataSpec.uri); + try { + return super.open(dataSpec); + } catch (final HttpDataSource.InvalidResponseCodeException e) { + Log.e(TAG, "HTTP error for URL: " + dataSpec.uri); + Log.e(TAG, "Response code: " + e.responseCode); + Log.e(TAG, "Headers: " + e.headerFields); + Log.e(TAG, "Body: " + new String(e.responseBody, StandardCharsets.UTF_8)); + throw e; + } + } + + @SuppressWarnings("checkstyle:hiddenField") + public static class Factory implements HttpDataSource.Factory { + + final RequestProperties defaultRequestProperties; + + @Nullable + TransferListener transferListener; + @Nullable + String userAgent; + int connectTimeoutMs; + int readTimeoutMs; + boolean allowCrossProtocolRedirects; + + public Factory() { + defaultRequestProperties = new RequestProperties(); + connectTimeoutMs = DEFAULT_CONNECT_TIMEOUT_MILLIS; + readTimeoutMs = DEFAULT_READ_TIMEOUT_MILLIS; + userAgent = DownloaderImpl.USER_AGENT; + } + + @NonNull + @Override + public HttpDataSource createDataSource() { + final var dataSource = new LoggingHttpDataSource(userAgent, + connectTimeoutMs, + readTimeoutMs, + allowCrossProtocolRedirects, + defaultRequestProperties); + if (transferListener != null) { + dataSource.addTransferListener(transferListener); + } + return dataSource; + } + + @NonNull + @Override + public Factory setDefaultRequestProperties( + @NonNull final Map defaultRequestProperties) { + this.defaultRequestProperties.clearAndSet(defaultRequestProperties); + return this; + } + + public Factory setUserAgent(@Nullable final String userAgent) { + this.userAgent = userAgent; + return this; + } + + public Factory setConnectTimeoutMs(final int connectTimeoutMs) { + this.connectTimeoutMs = connectTimeoutMs; + return this; + } + + public Factory setReadTimeoutMs(final int readTimeoutMs) { + this.readTimeoutMs = readTimeoutMs; + return this; + } + + public Factory setAllowCrossProtocolRedirects(final boolean allowCrossProtocolRedirects) { + this.allowCrossProtocolRedirects = allowCrossProtocolRedirects; + return this; + } + + public Factory setTransferListener(@Nullable final TransferListener transferListener) { + this.transferListener = transferListener; + return this; + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/NonUriHlsDataSourceFactory.java b/app/src/main/java/org/schabi/newpipe/player/datasource/NonUriHlsDataSourceFactory.java index 676443a9c78..90ed3af1f4d 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/NonUriHlsDataSourceFactory.java +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/NonUriHlsDataSourceFactory.java @@ -112,7 +112,7 @@ private NonUriHlsDataSourceFactory(@NonNull final DataSource.Factory dataSourceF *

* *

- * This change allow playback of non-URI HLS contents, when the manifest is not a master + * This change allows playback of non-URI HLS contents, when the manifest is not a master * manifest/playlist (otherwise, endless loops should be encountered because the * {@link DataSource}s created for media playlists should use the master playlist response * instead). diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/RefreshableHlsHttpDataSource.java b/app/src/main/java/org/schabi/newpipe/player/datasource/RefreshableHlsHttpDataSource.java new file mode 100644 index 00000000000..929c577c0f4 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/RefreshableHlsHttpDataSource.java @@ -0,0 +1,248 @@ +package org.schabi.newpipe.player.datasource; + +import static org.schabi.newpipe.MainActivity.DEBUG; + +import android.net.Uri; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.android.exoplayer2.source.hls.playlist.HlsMediaPlaylist; +import com.google.android.exoplayer2.source.hls.playlist.HlsPlaylistParser; +import com.google.android.exoplayer2.upstream.DataSourceInputStream; +import com.google.android.exoplayer2.upstream.DataSpec; +import com.google.android.exoplayer2.upstream.HttpDataSource; + +import org.schabi.newpipe.extractor.exceptions.ExtractionException; +import org.schabi.newpipe.extractor.stream.RefreshableStream; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +public class RefreshableHlsHttpDataSource extends LoggingHttpDataSource { + + private final String TAG = + RefreshableHlsHttpDataSource.class.getSimpleName() + "@" + hashCode(); + private final RefreshableStream refreshableStream; + private final String originalPlaylistUrl; + private final Map chunkUrlMap = new LinkedHashMap<>(); + private boolean isError; + + public RefreshableHlsHttpDataSource(final RefreshableStream refreshableStream) { + this.refreshableStream = refreshableStream; + originalPlaylistUrl = refreshableStream.initialUrl(); + } + + @SuppressWarnings("checkstyle:LineLength") + public RefreshableHlsHttpDataSource(final RefreshableStream refreshableStream, + @Nullable final String userAgent, + final int connectTimeoutMillis, + final int readTimeoutMillis, + final boolean allowCrossProtocolRedirects, + @Nullable final RequestProperties defaultRequestProperties) { + super(userAgent, + connectTimeoutMillis, + readTimeoutMillis, + allowCrossProtocolRedirects, + defaultRequestProperties); + this.refreshableStream = refreshableStream; + originalPlaylistUrl = refreshableStream.initialUrl(); + } + + @Override + public long open(final DataSpec dataSpec) throws HttpDataSourceException { + final var url = dataSpec.uri.toString(); + if (DEBUG) { + Log.d(TAG, "called open(" + url + ")"); + } + + if (!url.contains(refreshableStream.playlistId())) { + // TODO: throw error or no? + if (DEBUG) { + Log.e(TAG, "Playlist id does not match"); + } + } + return chunkUrlMap.isEmpty() + ? openInternal(dataSpec) + : openInternal(getUpdatedDataSpec(dataSpec)); + } + + private long openInternal(final DataSpec dataSpec) throws HttpDataSourceException { + try { + final var bytesToRead = super.open(dataSpec); + if (DEBUG) { + Log.d(TAG, "Bytes to read: " + bytesToRead); + } + isError = false; // if we got to this line there was no error + return bytesToRead; + } catch (final InvalidResponseCodeException e) { + // TODO: This assumes SoundCloud returning 403 when playlist expires + // If we need to refresh playlists for other services at a later date then + // need to generalize this class + if (isError || e.responseCode != 403) { + // Use isError to prevent infinite loop if playlist expires, we replace signature + // but then that one gives an error, and then we replace signature again, and so on + // The expectation is that no error will be thrown on the first recursion + throw e; + } + + try { + refreshPlaylist(); + } catch (final ExtractionException | IOException ex) { + throw new HttpDataSourceException("Error refreshing Hls playlist: " + + originalPlaylistUrl, + new IOException(ex), dataSpec, + HttpDataSourceException.TYPE_OPEN); + } + isError = true; + // Use recursion to reuse error handling without code duplication + return openInternal(getUpdatedDataSpec(dataSpec)); + } + } + + private void refreshPlaylist() throws ExtractionException, IOException { + if (DEBUG) { + Log.d(TAG, "refreshPlaylist() - originalPlaylistUrl " + originalPlaylistUrl); + } + + final var newPlaylistUrl = refreshableStream.fetchLatestUrl(); + + if (DEBUG) { + Log.d(TAG, "New playlist url " + newPlaylistUrl); + Log.d(TAG, "Extracting new playlist Chunks"); + } + final var newChunks = extractChunksFromPlaylist(newPlaylistUrl); + + if (!chunkUrlMap.isEmpty()) { + updateChunkMap(chunkUrlMap, newChunks); + } + initializeChunkMappings(chunkUrlMap, newChunks); + } + + private static void initializeChunkMappings(final Map chunkMap, + final List newChunks) { + for (int i = 0; i < newChunks.size(); ++i) { + final var newUrl = newChunks.get(i); + chunkMap.put(removeQueryParameters(newUrl), newUrl); + } + } + + private static void updateChunkMap(final Map chunkMap, + final List newChunks) throws IOException { + if (chunkMap.size() != newChunks.size()) { + throw new IOException("Error extracting chunks: chunks are not same size\n" + + "Expected " + chunkMap.size() + + " and got " + newChunks.size()); + } + + final var baseUrlIt = chunkMap.keySet().iterator(); + final var newChunkUrlIt = newChunks.iterator(); + while (baseUrlIt.hasNext()) { + chunkMap.put(baseUrlIt.next(), newChunkUrlIt.next()); + } + } + + private static String removeQueryParameters(final String url) { + final int idx = url.indexOf('?'); + return idx == -1 ? url : url.substring(0, idx); + } + + private DataSpec getUpdatedDataSpec(final DataSpec dataSpec) { + final var currentUrl = dataSpec.uri.toString(); + if (DEBUG) { + Log.d(TAG, "getUpdatedDataSpec(" + currentUrl + ')'); + } + + final var baseUrl = removeQueryParameters(currentUrl); + + if (baseUrl.equals(currentUrl)) { + if (DEBUG) { + Log.e(TAG, "Url has no query parameters"); + } + } + + final var updatedUrl = chunkUrlMap.get(baseUrl); + if (updatedUrl == null) { + throw new IllegalStateException("baseUrl not found in mappings: " + baseUrl); + } + if (DEBUG) { + Log.d(TAG, "updated url:" + updatedUrl); + } + return dataSpec.buildUpon() + .setUri(Uri.parse(updatedUrl)) + .build(); + } + + /** + * Extracts the chunks/segments from an m3u8 playlist using + * ExoPlayer's {@link HlsPlaylistParser}. + * @param playlistUrl url of m3u8 playlist to extract + * @return Urls for all the chunks/segments in the playlist + * @throws IOException If error extracting the chunks + */ + private List extractChunksFromPlaylist(final String playlistUrl) + throws IOException { + if (DEBUG) { + Log.d(TAG, "extractChunksFromPlaylist(" + playlistUrl + ')'); + } + final var chunks = new ArrayList(); + final var parser = new HlsPlaylistParser(); + final var dataSpec = new DataSpec(Uri.parse(playlistUrl)); + final var httpDataSource = new LoggingHttpDataSource.Factory().createDataSource(); + + // Adapted from ParsingLoadable.load() + // DataSourceInputStream opens the data source internally on open() + // It passes dataSpec to data source + // httpDataSource is a DefaultHttpDataSource, and getUri will return dataSpec's uri + // which == playlistUrl + try (@SuppressWarnings("LocalCanBeFinal") + var inputStream = new DataSourceInputStream(httpDataSource, dataSpec)) { + inputStream.open(); + + final var playlist = + parser.parse(Objects.requireNonNull(httpDataSource.getUri()), inputStream); + + if (!(playlist instanceof final HlsMediaPlaylist hlsMediaPlaylist)) { + throw new IOException("Expected Hls playlist to be an HlsMediaPlaylist, but was a " + + playlist.getClass().getSimpleName()); + } + + for (final var segment : hlsMediaPlaylist.segments) { + chunks.add(segment.url); + } + + if (DEBUG) { + Log.d(TAG, "Extracted " + chunks.size() + " chunks"); + chunks.stream().forEach(m -> Log.d(TAG, "Chunk " + m)); + } + + return chunks; + } finally { + httpDataSource.close(); + } + } + + public static class Factory extends LoggingHttpDataSource.Factory { + private final RefreshableStream refreshableStream; + + public Factory(final RefreshableStream refreshableStream) { + this.refreshableStream = refreshableStream; + } + + @NonNull + @Override + public HttpDataSource createDataSource() { + return new RefreshableHlsHttpDataSource(refreshableStream, + userAgent, + connectTimeoutMs, + readTimeoutMs, + allowCrossProtocolRedirects, + defaultRequestProperties); + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerDataSource.java b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerDataSource.java index 0530d56e921..ebdea575796 100644 --- a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerDataSource.java +++ b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerDataSource.java @@ -5,7 +5,7 @@ import android.content.Context; import android.util.Log; -import androidx.annotation.Nullable; +import androidx.annotation.NonNull; import com.google.android.exoplayer2.database.StandaloneDatabaseProvider; import com.google.android.exoplayer2.source.ProgressiveMediaSource; @@ -18,16 +18,19 @@ import com.google.android.exoplayer2.source.smoothstreaming.SsMediaSource; import com.google.android.exoplayer2.upstream.DataSource; import com.google.android.exoplayer2.upstream.DefaultDataSource; -import com.google.android.exoplayer2.upstream.DefaultHttpDataSource; import com.google.android.exoplayer2.upstream.TransferListener; import com.google.android.exoplayer2.upstream.cache.LeastRecentlyUsedCacheEvictor; import com.google.android.exoplayer2.upstream.cache.SimpleCache; -import org.schabi.newpipe.DownloaderImpl; +import org.jetbrains.annotations.Contract; import org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators.YoutubeOtfDashManifestCreator; import org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators.YoutubePostLiveStreamDvrDashManifestCreator; import org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators.YoutubeProgressiveDashManifestCreator; +import org.schabi.newpipe.extractor.stream.RefreshableStream; +import org.schabi.newpipe.extractor.stream.Stream; +import org.schabi.newpipe.player.datasource.LoggingHttpDataSource; import org.schabi.newpipe.player.datasource.NonUriHlsDataSourceFactory; +import org.schabi.newpipe.player.datasource.RefreshableHlsHttpDataSource; import org.schabi.newpipe.player.datasource.YoutubeHttpDataSource; import java.io.File; @@ -78,27 +81,32 @@ public class PlayerDataSource { private final CacheFactory ytProgressiveDashCacheDataSourceFactory; + private final Context context; + private final TransferListener transferListener; + + public PlayerDataSource(final Context context, final TransferListener transferListener) { - + this.context = context; + this.transferListener = transferListener; progressiveLoadIntervalBytes = PlayerHelper.getProgressiveLoadIntervalBytes(context); // make sure the static cache was created: needed by CacheFactories below instantiateCacheIfNeeded(context); - // generic data source factories use DefaultHttpDataSource.Factory + // generic data source factories use LoggingHttpDataSource.Factory, which is a wrapper + // around DefaultHttpDataSource cachelessDataSourceFactory = new DefaultDataSource.Factory(context, - new DefaultHttpDataSource.Factory().setUserAgent(DownloaderImpl.USER_AGENT)) + new LoggingHttpDataSource.Factory()) .setTransferListener(transferListener); - cacheDataSourceFactory = new CacheFactory(context, transferListener, cache, - new DefaultHttpDataSource.Factory().setUserAgent(DownloaderImpl.USER_AGENT)); + cacheDataSourceFactory = createCacheDataSourceFactory(new LoggingHttpDataSource.Factory()); // YouTube-specific data source factories use getYoutubeHttpDataSourceFactory() - ytHlsCacheDataSourceFactory = new CacheFactory(context, transferListener, cache, + ytHlsCacheDataSourceFactory = createCacheDataSourceFactory( getYoutubeHttpDataSourceFactory(false, false)); - ytDashCacheDataSourceFactory = new CacheFactory(context, transferListener, cache, + ytDashCacheDataSourceFactory = createCacheDataSourceFactory( getYoutubeHttpDataSourceFactory(true, true)); - ytProgressiveDashCacheDataSourceFactory = new CacheFactory(context, transferListener, cache, + ytProgressiveDashCacheDataSourceFactory = createCacheDataSourceFactory( getYoutubeHttpDataSourceFactory(false, true)); // set the maximum size to manifest creators @@ -108,6 +116,11 @@ public PlayerDataSource(final Context context, MAX_MANIFEST_CACHE_SIZE); } + @NonNull + @Contract(value = "_ -> new", pure = true) + private CacheFactory createCacheDataSourceFactory(final DataSource.Factory wrappedFactory) { + return new CacheFactory(context, transferListener, cache, wrappedFactory); + } //region Live media source factories public SsMediaSource.Factory getLiveSsMediaSourceFactory() { @@ -134,12 +147,23 @@ public DashMediaSource.Factory getLiveDashMediaSourceFactory() { //region Generic media source factories public HlsMediaSource.Factory getHlsMediaSourceFactory( - @Nullable final NonUriHlsDataSourceFactory.Builder hlsDataSourceFactoryBuilder) { - if (hlsDataSourceFactoryBuilder != null) { - hlsDataSourceFactoryBuilder.setDataSourceFactory(cacheDataSourceFactory); - return new HlsMediaSource.Factory(hlsDataSourceFactoryBuilder.build()); + @NonNull final NonUriHlsDataSourceFactory.Builder hlsDataSourceFactoryBuilder) { + hlsDataSourceFactoryBuilder.setDataSourceFactory(cacheDataSourceFactory); + return new HlsMediaSource.Factory(hlsDataSourceFactoryBuilder.build()); + } + + public HlsMediaSource.Factory getHlsMediaSourceFactory(final Stream stream) { + if (stream instanceof final RefreshableStream refreshableStream) { + return new HlsMediaSource.Factory( + createCacheDataSourceFactory( + new RefreshableHlsHttpDataSource.Factory(refreshableStream) + ) + ); } + return getHlsMediaSourceFactory(); + } + public HlsMediaSource.Factory getHlsMediaSourceFactory() { return new HlsMediaSource.Factory(cacheDataSourceFactory); } diff --git a/app/src/main/java/org/schabi/newpipe/player/mediasession/MediaSessionPlayerUi.java b/app/src/main/java/org/schabi/newpipe/player/mediasession/MediaSessionPlayerUi.java index fe884834bc3..abc3c1844ad 100644 --- a/app/src/main/java/org/schabi/newpipe/player/mediasession/MediaSessionPlayerUi.java +++ b/app/src/main/java/org/schabi/newpipe/player/mediasession/MediaSessionPlayerUi.java @@ -36,7 +36,7 @@ public class MediaSessionPlayerUi extends PlayerUi implements SharedPreferences.OnSharedPreferenceChangeListener { - private static final String TAG = "MediaSessUi"; + private static final String TAG = MediaSessionPlayerUi.class.getSimpleName(); @NonNull private final MediaSessionCompat mediaSession; diff --git a/app/src/main/java/org/schabi/newpipe/player/playback/MediaSourceManager.java b/app/src/main/java/org/schabi/newpipe/player/playback/MediaSourceManager.java index 88d7145bceb..67eec757cee 100644 --- a/app/src/main/java/org/schabi/newpipe/player/playback/MediaSourceManager.java +++ b/app/src/main/java/org/schabi/newpipe/player/playback/MediaSourceManager.java @@ -9,6 +9,7 @@ import org.reactivestreams.Subscriber; import org.reactivestreams.Subscription; +import org.schabi.newpipe.extractor.StreamingServiceId; import org.schabi.newpipe.extractor.exceptions.ExtractionException; import org.schabi.newpipe.player.mediaitem.MediaItemTag; import org.schabi.newpipe.player.mediasource.FailedMediaSource; @@ -114,6 +115,9 @@ public class MediaSourceManager { @NonNull private final CompositeDisposable loaderReactor; + /** + * The items that are currently being loaded. + */ @NonNull private final Set loadingItems; @@ -146,6 +150,20 @@ private MediaSourceManager(@NonNull final PlaybackListener listener, + " ms] for them to be useful."); } + if (DEBUG) { + final var currentItem = playQueue.getItem(); + + if (currentItem != null) { + Log.d(TAG, "Creating MediaSourceManager[" + + StreamingServiceId.nameFromId(currentItem.getServiceId()) + + " currentIndex=" + playQueue.getIndex() + + " currentTitle=" + currentItem.getTitle()); + } else { + Log.d(TAG, + "Creating MediaSourceManager[currentIndex=" + playQueue.getIndex() + "]"); + } + } + this.playbackListener = listener; this.playQueue = playQueue; @@ -180,13 +198,15 @@ private MediaSourceManager(@NonNull final PlaybackListener listener, */ public void dispose() { if (DEBUG) { - Log.d(TAG, "close() called."); + Log.d(TAG, "dispose() called."); } debouncedSignal.onComplete(); debouncedLoader.dispose(); playQueueReactor.cancel(); + //TODO: Why not clear here? + //TODO: Also why not clear loadingItems here? loaderReactor.dispose(); } @@ -483,7 +503,7 @@ private void onMediaSourceReceived(@NonNull final PlayQueueItem item, * readiness or playlist desynchronization. *

* If the given {@link PlayQueueItem} is currently being played and is already loaded, - * then correction is not only needed if the playlist is desynchronized. Otherwise, the + * then correction is only needed if the playlist is desynchronized. Otherwise, the * check depends on the status (e.g. expiration or placeholder) of the * {@link ManagedMediaSource}. *

@@ -530,10 +550,12 @@ private void maybeRenewCurrentIndex() { } private void maybeClearLoaders() { + final var currentItem = playQueue.getItem(); if (DEBUG) { - Log.d(TAG, "MediaSource - maybeClearLoaders() called."); + final var url = currentItem != null ? currentItem.getUrl() : ""; + Log.d(TAG, "MediaSource - maybeClearLoaders() called. currentItem: " + url); } - if (!loadingItems.contains(playQueue.getItem()) + if (!loadingItems.contains(currentItem) && loaderReactor.size() > MAXIMUM_LOADER_SIZE) { loaderReactor.clear(); loadingItems.clear(); diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueue.java b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueue.java index cfa2ab3162c..1cb2a25eb72 100644 --- a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueue.java +++ b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueue.java @@ -538,11 +538,8 @@ public boolean equalStreams(@Nullable final PlayQueue other) { } public boolean equalStreamsAndIndex(@Nullable final PlayQueue other) { - if (equalStreams(other)) { - //noinspection ConstantConditions - return other.getIndex() == getIndex(); //NOSONAR: other is not null - } - return false; + //noinspection ConstantConditions + return equalStreams(other) && other.getIndex() == getIndex(); //NOSONAR: other is not null } public boolean isDisposed() { diff --git a/app/src/main/java/org/schabi/newpipe/player/resolver/PlaybackResolver.java b/app/src/main/java/org/schabi/newpipe/player/resolver/PlaybackResolver.java index e204b8372a7..eff9ae5a23d 100644 --- a/app/src/main/java/org/schabi/newpipe/player/resolver/PlaybackResolver.java +++ b/app/src/main/java/org/schabi/newpipe/player/resolver/PlaybackResolver.java @@ -335,7 +335,7 @@ private static HlsMediaSource buildHlsMediaSource(final PlayerDataSource dataSou throws ResolverException { if (stream.isUrl()) { throwResolverExceptionIfUrlNullOrEmpty(stream.getContent()); - return dataSource.getHlsMediaSourceFactory(null).createMediaSource( + return dataSource.getHlsMediaSourceFactory(stream).createMediaSource( new MediaItem.Builder() .setTag(metadata) .setUri(Uri.parse(stream.getContent())) @@ -343,8 +343,7 @@ private static HlsMediaSource buildHlsMediaSource(final PlayerDataSource dataSou .build()); } - final NonUriHlsDataSourceFactory.Builder hlsDataSourceFactoryBuilder = - new NonUriHlsDataSourceFactory.Builder(); + final var hlsDataSourceFactoryBuilder = new NonUriHlsDataSourceFactory.Builder(); hlsDataSourceFactoryBuilder.setPlaylistString(stream.getContent()); return dataSource.getHlsMediaSourceFactory(hlsDataSourceFactoryBuilder) diff --git a/app/src/main/java/org/schabi/newpipe/util/InfoCache.java b/app/src/main/java/org/schabi/newpipe/util/InfoCache.java index b9c91f8a5b0..e8a91f6ea55 100644 --- a/app/src/main/java/org/schabi/newpipe/util/InfoCache.java +++ b/app/src/main/java/org/schabi/newpipe/util/InfoCache.java @@ -27,8 +27,11 @@ import org.schabi.newpipe.MainActivity; import org.schabi.newpipe.extractor.Info; +import org.schabi.newpipe.extractor.StreamingServiceId; -import java.util.Map; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; public final class InfoCache { private final String TAG = getClass().getSimpleName(); @@ -71,23 +74,23 @@ private static String keyOf(final int serviceId, } private static void removeStaleCache() { - for (final Map.Entry entry : InfoCache.LRU_CACHE.snapshot().entrySet()) { + for (final var entry : LRU_CACHE.snapshot().entrySet()) { final CacheData data = entry.getValue(); if (data != null && data.isExpired()) { - InfoCache.LRU_CACHE.remove(entry.getKey()); + LRU_CACHE.remove(entry.getKey()); } } } @Nullable private static Info getInfo(@NonNull final String key) { - final CacheData data = InfoCache.LRU_CACHE.get(key); + final CacheData data = LRU_CACHE.get(key); if (data == null) { return null; } if (data.isExpired()) { - InfoCache.LRU_CACHE.remove(key); + LRU_CACHE.remove(key); return null; } @@ -100,22 +103,29 @@ public Info getFromKey(final int serviceId, @NonNull final Type cacheType) { if (DEBUG) { Log.d(TAG, "getFromKey() called with: " - + "serviceId = [" + serviceId + "], url = [" + url + "]"); + + StreamingServiceId.nameFromId(serviceId) + "[" + url + "]"); } synchronized (LRU_CACHE) { return getInfo(keyOf(serviceId, url, cacheType)); } } + @SuppressWarnings("checkStyle:linelength") public void putInfo(final int serviceId, @NonNull final String url, @NonNull final Info info, @NonNull final Type cacheType) { + final long expirationMillis = ServiceHelper.getCacheExpirationMillis(info.getServiceId()); if (DEBUG) { - Log.d(TAG, "putInfo() called with: info = [" + info + "]"); + final var expiryDateInstant = Instant.now().plusMillis(expirationMillis); + final var expiryDate = LocalDateTime.ofInstant(expiryDateInstant, + ZoneId.systemDefault()); + Log.d(TAG, "putInfo(): add to cache " + StreamingServiceId.nameFromId(serviceId) + " " + + cacheType.name() + + " expires on " + expiryDate + + " " + info); } - final long expirationMillis = ServiceHelper.getCacheExpirationMillis(info.getServiceId()); synchronized (LRU_CACHE) { final CacheData data = new CacheData(info, expirationMillis); LRU_CACHE.put(keyOf(serviceId, url, cacheType), data); diff --git a/app/src/main/java/org/schabi/newpipe/util/ListHelper.java b/app/src/main/java/org/schabi/newpipe/util/ListHelper.java index 282a88b1eaf..ebb486a1f5a 100644 --- a/app/src/main/java/org/schabi/newpipe/util/ListHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/ListHelper.java @@ -293,7 +293,7 @@ public static boolean isHighResolutionSelected(final String selectedResolution, public static List getFilteredAudioStreams( @NonNull final Context context, @Nullable final List audioStreams) { - if (audioStreams == null) { + if (audioStreams == null || audioStreams.isEmpty()) { return Collections.emptyList(); } @@ -302,6 +302,9 @@ public static List getFilteredAudioStreams( final Comparator cmp = getAudioFormatComparator(context); for (final AudioStream stream : audioStreams) { + // TODO: this doesn't add HLS OPUS streams, but soundcloud has that. + // Meaning it never actually plays the OPUS soundcloud streams, only + // progressive and hls mp3. So should we change this to allow HLS OPUS? if (stream.getDeliveryMethod() == DeliveryMethod.TORRENT || (stream.getDeliveryMethod() == DeliveryMethod.HLS && stream.getFormat() == MediaFormat.OPUS)) { diff --git a/app/src/main/java/org/schabi/newpipe/util/SparseItemUtil.java b/app/src/main/java/org/schabi/newpipe/util/SparseItemUtil.java index 6e9ea7a47e7..99bf52350cc 100644 --- a/app/src/main/java/org/schabi/newpipe/util/SparseItemUtil.java +++ b/app/src/main/java/org/schabi/newpipe/util/SparseItemUtil.java @@ -32,7 +32,7 @@ private SparseItemUtil() { } /** - * Use this to certainly obtain an single play queue with all of the data filled in when the + * Use this to certainly obtain a single play queue with all of the data filled in when the * stream info item you are handling might be sparse, e.g. because it was fetched via a {@link * org.schabi.newpipe.extractor.feed.FeedExtractor}. FeedExtractors provide a fast and * lightweight method to fetch info, but the info might be incomplete (see diff --git a/checkstyle/checkstyle.xml b/checkstyle/checkstyle.xml index ee091fa9f44..ff2554f77de 100644 --- a/checkstyle/checkstyle.xml +++ b/checkstyle/checkstyle.xml @@ -70,6 +70,8 @@ + + diff --git a/settings.gradle b/settings.gradle index 0338fde6c55..b878803369a 100644 --- a/settings.gradle +++ b/settings.gradle @@ -6,6 +6,6 @@ include ':app' //includeBuild('../NewPipeExtractor') { // dependencySubstitution { -// substitute module('com.github.TeamNewPipe:NewPipeExtractor') using project(':extractor') +// substitute module('com.github.TeamNewPipe.NewPipeExtractor:NewPipeExtractor') using project(':extractor') // } //}