mirror of
https://gitlab.com/ultrasonic/ultrasonic.git
synced 2025-04-14 00:17:15 +03:00
Added custom buttons for shuffling the current queue and setting repeat mode
This commit is contained in:
parent
7209779b64
commit
77c1329be5
@ -17,6 +17,7 @@ import androidx.media3.common.MediaMetadata.FOLDER_TYPE_ARTISTS
|
||||
import androidx.media3.common.MediaMetadata.FOLDER_TYPE_MIXED
|
||||
import androidx.media3.common.MediaMetadata.FOLDER_TYPE_PLAYLISTS
|
||||
import androidx.media3.common.MediaMetadata.FOLDER_TYPE_TITLES
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.Rating
|
||||
import androidx.media3.common.StarRating
|
||||
import androidx.media3.session.CommandButton
|
||||
@ -116,22 +117,59 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) :
|
||||
private val isOffline get() = ActiveServerProvider.isOffline()
|
||||
private val musicFolderId get() = activeServerProvider.getActiveServer().musicFolderId
|
||||
|
||||
private var customCommands: List<CommandButton>
|
||||
internal var customLayout = ImmutableList.of<CommandButton>()
|
||||
private val placeholderButton = getPlaceholderButton()
|
||||
|
||||
private var heartIsCurrentlyOn = false
|
||||
|
||||
// This button is used for an unstarred track, and its action will star the track
|
||||
private val heartButtonToggleOn =
|
||||
getHeartCommandButton(
|
||||
SessionCommand(
|
||||
PlaybackService.CUSTOM_COMMAND_TOGGLE_HEART_ON,
|
||||
Bundle.EMPTY
|
||||
),
|
||||
willHeart = true
|
||||
)
|
||||
|
||||
// This button is used for an starred track, and its action will star the track
|
||||
private val heartButtonToggleOff =
|
||||
getHeartCommandButton(
|
||||
SessionCommand(
|
||||
PlaybackService.CUSTOM_COMMAND_TOGGLE_HEART_OFF,
|
||||
Bundle.EMPTY
|
||||
),
|
||||
willHeart = false
|
||||
)
|
||||
|
||||
private val shuffleButton: CommandButton
|
||||
|
||||
private val repeatOffButton: CommandButton
|
||||
private val repeatOneButton: CommandButton
|
||||
private val repeatAllButton: CommandButton
|
||||
|
||||
private val allCustomCommands: List<CommandButton>
|
||||
|
||||
val defaultCustomCommands: List<CommandButton>
|
||||
|
||||
init {
|
||||
customCommands =
|
||||
listOf(
|
||||
// This button is used for an unstarred track, and its action will star the track
|
||||
getHeartCommandButton(
|
||||
SessionCommand(PlaybackService.CUSTOM_COMMAND_TOGGLE_HEART_ON, Bundle.EMPTY)
|
||||
),
|
||||
// This button is used for an starred track, and its action will unstar the track
|
||||
getHeartCommandButton(
|
||||
SessionCommand(PlaybackService.CUSTOM_COMMAND_TOGGLE_HEART_OFF, Bundle.EMPTY)
|
||||
)
|
||||
)
|
||||
customLayout = ImmutableList.of(customCommands[0])
|
||||
val shuffleCommand = SessionCommand(PlaybackService.CUSTOM_COMMAND_SHUFFLE, Bundle.EMPTY)
|
||||
shuffleButton = getShuffleCommandButton(shuffleCommand)
|
||||
|
||||
val repeatCommand = SessionCommand(PlaybackService.CUSTOM_COMMAND_REPEAT_MODE, Bundle.EMPTY)
|
||||
repeatOffButton = getRepeatModeButton(repeatCommand, Player.REPEAT_MODE_OFF)
|
||||
repeatOneButton = getRepeatModeButton(repeatCommand, Player.REPEAT_MODE_ONE)
|
||||
repeatAllButton = getRepeatModeButton(repeatCommand, Player.REPEAT_MODE_ALL)
|
||||
|
||||
allCustomCommands = listOf(
|
||||
heartButtonToggleOn,
|
||||
heartButtonToggleOff,
|
||||
shuffleButton,
|
||||
repeatOffButton,
|
||||
repeatOneButton,
|
||||
repeatAllButton
|
||||
)
|
||||
|
||||
defaultCustomCommands = listOf(heartButtonToggleOn, shuffleButton, repeatOffButton)
|
||||
}
|
||||
|
||||
/**
|
||||
@ -188,14 +226,17 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) :
|
||||
controller: MediaSession.ControllerInfo
|
||||
): MediaSession.ConnectionResult {
|
||||
Timber.i("onConnect")
|
||||
|
||||
val connectionResult = super.onConnect(session, controller)
|
||||
val availableSessionCommands = connectionResult.availableSessionCommands.buildUpon()
|
||||
|
||||
for (commandButton in customCommands) {
|
||||
for (commandButton in allCustomCommands) {
|
||||
// Add custom command to available session commands.
|
||||
commandButton.sessionCommand?.let { availableSessionCommands.add(it) }
|
||||
}
|
||||
|
||||
session.player.repeatMode = Player.REPEAT_MODE_ALL
|
||||
|
||||
return MediaSession.ConnectionResult.accept(
|
||||
availableSessionCommands.build(),
|
||||
connectionResult.availablePlayerCommands
|
||||
@ -203,26 +244,72 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) :
|
||||
}
|
||||
|
||||
override fun onPostConnect(session: MediaSession, controller: MediaSession.ControllerInfo) {
|
||||
if (!customLayout.isEmpty() && controller.controllerVersion != 0) {
|
||||
if (controller.controllerVersion != 0) {
|
||||
// Let Media3 controller (for instance the MediaNotificationProvider)
|
||||
// know about the custom layout right after it connected.
|
||||
session.setCustomLayout(customLayout)
|
||||
with(session) {
|
||||
setCustomLayout(session.buildCustomCommands(canShuffle = canShuffle()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getHeartCommandButton(sessionCommand: SessionCommand): CommandButton {
|
||||
val willHeart =
|
||||
(sessionCommand.customAction == PlaybackService.CUSTOM_COMMAND_TOGGLE_HEART_ON)
|
||||
return CommandButton.Builder()
|
||||
.setDisplayName("Love")
|
||||
private fun getHeartCommandButton(sessionCommand: SessionCommand, willHeart: Boolean) =
|
||||
CommandButton.Builder()
|
||||
.setDisplayName(
|
||||
if (willHeart)
|
||||
"Love"
|
||||
else
|
||||
"Dislike"
|
||||
)
|
||||
.setIconResId(
|
||||
if (willHeart) R.drawable.ic_star_hollow
|
||||
else R.drawable.ic_star_full
|
||||
if (willHeart)
|
||||
R.drawable.ic_star_hollow
|
||||
else
|
||||
R.drawable.ic_star_full
|
||||
)
|
||||
.setSessionCommand(sessionCommand)
|
||||
.setEnabled(true)
|
||||
.build()
|
||||
|
||||
private fun getShuffleCommandButton(sessionCommand: SessionCommand) =
|
||||
CommandButton.Builder()
|
||||
.setDisplayName("Shuffle")
|
||||
.setIconResId(R.drawable.media_shuffle)
|
||||
.setSessionCommand(sessionCommand)
|
||||
.setEnabled(true)
|
||||
.build()
|
||||
|
||||
private fun getPlaceholderButton() = CommandButton.Builder()
|
||||
.setDisplayName("Placeholder")
|
||||
.setIconResId(R.drawable.empty)
|
||||
.setSessionCommand(
|
||||
SessionCommand(
|
||||
PlaybackService.CUSTOM_COMMAND_PLACEHOLDER,
|
||||
Bundle.EMPTY
|
||||
)
|
||||
)
|
||||
.setEnabled(false)
|
||||
.build()
|
||||
|
||||
private fun getRepeatModeButton(sessionCommand: SessionCommand, repeatMode: Int) =
|
||||
CommandButton.Builder()
|
||||
.setDisplayName(
|
||||
when (repeatMode) {
|
||||
Player.REPEAT_MODE_ONE -> "Repeat One"
|
||||
Player.REPEAT_MODE_ALL -> "Repeat All"
|
||||
else -> "Repeat None"
|
||||
}
|
||||
)
|
||||
.setIconResId(
|
||||
when (repeatMode) {
|
||||
Player.REPEAT_MODE_ONE -> R.drawable.media_repeat_one
|
||||
Player.REPEAT_MODE_ALL -> R.drawable.media_repeat_all
|
||||
else -> R.drawable.media_repeat_off
|
||||
}
|
||||
)
|
||||
.setSessionCommand(sessionCommand)
|
||||
.setEnabled(true)
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun onGetItem(
|
||||
session: MediaLibraryService.MediaLibrarySession,
|
||||
@ -266,18 +353,30 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) :
|
||||
customCommand: SessionCommand,
|
||||
args: Bundle
|
||||
): ListenableFuture<SessionResult> {
|
||||
Timber.i("onCustomCommand")
|
||||
Timber.i("onCustomCommand %s", customCommand.customAction)
|
||||
var customCommandFuture: ListenableFuture<SessionResult>? = null
|
||||
|
||||
when (customCommand.customAction) {
|
||||
PlaybackService.CUSTOM_COMMAND_TOGGLE_HEART_ON -> {
|
||||
customCommandFuture = onSetRating(session, controller, HeartRating(true))
|
||||
updateCustomHeartButton(session, true)
|
||||
updateCustomHeartButton(session, isHeart = true)
|
||||
}
|
||||
|
||||
PlaybackService.CUSTOM_COMMAND_TOGGLE_HEART_OFF -> {
|
||||
customCommandFuture = onSetRating(session, controller, HeartRating(false))
|
||||
updateCustomHeartButton(session, false)
|
||||
updateCustomHeartButton(session, isHeart = false)
|
||||
}
|
||||
|
||||
PlaybackService.CUSTOM_COMMAND_SHUFFLE -> {
|
||||
customCommandFuture = Futures.immediateFuture(SessionResult(RESULT_SUCCESS))
|
||||
shuffleCurrentPlaylist(session.player)
|
||||
}
|
||||
|
||||
PlaybackService.CUSTOM_COMMAND_REPEAT_MODE -> {
|
||||
customCommandFuture = Futures.immediateFuture(SessionResult(RESULT_SUCCESS))
|
||||
|
||||
session.player.setNextRepeatMode()
|
||||
session.updateCustomCommands()
|
||||
}
|
||||
|
||||
else -> {
|
||||
@ -288,9 +387,14 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) :
|
||||
)
|
||||
}
|
||||
}
|
||||
if (customCommandFuture != null)
|
||||
return customCommandFuture
|
||||
return super.onCustomCommand(session, controller, customCommand, args)
|
||||
|
||||
return customCommandFuture
|
||||
?: super.onCustomCommand(
|
||||
session,
|
||||
controller,
|
||||
customCommand,
|
||||
args
|
||||
)
|
||||
}
|
||||
|
||||
override fun onSetRating(
|
||||
@ -299,6 +403,7 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) :
|
||||
rating: Rating
|
||||
): ListenableFuture<SessionResult> {
|
||||
val mediaItem = session.player.currentMediaItem
|
||||
|
||||
if (mediaItem != null) {
|
||||
if (rating is HeartRating) {
|
||||
mediaItem.toTrack().starred = rating.isHeart
|
||||
@ -312,6 +417,7 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) :
|
||||
rating
|
||||
)
|
||||
}
|
||||
|
||||
return super.onSetRating(session, controller, rating)
|
||||
}
|
||||
|
||||
@ -381,6 +487,8 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) :
|
||||
private fun onAddLegacyAutoItems(
|
||||
mediaItems: MutableList<MediaItem>
|
||||
): ListenableFuture<List<MediaItem>> {
|
||||
Timber.i("onAddLegacyAutoItems %s", mediaItems.first().mediaId)
|
||||
|
||||
val mediaIdParts = mediaItems.first().mediaId.split('|')
|
||||
|
||||
val tracks = when (mediaIdParts.first()) {
|
||||
@ -410,55 +518,54 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) :
|
||||
else -> null
|
||||
}
|
||||
|
||||
if (tracks != null) {
|
||||
return Futures.immediateFuture(
|
||||
tracks.map { track -> track.toMediaItem() }
|
||||
.toMutableList()
|
||||
)
|
||||
}
|
||||
|
||||
// Fallback to the original list
|
||||
return Futures.immediateFuture(mediaItems)
|
||||
return tracks
|
||||
?.let {
|
||||
Futures.immediateFuture(
|
||||
it.map { track -> track.toMediaItem() }
|
||||
.toMutableList()
|
||||
)
|
||||
}
|
||||
?: Futures.immediateFuture(mediaItems)
|
||||
}
|
||||
|
||||
@Suppress("ReturnCount", "ComplexMethod")
|
||||
fun onLoadChildren(
|
||||
@Suppress("ComplexMethod")
|
||||
private fun onLoadChildren(
|
||||
parentId: String,
|
||||
): ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> {
|
||||
Timber.d("AutoMediaBrowserService onLoadChildren called. ParentId: %s", parentId)
|
||||
|
||||
val parentIdParts = parentId.split('|')
|
||||
|
||||
when (parentIdParts.first()) {
|
||||
MEDIA_ROOT_ID -> return getRootItems()
|
||||
MEDIA_LIBRARY_ID -> return getLibrary()
|
||||
MEDIA_ARTIST_ID -> return getArtists()
|
||||
MEDIA_ARTIST_SECTION -> return getArtists(parentIdParts[1])
|
||||
MEDIA_ALBUM_ID -> return getAlbums(AlbumListType.SORTED_BY_NAME)
|
||||
MEDIA_ALBUM_PAGE_ID -> return getAlbums(
|
||||
return when (parentIdParts.first()) {
|
||||
MEDIA_ROOT_ID -> getRootItems()
|
||||
MEDIA_LIBRARY_ID -> getLibrary()
|
||||
MEDIA_ARTIST_ID -> getArtists()
|
||||
MEDIA_ARTIST_SECTION -> getArtists(parentIdParts[1])
|
||||
MEDIA_ALBUM_ID -> getAlbums(AlbumListType.SORTED_BY_NAME)
|
||||
MEDIA_ALBUM_PAGE_ID -> getAlbums(
|
||||
AlbumListType.fromName(parentIdParts[1]), parentIdParts[2].toInt()
|
||||
)
|
||||
|
||||
MEDIA_PLAYLIST_ID -> return getPlaylists()
|
||||
MEDIA_ALBUM_FREQUENT_ID -> return getAlbums(AlbumListType.FREQUENT)
|
||||
MEDIA_ALBUM_NEWEST_ID -> return getAlbums(AlbumListType.NEWEST)
|
||||
MEDIA_ALBUM_RECENT_ID -> return getAlbums(AlbumListType.RECENT)
|
||||
MEDIA_ALBUM_RANDOM_ID -> return getAlbums(AlbumListType.RANDOM)
|
||||
MEDIA_ALBUM_STARRED_ID -> return getAlbums(AlbumListType.STARRED)
|
||||
MEDIA_SONG_RANDOM_ID -> return getRandomSongs()
|
||||
MEDIA_SONG_STARRED_ID -> return getStarredSongs()
|
||||
MEDIA_SHARE_ID -> return getShares()
|
||||
MEDIA_BOOKMARK_ID -> return getBookmarks()
|
||||
MEDIA_PODCAST_ID -> return getPodcasts()
|
||||
MEDIA_PLAYLIST_ITEM -> return getPlaylist(parentIdParts[1], parentIdParts[2])
|
||||
MEDIA_ARTIST_ITEM -> return getAlbumsForArtist(
|
||||
MEDIA_PLAYLIST_ID -> getPlaylists()
|
||||
MEDIA_ALBUM_FREQUENT_ID -> getAlbums(AlbumListType.FREQUENT)
|
||||
MEDIA_ALBUM_NEWEST_ID -> getAlbums(AlbumListType.NEWEST)
|
||||
MEDIA_ALBUM_RECENT_ID -> getAlbums(AlbumListType.RECENT)
|
||||
MEDIA_ALBUM_RANDOM_ID -> getAlbums(AlbumListType.RANDOM)
|
||||
MEDIA_ALBUM_STARRED_ID -> getAlbums(AlbumListType.STARRED)
|
||||
MEDIA_SONG_RANDOM_ID -> getRandomSongs()
|
||||
MEDIA_SONG_STARRED_ID -> getStarredSongs()
|
||||
MEDIA_SHARE_ID -> getShares()
|
||||
MEDIA_BOOKMARK_ID -> getBookmarks()
|
||||
MEDIA_PODCAST_ID -> getPodcasts()
|
||||
MEDIA_PLAYLIST_ITEM -> getPlaylist(parentIdParts[1], parentIdParts[2])
|
||||
MEDIA_ARTIST_ITEM -> getAlbumsForArtist(
|
||||
parentIdParts[1], parentIdParts[2]
|
||||
)
|
||||
|
||||
MEDIA_ALBUM_ITEM -> return getSongsForAlbum(parentIdParts[1], parentIdParts[2])
|
||||
MEDIA_SHARE_ITEM -> return getSongsForShare(parentIdParts[1])
|
||||
MEDIA_PODCAST_ITEM -> return getPodcastEpisodes(parentIdParts[1])
|
||||
else -> return Futures.immediateFuture(LibraryResult.ofItemList(listOf(), null))
|
||||
MEDIA_ALBUM_ITEM -> getSongsForAlbum(parentIdParts[1], parentIdParts[2])
|
||||
MEDIA_SHARE_ITEM -> getSongsForShare(parentIdParts[1])
|
||||
MEDIA_PODCAST_ITEM -> getPodcastEpisodes(parentIdParts[1])
|
||||
else -> Futures.immediateFuture(LibraryResult.ofItemList(listOf(), null))
|
||||
}
|
||||
}
|
||||
|
||||
@ -1316,14 +1423,102 @@ class AutoMediaBrowserCallback(val libraryService: MediaLibraryService) :
|
||||
}
|
||||
}
|
||||
|
||||
fun updateCustomHeartButton(
|
||||
session: MediaSession,
|
||||
isHeart: Boolean
|
||||
) {
|
||||
val command = if (isHeart) customCommands[1] else customCommands[0]
|
||||
// Change the custom layout to contain the right heart button
|
||||
customLayout = ImmutableList.of(command)
|
||||
// Send the updated custom layout to controllers.
|
||||
session.setCustomLayout(customLayout)
|
||||
private fun Player.setNextRepeatMode() {
|
||||
repeatMode =
|
||||
when (repeatMode) {
|
||||
Player.REPEAT_MODE_OFF -> Player.REPEAT_MODE_ALL
|
||||
Player.REPEAT_MODE_ALL -> Player.REPEAT_MODE_ONE
|
||||
else -> Player.REPEAT_MODE_OFF
|
||||
}
|
||||
}
|
||||
|
||||
private fun MediaSession.updateCustomCommands() {
|
||||
setCustomLayout(
|
||||
buildCustomCommands(
|
||||
heartIsCurrentlyOn,
|
||||
canShuffle()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun updateCustomHeartButton(session: MediaSession, isHeart: Boolean) {
|
||||
with(session) {
|
||||
setCustomLayout(
|
||||
buildCustomCommands(
|
||||
isHeart = isHeart,
|
||||
canShuffle = canShuffle()
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun MediaSession.canShuffle() =
|
||||
player.mediaItemCount > 2
|
||||
|
||||
private fun MediaSession.buildCustomCommands(
|
||||
isHeart: Boolean = false,
|
||||
canShuffle: Boolean = false
|
||||
): ImmutableList<CommandButton> {
|
||||
Timber.d("building custom commands (isHeart = %s, canShuffle = %s)", isHeart, canShuffle)
|
||||
|
||||
heartIsCurrentlyOn = isHeart
|
||||
|
||||
return ImmutableList.copyOf(
|
||||
buildList {
|
||||
// placeholder must come first here because if there is no next button the first
|
||||
// custom command button is place right next to the play/pause button
|
||||
if (
|
||||
player.repeatMode != Player.REPEAT_MODE_ALL &&
|
||||
player.currentMediaItemIndex == player.mediaItemCount - 1
|
||||
)
|
||||
add(placeholderButton)
|
||||
|
||||
// due to the previous placeholder this heart button will always appear to the left
|
||||
// of the default playback items
|
||||
add(
|
||||
if (isHeart)
|
||||
heartButtonToggleOff
|
||||
else
|
||||
heartButtonToggleOn
|
||||
)
|
||||
|
||||
// both the shuffle and the active repeat mode button will end up in the overflow
|
||||
// menu if both are available at the same time
|
||||
if (canShuffle)
|
||||
add(shuffleButton)
|
||||
|
||||
add(
|
||||
when (player.repeatMode) {
|
||||
Player.REPEAT_MODE_ONE -> repeatOneButton
|
||||
Player.REPEAT_MODE_ALL -> repeatAllButton
|
||||
else -> repeatOffButton
|
||||
}
|
||||
)
|
||||
}.asIterable()
|
||||
)
|
||||
}
|
||||
|
||||
private fun shuffleCurrentPlaylist(player: Player) {
|
||||
Timber.d("shuffleCurrentPlaylist")
|
||||
|
||||
// 3 was chosen because that leaves at least two other songs to be shuffled around
|
||||
@Suppress("MagicNumber")
|
||||
if (player.mediaItemCount < 3)
|
||||
return
|
||||
|
||||
val mediaItemsToShuffle = mutableListOf<MediaItem>()
|
||||
|
||||
for (i in 0 until player.currentMediaItemIndex) {
|
||||
mediaItemsToShuffle += player.getMediaItemAt(i)
|
||||
}
|
||||
|
||||
for (i in player.currentMediaItemIndex + 1 until player.mediaItemCount) {
|
||||
mediaItemsToShuffle += player.getMediaItemAt(i)
|
||||
}
|
||||
|
||||
player.removeMediaItems(player.currentMediaItemIndex + 1, player.mediaItemCount)
|
||||
player.removeMediaItems(0, player.currentMediaItemIndex)
|
||||
|
||||
player.addMediaItems(mediaItemsToShuffle.shuffled())
|
||||
}
|
||||
}
|
||||
|
@ -150,10 +150,8 @@ class PlaybackService :
|
||||
.setBitmapLoader(ArtworkBitmapLoader())
|
||||
.build()
|
||||
|
||||
if (!librarySessionCallback.customLayout.isEmpty()) {
|
||||
// Send custom layout to legacy session.
|
||||
mediaLibrarySession.setCustomLayout(librarySessionCallback.customLayout)
|
||||
}
|
||||
// Send custom layout to legacy session.
|
||||
mediaLibrarySession.setCustomLayout(librarySessionCallback.defaultCustomCommands)
|
||||
|
||||
// Set a listener to update the API client when the active server has changed
|
||||
rxBusSubscription += RxBus.activeServerChangedObservable.subscribe {
|
||||
@ -422,6 +420,12 @@ class PlaybackService :
|
||||
"org.moire.ultrasonic.HEART_ON"
|
||||
const val CUSTOM_COMMAND_TOGGLE_HEART_OFF =
|
||||
"org.moire.ultrasonic.HEART_OFF"
|
||||
const val CUSTOM_COMMAND_SHUFFLE =
|
||||
"org.moire.ultrasonic.SHUFFLE"
|
||||
const val CUSTOM_COMMAND_PLACEHOLDER =
|
||||
"org.moire.ultrasonic.PLACEHOLDER"
|
||||
const val CUSTOM_COMMAND_REPEAT_MODE =
|
||||
"org.moire.ultrasonic.REPEAT_MODE"
|
||||
private const val NOTIFICATION_ID = 3009
|
||||
}
|
||||
}
|
||||
|
7
ultrasonic/src/main/res/drawable/empty.xml
Normal file
7
ultrasonic/src/main/res/drawable/empty.xml
Normal file
@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<size
|
||||
android:width="120dp"
|
||||
android:height="120dp"/>
|
||||
</shape>
|
Loading…
x
Reference in New Issue
Block a user