diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 30f0e80..5ac8e8f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -58,6 +58,7 @@ dependencies { implementation("com.google.android.material:material:1.11.0") implementation("androidx.constraintlayout:constraintlayout:2.1.4") implementation("androidx.media3:media3-session:1.3.1") + testImplementation("junit:junit:4.13.2") androidTestImplementation("androidx.test.ext:junit:1.1.5") androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") @@ -71,8 +72,12 @@ dependencies { implementation("com.github.lihangleo2:ShadowLayout:3.4.0") implementation("androidx.media3:media3-exoplayer:1.3.1") + implementation("androidx.media3:media3-exoplayer-dash:1.3.1") implementation("androidx.media3:media3-ui:1.3.1") implementation("androidx.media3:media3-common:1.3.1") +// implementation("com.android.tools.compose:compose-preview-renderer:0.0.1-alpha01") +// implementation("org.chromium.net:cronet-api:119.6045.31") + implementation("androidx.media3:media3-datasource-cronet:1.3.1") implementation("io.coil-kt:coil-compose:2.6.0") implementation("io.ktor:ktor-client-core:2.3.8") diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 0f751ca..d462a8d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -8,6 +8,7 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/com/player/musicoo/activity/MoPlayDetailsActivity.kt b/app/src/main/java/com/player/musicoo/activity/MoPlayDetailsActivity.kt index 1d48a3b..e3c22d1 100644 --- a/app/src/main/java/com/player/musicoo/activity/MoPlayDetailsActivity.kt +++ b/app/src/main/java/com/player/musicoo/activity/MoPlayDetailsActivity.kt @@ -9,10 +9,13 @@ import android.os.Message import android.view.View import android.view.animation.AnimationUtils import androidx.annotation.OptIn +import androidx.core.net.toUri import androidx.media3.common.MediaItem import androidx.media3.common.PlaybackException import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.offline.DownloadRequest +import androidx.media3.exoplayer.offline.DownloadService import androidx.recyclerview.widget.LinearLayoutManager import com.bumptech.glide.Glide import com.bumptech.glide.request.target.CustomTarget @@ -22,9 +25,17 @@ import com.player.musicoo.R import com.player.musicoo.adapter.PlayListAdapter import com.player.musicoo.databinding.ActivityMoPlayDetailsBinding import com.player.musicoo.innertube.Innertube +import com.player.musicoo.innertube.models.bodies.PlayerBody +import com.player.musicoo.innertube.requests.player import com.player.musicoo.media.MediaControllerManager import com.player.musicoo.media.SongRadio +import com.player.musicoo.service.LoginRequiredException +import com.player.musicoo.service.MyDownloadService +import com.player.musicoo.service.PlayableFormatNotFoundException +import com.player.musicoo.service.UnplayableException +import com.player.musicoo.service.VideoIdMismatchException import com.player.musicoo.sp.AppStore +import com.player.musicoo.util.DemoUtil import com.player.musicoo.util.LogTag.LogD import com.player.musicoo.util.PlayMode import com.player.musicoo.util.asMediaItem @@ -32,6 +43,7 @@ import com.player.musicoo.util.convertMillisToMinutesAndSecondsString import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.selects.select @OptIn(UnstableApi::class) @@ -299,6 +311,61 @@ class MoPlayDetailsActivity : MoBaseActivity(), Player.Listener { } } } + + binding.downloadBtn.setOnClickListener { + if (meController != null && meController.currentMediaItem != null) { + val contentId = meController.currentMediaItem?.mediaId!! + + val downloadManager = DemoUtil.getDownloadManager(this) + val downloadIndex = downloadManager.downloadIndex + downloadIndex.getDownloads() + .use { cursor -> + while (cursor.moveToNext()) { + val download = cursor.download + if(download.request.id == contentId){ + return@setOnClickListener + } + } + } + + LogD(TAG, "download get contentUrl") + val urlResult = runBlocking(Dispatchers.IO) { + Innertube.player(PlayerBody(videoId = contentId)) + }?.mapCatching { body -> + if (body.videoDetails?.videoId != contentId) { + throw VideoIdMismatchException() + } + + when (val status = body.playabilityStatus?.status) { + "OK" -> body.streamingData?.highestQualityFormat?.let { format -> + format.url + } ?: throw PlayableFormatNotFoundException() + + "UNPLAYABLE" -> throw UnplayableException() + "LOGIN_REQUIRED" -> throw LoginRequiredException() + else -> throw PlaybackException( + status, + null, + PlaybackException.ERROR_CODE_REMOTE_ERROR + ) + } + } + + urlResult?.getOrThrow()?.let { url -> + val contentUrl = url.toUri() + LogD(TAG, "download contentUrl->${contentUrl}") + val downloadRequest = DownloadRequest.Builder(contentId, contentUrl) + .build() + DownloadService.sendAddDownload( + this, + MyDownloadService::class.java, + downloadRequest, + false + ) + } + } + + } } private fun updatePlayModeUi() { diff --git a/app/src/main/java/com/player/musicoo/service/MyDownloadService.kt b/app/src/main/java/com/player/musicoo/service/MyDownloadService.kt new file mode 100644 index 0000000..e4d80f5 --- /dev/null +++ b/app/src/main/java/com/player/musicoo/service/MyDownloadService.kt @@ -0,0 +1,100 @@ +package com.player.musicoo.service + +import android.annotation.SuppressLint +import android.app.Notification +import android.content.Context +import androidx.annotation.OptIn +import androidx.media3.common.util.NotificationUtil +import androidx.media3.common.util.UnstableApi +import androidx.media3.common.util.Util +import androidx.media3.exoplayer.offline.Download +import androidx.media3.exoplayer.offline.DownloadManager +import androidx.media3.exoplayer.offline.DownloadNotificationHelper +import androidx.media3.exoplayer.offline.DownloadService +import androidx.media3.exoplayer.scheduler.PlatformScheduler +import com.player.musicoo.R +import com.player.musicoo.util.DemoUtil + + +@OptIn(UnstableApi::class) +class MyDownloadService : DownloadService( + FOREGROUND_NOTIFICATION_ID, + DEFAULT_FOREGROUND_NOTIFICATION_UPDATE_INTERVAL, + DOWNLOAD_NOTIFICATION_CHANNEL_ID, + R.string.downloads, + 0 +) { + + companion object { + private const val JOB_ID = 1 + private const val FOREGROUND_NOTIFICATION_ID = 1 + private const val DOWNLOAD_NOTIFICATION_CHANNEL_ID = "download_channel" + + } + + @SuppressLint("ServiceCast") + override fun getDownloadManager(): DownloadManager { + val downloadManager = DemoUtil.getDownloadManager(this) + val downloadNotificationHelper = DemoUtil.getDownloadNotificationHelper(this) + downloadManager.addListener( + TerminalStateNotificationHelper( + this, downloadNotificationHelper, FOREGROUND_NOTIFICATION_ID + 1 + ) + ) + return downloadManager + } + + override fun getScheduler(): PlatformScheduler? { + return if (Util.SDK_INT >= 21) PlatformScheduler(this, JOB_ID) else null + } + + override fun getForegroundNotification( + downloads: MutableList, + notMetRequirements: Int + ): Notification { + return DownloadNotificationHelper(this, DOWNLOAD_NOTIFICATION_CHANNEL_ID) + .buildProgressNotification( + this, + R.mipmap.musicoo_logo_img, + null, + null, + downloads, + notMetRequirements + ) + } + + + private class TerminalStateNotificationHelper( + private val context: Context, + private val notificationHelper: DownloadNotificationHelper, + private var nextNotificationId: Int + ) : + DownloadManager.Listener { + + override fun onDownloadChanged( + downloadManager: DownloadManager, + download: Download, + finalException: Exception? + ) { + val notification: Notification = if (download.state == Download.STATE_COMPLETED) { + notificationHelper.buildDownloadCompletedNotification( + context, + R.mipmap.ic_download_done, + null, + Util.fromUtf8Bytes(download.request.data) + ) + } else if (download.state == Download.STATE_FAILED) { + notificationHelper.buildDownloadFailedNotification( + context, + R.mipmap.ic_download_done, + null, + Util.fromUtf8Bytes(download.request.data) + ) + } else { + return + } + NotificationUtil.setNotification(context, nextNotificationId++, notification) + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/player/musicoo/util/DemoUtil.java b/app/src/main/java/com/player/musicoo/util/DemoUtil.java new file mode 100644 index 0000000..887dbff --- /dev/null +++ b/app/src/main/java/com/player/musicoo/util/DemoUtil.java @@ -0,0 +1,182 @@ +package com.player.musicoo.util; +import android.content.Context; + +import org.chromium.net.CronetEngine; + +import androidx.annotation.Nullable; +import androidx.annotation.OptIn; +import androidx.media3.database.DatabaseProvider; +import androidx.media3.database.StandaloneDatabaseProvider; +import androidx.media3.datasource.DataSource; +import androidx.media3.datasource.DefaultDataSource; +import androidx.media3.datasource.DefaultHttpDataSource; +import androidx.media3.datasource.cache.Cache; +import androidx.media3.datasource.cache.CacheDataSource; +import androidx.media3.datasource.cache.NoOpCacheEvictor; +import androidx.media3.datasource.cache.SimpleCache; +import androidx.media3.datasource.cronet.CronetDataSource; +import androidx.media3.datasource.cronet.CronetUtil; +import androidx.media3.exoplayer.DefaultRenderersFactory; +import androidx.media3.exoplayer.RenderersFactory; +import androidx.media3.exoplayer.offline.DownloadManager; +import androidx.media3.exoplayer.offline.DownloadNotificationHelper; +import java.io.File; +import java.net.CookieHandler; +import java.net.CookieManager; +import java.net.CookiePolicy; +import java.util.concurrent.Executors; + +/** Utility methods for the demo app. */ +public final class DemoUtil { + + public static final String DOWNLOAD_NOTIFICATION_CHANNEL_ID = "download_channel"; + + /** + * Whether the demo application uses Cronet for networking. Note that Cronet does not provide + * automatic support for cookies (https://github.com/google/ExoPlayer/issues/5975). + * + *

If set to false, the platform's default network stack is used with a {@link CookieManager} + * configured in {@link #getHttpDataSourceFactory}. + */ + private static final boolean USE_CRONET_FOR_NETWORKING = true; + + private static final String TAG = "DemoUtil"; + private static final String DOWNLOAD_CONTENT_DIRECTORY = "downloads"; + + private static DataSource.Factory dataSourceFactory; + private static DataSource.Factory httpDataSourceFactory; + private static DatabaseProvider databaseProvider; + private static File downloadDirectory; + private static Cache downloadCache; + private static DownloadManager downloadManager; +// private static @MonotonicNonNull DownloadTracker downloadTracker; + private static DownloadNotificationHelper downloadNotificationHelper; + + /** Returns whether extension renderers should be used. */ + public static boolean useExtensionRenderers() { + return true; + } + + @OptIn(markerClass = androidx.media3.common.util.UnstableApi.class) + public static RenderersFactory buildRenderersFactory( + Context context, boolean preferExtensionRenderer) { + @DefaultRenderersFactory.ExtensionRendererMode + int extensionRendererMode = + useExtensionRenderers() + ? (preferExtensionRenderer + ? DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER + : DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON) + : DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF; + return new DefaultRenderersFactory(context.getApplicationContext()) + .setExtensionRendererMode(extensionRendererMode); + } + + public static synchronized DataSource.Factory getHttpDataSourceFactory(Context context) { + if (httpDataSourceFactory == null) { + if (USE_CRONET_FOR_NETWORKING) { + context = context.getApplicationContext(); + @Nullable CronetEngine cronetEngine = CronetUtil.buildCronetEngine(context); + if (cronetEngine != null) { + httpDataSourceFactory = + new CronetDataSource.Factory(cronetEngine, Executors.newSingleThreadExecutor()); + } + } + if (httpDataSourceFactory == null) { + // We don't want to use Cronet, or we failed to instantiate a CronetEngine. + CookieManager cookieManager = new CookieManager(); + cookieManager.setCookiePolicy(CookiePolicy.ACCEPT_ORIGINAL_SERVER); + CookieHandler.setDefault(cookieManager); + httpDataSourceFactory = new DefaultHttpDataSource.Factory(); + } + } + return httpDataSourceFactory; + } + + /** Returns a {@link DataSource.Factory}. */ + public static synchronized DataSource.Factory getDataSourceFactory(Context context) { + if (dataSourceFactory == null) { + context = context.getApplicationContext(); + DefaultDataSource.Factory upstreamFactory = + new DefaultDataSource.Factory(context, getHttpDataSourceFactory(context)); + dataSourceFactory = buildReadOnlyCacheDataSource(upstreamFactory, getDownloadCache(context)); + } + return dataSourceFactory; + } + + @OptIn(markerClass = androidx.media3.common.util.UnstableApi.class) + public static synchronized DownloadNotificationHelper getDownloadNotificationHelper( + Context context) { + if (downloadNotificationHelper == null) { + downloadNotificationHelper = + new DownloadNotificationHelper(context, DOWNLOAD_NOTIFICATION_CHANNEL_ID); + } + return downloadNotificationHelper; + } + + public static synchronized DownloadManager getDownloadManager(Context context) { + ensureDownloadManagerInitialized(context); + return downloadManager; + } + +// public static synchronized DownloadTracker getDownloadTracker(Context context) { +// ensureDownloadManagerInitialized(context); +// return downloadTracker; +// } + + @OptIn(markerClass = androidx.media3.common.util.UnstableApi.class) + private static synchronized Cache getDownloadCache(Context context) { + if (downloadCache == null) { + File downloadContentDirectory = + new File(getDownloadDirectory(context), DOWNLOAD_CONTENT_DIRECTORY); + downloadCache = + new SimpleCache( + downloadContentDirectory, new NoOpCacheEvictor(), getDatabaseProvider(context)); + } + return downloadCache; + } + + @OptIn(markerClass = androidx.media3.common.util.UnstableApi.class) + private static synchronized void ensureDownloadManagerInitialized(Context context) { + if (downloadManager == null) { + downloadManager = + new DownloadManager( + context, + getDatabaseProvider(context), + getDownloadCache(context), + getHttpDataSourceFactory(context), + Executors.newFixedThreadPool(/* nThreads= */ 6)); +// downloadTracker = +// new DownloadTracker(context, getHttpDataSourceFactory(context), downloadManager); + } + } + + @OptIn(markerClass = androidx.media3.common.util.UnstableApi.class) + private static synchronized DatabaseProvider getDatabaseProvider(Context context) { + if (databaseProvider == null) { + databaseProvider = new StandaloneDatabaseProvider(context); + } + return databaseProvider; + } + + private static synchronized File getDownloadDirectory(Context context) { + if (downloadDirectory == null) { + downloadDirectory = context.getExternalFilesDir(/* type= */ null); + if (downloadDirectory == null) { + downloadDirectory = context.getFilesDir(); + } + } + return downloadDirectory; + } + + @OptIn(markerClass = androidx.media3.common.util.UnstableApi.class) + private static CacheDataSource.Factory buildReadOnlyCacheDataSource( + DataSource.Factory upstreamFactory, Cache cache) { + return new CacheDataSource.Factory() + .setCache(cache) + .setUpstreamDataSourceFactory(upstreamFactory) + .setCacheWriteDataSinkFactory(null) + .setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR); + } + + private DemoUtil() {} +} \ No newline at end of file diff --git a/app/src/main/res/drawable/download_icon.xml b/app/src/main/res/drawable/download_icon.xml new file mode 100644 index 0000000..3af9d16 --- /dev/null +++ b/app/src/main/res/drawable/download_icon.xml @@ -0,0 +1,18 @@ + + + + diff --git a/app/src/main/res/layout/activity_mo_play_details.xml b/app/src/main/res/layout/activity_mo_play_details.xml index 541cb11..0914eb5 100644 --- a/app/src/main/res/layout/activity_mo_play_details.xml +++ b/app/src/main/res/layout/activity_mo_play_details.xml @@ -175,6 +175,18 @@ android:layout_height="wrap_content" android:orientation="vertical"> + + + + + History No Found More + Download + Downloads \ No newline at end of file