diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 64a46cd4f..b02290733 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -4,7 +4,7 @@ on: branches: - '**' pull_request: - branches: + branches: - master - dev @@ -13,15 +13,19 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - - name: Set up JDK 1.8 - uses: actions/setup-java@v1 + + - name: set up JDK 11 + uses: actions/setup-java@v2 + with: + java-version: 11 + distribution: "temurin" + cache: 'gradle' + + - name: Build debug APK and run jvm tests + run: ./gradlew assembleDebug lintDebug testDebugUnitTest --stacktrace -DskipFormatKtlint + + - name: Upload APK + uses: actions/upload-artifact@v2 with: - java-version: 1.8 - - name: Grant execute permission for gradlew - run: chmod +x gradlew - - name: Check Lint - run: sudo ./gradlew lintDebug - - name: Run tests - run: sudo ./gradlew test - - name: Build with Gradle - run: sudo ./gradlew build + name: app + path: app/build/outputs/apk/debug/*.apk \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle index 60f8c84d9..a87ec2fd6 100755 --- a/app/build.gradle +++ b/app/build.gradle @@ -12,8 +12,8 @@ android { applicationId "com.zionhuang.music" minSdkVersion 26 targetSdkVersion 31 - versionCode 4 - versionName "0.1.3-beta" + versionCode 5 + versionName "0.2.0-beta" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } applicationVariants.all { variant -> diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 114540510..0bd655ee7 100755 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -35,3 +35,63 @@ -keep class org.ocpsoft.prettytime.i18n.** { *; } -keep class org.mozilla.javascript.** { *; } + +##---------------Begin: proguard configuration for Gson ---------- +# Gson uses generic type information stored in a class file when working with fields. Proguard +# removes such information by default, so configure it to keep all of it. +-keepattributes Signature + +# For using GSON @Expose annotation +-keepattributes *Annotation* + +# Gson specific classes +-dontwarn sun.misc.** +#-keep class com.google.gson.stream.** { *; } + +# Application classes that will be serialized/deserialized over Gson +-keep class com.google.gson.examples.android.model.** { ; } + +# Prevent proguard from stripping interface information from TypeAdapter, TypeAdapterFactory, +# JsonSerializer, JsonDeserializer instances (so they can be used in @JsonAdapter) +-keep class * extends com.google.gson.TypeAdapter +-keep class * implements com.google.gson.TypeAdapterFactory +-keep class * implements com.google.gson.JsonSerializer +-keep class * implements com.google.gson.JsonDeserializer + +# Prevent R8 from leaving Data object members always null +-keepclassmembers,allowobfuscation class * { + @com.google.gson.annotations.SerializedName ; +} + +# Retain generic signatures of TypeToken and its subclasses with R8 version 3.0 and higher. +-keep,allowobfuscation,allowshrinking class com.google.gson.reflect.TypeToken +-keep,allowobfuscation,allowshrinking class * extends com.google.gson.reflect.TypeToken +##---------------End: proguard configuration for Gson ---------- + +## Kotlin Serialization +# Keep `Companion` object fields of serializable classes. +# This avoids serializer lookup through `getDeclaredClasses` as done for named companion objects. +-if @kotlinx.serialization.Serializable class ** +-keepclassmembers class <1> { + static <1>$Companion Companion; +} + +# Keep `serializer()` on companion objects (both default and named) of serializable classes. +-if @kotlinx.serialization.Serializable class ** { + static **$* *; +} +-keepclassmembers class <2>$<3> { + kotlinx.serialization.KSerializer serializer(...); +} + +# Keep `INSTANCE.serializer()` of serializable objects. +-if @kotlinx.serialization.Serializable class ** { + public static ** INSTANCE; +} +-keepclassmembers class <1> { + public static <1> INSTANCE; + kotlinx.serialization.KSerializer serializer(...); +} + +# @Serializable and @Polymorphic are used at runtime for polymorphic serialization. +-keepattributes RuntimeVisibleAnnotations,AnnotationDefault diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 3ca3eb58b..9c891a397 100755 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -22,7 +22,7 @@ @@ -30,6 +30,159 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -52,7 +205,8 @@ + android:exported="true" + android:foregroundServiceType="mediaPlayback"> diff --git a/app/src/main/java/com/zionhuang/music/download/DownloadBroadcastReceiver.kt b/app/src/main/java/com/zionhuang/music/download/DownloadBroadcastReceiver.kt index 07ae1bb6b..7d892a734 100644 --- a/app/src/main/java/com/zionhuang/music/download/DownloadBroadcastReceiver.kt +++ b/app/src/main/java/com/zionhuang/music/download/DownloadBroadcastReceiver.kt @@ -29,7 +29,7 @@ class DownloadBroadcastReceiver : BroadcastReceiver() { val songId = songRepository.getDownloadEntity(id)?.songId ?: return@launch val song = songRepository.getSongById(songId) ?: return@launch songRepository.updateSong(song.copy(downloadState = if (isSuccess) STATE_DOWNLOADED else STATE_NOT_DOWNLOADED)) - songRepository.removeDownload(id) + songRepository.removeDownloadEntity(id) } } } diff --git a/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt b/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt index fe1207cb5..f3a5cf88c 100644 --- a/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt +++ b/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt @@ -1,6 +1,7 @@ package com.zionhuang.music.playback import android.app.PendingIntent +import android.app.PendingIntent.FLAG_IMMUTABLE import android.content.Context import android.content.Intent import android.graphics.Bitmap @@ -12,6 +13,7 @@ import android.os.ResultReceiver import android.support.v4.media.MediaDescriptionCompat import android.support.v4.media.session.MediaSessionCompat import android.support.v4.media.session.PlaybackStateCompat.* +import android.util.Log import android.util.Pair import androidx.core.content.getSystemService import androidx.core.net.toUri @@ -21,8 +23,7 @@ import com.google.android.exoplayer2.C import com.google.android.exoplayer2.ExoPlayer import com.google.android.exoplayer2.MediaItem import com.google.android.exoplayer2.Player -import com.google.android.exoplayer2.Player.MEDIA_ITEM_TRANSITION_REASON_REPEAT -import com.google.android.exoplayer2.Player.STATE_IDLE +import com.google.android.exoplayer2.Player.* import com.google.android.exoplayer2.audio.AudioAttributes import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector import com.google.android.exoplayer2.ext.mediasession.TimelineQueueEditor.* @@ -124,7 +125,6 @@ class SongPlayer( setAudioAttributes(audioAttributes, true) } - private var autoDownload by context.preference(R.string.pref_auto_download, false) private var autoAddSong by context.preference(R.string.pref_auto_add_song, true) private fun updateMediaData(mediaId: String, applier: MediaData.() -> Unit) { @@ -204,22 +204,8 @@ class SongPlayer( } } setCustomActionProviders(context.createCustomAction(ACTION_ADD_TO_LIBRARY, R.string.custom_action_add_to_library, R.drawable.ic_library_add) { _, _, _ -> - scope.launch { - player.currentMetadata?.let { - it.artwork - localRepository.addSong(Song( - id = it.id, - title = it.title, - artistName = it.artist, - duration = if (player.duration != C.TIME_UNSET) (player.duration / 1000).toInt() else -1, - artworkType = it.artworkType - )) - if (autoDownload) { - player.currentMetadata?.let { metadata -> - localRepository.downloadSong(metadata.id) - } - } - } + player.currentMetadata?.let { + addToLibrary(it) } }) setQueueNavigator { player, windowIndex -> player.getMediaItemAt(windowIndex).metadata.toMediaDescription() } @@ -280,7 +266,7 @@ class SongPlayer( } override fun createCurrentContentIntent(player: Player): PendingIntent? = - PendingIntent.getActivity(context, 0, Intent(context, MainActivity::class.java), 0) + PendingIntent.getActivity(context, 0, Intent(context, MainActivity::class.java), FLAG_IMMUTABLE) }) .setChannelNameResourceId(R.string.channel_name_playback) .setNotificationListener(notificationListener) @@ -305,6 +291,34 @@ class SongPlayer( } } + override fun onPositionDiscontinuity(oldPosition: PositionInfo, newPosition: PositionInfo, @Player.DiscontinuityReason reason: Int) { + if (reason == DISCONTINUITY_REASON_AUTO_TRANSITION && autoAddSong) { + oldPosition.mediaItem?.metadata?.let { + addToLibrary(it) + } + } + } + + override fun onPlaybackStateChanged(@Player.State playbackState: Int) { + if (playbackState == STATE_ENDED && autoAddSong) { + player.currentMetadata?.let { + addToLibrary(it) + } + } + } + + private fun addToLibrary(mediaData: MediaData) { + scope.launch { + localRepository.addSong(Song( + id = mediaData.id, + title = mediaData.title, + artistName = mediaData.artist, + duration = if (player.duration != C.TIME_UNSET) (player.duration / 1000).toInt() else -1, + artworkType = mediaData.artworkType + )) + } + } + fun release() { mediaSession.apply { isActive = false diff --git a/app/src/main/java/com/zionhuang/music/repos/SongRepository.kt b/app/src/main/java/com/zionhuang/music/repos/SongRepository.kt index fabbf94cc..1833c1698 100644 --- a/app/src/main/java/com/zionhuang/music/repos/SongRepository.kt +++ b/app/src/main/java/com/zionhuang/music/repos/SongRepository.kt @@ -4,6 +4,7 @@ import android.app.DownloadManager import android.util.Log import androidx.core.content.getSystemService import androidx.core.net.toUri +import com.zionhuang.music.R import com.zionhuang.music.constants.MediaConstants.STATE_DOWNLOADED import com.zionhuang.music.constants.MediaConstants.STATE_DOWNLOADING import com.zionhuang.music.constants.MediaConstants.STATE_NOT_DOWNLOADED @@ -17,6 +18,7 @@ import com.zionhuang.music.db.entities.* import com.zionhuang.music.extensions.TAG import com.zionhuang.music.extensions.div import com.zionhuang.music.extensions.getApplication +import com.zionhuang.music.extensions.preference import com.zionhuang.music.models.ListWrapper import com.zionhuang.music.models.base.ISortInfo import com.zionhuang.music.repos.base.LocalRepository @@ -38,6 +40,8 @@ object SongRepository : LocalRepository { private val downloadDao: DownloadDao = musicDatabase.downloadDao private val remoteRepository: RemoteRepository = YouTubeRepository + private var autoDownload by context.preference(R.string.pref_auto_download, false) + override suspend fun getSongById(songId: String): Song? = withContext(IO) { songDao.getSong(songId) } override fun searchSongs(query: String) = ListWrapper( getPagingSource = { songDao.searchSongsAsPagingSource(query) } @@ -51,6 +55,9 @@ object SongRepository : LocalRepository { songDao.insert(listOf(it.toSongEntity().copy( duration = if (it.duration == -1) stream.duration.toInt() else it.duration) )) + if (autoDownload) { + downloadSong(it.id) + } } catch (e: Exception) { // TODO: Handle error Log.d(TAG, e.localizedMessage.orEmpty()) @@ -76,7 +83,7 @@ object SongRepository : LocalRepository { override suspend fun downloadSongs(songIds: List) = songIds.forEach { id -> // the given songs should be already added to the local repository val song = getSongById(id) ?: return@forEach - if (song.downloadState == STATE_DOWNLOADED) return@forEach + if (song.downloadState != STATE_NOT_DOWNLOADED) return@forEach updateSong(song.copy(downloadState = STATE_PREPARING)) try { val streamInfo = remoteRepository.getStream(id) @@ -95,19 +102,24 @@ object SongRepository : LocalRepository { } } - override suspend fun deleteLocalMedia(songId: String) { - val song = getSongById(songId) ?: return + override suspend fun removeDownloads(songIds: List) = songIds.forEach { songId -> + val song = getSongById(songId) ?: return@forEach + if (song.downloadState != STATE_DOWNLOADED) return@forEach if (getSongFile(songId).delete()) { updateSong(song.copy(downloadState = STATE_NOT_DOWNLOADED)) } } override fun getSongFile(songId: String): File { - return context.getExternalFilesDir(null)!! / "media" / md5(songId) + val mediaDir = context.getExternalFilesDir(null)!! / "media" + if (!mediaDir.isDirectory) mediaDir.mkdirs() + return mediaDir / md5(songId) } override fun getSongArtworkFile(songId: String): File { - return context.getExternalFilesDir(null)!! / "artwork" / md5(songId) + val artworkDir = context.getExternalFilesDir(null)!! / "artwork" + if (!artworkDir.isDirectory) artworkDir.mkdirs() + return artworkDir / md5(songId) } @@ -188,7 +200,7 @@ object SongRepository : LocalRepository { override suspend fun getDownloadEntity(downloadId: Long): DownloadEntity? = withContext(IO) { downloadDao.getDownloadEntity(downloadId) } override suspend fun addDownload(item: DownloadEntity) = withContext(IO) { downloadDao.insert(item) } - override suspend fun removeDownload(downloadId: Long) = withContext(IO) { downloadDao.delete(downloadId) } + override suspend fun removeDownloadEntity(downloadId: Long) = withContext(IO) { downloadDao.delete(downloadId) } private suspend fun Song.toSongEntity() = SongEntity( diff --git a/app/src/main/java/com/zionhuang/music/repos/base/LocalRepository.kt b/app/src/main/java/com/zionhuang/music/repos/base/LocalRepository.kt index 04b746490..92778f462 100644 --- a/app/src/main/java/com/zionhuang/music/repos/base/LocalRepository.kt +++ b/app/src/main/java/com/zionhuang/music/repos/base/LocalRepository.kt @@ -18,7 +18,8 @@ interface LocalRepository { suspend fun setLiked(liked: Boolean, songs: List) suspend fun downloadSongs(songIds: List) suspend fun downloadSong(songId: String) = downloadSongs(listOf(songId)) - suspend fun deleteLocalMedia(songId: String) + suspend fun removeDownloads(songIds: List) + suspend fun removeDownload(songId: String) = removeDownloads(listOf(songId)) fun getSongFile(songId: String): File fun getSongArtworkFile(songId: String): File @@ -49,5 +50,5 @@ interface LocalRepository { fun getAllDownloads(): ListWrapper suspend fun getDownloadEntity(downloadId: Long): DownloadEntity? suspend fun addDownload(item: DownloadEntity) - suspend fun removeDownload(downloadId: Long) + suspend fun removeDownloadEntity(downloadId: Long) } \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/activities/MainActivity.kt b/app/src/main/java/com/zionhuang/music/ui/activities/MainActivity.kt index b188980a6..4d9598543 100644 --- a/app/src/main/java/com/zionhuang/music/ui/activities/MainActivity.kt +++ b/app/src/main/java/com/zionhuang/music/ui/activities/MainActivity.kt @@ -1,9 +1,15 @@ package com.zionhuang.music.ui.activities +import android.content.Intent +import android.content.Intent.ACTION_VIEW +import android.content.Intent.EXTRA_TEXT +import android.net.Uri import android.os.Bundle import android.support.v4.media.session.PlaybackStateCompat.STATE_NONE +import android.util.Log import android.view.ActionMode import android.view.View +import androidx.core.os.bundleOf import androidx.core.view.isVisible import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.lifecycleScope @@ -15,17 +21,24 @@ import androidx.navigation.ui.AppBarConfiguration import androidx.navigation.ui.setupWithNavController import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetBehavior.* -import com.google.android.material.snackbar.Snackbar import com.zionhuang.music.R +import com.zionhuang.music.constants.MediaConstants.EXTRA_QUEUE_DATA +import com.zionhuang.music.constants.MediaConstants.QUEUE_YT_SINGLE import com.zionhuang.music.databinding.ActivityMainBinding +import com.zionhuang.music.extensions.TAG import com.zionhuang.music.extensions.getDensity import com.zionhuang.music.extensions.replaceFragment +import com.zionhuang.music.models.QueueData import com.zionhuang.music.ui.fragments.BottomControlsFragment import com.zionhuang.music.ui.widgets.BottomSheetListener import com.zionhuang.music.ui.widgets.MainFloatingActionButton import com.zionhuang.music.viewmodels.PlaybackViewModel import com.zionhuang.music.viewmodels.SongsViewModel +import com.zionhuang.music.youtube.NewPipeYouTubeHelper.extractVideoId +import com.zionhuang.music.youtube.NewPipeYouTubeHelper.getLinkType +import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import org.schabi.newpipe.extractor.StreamingService.LinkType class MainActivity : BindingActivity(), NavController.OnDestinationChangedListener { override fun getViewBinding() = ActivityMainBinding.inflate(layoutInflater) @@ -33,8 +46,8 @@ class MainActivity : BindingActivity(), NavController.OnDes private var bottomSheetCallback: BottomSheetListener? = null private lateinit var bottomSheetBehavior: BottomSheetBehavior<*> - private val songsViewModel by lazy { ViewModelProvider(this).get(SongsViewModel::class.java) } - private val playbackViewModel by lazy { ViewModelProvider(this).get(PlaybackViewModel::class.java) } + private val songsViewModel by lazy { ViewModelProvider(this)[SongsViewModel::class.java] } + private val playbackViewModel by lazy { ViewModelProvider(this)[PlaybackViewModel::class.java] } val fab: MainFloatingActionButton get() = binding.fab @@ -43,6 +56,7 @@ class MainActivity : BindingActivity(), NavController.OnDes override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setupUI() + handleIntent(intent) // TODO // songsViewModel.deletedSongs.observe(this) { songs -> // Snackbar.make(binding.root, resources.getQuantityString(R.plurals.snack_bar_delete_song, songs.size, songs.size), Snackbar.LENGTH_LONG) @@ -56,19 +70,41 @@ class MainActivity : BindingActivity(), NavController.OnDes // } } + override fun onNewIntent(intent: Intent?) { + super.onNewIntent(intent) + intent?.let { handleIntent(it) } + } + + private fun handleIntent(intent: Intent) { + // Handle url + val url = (intent.data ?: intent.getStringExtra(EXTRA_TEXT)).toString() + Log.d(TAG, "${intent.action} ${url}") + when (getLinkType(url)) { + LinkType.STREAM -> { + lifecycleScope.launch { + while (playbackViewModel.mediaSessionIsConnected.value == false) delay(100) + val videoId = extractVideoId(url)!! + playbackViewModel.playMedia(this@MainActivity, videoId, bundleOf( + EXTRA_QUEUE_DATA to QueueData(QUEUE_YT_SINGLE, queueId = videoId) + )) + } + } + LinkType.CHANNEL -> {} + LinkType.PLAYLIST -> {} + LinkType.NONE -> {} + } + } + private fun setupUI() { setSupportActionBar(binding.toolbar) - val navHostFragment = - supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment + val navHostFragment = supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment val navController = navHostFragment.navController - val appBarConfiguration = AppBarConfiguration( - setOf( - R.id.songsFragment, - R.id.artistsFragment, - R.id.playlistsFragment, - R.id.explorationFragment - ) - ) + val appBarConfiguration = AppBarConfiguration(setOf( + R.id.songsFragment, + R.id.artistsFragment, + R.id.playlistsFragment, + R.id.explorationFragment + )) binding.toolbar.setupWithNavController(navController, appBarConfiguration) binding.bottomNav.setupWithNavController(navController) navController.addOnDestinationChangedListener(this) @@ -89,11 +125,7 @@ class MainActivity : BindingActivity(), NavController.OnDes } } - override fun onDestinationChanged( - controller: NavController, - destination: NavDestination, - arguments: Bundle? - ) { + override fun onDestinationChanged(controller: NavController, destination: NavDestination, arguments: Bundle?) { actionMode?.finish() if (destination.id == R.id.playlistsFragment) { binding.fab.show() @@ -104,10 +136,7 @@ class MainActivity : BindingActivity(), NavController.OnDes override fun onWindowFocusChanged(hasFocus: Boolean) { super.onWindowFocusChanged(hasFocus) - bottomSheetBehavior.setPeekHeight( - binding.bottomNav.height + (54 * getDensity()).toInt(), - true - ) + bottomSheetBehavior.setPeekHeight(binding.bottomNav.height + (54 * getDensity()).toInt(), true) } fun setBottomSheetListener(bottomSheetListener: BottomSheetListener) { diff --git a/app/src/main/java/com/zionhuang/music/ui/fragments/BottomControlsFragment.kt b/app/src/main/java/com/zionhuang/music/ui/fragments/BottomControlsFragment.kt index 21709f216..cec6e0145 100644 --- a/app/src/main/java/com/zionhuang/music/ui/fragments/BottomControlsFragment.kt +++ b/app/src/main/java/com/zionhuang/music/ui/fragments/BottomControlsFragment.kt @@ -1,5 +1,6 @@ package com.zionhuang.music.ui.fragments +import android.content.Intent import android.os.Bundle import android.support.v4.media.session.MediaControllerCompat import android.support.v4.media.session.PlaybackStateCompat.STATE_NONE @@ -8,6 +9,7 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.constraintlayout.motion.widget.MotionLayout +import androidx.core.net.toUri import androidx.core.view.isVisible import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels @@ -23,6 +25,7 @@ import com.zionhuang.music.ui.widgets.BottomSheetListener import com.zionhuang.music.ui.widgets.MediaWidgetsController import com.zionhuang.music.ui.widgets.PlayPauseBehavior import com.zionhuang.music.viewmodels.PlaybackViewModel +import com.zionhuang.music.youtube.NewPipeYouTubeHelper.videoIdToUrl class BottomControlsFragment : Fragment(), BottomSheetListener, MotionLayout.TransitionListener { companion object { @@ -85,6 +88,12 @@ class BottomControlsFragment : Fragment(), BottomSheetListener, MotionLayout.Tra viewModel.transportControls?.sendCustomAction(ACTION_TOGGLE_LIKE, null) } + binding.btnShare.setOnClickListener { + viewModel.mediaData.value?.id?.let { id -> + startActivity(Intent(Intent.ACTION_VIEW, videoIdToUrl(id)?.toUri())) + } + } + with(PlayPauseBehavior(requireContext())) { binding.btnBtmPlayPause.setBehavior(this) binding.btnPlayPause.setBehavior(this) diff --git a/app/src/main/java/com/zionhuang/music/ui/fragments/SongsFragment.kt b/app/src/main/java/com/zionhuang/music/ui/fragments/SongsFragment.kt index d4e4a5014..f305e46c0 100644 --- a/app/src/main/java/com/zionhuang/music/ui/fragments/SongsFragment.kt +++ b/app/src/main/java/com/zionhuang/music/ui/fragments/SongsFragment.kt @@ -99,6 +99,7 @@ class SongsFragment : BindingFragment() { R.id.action_add_to_queue -> songsViewModel.songPopupMenuListener.addToQueue(songs, requireContext()) R.id.action_add_to_playlist -> songsViewModel.songPopupMenuListener.addToPlaylist(songs, requireContext()) R.id.action_download -> songsViewModel.songPopupMenuListener.downloadSongs(tracker.selection.toList(), requireContext()) + R.id.action_remove_download -> songsViewModel.songPopupMenuListener.removeDownloads(tracker.selection.toList(), requireContext()) R.id.action_delete -> songsViewModel.songPopupMenuListener.deleteSongs(songs) } true diff --git a/app/src/main/java/com/zionhuang/music/ui/fragments/YouTubeChannelFragment.kt b/app/src/main/java/com/zionhuang/music/ui/fragments/YouTubeChannelFragment.kt index 22306651f..e418fdd80 100644 --- a/app/src/main/java/com/zionhuang/music/ui/fragments/YouTubeChannelFragment.kt +++ b/app/src/main/java/com/zionhuang/music/ui/fragments/YouTubeChannelFragment.kt @@ -19,6 +19,7 @@ import com.zionhuang.music.databinding.LayoutRecyclerviewBinding import com.zionhuang.music.extensions.addOnClickListener import com.zionhuang.music.extensions.themeColor import com.zionhuang.music.models.QueueData +import com.zionhuang.music.ui.activities.MainActivity import com.zionhuang.music.ui.adapters.InfoItemAdapter import com.zionhuang.music.ui.adapters.LoadStateAdapter import com.zionhuang.music.ui.fragments.base.BindingFragment @@ -84,6 +85,8 @@ class YouTubeChannelFragment : BindingFragment() { } lifecycleScope.launch { + val channel = viewModel.getChannelInfo(channelId) + (requireActivity() as MainActivity).supportActionBar?.title = channel.name viewModel.getChannel(channelId).collectLatest { infoItemAdapter.submitData(it) } diff --git a/app/src/main/java/com/zionhuang/music/ui/fragments/YouTubePlaylistFragment.kt b/app/src/main/java/com/zionhuang/music/ui/fragments/YouTubePlaylistFragment.kt index 3a736386f..fa9163a00 100644 --- a/app/src/main/java/com/zionhuang/music/ui/fragments/YouTubePlaylistFragment.kt +++ b/app/src/main/java/com/zionhuang/music/ui/fragments/YouTubePlaylistFragment.kt @@ -19,10 +19,12 @@ import com.zionhuang.music.databinding.LayoutRecyclerviewBinding import com.zionhuang.music.extensions.addOnClickListener import com.zionhuang.music.extensions.themeColor import com.zionhuang.music.models.QueueData +import com.zionhuang.music.ui.activities.MainActivity import com.zionhuang.music.ui.adapters.InfoItemAdapter import com.zionhuang.music.ui.adapters.LoadStateAdapter import com.zionhuang.music.ui.fragments.base.BindingFragment import com.zionhuang.music.viewmodels.PlaybackViewModel +import com.zionhuang.music.viewmodels.SongsViewModel import com.zionhuang.music.viewmodels.YouTubePlaylistViewModel import com.zionhuang.music.youtube.NewPipeYouTubeHelper import kotlinx.coroutines.flow.collectLatest @@ -36,6 +38,7 @@ class YouTubePlaylistFragment : BindingFragment() { private val playlistId by lazy { args.playlistId } private val viewModel by viewModels() + private val songsViewModel by activityViewModels() private val playbackViewModel by activityViewModels() private val infoItemAdapter = InfoItemAdapter() @@ -51,12 +54,15 @@ class YouTubePlaylistFragment : BindingFragment() { } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - infoItemAdapter.addLoadStateListener { loadState -> - binding.progressBar.isVisible = loadState.refresh is LoadState.Loading - binding.btnRetry.isVisible = loadState.refresh is LoadState.Error - binding.errorMsg.isVisible = loadState.refresh is LoadState.Error - if (loadState.refresh is LoadState.Error) { - binding.errorMsg.text = (loadState.refresh as LoadState.Error).error.localizedMessage + infoItemAdapter.apply { + streamMenuListener = songsViewModel.streamPopupMenuListener + addLoadStateListener { loadState -> + binding.progressBar.isVisible = loadState.refresh is LoadState.Loading + binding.btnRetry.isVisible = loadState.refresh is LoadState.Error + binding.errorMsg.isVisible = loadState.refresh is LoadState.Error + if (loadState.refresh is LoadState.Error) { + binding.errorMsg.text = (loadState.refresh as LoadState.Error).error.localizedMessage + } } } @@ -77,6 +83,8 @@ class YouTubePlaylistFragment : BindingFragment() { } lifecycleScope.launch { + val playlist = viewModel.getPlaylistInfo(playlistId) + (requireActivity() as MainActivity).supportActionBar?.title = playlist.name viewModel.getPlaylist(playlistId).collectLatest { infoItemAdapter.submitData(it) } diff --git a/app/src/main/java/com/zionhuang/music/ui/listeners/SongPopupMenuListener.kt b/app/src/main/java/com/zionhuang/music/ui/listeners/SongPopupMenuListener.kt index 84eb0f4af..ddc3afebd 100644 --- a/app/src/main/java/com/zionhuang/music/ui/listeners/SongPopupMenuListener.kt +++ b/app/src/main/java/com/zionhuang/music/ui/listeners/SongPopupMenuListener.kt @@ -9,11 +9,13 @@ interface SongPopupMenuListener { fun addToQueue(songs: List, context: Context) fun addToPlaylist(songs: List, context: Context) fun downloadSongs(songIds: List, context: Context) + fun removeDownloads(songIds: List, context: Context) fun deleteSongs(songs: List) fun playNext(song: Song, context: Context) = playNext(listOf(song), context) fun addToQueue(song: Song, context: Context) = addToQueue(listOf(song), context) fun addToPlaylist(song: Song, context: Context) = addToPlaylist(listOf(song), context) - fun downloadSongs(songId: String, context: Context) = downloadSongs(listOf(songId), context) + fun downloadSong(songId: String, context: Context) = downloadSongs(listOf(songId), context) + fun removeDownload(songId: String, context: Context) = removeDownloads(listOf(songId), context) fun deleteSongs(song: Song) = deleteSongs(listOf(song)) } \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/viewholders/SearchHeaderViewHolder.kt b/app/src/main/java/com/zionhuang/music/ui/viewholders/SearchHeaderViewHolder.kt index a4e85f5af..e34117977 100644 --- a/app/src/main/java/com/zionhuang/music/ui/viewholders/SearchHeaderViewHolder.kt +++ b/app/src/main/java/com/zionhuang/music/ui/viewholders/SearchHeaderViewHolder.kt @@ -18,6 +18,7 @@ class SearchHeaderViewHolder( R.id.chip_all -> ALL R.id.chip_songs -> MUSIC_SONGS R.id.chip_videos -> MUSIC_VIDEOS + R.id.chip_albums -> MUSIC_ALBUMS R.id.chip_playlists -> PLAYLISTS R.id.chip_channels -> CHANNELS else -> throw IllegalArgumentException("Unexpected filter type.") @@ -28,9 +29,10 @@ class SearchHeaderViewHolder( fun bind() { when (listener.filter) { - //ALL -> binding.chipAll + ALL -> binding.chipAll MUSIC_SONGS -> binding.chipSongs - VIDEOS -> binding.chipVideos + MUSIC_VIDEOS -> binding.chipVideos + MUSIC_ALBUMS -> binding.chipAlbums PLAYLISTS -> binding.chipPlaylists CHANNELS -> binding.chipChannels else -> null diff --git a/app/src/main/java/com/zionhuang/music/ui/viewholders/SearchPlaylistViewHolder.kt b/app/src/main/java/com/zionhuang/music/ui/viewholders/SearchPlaylistViewHolder.kt index fdf40e24e..6a0dcae5e 100644 --- a/app/src/main/java/com/zionhuang/music/ui/viewholders/SearchPlaylistViewHolder.kt +++ b/app/src/main/java/com/zionhuang/music/ui/viewholders/SearchPlaylistViewHolder.kt @@ -13,7 +13,11 @@ class SearchPlaylistViewHolder(private val binding: ItemSearchPlaylistBinding) : binding.root.transitionName = binding.context.resources.getString(R.string.youtube_playlist_item_transition_name, item.url) binding.playlistTitle.text = item.name binding.uploader.text = item.uploaderName - binding.streams.text = item.streamCount.toString() + if (item.streamCount > 0) { + binding.streams.text = item.streamCount.toString() + } else { + binding.thumbnail.alpha = 1f + } binding.thumbnail.load(item.thumbnailUrl) { placeholder(R.drawable.ic_music_note) roundCorner(binding.thumbnail.context.resources.getDimensionPixelSize(R.dimen.song_cover_radius)) diff --git a/app/src/main/java/com/zionhuang/music/ui/viewholders/SongViewHolder.kt b/app/src/main/java/com/zionhuang/music/ui/viewholders/SongViewHolder.kt index fdb90a628..03c027870 100644 --- a/app/src/main/java/com/zionhuang/music/ui/viewholders/SongViewHolder.kt +++ b/app/src/main/java/com/zionhuang/music/ui/viewholders/SongViewHolder.kt @@ -4,6 +4,7 @@ import android.widget.PopupMenu import androidx.recyclerview.selection.ItemDetailsLookup import androidx.recyclerview.widget.RecyclerView import com.zionhuang.music.R +import com.zionhuang.music.constants.MediaConstants.STATE_DOWNLOADED import com.zionhuang.music.constants.MediaConstants.STATE_NOT_DOWNLOADED import com.zionhuang.music.databinding.ItemSongBinding import com.zionhuang.music.db.entities.Song @@ -32,12 +33,14 @@ open class SongViewHolder( R.id.action_play_next -> popupMenuListener?.playNext(song, binding.context) R.id.action_add_to_queue -> popupMenuListener?.addToQueue(song, binding.context) R.id.action_add_to_playlist -> popupMenuListener?.addToPlaylist(song, binding.context) - R.id.action_download -> popupMenuListener?.downloadSongs(song.id, binding.context) + R.id.action_download -> popupMenuListener?.downloadSong(song.id, binding.context) + R.id.action_remove_download -> popupMenuListener?.removeDownload(song.id, binding.context) R.id.action_delete -> popupMenuListener?.deleteSongs(song) } true } menu.findItem(R.id.action_download).isVisible = song.downloadState == STATE_NOT_DOWNLOADED + menu.findItem(R.id.action_remove_download).isVisible = song.downloadState == STATE_DOWNLOADED show() } } diff --git a/app/src/main/java/com/zionhuang/music/update/Asset.kt b/app/src/main/java/com/zionhuang/music/update/Asset.kt index 88161f82a..98c3aa658 100644 --- a/app/src/main/java/com/zionhuang/music/update/Asset.kt +++ b/app/src/main/java/com/zionhuang/music/update/Asset.kt @@ -3,13 +3,10 @@ package com.zionhuang.music.update import com.google.gson.annotations.SerializedName data class Asset( - val id: String, - @SerializedName("node_id") - val nodeId: String, - val name: String, - @SerializedName("content_type") - val contentType: String, - val size: Long, - @SerializedName("browser_download_url") - val downloadUrl: String, + @SerializedName("id") val id: String, + @SerializedName("node_id") val nodeId: String, + @SerializedName("name") val name: String, + @SerializedName("content_type") val contentType: String, + @SerializedName("size") val size: Long, + @SerializedName("browser_download_url") val downloadUrl: String, ) diff --git a/app/src/main/java/com/zionhuang/music/update/Release.kt b/app/src/main/java/com/zionhuang/music/update/Release.kt index 263b8fa18..851d1018b 100644 --- a/app/src/main/java/com/zionhuang/music/update/Release.kt +++ b/app/src/main/java/com/zionhuang/music/update/Release.kt @@ -3,17 +3,14 @@ package com.zionhuang.music.update import com.google.gson.annotations.SerializedName data class Release( - val id: Int, - val url: String, - @SerializedName("node_id") - val nodeId: String, - @SerializedName("tag_name") - val tagName: String, - val name: String, - @SerializedName("prerelease") - val preRelease: Boolean, - val body: String, - val assets: List, + @SerializedName("id") val id: Int, + @SerializedName("url") val url: String, + @SerializedName("node_id") val nodeId: String, + @SerializedName("tag_name") val tagName: String, + @SerializedName("name") val name: String, + @SerializedName("prerelease") val preRelease: Boolean, + @SerializedName("body") val body: String, + @SerializedName("assets") val assets: List, ) { val version: Version get() = Version.parse(name) } diff --git a/app/src/main/java/com/zionhuang/music/viewmodels/PlaybackViewModel.kt b/app/src/main/java/com/zionhuang/music/viewmodels/PlaybackViewModel.kt index 30b59c728..a48a882ba 100644 --- a/app/src/main/java/com/zionhuang/music/viewmodels/PlaybackViewModel.kt +++ b/app/src/main/java/com/zionhuang/music/viewmodels/PlaybackViewModel.kt @@ -33,7 +33,7 @@ class PlaybackViewModel(application: Application) : AndroidViewModel(application private val mediaMetadataObserver = Observer { mediaMetadata -> if (mediaMetadata != null) { _mediaData.postValue(mediaData.value?.pullMediaMetadata(mediaMetadata) - ?: mediaMetadata.toMediaData()) + ?: mediaMetadata.toMediaData()) } } @@ -54,7 +54,9 @@ class PlaybackViewModel(application: Application) : AndroidViewModel(application queueData.observeForever(queueDataObserver) } - val mediaController: LiveData = mediaSessionConnection.isConnected.map { isConnected -> + val mediaSessionIsConnected = mediaSessionConnection.isConnected + + val mediaController: LiveData = mediaSessionIsConnected.map { isConnected -> if (isConnected) mediaSessionConnection.mediaController else null } diff --git a/app/src/main/java/com/zionhuang/music/viewmodels/SongsViewModel.kt b/app/src/main/java/com/zionhuang/music/viewmodels/SongsViewModel.kt index e1ca65d62..d09d2e537 100644 --- a/app/src/main/java/com/zionhuang/music/viewmodels/SongsViewModel.kt +++ b/app/src/main/java/com/zionhuang/music/viewmodels/SongsViewModel.kt @@ -126,6 +126,12 @@ class SongsViewModel(application: Application) : AndroidViewModel(application) { } } + override fun removeDownloads(songIds: List, context: Context) { + viewModelScope.launch { + songRepository.removeDownloads(songIds) + } + } + override fun deleteSongs(songs: List) { viewModelScope.launch { songRepository.deleteSongs(songs) diff --git a/app/src/main/java/com/zionhuang/music/viewmodels/YouTubeChannelViewModel.kt b/app/src/main/java/com/zionhuang/music/viewmodels/YouTubeChannelViewModel.kt index ee2bd2c61..86fa0870e 100644 --- a/app/src/main/java/com/zionhuang/music/viewmodels/YouTubeChannelViewModel.kt +++ b/app/src/main/java/com/zionhuang/music/viewmodels/YouTubeChannelViewModel.kt @@ -7,8 +7,11 @@ import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.cachedIn import com.zionhuang.music.repos.YouTubeRepository +import com.zionhuang.music.youtube.NewPipeYouTubeHelper class YouTubeChannelViewModel(application: Application) : AndroidViewModel(application) { + suspend fun getChannelInfo(channelId: String) = NewPipeYouTubeHelper.getChannel(channelId) + fun getChannel(channelId: String) = Pager(PagingConfig(pageSize = 20)) { YouTubeRepository.getChannel(channelId) }.flow.cachedIn(viewModelScope) diff --git a/app/src/main/java/com/zionhuang/music/viewmodels/YouTubePlaylistViewModel.kt b/app/src/main/java/com/zionhuang/music/viewmodels/YouTubePlaylistViewModel.kt index f2ddf41ff..c9442ae42 100644 --- a/app/src/main/java/com/zionhuang/music/viewmodels/YouTubePlaylistViewModel.kt +++ b/app/src/main/java/com/zionhuang/music/viewmodels/YouTubePlaylistViewModel.kt @@ -7,8 +7,11 @@ import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.cachedIn import com.zionhuang.music.repos.YouTubeRepository +import com.zionhuang.music.youtube.NewPipeYouTubeHelper class YouTubePlaylistViewModel(application: Application) : AndroidViewModel(application) { + suspend fun getPlaylistInfo(playlistId: String) = NewPipeYouTubeHelper.getPlaylist(playlistId) + fun getPlaylist(playlistId: String) = Pager(PagingConfig(pageSize = 20)) { YouTubeRepository.getPlaylist(playlistId) }.flow.cachedIn(viewModelScope) diff --git a/app/src/main/java/com/zionhuang/music/youtube/NewPipeYouTubeHelper.kt b/app/src/main/java/com/zionhuang/music/youtube/NewPipeYouTubeHelper.kt index 6f2ce09bb..b48206eb0 100644 --- a/app/src/main/java/com/zionhuang/music/youtube/NewPipeYouTubeHelper.kt +++ b/app/src/main/java/com/zionhuang/music/youtube/NewPipeYouTubeHelper.kt @@ -3,11 +3,8 @@ package com.zionhuang.music.youtube import com.zionhuang.music.extensions.tryOrNull import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.withContext -import org.schabi.newpipe.extractor.InfoItem +import org.schabi.newpipe.extractor.* import org.schabi.newpipe.extractor.ListExtractor.InfoItemsPage -import org.schabi.newpipe.extractor.NewPipe -import org.schabi.newpipe.extractor.Page -import org.schabi.newpipe.extractor.ServiceList import org.schabi.newpipe.extractor.channel.ChannelInfo import org.schabi.newpipe.extractor.exceptions.ExtractionException import org.schabi.newpipe.extractor.playlist.PlaylistInfo @@ -21,11 +18,15 @@ import java.io.IOException object NewPipeYouTubeHelper { private val service = NewPipe.getService(ServiceList.YouTube.serviceId) as YoutubeService + fun getLinkType(url: String): StreamingService.LinkType = service.getLinkTypeByUrl(url) + /** * Stream */ fun extractVideoId(url: String): String? = tryOrNull { service.streamLHFactory.getId(url) } + fun videoIdToUrl(id: String): String? = tryOrNull { service.streamLHFactory.getUrl(id) } + /** * Search */ diff --git a/app/src/main/res/drawable/ic_file_download.xml b/app/src/main/res/drawable/ic_file_download.xml index 492b41d34..56b2e31c1 100644 --- a/app/src/main/res/drawable/ic_file_download.xml +++ b/app/src/main/res/drawable/ic_file_download.xml @@ -1,9 +1,10 @@ + android:width="24dp" + android:height="24dp" + android:tint="?attr/colorControlNormal" + android:viewportWidth="24.0" + android:viewportHeight="24.0"> + android:fillColor="@android:color/white" + android:pathData="M19,9h-4V3H9v6H5l7,7 7,-7zM5,18v2h14v-2H5z" /> diff --git a/app/src/main/res/layout/bottom_controls_sheet.xml b/app/src/main/res/layout/bottom_controls_sheet.xml index b91cbfef1..2c91b2ef8 100644 --- a/app/src/main/res/layout/bottom_controls_sheet.xml +++ b/app/src/main/res/layout/bottom_controls_sheet.xml @@ -75,6 +75,7 @@ android:layout_margin="13dp" android:padding="2dp" android:src="@drawable/ic_more_vert" + android:visibility="gone" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toTopOf="parent" /> @@ -272,7 +273,8 @@ android:background="?selectableItemBackgroundBorderless" android:clickable="true" android:scaleType="center" - android:src="@drawable/ic_favorite_border" /> + android:src="@drawable/ic_favorite_border" + android:visibility="gone" /> - + android:scrollbars="none"> @@ -38,6 +38,13 @@ android:layout_height="wrap_content" android:text="@string/search_filter_videos" /> + + - + \ No newline at end of file diff --git a/app/src/main/res/menu/song.xml b/app/src/main/res/menu/song.xml index 300a6a94d..68c13af2c 100644 --- a/app/src/main/res/menu/song.xml +++ b/app/src/main/res/menu/song.xml @@ -15,6 +15,9 @@ + diff --git a/app/src/main/res/menu/song_contextual_action_bar.xml b/app/src/main/res/menu/song_contextual_action_bar.xml index fbc34b622..323913877 100644 --- a/app/src/main/res/menu/song_contextual_action_bar.xml +++ b/app/src/main/res/menu/song_contextual_action_bar.xml @@ -16,6 +16,10 @@ android:id="@+id/action_download" android:icon="@drawable/ic_file_download" android:title="@string/menu_song_download" /> + + android:name="com.zionhuang.music.ui.fragments.search.YouTubeSearchFragment"> @@ -110,16 +109,14 @@ + android:name="com.zionhuang.music.ui.fragments.YouTubePlaylistFragment"> + android:name="com.zionhuang.music.ui.fragments.YouTubeChannelFragment"> diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 56bc08c43..f9fc28c73 100755 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -41,6 +41,7 @@ 加入待播清單 加入播放清單 下載 + 刪除下載 移除 @@ -98,6 +99,7 @@ 全部 歌曲 影片 + 專輯 播放清單 頻道 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 055b519c3..ba1301951 100755 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -22,8 +22,8 @@ Add song to your library when it completes playing Expand bottom player on play About - App Version - NewPipe Extractor Version + App version + NewPipe Extractor version Check for updates Checking… You are up to date. @@ -41,6 +41,7 @@ Add to queue Add to playlist Download + Remove download Delete @@ -122,6 +123,7 @@ All Songs Videos + Albums Playlists Channels diff --git a/build.gradle b/build.gradle index a53a221b0..e093e6c53 100755 --- a/build.gradle +++ b/build.gradle @@ -7,7 +7,7 @@ buildscript { } ext { kotlin_version = "1.6.10" - newpipe_version = "0.21.13" + newpipe_version = "0.21.14" } dependencies { classpath "com.android.tools.build:gradle:7.1.0" diff --git a/fastlane/metadata/android/en-US/changelogs/5.txt b/fastlane/metadata/android/en-US/changelogs/5.txt new file mode 100644 index 000000000..371bc10f9 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/5.txt @@ -0,0 +1,27 @@ +
New + +* App updater (Preview) +* Share button function in bottom control fragment +* Open YouTube urls by this app #11 +* You can now search for YouTube Music albums +* Remove download of a song + +
Improved + +* Show channel or playlist name in search fragment title +* Scrollable search filter chip group + +
Fixed + +* Fixed artwork updates incorrectly when preloading the next song +* Fixed play artist songs not working +* Fixed media notification PendingIntent mutability flag missing, causing Android S+ to crash #6 +* Fixed can't add songs to library #9 +* Auto download not working +* Auto add song to library not working + +
Development + +* Bumped NewPipe Extractor from 0.21.13 to 0.21.14 +* Upload APK in GitHub Action +* Added Fastlane structure \ No newline at end of file