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.CurrentAudioManager
import com.player.musicoo.database.DatabaseManager import com.player.musicoo.database.DatabaseManager
import com.player.musicoo.media.MediaControllerManager import com.player.musicoo.media.MediaControllerManager
import com.player.musicoo.util.CacheManager
import com.player.musicoo.util.parseResources import com.player.musicoo.util.parseResources
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -105,6 +106,7 @@ class App : Application() {
databaseManager = DatabaseManager.getInstance(this) databaseManager = DatabaseManager.getInstance(this)
initCurrentPlayingAudio() initCurrentPlayingAudio()
initImportAudio() initImportAudio()
CacheManager.initializeCaches(this)
} }
} }

View File

@ -22,11 +22,13 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.session.MediaController import androidx.media3.session.MediaController
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.player.musicoo.App
import com.player.musicoo.R import com.player.musicoo.R
import com.player.musicoo.innertube.Innertube import com.player.musicoo.innertube.Innertube
import com.player.musicoo.innertube.Innertube.TAG import com.player.musicoo.innertube.Innertube.TAG
import com.player.musicoo.media.MediaControllerManager import com.player.musicoo.media.MediaControllerManager
import com.player.musicoo.sp.AppStore import com.player.musicoo.sp.AppStore
import com.player.musicoo.util.DownloadUtil
import com.player.musicoo.util.LogTag import com.player.musicoo.util.LogTag
import com.player.musicoo.view.MusicPlayerView import com.player.musicoo.view.MusicPlayerView
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope

View File

@ -14,6 +14,8 @@ import androidx.media3.common.MediaItem
import androidx.media3.common.PlaybackException import androidx.media3.common.PlaybackException
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi 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.DownloadRequest
import androidx.media3.exoplayer.offline.DownloadService import androidx.media3.exoplayer.offline.DownloadService
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
@ -25,17 +27,12 @@ import com.player.musicoo.R
import com.player.musicoo.adapter.PlayListAdapter import com.player.musicoo.adapter.PlayListAdapter
import com.player.musicoo.databinding.ActivityMoPlayDetailsBinding import com.player.musicoo.databinding.ActivityMoPlayDetailsBinding
import com.player.musicoo.innertube.Innertube 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.MediaControllerManager
import com.player.musicoo.media.SongRadio import com.player.musicoo.media.SongRadio
import com.player.musicoo.service.LoginRequiredException
import com.player.musicoo.service.MyDownloadService 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.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.LogTag.LogD
import com.player.musicoo.util.PlayMode import com.player.musicoo.util.PlayMode
import com.player.musicoo.util.asMediaItem import com.player.musicoo.util.asMediaItem
@ -43,8 +40,8 @@ import com.player.musicoo.util.convertMillisToMinutesAndSecondsString
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.selects.select import kotlinx.coroutines.selects.select
import java.lang.Exception
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
class MoPlayDetailsActivity : MoBaseActivity(), Player.Listener { class MoPlayDetailsActivity : MoBaseActivity(), Player.Listener {
@ -65,6 +62,7 @@ class MoPlayDetailsActivity : MoBaseActivity(), Player.Listener {
private var comeFrom: Class<*>? = null private var comeFrom: Class<*>? = null
private var playListAdapter: PlayListAdapter? = null private var playListAdapter: PlayListAdapter? = null
private var downloadManager: DownloadManager? = null
private fun initImmersionBar() { private fun initImmersionBar() {
immersionBar { immersionBar {
@ -79,6 +77,7 @@ class MoPlayDetailsActivity : MoBaseActivity(), Player.Listener {
initImmersionBar() initImmersionBar()
initClick() initClick()
initPlayerListener() initPlayerListener()
initDownloadListener()
initPlayListAdapter() initPlayListAdapter()
updatePlayModeUi() updatePlayModeUi()
val videoId = intent.getStringExtra(PLAY_DETAILS_VIDEO_ID) val videoId = intent.getStringExtra(PLAY_DETAILS_VIDEO_ID)
@ -140,12 +139,62 @@ class MoPlayDetailsActivity : MoBaseActivity(), Player.Listener {
updateInfoUi(meController.currentMediaItem) 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() { private fun initPlayerListener() {
meController?.addListener(playerListener) 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() { private fun updateCurrentMediaItemInfo() {
if (meController != null && meController.currentMediaItem != null) { if (meController != null && meController.currentMediaItem != null) {
binding.playbackErrorLayout.visibility = View.GONE binding.playbackErrorLayout.visibility = View.GONE
@ -315,46 +364,24 @@ class MoPlayDetailsActivity : MoBaseActivity(), Player.Listener {
binding.downloadBtn.setOnClickListener { binding.downloadBtn.setOnClickListener {
if (meController != null && meController.currentMediaItem != null) { if (meController != null && meController.currentMediaItem != null) {
val contentId = meController.currentMediaItem?.mediaId!! val contentId = meController.currentMediaItem?.mediaId!!
//如果已经存在就不进行下载
val downloadManager = DemoUtil.getDownloadManager(this) if (DownloadUtil.downloadResourceExist(contentId)) {
val downloadIndex = downloadManager.downloadIndex return@setOnClickListener
downloadIndex.getDownloads() }
.use { cursor -> val currentDownload = DownloadUtil.currentDownload(contentId)
while (cursor.moveToNext()) { if (currentDownload != null) {
val download = cursor.download if (currentDownload.state == Download.STATE_DOWNLOADING) {
if(download.request.id == contentId){ DownloadService.sendRemoveDownload(
return@setOnClickListener this,
} MyDownloadService::class.java,
} contentId,
} false
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
) )
} }
} } else {
val downloadRequest = DownloadRequest
urlResult?.getOrThrow()?.let { url -> .Builder(contentId, contentId.toUri())
val contentUrl = url.toUri() .setCustomCacheKey(contentId)
LogD(TAG, "download contentUrl->${contentUrl}")
val downloadRequest = DownloadRequest.Builder(contentId, contentUrl)
.build() .build()
DownloadService.sendAddDownload( DownloadService.sendAddDownload(
this, this,
@ -363,8 +390,8 @@ class MoPlayDetailsActivity : MoBaseActivity(), Player.Listener {
false false
) )
} }
}
}
} }
} }

View File

@ -13,7 +13,7 @@ import androidx.media3.exoplayer.offline.DownloadNotificationHelper
import androidx.media3.exoplayer.offline.DownloadService import androidx.media3.exoplayer.offline.DownloadService
import androidx.media3.exoplayer.scheduler.PlatformScheduler import androidx.media3.exoplayer.scheduler.PlatformScheduler
import com.player.musicoo.R import com.player.musicoo.R
import com.player.musicoo.util.DemoUtil import com.player.musicoo.util.DownloadUtil
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
@ -34,11 +34,12 @@ class MyDownloadService : DownloadService(
@SuppressLint("ServiceCast") @SuppressLint("ServiceCast")
override fun getDownloadManager(): DownloadManager { override fun getDownloadManager(): DownloadManager {
val downloadManager = DemoUtil.getDownloadManager(this) val downloadManager = DownloadUtil.getDownloadManager(this)
val downloadNotificationHelper = DemoUtil.getDownloadNotificationHelper(this) downloadManager!!.maxParallelDownloads = 3
val downloadNotificationHelper = DownloadUtil.getDownloadNotificationHelper(this)
downloadManager.addListener( downloadManager.addListener(
TerminalStateNotificationHelper( TerminalStateNotificationHelper(
this, downloadNotificationHelper, FOREGROUND_NOTIFICATION_ID + 1 this, downloadNotificationHelper!!, FOREGROUND_NOTIFICATION_ID + 1
) )
) )
return downloadManager return downloadManager
@ -55,7 +56,7 @@ class MyDownloadService : DownloadService(
return DownloadNotificationHelper(this, DOWNLOAD_NOTIFICATION_CHANNEL_ID) return DownloadNotificationHelper(this, DOWNLOAD_NOTIFICATION_CHANNEL_ID)
.buildProgressNotification( .buildProgressNotification(
this, this,
R.mipmap.musicoo_logo_img, R.drawable.download_icon,
null, null,
null, null,
downloads, downloads,
@ -76,22 +77,26 @@ class MyDownloadService : DownloadService(
download: Download, download: Download,
finalException: Exception? finalException: Exception?
) { ) {
val notification: Notification = if (download.state == Download.STATE_COMPLETED) { val notification: Notification = when (download.state) {
notificationHelper.buildDownloadCompletedNotification( Download.STATE_COMPLETED -> {
context, notificationHelper.buildDownloadCompletedNotification(
R.mipmap.ic_download_done, context,
null, R.drawable.download_done_icon,
Util.fromUtf8Bytes(download.request.data) null,
) Util.fromUtf8Bytes(download.request.data)
} else if (download.state == Download.STATE_FAILED) { )
notificationHelper.buildDownloadFailedNotification( }
context, Download.STATE_FAILED -> {
R.mipmap.ic_download_done, notificationHelper.buildDownloadFailedNotification(
null, context,
Util.fromUtf8Bytes(download.request.data) R.drawable.error,
) null,
} else { Util.fromUtf8Bytes(download.request.data)
return )
}
else -> {
return
}
} }
NotificationUtil.setNotification(context, nextNotificationId++, notification) NotificationUtil.setNotification(context, nextNotificationId++, notification)
} }

View File

@ -3,21 +3,16 @@ package com.player.musicoo.service
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Handler import android.os.Handler
import android.util.Log
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.core.text.isDigitsOnly
import androidx.media3.common.AudioAttributes import androidx.media3.common.AudioAttributes
import androidx.media3.common.C import androidx.media3.common.C
import androidx.media3.common.PlaybackException import androidx.media3.common.PlaybackException
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.media3.database.StandaloneDatabaseProvider
import androidx.media3.datasource.DataSource import androidx.media3.datasource.DataSource
import androidx.media3.datasource.DefaultHttpDataSource import androidx.media3.datasource.DefaultHttpDataSource
import androidx.media3.datasource.ResolvingDataSource import androidx.media3.datasource.ResolvingDataSource
import androidx.media3.datasource.cache.CacheDataSource 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.datasource.cache.SimpleCache
import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.RenderersFactory 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.models.bodies.PlayerBody
import com.player.musicoo.innertube.requests.player import com.player.musicoo.innertube.requests.player
import com.player.musicoo.sp.AppStore 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
import com.player.musicoo.util.LogTag.LogD import com.player.musicoo.util.LogTag.LogD
import com.player.musicoo.util.PlayMode import com.player.musicoo.util.PlayMode
@ -53,34 +49,16 @@ import kotlinx.coroutines.runBlocking
class PlaybackService : MediaSessionService(), Player.Listener { class PlaybackService : MediaSessionService(), Player.Listener {
private val TAG = LogTag.VO_SERVICE_LOG private val TAG = LogTag.VO_SERVICE_LOG
private var mediaSession: MediaSession? = null private var mediaSession: MediaSession? = null
private lateinit var cache: SimpleCache
private lateinit var player: ExoPlayer private lateinit var player: ExoPlayer
private val playerCache = CacheManager.getPlayerCache()
private val downloadCache = CacheManager.getDownloadCache()
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
player = ExoPlayer.Builder(this)
val cacheEvictor = when (val size = ExoPlayerDiskCacheMaxSize.`2GB`) { .setMediaSourceFactory(createMediaSourceFactory())
ExoPlayerDiskCacheMaxSize.Unlimited -> NoOpCacheEvictor() .setRenderersFactory(createRendersFactory())
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())
.setHandleAudioBecomingNoisy(true) .setHandleAudioBecomingNoisy(true)
.setWakeMode(C.WAKE_MODE_LOCAL) .setWakeMode(C.WAKE_MODE_LOCAL)
.setAudioAttributes( .setAudioAttributes(
@ -98,10 +76,12 @@ class PlaybackService : MediaSessionService(), Player.Listener {
player.repeatMode = Player.REPEAT_MODE_ALL player.repeatMode = Player.REPEAT_MODE_ALL
player.shuffleModeEnabled = false player.shuffleModeEnabled = false
} }
PlayMode.SINGLE_LOOP.value -> { PlayMode.SINGLE_LOOP.value -> {
player.repeatMode = Player.REPEAT_MODE_ONE player.repeatMode = Player.REPEAT_MODE_ONE
player.shuffleModeEnabled = false player.shuffleModeEnabled = false
} }
PlayMode.RANDOM.value -> { PlayMode.RANDOM.value -> {
player.repeatMode = Player.REPEAT_MODE_ALL player.repeatMode = Player.REPEAT_MODE_ALL
player.shuffleModeEnabled = true player.shuffleModeEnabled = true
@ -146,21 +126,24 @@ class PlaybackService : MediaSessionService(), Player.Listener {
release() release()
mediaSession = null mediaSession = null
} }
cache.release()
super.onDestroy() super.onDestroy()
} }
private fun createCacheDataSource(): CacheDataSource.Factory =
private fun createCacheDataSource(): DataSource.Factory { CacheDataSource.Factory()
return CacheDataSource.Factory().setCache(cache).apply { .setCache(downloadCache!!)
setUpstreamDataSourceFactory( .setUpstreamDataSourceFactory(
DefaultHttpDataSource.Factory() CacheDataSource.Factory()
.setConnectTimeoutMs(16000) .setCache(playerCache!!)
.setReadTimeoutMs(8000) .setUpstreamDataSourceFactory(
.setUserAgent("Mozilla/5.0 (Windows NT 10.0; rv:91.0) Gecko/20100101 Firefox/91.0") 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 { private fun createDataSourceFactory(): DataSource.Factory {
val chunkLength = 512 * 1024L val chunkLength = 512 * 1024L
@ -168,51 +151,62 @@ class PlaybackService : MediaSessionService(), Player.Listener {
return ResolvingDataSource.Factory(createCacheDataSource()) { dataSpec -> return ResolvingDataSource.Factory(createCacheDataSource()) { dataSpec ->
val videoId = dataSpec.key ?: error("A key must be set") val videoId = dataSpec.key ?: error("A key must be set")
if (cache.isCached(videoId, dataSpec.position, chunkLength)) { val position = dataSpec.position
dataSpec val length = if (dataSpec.length >= 0) dataSpec.length else 1
} 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()
}
when (val status = body.playabilityStatus?.status) { if (downloadCache!!.isCached(videoId, position, length)) {
"OK" -> body.streamingData?.highestQualityFormat?.let { format -> LogD(TAG, "playbackService downloadCache contains data for $videoId at position $position")
format.url return@Factory dataSpec
} ?: throw PlayableFormatNotFoundException() }
"UNPLAYABLE" -> throw UnplayableException() if (playerCache!!.isCached(videoId, position, chunkLength)) {
"LOGIN_REQUIRED" -> throw LoginRequiredException() LogD(TAG, "playbackService playerCache contains data for $videoId at position $position")
else -> throw PlaybackException( return@Factory dataSpec
status, }
null,
PlaybackException.ERROR_CODE_REMOTE_ERROR 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 -> when (val status = body.playabilityStatus?.status) {
ringBuffer.append(videoId to url.toUri()) "OK" -> body.streamingData?.highestQualityFormat?.let { format ->
dataSpec.withUri(url.toUri()) format.url
.subrange(dataSpec.uriPositionOffset, chunkLength) } ?: throw PlayableFormatNotFoundException()
} ?: throw PlaybackException(
null, "UNPLAYABLE" -> throw UnplayableException()
urlResult?.exceptionOrNull(), "LOGIN_REQUIRED" -> throw LoginRequiredException()
PlaybackException.ERROR_CODE_REMOTE_ERROR 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 { private fun createMediaSourceFactory(): MediaSource.Factory {
return DefaultMediaSourceFactory(createDataSourceFactory(), createExtractorsFactory()) return DefaultMediaSourceFactory(createDataSourceFactory(), createExtractorsFactory())
} }
@ -227,7 +221,6 @@ class PlaybackService : MediaSessionService(), Player.Listener {
val audioSink = DefaultAudioSink.Builder() val audioSink = DefaultAudioSink.Builder()
.setEnableFloatOutput(false) .setEnableFloatOutput(false)
.setEnableAudioTrackPlaybackParams(false) .setEnableAudioTrackPlaybackParams(false)
// .setOffloadMode(DefaultAudioSink.OFFLOAD_MODE_DISABLED)
.setAudioProcessorChain( .setAudioProcessorChain(
DefaultAudioSink.DefaultAudioProcessorChain( DefaultAudioSink.DefaultAudioProcessorChain(
emptyArray(), 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:height="24dp"
android:viewportWidth="24" android:viewportWidth="24"
android:viewportHeight="24"> android:viewportHeight="24">
<path <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: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" />
android:fillAlpha="0.85"/> <path
<path android:fillColor="#00000000"
android:pathData="M12,8.5V15.5M12,15.5L14.5,13M12,15.5L9.5,13" android:pathData="M12,8.5V15.5M12,15.5L14.5,13M12,15.5L9.5,13"
android:strokeAlpha="0.85" android:strokeWidth="1.5"
android:strokeLineJoin="round" android:strokeColor="#ffffff"
android:strokeWidth="1.5" android:strokeLineCap="round"
android:fillColor="#00000000" android:strokeLineJoin="round" />
android:strokeColor="#ffffff"
android:strokeLineCap="round"/>
</vector> </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"> android:orientation="vertical">
<LinearLayout <LinearLayout
android:id="@+id/downloadBtn" android:layout_width="match_parent"
android:layout_width="40dp" android:layout_height="wrap_content"
android:layout_height="40dp" android:layout_marginStart="20dp"
android:gravity="center"> android:layout_marginTop="16dp"
android:layout_marginEnd="20dp"
android:orientation="horizontal">
<ImageView <LinearLayout
android:layout_width="wrap_content" android:layout_width="0dp"
android:layout_height="wrap_content" 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> </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 <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginStart="16dp" android:layout_marginTop="8dp"
android:layout_marginTop="24dp" android:layout_marginStart="8dp"
android:layout_marginEnd="16dp" android:layout_marginEnd="8dp"
android:gravity="center_vertical" android:gravity="center_vertical"
android:orientation="horizontal"> android:orientation="horizontal">
@ -264,8 +300,8 @@
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginStart="10dp" android:layout_marginStart="12dp"
android:layout_marginEnd="14dp" android:layout_marginEnd="12dp"
android:orientation="horizontal"> android:orientation="horizontal">
<TextView <TextView