Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions app/proguard-rules.pro
Original file line number Diff line number Diff line change
Expand Up @@ -132,8 +132,13 @@
# Keep queue-related classes to prevent serialization issues in release builds
-keep class com.metrolist.music.models.PersistQueue { *; }
-keep class com.metrolist.music.models.PersistPlayerState { *; }
-keep class com.metrolist.music.models.MediaMetadata { *; }
-keep class com.metrolist.music.models.MediaMetadata$* { *; }
-keep class com.metrolist.music.utils.LegacyPlaylistMediaMetadata { *; }
-keep class com.metrolist.music.models.QueueData { *; }
-keep class com.metrolist.music.models.QueueData$* { *; }
-keep class com.metrolist.music.models.QueueType { *; }
-keep class com.metrolist.music.models.QueueType$* { *; }
-keep class com.metrolist.music.playback.queues.** { *; }

# Keep serialization methods for queue persistence
Expand Down
3 changes: 3 additions & 0 deletions app/src/main/kotlin/com/metrolist/music/db/DatabaseDao.kt
Original file line number Diff line number Diff line change
Expand Up @@ -1999,6 +1999,9 @@ interface DatabaseDao {
@Query("SELECT * FROM playlist_song_map WHERE songId = :songId")
fun playlistSongMaps(songId: String): List<PlaylistSongMap>

@Query("SELECT EXISTS(SELECT 1 FROM playlist_song_map JOIN playlist ON playlist.id = playlist_song_map.playlistId WHERE songId = :songId AND playlist.isEditable = 1 AND playlist.bookmarkedAt IS NOT NULL)")
fun songInEditablePlaylist(songId: String): Flow<Boolean>

@Transaction
@Query("SELECT * FROM playlist_song_map WHERE playlistId = :playlistId AND position >= :from ORDER BY position")
fun playlistSongMaps(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@ package com.metrolist.music.extensions
import android.os.Bundle
import androidx.core.net.toUri
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.common.MediaMetadata.MEDIA_TYPE_MUSIC
import androidx.media3.session.MediaConstants.EXTRAS_KEY_IS_EXPLICIT
import androidx.media3.session.MediaConstants.EXTRAS_VALUE_ATTRIBUTE_PRESENT
import com.metrolist.innertube.models.SongItem
import com.metrolist.music.db.entities.PlaylistSong
import com.metrolist.music.db.entities.Song
import com.metrolist.music.models.MediaMetadata
import com.metrolist.music.models.toMediaMetadata
Expand All @@ -22,7 +24,30 @@ val MediaItem.metadata: MediaMetadata?

fun Song.toMediaItem() = toMediaMetadata().toMediaItem()

fun SongItem.toMediaItem() = toMediaMetadata().toMediaItem()
fun PlaylistSong.toMediaItem(playlistBrowseId: String? = null) =
song.toMediaMetadata().copy(
setVideoId = map.setVideoId,
playlistBrowseId = playlistBrowseId,
localPlaylistId = map.playlistId,
playlistSongMapId = map.id,
).toMediaItem()

fun SongItem.toMediaItem(playlistBrowseId: String? = null) =
toMediaMetadata().copy(playlistBrowseId = playlistBrowseId).toMediaItem()

fun MediaItem.withoutPlaylistSource(): MediaItem {
val metadata = metadata ?: return this
return if (metadata.playlistBrowseId == null && metadata.localPlaylistId == null) this else buildUpon()
.setTag(metadata.copy(playlistBrowseId = null, localPlaylistId = null)).build()
}

fun Player.clearPlaylistSource(matches: (MediaMetadata) -> Boolean) {
repeat(mediaItemCount) { index ->
val item = getMediaItemAt(index)
val metadata = item.metadata ?: return@repeat
if (matches(metadata)) replaceMediaItem(index, item.withoutPlaylistSource())
}
}

fun MediaMetadata.toMediaItem(): MediaItem {
val resolvedMetadata = withResolvedArtistNameAliases()
Expand Down
26 changes: 21 additions & 5 deletions app/src/main/kotlin/com/metrolist/music/extensions/QueueExt.kt
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import com.metrolist.music.playback.queues.ListQueue
import com.metrolist.music.playback.queues.LocalAlbumRadio
import com.metrolist.music.playback.queues.Queue
import com.metrolist.music.playback.queues.YouTubeAlbumRadio
import com.metrolist.music.playback.queues.YouTubePlaylistQueue
import com.metrolist.music.playback.queues.YouTubeQueue

fun Queue.toPersistQueue(
Expand All @@ -27,7 +28,19 @@ fun Queue.toPersistQueue(
items = items,
mediaItemIndex = mediaItemIndex,
position = position,
queueType = QueueType.LIST
queueType = QueueType.LIST,
playlistBrowseId = playlistBrowseId,
playlistId = playlistId,
playlistIsEditable = playlistIsEditable,
)
is YouTubePlaylistQueue -> PersistQueue(
title = title,
items = items,
mediaItemIndex = mediaItemIndex,
position = position,
queueType = QueueType.LIST,
playlistBrowseId = playlistId,
playlistIsEditable = isEditable,
)
is YouTubeQueue -> {
// Since endpoint is private, we'll store a simplified version
Expand Down Expand Up @@ -84,15 +97,18 @@ fun PersistQueue.toQueue(): Queue {
title = title,
items = items.map { it.toMediaItem() },
startIndex = mediaItemIndex,
position = position
position = position,
playlistBrowseId = playlistBrowseId,
playlistId = playlistId,
playlistIsEditable = playlistIsEditable,
)
is QueueType.YOUTUBE -> {
// For now, fallback to ListQueue since we can't reconstruct YouTubeQueue properly
ListQueue(
title = title,
items = items.map { it.toMediaItem() },
startIndex = mediaItemIndex,
position = position
position = position,
)
}
is QueueType.YOUTUBE_ALBUM_RADIO -> {
Expand All @@ -101,7 +117,7 @@ fun PersistQueue.toQueue(): Queue {
title = title,
items = items.map { it.toMediaItem() },
startIndex = mediaItemIndex,
position = position
position = position,
)
}
is QueueType.LOCAL_ALBUM_RADIO -> {
Expand All @@ -110,7 +126,7 @@ fun PersistQueue.toQueue(): Queue {
title = title,
items = items.map { it.toMediaItem() },
startIndex = mediaItemIndex,
position = position
position = position,
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,15 @@ data class MediaMetadata(
val suggestedBy: String? = null,
val isEpisode: Boolean = false,
val uploadEntityId: String? = null,
val playlistBrowseId: String? = null,
val localPlaylistId: String? = null,
val playlistSongMapId: Int? = null,
) : Serializable {
companion object {
// Preserve saved songs from builds without playlist source fields.
private const val serialVersionUID = -3488332219501634879L
}

val isVideoSong: Boolean
get() = musicVideoType != null && musicVideoType != MUSIC_VIDEO_TYPE_ATV

Expand Down
10 changes: 9 additions & 1 deletion app/src/main/kotlin/com/metrolist/music/models/PersistQueue.kt
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,15 @@ data class PersistQueue(
val position: Long,
val queueType: QueueType = QueueType.LIST,
val queueData: QueueData? = null,
) : Serializable
val playlistBrowseId: String? = null,
val playlistId: String? = null,
val playlistIsEditable: Boolean = false,
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Resolved
) : Serializable {
companion object {
// Preserve the UID of the model before playlist context was added.
private const val serialVersionUID = -8948321821791932325L
}
}

sealed class QueueType : Serializable {
object LIST : QueueType()
Expand Down
86 changes: 65 additions & 21 deletions app/src/main/kotlin/com/metrolist/music/playback/MusicService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,8 @@ import com.metrolist.music.extensions.metadata
import com.metrolist.music.extensions.setOffloadEnabled
import com.metrolist.music.extensions.toEnum
import com.metrolist.music.extensions.toMediaItem
import com.metrolist.music.extensions.withoutPlaylistSource
import com.metrolist.music.extensions.clearPlaylistSource
import com.metrolist.music.extensions.toPersistQueue
import com.metrolist.music.extensions.toQueue
import com.metrolist.music.lyrics.LyricsHelper
Expand Down Expand Up @@ -251,6 +253,7 @@ import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import timber.log.Timber
import java.io.ObjectInputStream
import com.metrolist.music.utils.QueueObjectInputStream
import java.io.ObjectOutputStream
import java.time.LocalDateTime
import javax.inject.Inject
Expand Down Expand Up @@ -343,7 +346,8 @@ class MusicService :

private lateinit var audioQuality: com.metrolist.music.constants.AudioQuality

private var currentQueue: Queue = EmptyQueue
private val _currentQueue = MutableStateFlow<Queue>(EmptyQueue)
val currentQueue = _currentQueue.asStateFlow()
var queueTitle: String? = null

val currentMediaMetadata = MutableStateFlow<com.metrolist.music.models.MediaMetadata?>(null)
Expand Down Expand Up @@ -611,6 +615,14 @@ class MusicService :

playerInitialized.value = false

scope.launch {
syncUtils.playlistRemovalUpdates.collect { update ->
if (update.removed) {
update.setVideoId?.let { clearPlaylistSource(update.songId, it, update.browseId) }
}
}
}

// Call startForeground() as early as possible to satisfy the
// 5-second timeout imposed by Context.startForegroundService().
// On some OEMs (e.g. MIUI), even a DataStore read can be slow
Expand Down Expand Up @@ -1190,7 +1202,7 @@ class MusicService :
if (queueFile.exists()) {
runCatching {
queueFile.inputStream().use { fis ->
ObjectInputStream(fis).use { oos ->
QueueObjectInputStream(fis).use { oos ->
oos.readObject() as PersistQueue
}
}
Expand Down Expand Up @@ -1221,7 +1233,7 @@ class MusicService :
if (automixFile.exists()) {
runCatching {
automixFile.inputStream().use { fis ->
ObjectInputStream(fis).use { oos ->
QueueObjectInputStream(fis).use { oos ->
oos.readObject() as PersistQueue
}
}
Expand Down Expand Up @@ -1800,7 +1812,7 @@ class MusicService :
return
}

currentQueue = queue
_currentQueue.value = queue
queueTitle = null
val persistShuffleAcrossQueues = dataStore.get(PersistentShuffleAcrossQueuesKey, false)
if (!persistShuffleAcrossQueues && !restoringQueue) {
Expand Down Expand Up @@ -1862,7 +1874,7 @@ class MusicService :
}

fun adoptQueue(queue: Queue, title: String? = null, initialQueueSize: Int = 0) {
currentQueue = queue
_currentQueue.value = queue
queueTitle = title
originalQueueSize = initialQueueSize
}
Expand Down Expand Up @@ -1920,7 +1932,7 @@ class MusicService :
}
}

currentQueue = radioQueue
_currentQueue.value = radioQueue
} catch (e: Exception) {
try {
val nextResult =
Expand Down Expand Up @@ -2173,6 +2185,32 @@ class MusicService :
player.prepare()
}

fun clearLocalPlaylistSource(songId: String, playlistId: String, playlistSongMapId: Int) {
scope.launch(Dispatchers.Main.immediate) {
if (!playerInitialized.value) return@launch
player.clearPlaylistSource { metadata ->
metadata.id == songId && metadata.localPlaylistId == playlistId &&
metadata.playlistSongMapId == playlistSongMapId
}
currentMediaMetadata.value = player.currentMetadata
}
}

fun clearPlaylistSource(
songId: String,
setVideoId: String,
playlistBrowseId: String,
) {
scope.launch(Dispatchers.Main.immediate) {
if (!playerInitialized.value) return@launch
player.clearPlaylistSource { metadata ->
metadata.id == songId && metadata.setVideoId == setVideoId &&
metadata.playlistBrowseId?.removePrefix("VL") == playlistBrowseId.removePrefix("VL")
}
currentMediaMetadata.value = player.currentMetadata
}
}

fun toggleLibrary() {
scope.launch {
val songToToggle = currentSong.first()
Expand Down Expand Up @@ -2605,13 +2643,13 @@ class MusicService :
if (cachedAutoLoadMore &&
reason != Player.MEDIA_ITEM_TRANSITION_REASON_REPEAT &&
player.mediaItemCount - player.currentMediaItemIndex <= 5 &&
currentQueue.hasNextPage() &&
_currentQueue.value.hasNextPage() &&
!(cachedDisableLoadMoreWhenRepeatAll && player.repeatMode == REPEAT_MODE_ALL)
) {
scope.launch(SilentHandler) {
val mediaItems =
withContext(Dispatchers.IO) {
currentQueue
_currentQueue.value
.nextPage()
.filterExplicit(cachedHideExplicit)
.filterVideoSongs(cachedHideVideoSongs)
Expand Down Expand Up @@ -4100,7 +4138,7 @@ class MusicService :

try {
val persistQueue =
currentQueue.toPersistQueue(
_currentQueue.value.toPersistQueue(
title = queueTitle,
items = player.mediaItems.mapNotNull { it.metadata },
mediaItemIndex = player.currentMediaItemIndex,
Expand Down Expand Up @@ -4515,10 +4553,13 @@ class MusicService :
if (targetId.isBlank()) return null
val songs = database.playlistSongs(targetId).first()
if (songs.isEmpty()) return null
val playlistName = database.playlist(targetId).first()?.playlist?.name ?: targetTitle
val playlist = database.playlist(targetId).first()?.playlist
ListQueue(
title = playlistName,
items = songs.map { it.song.toMediaItem() },
title = playlist?.name ?: targetTitle,
items = songs.map { it.toMediaItem(playlist?.browseId) },
playlistBrowseId = playlist?.browseId,
playlistId = playlist?.id,
playlistIsEditable = playlist?.isEditable == true,
Comment on lines +4560 to +4562

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve occurrence IDs in widget and alarm queues

For an editable playlist started from a widget, the new playlist context is attached but items = songs.map { it.song.toMediaItem() } discards PlaylistSong.map.setVideoId; resolvePlaylistRemovalTarget therefore always returns null and the player menu still lacks Remove from playlist. The cached-online widget path and alarm path construct items the same way, so they need the occurrence-aware conversion used by Playlist.toListQueue.

Useful? React with 👍 / 👎.

Comment thread
greptile-apps[bot] marked this conversation as resolved.
Resolved
)
}

Expand All @@ -4529,11 +4570,15 @@ class MusicService :
if (cachedSongs.isNotEmpty()) {
ListQueue(
title = cachedPlaylist?.playlist?.name ?: targetTitle,
items = cachedSongs.map { it.song.toMediaItem() },
items = cachedSongs.map { it.toMediaItem(cachedPlaylist?.playlist?.browseId) },
playlistBrowseId = cachedPlaylist?.playlist?.browseId,
playlistId = cachedPlaylist?.playlist?.id,
playlistIsEditable = cachedPlaylist?.playlist?.isEditable == true,
)
} else {
YouTubePlaylistQueue(
playlistId = targetId,
isEditable = cachedPlaylist?.playlist?.isEditable ?: false,
playlistTitle = targetTitle,
)
}
Expand Down Expand Up @@ -4618,15 +4663,11 @@ class MusicService :
}
return@launch
}
val items = playlistSongs.map { it.song.toMediaItem() }
val playlistName =
val playlist =
withContext(Dispatchers.IO) {
database
.playlist(playlistId)
.first()
?.playlist
?.name
database.playlist(playlistId).first()?.playlist
}
val items = playlistSongs.map { it.toMediaItem(playlist?.browseId) }
withContext(Dispatchers.IO) {
MusicAlarmScheduler.scheduleFromPreferences(this@MusicService)
}
Expand All @@ -4648,10 +4689,13 @@ class MusicService :
player.clearMediaItems()
playQueue(
ListQueue(
title = playlistName,
title = playlist?.name,
items = alarmItems,
startIndex = 0,
position = 0L,
playlistBrowseId = playlist?.browseId,
playlistId = playlist?.id,
playlistIsEditable = playlist?.isEditable == true,
),
playWhenReady = true,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -624,6 +624,7 @@ class PlayerConnection(
timeline: Timeline,
reason: Int,
) {
mediaMetadata.value = player.currentMetadata
queueWindows.value = player.getQueueWindows()
queueTitle.value = service.queueTitle
currentMediaItemIndex.value = player.currentMediaItemIndex
Expand Down
Loading