This commit is contained in:
ocean 2024-05-23 09:55:22 +08:00
parent 058416facd
commit 0fa6e9156c
15 changed files with 625 additions and 380 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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
}
}

View File

@ -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).
*
* <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,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<Pair<String, Uri>?>(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)
}
}

View File

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

View File

@ -0,0 +1,16 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#ff666666"
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" />
<path
android:fillColor="#00000000"
android:pathData="M8.5,12L11,14.5L16,9.5"
android:strokeWidth="2"
android:strokeColor="#ff666666"
android:strokeLineCap="round"
android:strokeLineJoin="round" />
</vector>

View File

@ -3,16 +3,14 @@
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"/>
<path
android:fillColor="#ffffff"
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" />
<path
android:fillColor="#00000000"
android:pathData="M12,8.5V15.5M12,15.5L14.5,13M12,15.5L9.5,13"
android:strokeWidth="1.5"
android:strokeColor="#ffffff"
android:strokeLineCap="round"
android:strokeLineJoin="round" />
</vector>

View File

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="#c62828">
<path
android:fillColor="@android:color/white"
android:pathData="M480,680Q497,680 508.5,668.5Q520,657 520,640Q520,623 508.5,611.5Q497,600 480,600Q463,600 451.5,611.5Q440,623 440,640Q440,657 451.5,668.5Q463,680 480,680ZM440,520L520,520L520,280L440,280L440,520ZM480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880Z" />
</vector>

View File

@ -0,0 +1,9 @@
<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="M15.698,3.065C17.377,2.805 18.953,3.333 20.229,4.577C21.526,5.841 22.147,7.507 21.97,9.254C21.795,10.979 20.879,12.684 19.338,14.186C18.908,14.606 17.752,15.738 16.093,17.365C15.347,18.097 14.553,18.876 13.753,19.662L12.975,20.425L12.666,20.729C12.488,20.903 12.249,21.001 12,21.001C11.75,21.001 11.511,20.903 11.334,20.729L8.648,18.088L8.08,17.532C6.941,16.417 5.801,15.302 4.662,14.186C3.12,12.684 2.205,10.98 2.03,9.254C1.853,7.507 2.474,5.841 3.771,4.577C5.047,3.333 6.623,2.805 8.302,3.065C9.548,3.257 10.812,3.878 12,4.868C13.189,3.878 14.452,3.257 15.698,3.065H15.698Z"
android:fillColor="#80F988"/>
</vector>

View File

@ -0,0 +1,9 @@
<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="M11.995,21C11.775,21.001 11.557,20.959 11.353,20.875C11.15,20.791 10.965,20.668 10.81,20.513L3.9,13.646C2.72,12.505 2.036,10.945 1.997,9.304C1.958,7.663 2.566,6.072 3.69,4.876C4.764,3.777 6.206,3.115 7.739,3.016C9.272,2.917 10.788,3.388 11.995,4.339C13.2,3.388 14.715,2.917 16.247,3.016C17.78,3.115 19.221,3.778 20.295,4.876C21.419,6.072 22.027,7.663 21.987,9.304C21.948,10.945 21.264,12.505 20.085,13.646L13.175,20.513C13.02,20.668 12.836,20.791 12.633,20.874C12.431,20.958 12.214,21.001 11.995,21ZM8.14,4.482H8.095C7.472,4.486 6.857,4.614 6.284,4.858C5.712,5.102 5.193,5.458 4.76,5.905C3.907,6.823 3.45,8.039 3.485,9.291C3.521,10.544 4.047,11.732 4.95,12.601L11.86,19.466C11.875,19.487 11.895,19.504 11.918,19.516C11.941,19.528 11.966,19.534 11.992,19.534C12.018,19.534 12.043,19.528 12.066,19.516C12.089,19.504 12.109,19.487 12.125,19.466L19.035,12.601C19.939,11.733 20.465,10.544 20.501,9.291C20.536,8.039 20.078,6.822 19.225,5.905C18.791,5.457 18.273,5.101 17.701,4.857C17.128,4.612 16.512,4.485 15.89,4.482C15.268,4.477 14.651,4.594 14.074,4.827C13.498,5.061 12.973,5.406 12.53,5.842C12.386,5.98 12.194,6.057 11.995,6.057C11.795,6.057 11.603,5.98 11.46,5.842C10.577,4.966 9.383,4.477 8.14,4.482V4.482Z"
android:fillColor="#ffffff"/>
</vector>

View File

@ -176,52 +176,88 @@
android:orientation="vertical">
<LinearLayout
android:id="@+id/downloadBtn"
android:layout_width="40dp"
android:layout_height="40dp"
android:gravity="center">
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">
<ImageView
android:layout_width="wrap_content"
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:src="@drawable/download_icon" />
android:layout_weight="1"
android:orientation="vertical">
<com.player.musicoo.view.MarqueeTextView
android:id="@+id/name_tv"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="marquee"
android:fontFamily="@font/medium_font"
android:maxLines="1"
android:text="@string/app_name"
android:textColor="@color/white"
android:textSize="22dp" />
<com.player.musicoo.view.MarqueeTextView
android:id="@+id/desc_tv"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="marquee"
android:fontFamily="@font/medium_font"
android:maxLines="1"
android:text="@string/app_name"
android:textColor="@color/white_60"
android:textSize="12dp" />
</LinearLayout>
<RelativeLayout
android:id="@+id/favoritesBtn"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_gravity="center_vertical"
android:gravity="center">
<ImageView
android:id="@+id/favoritesImg"
android:layout_width="24dp"
android:layout_height="24dp"
android:src="@drawable/not_favorited_icon" />
</RelativeLayout>
<RelativeLayout
android:id="@+id/downloadBtn"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_gravity="center_vertical"
android:gravity="center">
<ImageView
android:id="@+id/downloadImg"
android:layout_width="24dp"
android:layout_height="24dp"
android:src="@drawable/download_icon" />
<ProgressBar
android:id="@+id/downloadLoading"
android:visibility="gone"
android:layout_width="24dp"
android:layout_height="24dp"
android:indeterminateTint="@color/green"
android:progressBackgroundTint="@color/green"
android:progressTint="@color/green" />
</RelativeLayout>
</LinearLayout>
<com.player.musicoo.view.MarqueeTextView
android:id="@+id/name_tv"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="4dp"
android:layout_marginEnd="16dp"
android:ellipsize="marquee"
android:fontFamily="@font/medium_font"
android:gravity="center"
android:maxLines="2"
android:text="@string/app_name"
android:textColor="@color/white"
android:textSize="24dp" />
<com.player.musicoo.view.MarqueeTextView
android:id="@+id/desc_tv"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:ellipsize="marquee"
android:fontFamily="@font/medium_font"
android:gravity="center"
android:maxLines="1"
android:text="@string/app_name"
android:textColor="@color/white_60"
android:textSize="14dp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="24dp"
android:layout_marginEnd="16dp"
android:layout_marginTop="8dp"
android:layout_marginStart="8dp"
android:layout_marginEnd="8dp"
android:gravity="center_vertical"
android:orientation="horizontal">
@ -264,8 +300,8 @@
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="10dp"
android:layout_marginEnd="14dp"
android:layout_marginStart="12dp"
android:layout_marginEnd="12dp"
android:orientation="horizontal">
<TextView