From 0fa6e9156c4d662c105708648cd97efcda6d29ba Mon Sep 17 00:00:00 2001 From: ocean <503259349@qq.com> Date: Thu, 23 May 2024 09:55:22 +0800 Subject: [PATCH] update --- app/src/main/java/com/player/musicoo/App.kt | 2 + .../player/musicoo/activity/MoBaseActivity.kt | 2 + .../musicoo/activity/MoPlayDetailsActivity.kt | 121 ++++++---- .../musicoo/service/MyDownloadService.kt | 47 ++-- .../player/musicoo/service/PlaybackService.kt | 147 ++++++------ .../com/player/musicoo/util/CacheManager.kt | 50 ++++ .../com/player/musicoo/util/DemoUtil.java | 182 -------------- .../com/player/musicoo/util/DownloadUtil.kt | 224 ++++++++++++++++++ .../player/musicoo/util/FileSizeConverter.kt | 46 ++++ .../main/res/drawable/download_done_icon.xml | 16 ++ app/src/main/res/drawable/download_icon.xml | 22 +- app/src/main/res/drawable/error.xml | 10 + app/src/main/res/drawable/favorited_icon.xml | 9 + .../main/res/drawable/not_favorited_icon.xml | 9 + .../res/layout/activity_mo_play_details.xml | 118 +++++---- 15 files changed, 625 insertions(+), 380 deletions(-) create mode 100644 app/src/main/java/com/player/musicoo/util/CacheManager.kt delete mode 100644 app/src/main/java/com/player/musicoo/util/DemoUtil.java create mode 100644 app/src/main/java/com/player/musicoo/util/DownloadUtil.kt create mode 100644 app/src/main/java/com/player/musicoo/util/FileSizeConverter.kt create mode 100644 app/src/main/res/drawable/download_done_icon.xml create mode 100644 app/src/main/res/drawable/error.xml create mode 100644 app/src/main/res/drawable/favorited_icon.xml create mode 100644 app/src/main/res/drawable/not_favorited_icon.xml diff --git a/app/src/main/java/com/player/musicoo/App.kt b/app/src/main/java/com/player/musicoo/App.kt index e3b506f..1c4bdc6 100644 --- a/app/src/main/java/com/player/musicoo/App.kt +++ b/app/src/main/java/com/player/musicoo/App.kt @@ -11,6 +11,7 @@ import com.player.musicoo.database.CurrentAudioDatabase import com.player.musicoo.database.CurrentAudioManager import com.player.musicoo.database.DatabaseManager import com.player.musicoo.media.MediaControllerManager +import com.player.musicoo.util.CacheManager import com.player.musicoo.util.parseResources import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -105,6 +106,7 @@ class App : Application() { databaseManager = DatabaseManager.getInstance(this) initCurrentPlayingAudio() initImportAudio() + CacheManager.initializeCaches(this) } } \ No newline at end of file diff --git a/app/src/main/java/com/player/musicoo/activity/MoBaseActivity.kt b/app/src/main/java/com/player/musicoo/activity/MoBaseActivity.kt index 22e304b..75fc331 100644 --- a/app/src/main/java/com/player/musicoo/activity/MoBaseActivity.kt +++ b/app/src/main/java/com/player/musicoo/activity/MoBaseActivity.kt @@ -22,11 +22,13 @@ import androidx.appcompat.app.AppCompatActivity import androidx.media3.common.Player import androidx.media3.session.MediaController import com.bumptech.glide.Glide +import com.player.musicoo.App import com.player.musicoo.R import com.player.musicoo.innertube.Innertube import com.player.musicoo.innertube.Innertube.TAG import com.player.musicoo.media.MediaControllerManager import com.player.musicoo.sp.AppStore +import com.player.musicoo.util.DownloadUtil import com.player.musicoo.util.LogTag import com.player.musicoo.view.MusicPlayerView import kotlinx.coroutines.CoroutineScope 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 e3c22d1..d25d11f 100644 --- a/app/src/main/java/com/player/musicoo/activity/MoPlayDetailsActivity.kt +++ b/app/src/main/java/com/player/musicoo/activity/MoPlayDetailsActivity.kt @@ -14,6 +14,8 @@ 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.Download +import androidx.media3.exoplayer.offline.DownloadManager import androidx.media3.exoplayer.offline.DownloadRequest import androidx.media3.exoplayer.offline.DownloadService import androidx.recyclerview.widget.LinearLayoutManager @@ -25,17 +27,12 @@ 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.DownloadUtil +import com.player.musicoo.util.FileSizeConverter import com.player.musicoo.util.LogTag.LogD import com.player.musicoo.util.PlayMode import com.player.musicoo.util.asMediaItem @@ -43,8 +40,8 @@ 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 +import java.lang.Exception @OptIn(UnstableApi::class) class MoPlayDetailsActivity : MoBaseActivity(), Player.Listener { @@ -65,6 +62,7 @@ class MoPlayDetailsActivity : MoBaseActivity(), Player.Listener { private var comeFrom: Class<*>? = null private var playListAdapter: PlayListAdapter? = null + private var downloadManager: DownloadManager? = null private fun initImmersionBar() { immersionBar { @@ -79,6 +77,7 @@ class MoPlayDetailsActivity : MoBaseActivity(), Player.Listener { initImmersionBar() initClick() initPlayerListener() + initDownloadListener() initPlayListAdapter() updatePlayModeUi() val videoId = intent.getStringExtra(PLAY_DETAILS_VIDEO_ID) @@ -140,12 +139,62 @@ class MoPlayDetailsActivity : MoBaseActivity(), Player.Listener { updateInfoUi(meController.currentMediaItem) } } + if (meController != null && meController.currentMediaItem != null) { + LogD(TAG,"meController.currentMediaItem != null->${meController.currentMediaItem?.mediaId!!}") + updateDownloadUi(meController.currentMediaItem?.mediaId!!) + } else { + LogD(TAG,"currentVideoID->${currentVideoID}") + updateDownloadUi(currentVideoID) + } } + private fun updateDownloadUi(id: String) { + if (DownloadUtil.downloadResourceExist(id)) { + binding.downloadImg.setImageResource(R.drawable.download_done_icon) + } else { + binding.downloadImg.setImageResource(R.drawable.download_icon) + } + } + + private fun initPlayerListener() { meController?.addListener(playerListener) } + private fun initDownloadListener() { + downloadManager = DownloadUtil.getDownloadManager(this) + if (downloadManager != null) { + downloadManager?.addListener(object : DownloadManager.Listener { + override fun onDownloadChanged( + downloadManager: DownloadManager, + download: Download, + finalException: Exception? + ) { + when (download.state) { + Download.STATE_DOWNLOADING -> { + binding.downloadLoading.visibility = View.VISIBLE + binding.downloadImg.setImageResource(R.drawable.download_icon) + binding.downloadImg.visibility = View.GONE + } + + Download.STATE_COMPLETED -> { + binding.downloadLoading.visibility = View.GONE + binding.downloadImg.setImageResource(R.drawable.download_done_icon) + binding.downloadImg.visibility = View.VISIBLE + } + + else -> { + binding.downloadLoading.visibility = View.GONE + binding.downloadImg.setImageResource(R.drawable.error) + binding.downloadImg.visibility = View.VISIBLE + } + } + + } + }) + } + } + private fun updateCurrentMediaItemInfo() { if (meController != null && meController.currentMediaItem != null) { binding.playbackErrorLayout.visibility = View.GONE @@ -315,46 +364,24 @@ 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 + //如果已经存在就不进行下载 + if (DownloadUtil.downloadResourceExist(contentId)) { + return@setOnClickListener + } + val currentDownload = DownloadUtil.currentDownload(contentId) + if (currentDownload != null) { + if (currentDownload.state == Download.STATE_DOWNLOADING) { + DownloadService.sendRemoveDownload( + this, + MyDownloadService::class.java, + contentId, + false ) } - } - - urlResult?.getOrThrow()?.let { url -> - val contentUrl = url.toUri() - LogD(TAG, "download contentUrl->${contentUrl}") - val downloadRequest = DownloadRequest.Builder(contentId, contentUrl) + } else { + val downloadRequest = DownloadRequest + .Builder(contentId, contentId.toUri()) + .setCustomCacheKey(contentId) .build() DownloadService.sendAddDownload( this, @@ -363,8 +390,8 @@ class MoPlayDetailsActivity : MoBaseActivity(), Player.Listener { false ) } - } + } } } diff --git a/app/src/main/java/com/player/musicoo/service/MyDownloadService.kt b/app/src/main/java/com/player/musicoo/service/MyDownloadService.kt index e4d80f5..4fd2df9 100644 --- a/app/src/main/java/com/player/musicoo/service/MyDownloadService.kt +++ b/app/src/main/java/com/player/musicoo/service/MyDownloadService.kt @@ -13,7 +13,7 @@ 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 +import com.player.musicoo.util.DownloadUtil @OptIn(UnstableApi::class) @@ -34,11 +34,12 @@ class MyDownloadService : DownloadService( @SuppressLint("ServiceCast") override fun getDownloadManager(): DownloadManager { - val downloadManager = DemoUtil.getDownloadManager(this) - val downloadNotificationHelper = DemoUtil.getDownloadNotificationHelper(this) + val downloadManager = DownloadUtil.getDownloadManager(this) + downloadManager!!.maxParallelDownloads = 3 + val downloadNotificationHelper = DownloadUtil.getDownloadNotificationHelper(this) downloadManager.addListener( TerminalStateNotificationHelper( - this, downloadNotificationHelper, FOREGROUND_NOTIFICATION_ID + 1 + this, downloadNotificationHelper!!, FOREGROUND_NOTIFICATION_ID + 1 ) ) return downloadManager @@ -55,7 +56,7 @@ class MyDownloadService : DownloadService( return DownloadNotificationHelper(this, DOWNLOAD_NOTIFICATION_CHANNEL_ID) .buildProgressNotification( this, - R.mipmap.musicoo_logo_img, + R.drawable.download_icon, null, null, downloads, @@ -76,22 +77,26 @@ class MyDownloadService : DownloadService( 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 + val notification: Notification = when (download.state) { + Download.STATE_COMPLETED -> { + notificationHelper.buildDownloadCompletedNotification( + context, + R.drawable.download_done_icon, + null, + Util.fromUtf8Bytes(download.request.data) + ) + } + Download.STATE_FAILED -> { + notificationHelper.buildDownloadFailedNotification( + context, + R.drawable.error, + null, + Util.fromUtf8Bytes(download.request.data) + ) + } + else -> { + return + } } NotificationUtil.setNotification(context, nextNotificationId++, notification) } diff --git a/app/src/main/java/com/player/musicoo/service/PlaybackService.kt b/app/src/main/java/com/player/musicoo/service/PlaybackService.kt index 7fd7e03..f23adee 100644 --- a/app/src/main/java/com/player/musicoo/service/PlaybackService.kt +++ b/app/src/main/java/com/player/musicoo/service/PlaybackService.kt @@ -3,21 +3,16 @@ package com.player.musicoo.service import android.content.Intent import android.net.Uri import android.os.Handler -import android.util.Log import androidx.core.net.toUri -import androidx.core.text.isDigitsOnly import androidx.media3.common.AudioAttributes import androidx.media3.common.C import androidx.media3.common.PlaybackException import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi -import androidx.media3.database.StandaloneDatabaseProvider import androidx.media3.datasource.DataSource import androidx.media3.datasource.DefaultHttpDataSource import androidx.media3.datasource.ResolvingDataSource import androidx.media3.datasource.cache.CacheDataSource -import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor -import androidx.media3.datasource.cache.NoOpCacheEvictor import androidx.media3.datasource.cache.SimpleCache import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.RenderersFactory @@ -40,7 +35,8 @@ 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.sp.AppStore -import com.player.musicoo.util.ExoPlayerDiskCacheMaxSize +import com.player.musicoo.util.CacheManager +import com.player.musicoo.util.DownloadUtil import com.player.musicoo.util.LogTag import com.player.musicoo.util.LogTag.LogD import com.player.musicoo.util.PlayMode @@ -53,34 +49,16 @@ import kotlinx.coroutines.runBlocking class PlaybackService : MediaSessionService(), Player.Listener { private val TAG = LogTag.VO_SERVICE_LOG private var mediaSession: MediaSession? = null - private lateinit var cache: SimpleCache + private lateinit var player: ExoPlayer + private val playerCache = CacheManager.getPlayerCache() + private val downloadCache = CacheManager.getDownloadCache() override fun onCreate() { super.onCreate() - - val cacheEvictor = when (val size = ExoPlayerDiskCacheMaxSize.`2GB`) { - ExoPlayerDiskCacheMaxSize.Unlimited -> NoOpCacheEvictor() - else -> LeastRecentlyUsedCacheEvictor(size.bytes) - } - val directory = cacheDir.resolve("exoplayer").also { directory -> - if (directory.exists()) return@also - - directory.mkdir() - - cacheDir.listFiles()?.forEach { file -> - if (file.isDirectory && file.name.length == 1 && file.name.isDigitsOnly() || file.extension == "uid") { - if (!file.renameTo(directory.resolve(file.name))) { - file.deleteRecursively() - } - } - } - - filesDir.resolve("coil").deleteRecursively() - } - cache = SimpleCache(directory, cacheEvictor, StandaloneDatabaseProvider(this)) - - player = ExoPlayer.Builder(this, createRendersFactory(), createMediaSourceFactory()) + player = ExoPlayer.Builder(this) + .setMediaSourceFactory(createMediaSourceFactory()) + .setRenderersFactory(createRendersFactory()) .setHandleAudioBecomingNoisy(true) .setWakeMode(C.WAKE_MODE_LOCAL) .setAudioAttributes( @@ -98,10 +76,12 @@ class PlaybackService : MediaSessionService(), Player.Listener { player.repeatMode = Player.REPEAT_MODE_ALL player.shuffleModeEnabled = false } + PlayMode.SINGLE_LOOP.value -> { player.repeatMode = Player.REPEAT_MODE_ONE player.shuffleModeEnabled = false } + PlayMode.RANDOM.value -> { player.repeatMode = Player.REPEAT_MODE_ALL player.shuffleModeEnabled = true @@ -146,21 +126,24 @@ class PlaybackService : MediaSessionService(), Player.Listener { release() mediaSession = null } - cache.release() super.onDestroy() } - - private fun createCacheDataSource(): DataSource.Factory { - return CacheDataSource.Factory().setCache(cache).apply { - setUpstreamDataSourceFactory( - DefaultHttpDataSource.Factory() - .setConnectTimeoutMs(16000) - .setReadTimeoutMs(8000) - .setUserAgent("Mozilla/5.0 (Windows NT 10.0; rv:91.0) Gecko/20100101 Firefox/91.0") + private fun createCacheDataSource(): CacheDataSource.Factory = + CacheDataSource.Factory() + .setCache(downloadCache!!) + .setUpstreamDataSourceFactory( + CacheDataSource.Factory() + .setCache(playerCache!!) + .setUpstreamDataSourceFactory( + DefaultHttpDataSource.Factory() + .setConnectTimeoutMs(16000) + .setReadTimeoutMs(8000) + .setUserAgent("Mozilla/5.0 (Windows NT 10.0; rv:91.0) Gecko/20100101 Firefox/91.0") + ) ) - } - } + .setCacheWriteDataSinkFactory(null) + .setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR) private fun createDataSourceFactory(): DataSource.Factory { val chunkLength = 512 * 1024L @@ -168,51 +151,62 @@ class PlaybackService : MediaSessionService(), Player.Listener { return ResolvingDataSource.Factory(createCacheDataSource()) { dataSpec -> val videoId = dataSpec.key ?: error("A key must be set") - if (cache.isCached(videoId, dataSpec.position, chunkLength)) { - dataSpec - } else { - when (videoId) { - ringBuffer.getOrNull(0)?.first -> dataSpec.withUri(ringBuffer.getOrNull(0)!!.second) - ringBuffer.getOrNull(1)?.first -> dataSpec.withUri(ringBuffer.getOrNull(1)!!.second) - else -> { - val urlResult = runBlocking(Dispatchers.IO) { - Innertube.player(PlayerBody(videoId = videoId)) - }?.mapCatching { body -> - if (body.videoDetails?.videoId != videoId) { - throw VideoIdMismatchException() - } + val position = dataSpec.position + val length = if (dataSpec.length >= 0) dataSpec.length else 1 - when (val status = body.playabilityStatus?.status) { - "OK" -> body.streamingData?.highestQualityFormat?.let { format -> - format.url - } ?: throw PlayableFormatNotFoundException() + if (downloadCache!!.isCached(videoId, position, length)) { + LogD(TAG, "playbackService downloadCache contains data for $videoId at position $position") + return@Factory dataSpec + } - "UNPLAYABLE" -> throw UnplayableException() - "LOGIN_REQUIRED" -> throw LoginRequiredException() - else -> throw PlaybackException( - status, - null, - PlaybackException.ERROR_CODE_REMOTE_ERROR - ) - } + if (playerCache!!.isCached(videoId, position, chunkLength)) { + LogD(TAG, "playbackService playerCache contains data for $videoId at position $position") + return@Factory dataSpec + } + + when (videoId) { + ringBuffer.getOrNull(0)?.first -> return@Factory dataSpec.withUri(ringBuffer.getOrNull(0)!!.second) + ringBuffer.getOrNull(1)?.first -> return@Factory dataSpec.withUri(ringBuffer.getOrNull(1)!!.second) + else -> { + val urlResult = runBlocking(Dispatchers.IO) { + Innertube.player(PlayerBody(videoId = videoId)) + }?.mapCatching { body -> + if (body.videoDetails?.videoId != videoId) { + throw VideoIdMismatchException() } - LogD(TAG, "service urlResult->$urlResult") - urlResult?.getOrThrow()?.let { url -> - ringBuffer.append(videoId to url.toUri()) - dataSpec.withUri(url.toUri()) - .subrange(dataSpec.uriPositionOffset, chunkLength) - } ?: throw PlaybackException( - null, - urlResult?.exceptionOrNull(), - PlaybackException.ERROR_CODE_REMOTE_ERROR - ) + 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 + ) + } } + + LogD(TAG, "playbackService urlResult->$urlResult") + + urlResult?.getOrThrow()?.let { url -> + ringBuffer.append(videoId to url.toUri()) + return@Factory dataSpec.withUri(url.toUri()) + .subrange(dataSpec.uriPositionOffset, chunkLength) + } ?: throw PlaybackException( + null, + urlResult?.exceptionOrNull(), + PlaybackException.ERROR_CODE_REMOTE_ERROR + ) } } } } + private fun createMediaSourceFactory(): MediaSource.Factory { return DefaultMediaSourceFactory(createDataSourceFactory(), createExtractorsFactory()) } @@ -227,7 +221,6 @@ class PlaybackService : MediaSessionService(), Player.Listener { val audioSink = DefaultAudioSink.Builder() .setEnableFloatOutput(false) .setEnableAudioTrackPlaybackParams(false) -// .setOffloadMode(DefaultAudioSink.OFFLOAD_MODE_DISABLED) .setAudioProcessorChain( DefaultAudioSink.DefaultAudioProcessorChain( emptyArray(), diff --git a/app/src/main/java/com/player/musicoo/util/CacheManager.kt b/app/src/main/java/com/player/musicoo/util/CacheManager.kt new file mode 100644 index 0000000..f902695 --- /dev/null +++ b/app/src/main/java/com/player/musicoo/util/CacheManager.kt @@ -0,0 +1,50 @@ +package com.player.musicoo.util + +import android.content.Context +import androidx.annotation.OptIn +import androidx.media3.common.util.UnstableApi +import androidx.media3.database.DatabaseProvider +import androidx.media3.database.StandaloneDatabaseProvider +import androidx.media3.datasource.cache.CacheEvictor +import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor +import androidx.media3.datasource.cache.NoOpCacheEvictor +import androidx.media3.datasource.cache.SimpleCache +import java.io.File + +@OptIn(UnstableApi::class) +object CacheManager { + private var playerCache: SimpleCache? = null + private var downloadCache: SimpleCache? = null + private var databaseProvider: DatabaseProvider? = null + private const val DOWNLOAD_CONTENT_DIRECTORY = "DownloadOO" + private const val PLAYER_CONTENT_DIRECTORY = "PlayerOO" + + fun initializeCaches(context: Context) { + databaseProvider = StandaloneDatabaseProvider(context) + + // Initialize player cache + val playerCacheDir = File(context.filesDir, PLAYER_CONTENT_DIRECTORY) + val cacheEvictor: CacheEvictor = when (val size = ExoPlayerDiskCacheMaxSize.`1GB`) { + ExoPlayerDiskCacheMaxSize.Unlimited -> NoOpCacheEvictor() + else -> LeastRecentlyUsedCacheEvictor(size.bytes) + } + + playerCache = SimpleCache( + playerCacheDir, + cacheEvictor, + databaseProvider!! + ) + + // Initialize download cache + val downloadCacheDir = File(context.filesDir, DOWNLOAD_CONTENT_DIRECTORY) + downloadCache = SimpleCache(downloadCacheDir, NoOpCacheEvictor(), databaseProvider!!) + } + + fun getPlayerCache(): SimpleCache? { + return playerCache + } + + fun getDownloadCache(): SimpleCache? { + return downloadCache + } +} diff --git a/app/src/main/java/com/player/musicoo/util/DemoUtil.java b/app/src/main/java/com/player/musicoo/util/DemoUtil.java deleted file mode 100644 index 887dbff..0000000 --- a/app/src/main/java/com/player/musicoo/util/DemoUtil.java +++ /dev/null @@ -1,182 +0,0 @@ -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/java/com/player/musicoo/util/DownloadUtil.kt b/app/src/main/java/com/player/musicoo/util/DownloadUtil.kt new file mode 100644 index 0000000..983cbab --- /dev/null +++ b/app/src/main/java/com/player/musicoo/util/DownloadUtil.kt @@ -0,0 +1,224 @@ +package com.player.musicoo.util + +import android.content.Context +import android.net.Uri +import androidx.annotation.OptIn +import androidx.core.net.toUri +import androidx.media3.common.PlaybackException +import androidx.media3.common.util.UnstableApi +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.ResolvingDataSource +import androidx.media3.datasource.cache.Cache +import androidx.media3.datasource.cache.CacheDataSource +import androidx.media3.datasource.cache.CacheEvictor +import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor +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.offline.Download +import androidx.media3.exoplayer.offline.DownloadManager +import androidx.media3.exoplayer.offline.DownloadNotificationHelper +import com.player.musicoo.innertube.Innertube +import com.player.musicoo.innertube.Innertube.TAG +import com.player.musicoo.innertube.models.bodies.PlayerBody +import com.player.musicoo.innertube.requests.player +import com.player.musicoo.service.LoginRequiredException +import com.player.musicoo.service.PlayableFormatNotFoundException +import com.player.musicoo.service.UnplayableException +import com.player.musicoo.service.VideoIdMismatchException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import java.net.CookieHandler +import java.net.CookieManager +import java.net.CookiePolicy +import java.util.concurrent.Executor +import java.util.concurrent.Executors + + +@OptIn(markerClass = [UnstableApi::class]) +object DownloadUtil { + private const val DOWNLOAD_NOTIFICATION_CHANNEL_ID = "download_channel" + + private const val USE_CRONET_FOR_NETWORKING = true + + private var httpDataSourceFactory: DataSource.Factory? = null + private var databaseProvider: DatabaseProvider? = null + private val playerCache = CacheManager.getPlayerCache() + private val downloadCache = CacheManager.getDownloadCache() + private var downloadManager: DownloadManager? = null + + private var downloadNotificationHelper: DownloadNotificationHelper? = null + + @Synchronized + fun getHttpDataSourceFactory(context: Context): DataSource.Factory? { + if (httpDataSourceFactory == null) { + if (USE_CRONET_FOR_NETWORKING) { + val cronetEngine = CronetUtil.buildCronetEngine(context.applicationContext) + if (cronetEngine != null) { + httpDataSourceFactory = + CronetDataSource.Factory(cronetEngine, Executors.newSingleThreadExecutor()) + } + } + if (httpDataSourceFactory == null) { + // We don't want to use Cronet, or we failed to instantiate a CronetEngine. + val cookieManager = CookieManager() + cookieManager.setCookiePolicy(CookiePolicy.ACCEPT_ORIGINAL_SERVER) + CookieHandler.setDefault(cookieManager) + httpDataSourceFactory = DefaultHttpDataSource.Factory() + } + } + return httpDataSourceFactory + } + + /** + * Returns a [DataSource.Factory]. + */ + @Synchronized + fun getDataSourceFactory(context: Context): DataSource.Factory { + val upstreamFactory = DefaultDataSource.Factory( + context.applicationContext, + getHttpDataSourceFactory(context.applicationContext)!! + ) + val chunkLength = 512 * 1024L + val ringBuffer = RingBuffer?>(2) { null } + return ResolvingDataSource.Factory( + CacheDataSource.Factory() + .setCache(playerCache!!) + .setUpstreamDataSourceFactory(upstreamFactory) + ) { dataSpec -> + val videoId = dataSpec.key ?: error("A key must be set") + val length = if (dataSpec.length >= 0) dataSpec.length else 1 + + if (playerCache.isCached(videoId, dataSpec.position, length)) { + LogTag.LogD(TAG, "下载 getDataSourceFactory playerCache") + return@Factory dataSpec + } + when (videoId) { + ringBuffer.getOrNull(0)?.first -> dataSpec.withUri(ringBuffer.getOrNull(0)!!.second) + ringBuffer.getOrNull(1)?.first -> dataSpec.withUri(ringBuffer.getOrNull(1)!!.second) + else -> { + val urlResult = runBlocking(Dispatchers.IO) { + Innertube.player(PlayerBody(videoId = videoId)) + }?.mapCatching { body -> + if (body.videoDetails?.videoId != videoId) { + 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 + ) + } + } + LogTag.LogD(TAG, "DownloadUtil urlResult->$urlResult") + + urlResult?.getOrThrow()?.let { url -> + ringBuffer.append(videoId to url.toUri()) + dataSpec.withUri(url.toUri()) + } ?: throw PlaybackException( + null, + urlResult?.exceptionOrNull(), + PlaybackException.ERROR_CODE_REMOTE_ERROR + ) + } + } + } + + } + + @Synchronized + fun getDownloadNotificationHelper( + context: Context? + ): DownloadNotificationHelper? { + if (downloadNotificationHelper == null) { + downloadNotificationHelper = + DownloadNotificationHelper(context!!, DOWNLOAD_NOTIFICATION_CHANNEL_ID) + } + return downloadNotificationHelper + } + + fun downloadResourceExist(id: String): Boolean { + var isExist = false + if (downloadManager != null) { + val downloadIndex = downloadManager!!.downloadIndex + downloadIndex.getDownloads() + .use { cursor -> + while (cursor.moveToNext()) { + val download = cursor.download + LogTag.LogD(TAG, "download.request.id->${download.request.id}") + LogTag.LogD( + TAG, + "download formattedSize->${FileSizeConverter(download.bytesDownloaded).formattedSize()}" + ) + if (download.request.id == id) { + isExist = true + } + } + } + } + LogTag.LogD(TAG, "isExist->$isExist") + return isExist + } + + fun currentDownload(id: String): Download? { + var download: Download? = null + if (downloadManager != null) { + downloadManager?.currentDownloads?.map { + if (it.request.id === id) { + download = it + } + } + } + return download + } + + @Synchronized + fun getDownloadManager(context: Context): DownloadManager? { + ensureDownloadManagerInitialized(context) + return downloadManager + } + + @Synchronized + private fun ensureDownloadManagerInitialized(context: Context) { + if (downloadManager == null) { + downloadManager = DownloadManager( + context, + getDatabaseProvider(context)!!, + downloadCache!!, + getDataSourceFactory(context)!!, + Executor(Runnable::run) + ) + } + } + + @Synchronized + private fun getDatabaseProvider(context: Context): DatabaseProvider? { + if (databaseProvider == null) { + databaseProvider = StandaloneDatabaseProvider(context) + } + return databaseProvider + } + + private fun buildReadOnlyCacheDataSource( + upstreamFactory: DataSource.Factory, cache: Cache? + ): CacheDataSource.Factory { + return CacheDataSource.Factory() + .setCache(cache!!) + .setUpstreamDataSourceFactory(upstreamFactory) + .setCacheWriteDataSinkFactory(null) + .setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/player/musicoo/util/FileSizeConverter.kt b/app/src/main/java/com/player/musicoo/util/FileSizeConverter.kt new file mode 100644 index 0000000..cd3dea4 --- /dev/null +++ b/app/src/main/java/com/player/musicoo/util/FileSizeConverter.kt @@ -0,0 +1,46 @@ +package com.player.musicoo.util + +class FileSizeConverter(private val sizeInBytes: Long) { + + companion object { + private const val BYTE: Long = 1 + private const val KILOBYTE: Long = 1024 + private const val MEGABYTE: Long = 1024 * KILOBYTE + private const val GIGABYTE: Long = 1024 * MEGABYTE + private const val TERABYTE: Long = 1024 * GIGABYTE + + fun formatSize(sizeInBytes: Long): String { + return when { + sizeInBytes >= TERABYTE -> String.format("%.2f TB", sizeInBytes / TERABYTE.toDouble()) + sizeInBytes >= GIGABYTE -> String.format("%.2f GB", sizeInBytes / GIGABYTE.toDouble()) + sizeInBytes >= MEGABYTE -> String.format("%.2f MB", sizeInBytes / MEGABYTE.toDouble()) + sizeInBytes >= KILOBYTE -> String.format("%.2f KB", sizeInBytes / KILOBYTE.toDouble()) + else -> String.format("%d B", sizeInBytes) + } + } + } + + fun toBytes(): Long { + return sizeInBytes + } + + fun toKilobytes(): Double { + return sizeInBytes / KILOBYTE.toDouble() + } + + fun toMegabytes(): Double { + return sizeInBytes / MEGABYTE.toDouble() + } + + fun toGigabytes(): Double { + return sizeInBytes / GIGABYTE.toDouble() + } + + fun toTerabytes(): Double { + return sizeInBytes / TERABYTE.toDouble() + } + + fun formattedSize(): String { + return formatSize(sizeInBytes) + } +} diff --git a/app/src/main/res/drawable/download_done_icon.xml b/app/src/main/res/drawable/download_done_icon.xml new file mode 100644 index 0000000..8de6747 --- /dev/null +++ b/app/src/main/res/drawable/download_done_icon.xml @@ -0,0 +1,16 @@ + + + + diff --git a/app/src/main/res/drawable/download_icon.xml b/app/src/main/res/drawable/download_icon.xml index 3af9d16..5ec210e 100644 --- a/app/src/main/res/drawable/download_icon.xml +++ b/app/src/main/res/drawable/download_icon.xml @@ -3,16 +3,14 @@ android:height="24dp" android:viewportWidth="24" android:viewportHeight="24"> - - + + diff --git a/app/src/main/res/drawable/error.xml b/app/src/main/res/drawable/error.xml new file mode 100644 index 0000000..785630c --- /dev/null +++ b/app/src/main/res/drawable/error.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/favorited_icon.xml b/app/src/main/res/drawable/favorited_icon.xml new file mode 100644 index 0000000..2445315 --- /dev/null +++ b/app/src/main/res/drawable/favorited_icon.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/not_favorited_icon.xml b/app/src/main/res/drawable/not_favorited_icon.xml new file mode 100644 index 0000000..6b5fe93 --- /dev/null +++ b/app/src/main/res/drawable/not_favorited_icon.xml @@ -0,0 +1,9 @@ + + + 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 0914eb5..0aec8f4 100644 --- a/app/src/main/res/layout/activity_mo_play_details.xml +++ b/app/src/main/res/layout/activity_mo_play_details.xml @@ -176,52 +176,88 @@ android:orientation="vertical"> + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginStart="20dp" + android:layout_marginTop="16dp" + android:layout_marginEnd="20dp" + android:orientation="horizontal"> - + android:layout_weight="1" + android:orientation="vertical"> + + + + + + + + + + + + + + + + + + + - - - - @@ -264,8 +300,8 @@