This commit is contained in:
ocean 2024-05-21 10:25:41 +08:00
parent ac26183890
commit 058416facd
9 changed files with 398 additions and 0 deletions

View File

@ -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")

View File

@ -8,6 +8,7 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission
@ -76,6 +77,17 @@
</intent-filter>
</service>
<service
android:name=".service.MyDownloadService"
android:exported="false"
android:foregroundServiceType="dataSync"
tools:ignore="ForegroundServicePermission">
<intent-filter>
<action android:name="androidx.media3.exoplayer.downloadService.action.RESTART"/>
<category android:name="android.intent.category.DEFAULT"/>
</intent-filter>
</service>
</application>
</manifest>

View File

@ -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() {

View File

@ -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<Download>,
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)
}
}
}

View File

@ -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).
*
* <p>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() {}
}

View File

@ -0,0 +1,18 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M2.623,8.779C3.339,5.724 5.724,3.339 8.779,2.623C10.898,2.126 13.102,2.126 15.221,2.623C18.276,3.339 20.661,5.724 21.377,8.779C21.874,10.898 21.874,13.102 21.377,15.221C20.661,18.276 18.276,20.661 15.221,21.377C13.102,21.874 10.898,21.874 8.779,21.377C5.724,20.661 3.339,18.276 2.623,15.221C2.126,13.102 2.126,10.898 2.623,8.779L4.083,9.122C3.639,11.015 3.639,12.985 4.083,14.878C4.669,17.378 6.622,19.33 9.122,19.917C11.015,20.361 12.985,20.361 14.878,19.917C17.378,19.33 19.33,17.378 19.917,14.878C20.361,12.985 20.361,11.015 19.917,9.122C19.33,6.622 17.378,4.669 14.878,4.083C12.985,3.639 11.015,3.639 9.122,4.083C6.622,4.669 4.669,6.622 4.083,9.122L2.623,8.779Z"
android:fillColor="#ffffff"
android:fillAlpha="0.85"/>
<path
android:pathData="M12,8.5V15.5M12,15.5L14.5,13M12,15.5L9.5,13"
android:strokeAlpha="0.85"
android:strokeLineJoin="round"
android:strokeWidth="1.5"
android:fillColor="#00000000"
android:strokeColor="#ffffff"
android:strokeLineCap="round"/>
</vector>

View File

@ -175,6 +175,18 @@
android:layout_height="wrap_content"
android:orientation="vertical">
<LinearLayout
android:id="@+id/downloadBtn"
android:layout_width="40dp"
android:layout_height="40dp"
android:gravity="center">
<ImageView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@drawable/download_icon" />
</LinearLayout>
<com.player.musicoo.view.MarqueeTextView
android:id="@+id/name_tv"
android:layout_width="match_parent"

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 B

View File

@ -27,4 +27,6 @@
<string name="history">History</string>
<string name="no_found">No Found</string>
<string name="more">More</string>
<string name="download">Download</string>
<string name="downloads">Downloads</string>
</resources>