Merge branch '461' into 'master'

4.6.1 Release canditate

See merge request ultrasonic/ultrasonic!1065
This commit is contained in:
birdbird 2023-06-27 10:21:53 +00:00
commit fe555c076d
16 changed files with 105 additions and 46 deletions

View File

@ -87,26 +87,26 @@ Assemble Release:
# We generate a signed package for each commit to develop as well as when making a release. # We generate a signed package for each commit to develop as well as when making a release.
# Since the develop signed apk are not persistent they can be downloaded for around 3 weeks before Gitlab deletes them. # Since the develop signed apk are not persistent they can be downloaded for around 3 weeks before Gitlab deletes them.
Generate Signed APK: Generate Signed APK:
variables:
APK_NAME: ultrasonic-${CI_COMMIT_SHA}
stage: Sign APK stage: Sign APK
# We don't need the gradle cache here # We don't need the gradle cache here
cache: [] cache: []
script: script:
- openssl aes-256-cbc -K ${ULTRASONIC_KEYSTORE_KEY} -iv ${ULTRASONIC_KEYSTORE_IV} -in ultrasonic-keystore.enc -out ultrasonic-keystore -d - openssl aes-256-cbc -K ${ULTRASONIC_KEYSTORE_KEY} -iv ${ULTRASONIC_KEYSTORE_IV} -in ultrasonic-keystore.enc -out ultrasonic-keystore -d
- mkdir -p ${CI_PROJECT_DIR}/ultrasonic-release - mkdir -p ${CI_PROJECT_DIR}/ultrasonic-release
- ${ANDROID_HOME}/build-tools/*/zipalign -v 4 ultrasonic/build/outputs/apk/release/ultrasonic-release-unsigned.apk ${CI_PROJECT_DIR}/ultrasonic-release/${APK_NAME}.apk - ${ANDROID_HOME}/build-tools/*/zipalign -v 4 ultrasonic/build/outputs/apk/release/ultrasonic-release-unsigned.apk ${CI_PROJECT_DIR}/ultrasonic-release/${PACKAGE_APK}
- ${ANDROID_HOME}/build-tools/*/apksigner sign --verbose --ks ${CI_PROJECT_DIR}/ultrasonic-keystore --ks-pass pass:${ULTRASONIC_KEYSTORE_STOREPASS} --key-pass pass:${ULTRASONIC_KEYSTORE_KEYPASS} ${CI_PROJECT_DIR}/ultrasonic-release/${APK_NAME}.apk - ${ANDROID_HOME}/build-tools/*/apksigner sign --verbose --ks ${CI_PROJECT_DIR}/ultrasonic-keystore --ks-pass pass:${ULTRASONIC_KEYSTORE_STOREPASS} --key-pass pass:${ULTRASONIC_KEYSTORE_KEYPASS} ${CI_PROJECT_DIR}/ultrasonic-release/${PACKAGE_APK}
- ${ANDROID_HOME}/build-tools/*/apksigner verify --verbose ${CI_PROJECT_DIR}/ultrasonic-release/${APK_NAME}.apk - ${ANDROID_HOME}/build-tools/*/apksigner verify --verbose ${CI_PROJECT_DIR}/ultrasonic-release/${PACKAGE_APK}
artifacts: artifacts:
name: $APK_NAME name: $PACKAGE_APK
paths: paths:
- ultrasonic-release/ - ultrasonic-release/
rules: rules:
- if: $CI_COMMIT_REF_NAME == "develop" && $CI_PROJECT_ID == $ROOT_PROJECT_ID && $CI_PIPELINE_SOURCE != "merge_request_event" # Run when releasing a new tag
- if: $CI_COMMIT_TAG && $CI_PROJECT_ID == $ROOT_PROJECT_ID - if: $CI_COMMIT_TAG && $CI_PROJECT_ID == $ROOT_PROJECT_ID
# Or when adding a new commit to develop (but never inside merge events)
- if: $CI_COMMIT_REF_NAME == "develop" && $CI_PROJECT_ID == $ROOT_PROJECT_ID && $CI_PIPELINE_SOURCE != "merge_request_event"
variables: variables:
APK_NAME: ultrasonic-${CI_COMMIT_TAG} PACKAGE_APK: ultrasonic-${CI_COMMIT_SHA}.apk
Publish Signed APK: Publish Signed APK:
@ -133,10 +133,19 @@ Release:
RoboTest: RoboTest:
stage: Release stage: Release
image: gcr.io/google.com/cloudsdktool/google-cloud-cli:latest image: gcr.io/google.com/cloudsdktool/google-cloud-cli:latest
# We don't need the gradle cache here
cache: []
script: script:
- curl --silent "https://gitlab.com/gitlab-org/incubation-engineering/mobile-devops/download-secure-files/-/raw/main/installer" | bash - curl --silent "https://gitlab.com/gitlab-org/incubation-engineering/mobile-devops/download-secure-files/-/raw/main/installer" | bash
- gcloud auth activate-service-account --key-file .secure_files/ultrasonic-61089-8ab2ad46c8a8.json - gcloud auth activate-service-account --key-file .secure_files/firebase-key.json
- gcloud firebase test android run --token $FIREBASE_TOKEN --type robo --app ultrasonic-release/${PACKAGE_APK} --device model=Nexus6,version=21,locale=en,orientation=portrait --device model=Nexus7,version=19,locale=fr,orientation=landscape - gcloud firebase test android run --project ultrasonic-61089 --type robo --app ultrasonic-release/${PACKAGE_APK} --robo-directives click:button1= --device model=Nexus6,version=21,locale=en,orientation=portrait --device model=Pixel3,version=28,locale=fr,orientation=landscape --device model=Pixel5,version=30,locale=zh,orientation=portrait
rules: rules:
# Run when releasing a new tag
- if: $CI_COMMIT_TAG && $CI_PROJECT_ID == $ROOT_PROJECT_ID - if: $CI_COMMIT_TAG && $CI_PROJECT_ID == $ROOT_PROJECT_ID
# or when requested by using [ROBO] inside the commit message and merging to develop
# Would be nice to be able to run it in a MR as well, but currently not possible
# Because it would not have access to the protected keys.
- if: $CI_COMMIT_MESSAGE =~ /^\[ROBO\].*/ && $CI_PROJECT_ID == $ROOT_PROJECT_ID && $CI_COMMIT_REF_NAME == "develop" && $CI_PIPELINE_SOURCE != "merge_request_event"
variables:
PACKAGE_APK: ultrasonic-${CI_COMMIT_SHA}.apk

View File

@ -0,0 +1,15 @@
Features:
- Search is accessible through a new icon on the main screen
- Modernize Back Handling
- Reenable R8 Code minification
- Add a "Play Random Songs" shortcut
Bug fixes:
- Readd the "Star" button to the Now Playing screen
- Fix a rare crash when shuffling playlists with duplicate entries
- Fix a crash when choosing "Play next" on an empty playlist.
- Tracks buttons flash a scrollbar sometimes in Android 13
- Fix EndlessScrolling in genre listing
- Couldn't delete a track when shuffle was active
- Upgrade material to 1.9.0

View File

@ -6,7 +6,7 @@ navigation = "2.6.0"
gradlePlugin = "8.0.2" gradlePlugin = "8.0.2"
androidxcore = "1.10.1" androidxcore = "1.10.1"
ktlint = "0.43.2" ktlint = "0.43.2"
ktlintGradle = "11.4.0" ktlintGradle = "11.4.2"
detekt = "1.23.0" detekt = "1.23.0"
preferences = "1.2.0" preferences = "1.2.0"
media3 = "1.0.2" media3 = "1.0.2"
@ -15,7 +15,7 @@ androidSupport = "1.6.0"
materialDesign = "1.9.0" materialDesign = "1.9.0"
constraintLayout = "2.1.4" constraintLayout = "2.1.4"
multidex = "2.0.1" multidex = "2.0.1"
room = "2.5.1" room = "2.5.2"
kotlin = "1.8.22" kotlin = "1.8.22"
kotlinxCoroutines = "1.7.1" kotlinxCoroutines = "1.7.1"
viewModelKtx = "2.6.1" viewModelKtx = "2.6.1"
@ -30,10 +30,10 @@ picasso = "2.8"
junit4 = "4.13.2" junit4 = "4.13.2"
junit5 = "5.9.3" junit5 = "5.9.3"
mockito = "5.3.1" mockito = "5.4.0"
mockitoKotlin = "5.0.0" mockitoKotlin = "5.0.0"
kluent = "1.73" kluent = "1.73"
apacheCodecs = "1.15" apacheCodecs = "1.16.0"
robolectric = "4.10.3" robolectric = "4.10.3"
timber = "5.0.1" timber = "5.0.1"
fastScroll = "2.0.1" fastScroll = "2.0.1"

View File

@ -9,8 +9,8 @@ android {
defaultConfig { defaultConfig {
applicationId "org.moire.ultrasonic" applicationId "org.moire.ultrasonic"
versionCode 123 versionCode 124
versionName "4.6.0" versionName "4.6.1-RC"
minSdkVersion versions.minSdk minSdkVersion versions.minSdk
targetSdkVersion versions.targetSdk targetSdkVersion versions.targetSdk

View File

@ -26,7 +26,8 @@ import timber.log.Timber
* It should be kept generic enough that it can be used a Base for all lists in the app. * It should be kept generic enough that it can be used a Base for all lists in the app.
*/ */
@Suppress("unused", "UNUSED_PARAMETER") @Suppress("unused", "UNUSED_PARAMETER")
class BaseAdapter<T : Identifiable> : MultiTypeAdapter(), FastScrollRecyclerView.SectionedAdapter { class BaseAdapter<T : Identifiable>(allowDuplicateEntries: Boolean = false) :
MultiTypeAdapter(), FastScrollRecyclerView.SectionedAdapter {
// Update the BoundedTreeSet if selection type is changed // Update the BoundedTreeSet if selection type is changed
internal var selectionType: SelectionType = SelectionType.MULTIPLE internal var selectionType: SelectionType = SelectionType.MULTIPLE
@ -41,7 +42,7 @@ class BaseAdapter<T : Identifiable> : MultiTypeAdapter(), FastScrollRecyclerView
private val diffCallback = GenericDiffCallback<T>() private val diffCallback = GenericDiffCallback<T>()
init { init {
setHasStableIds(true) setHasStableIds(!allowDuplicateEntries)
} }
override fun getItemId(position: Int): Long { override fun getItemId(position: Int): Long {

View File

@ -43,14 +43,7 @@ class TrackViewBinder(
override fun onBindViewHolder(holder: TrackViewHolder, item: Identifiable) { override fun onBindViewHolder(holder: TrackViewHolder, item: Identifiable) {
val diffAdapter = adapter as BaseAdapter<*> val diffAdapter = adapter as BaseAdapter<*>
val track: Track = when (item) { val track = (item as? Track) ?: return
is Track -> {
item
}
else -> {
return
}
}
// Remove observer before binding // Remove observer before binding
holder.observableChecked.removeObservers(lifecycleOwner) holder.observableChecked.removeObservers(lifecycleOwner)
@ -59,7 +52,7 @@ class TrackViewBinder(
song = track, song = track,
checkable = checkable, checkable = checkable,
draggable = draggable, draggable = draggable,
diffAdapter.isSelected(item.longId) diffAdapter.isSelected(track.longId)
) )
holder.itemView.setOnLongClickListener { holder.itemView.setOnLongClickListener {
@ -110,7 +103,7 @@ class TrackViewBinder(
diffAdapter.selectionRevision.observe( diffAdapter.selectionRevision.observe(
lifecycleOwner lifecycleOwner
) { ) {
val newStatus = diffAdapter.isSelected(item.longId) val newStatus = diffAdapter.isSelected(track.longId)
if (newStatus != holder.check.isChecked) holder.check.isChecked = newStatus if (newStatus != holder.check.isChecked) holder.check.isChecked = newStatus
} }

View File

@ -135,7 +135,6 @@ abstract class EntryListFragment<T : GenericEntry> : MultiListFragment<T>() {
item.id, item.id,
append = false, append = false,
autoPlay = true, autoPlay = true,
shuffle = false,
playNext = false, playNext = false,
isArtist = isArtist isArtist = isArtist
) )
@ -145,7 +144,6 @@ abstract class EntryListFragment<T : GenericEntry> : MultiListFragment<T>() {
item.id, item.id,
append = false, append = false,
autoPlay = true, autoPlay = true,
shuffle = true,
playNext = true, playNext = true,
isArtist = isArtist isArtist = isArtist
) )
@ -155,7 +153,6 @@ abstract class EntryListFragment<T : GenericEntry> : MultiListFragment<T>() {
item.id, item.id,
append = true, append = true,
autoPlay = false, autoPlay = false,
shuffle = false,
playNext = false, playNext = false,
isArtist = isArtist isArtist = isArtist
) )

View File

@ -176,7 +176,7 @@ class PlayerFragment :
private val binding get() = _binding!! private val binding get() = _binding!!
private val viewAdapter: BaseAdapter<Identifiable> by lazy { private val viewAdapter: BaseAdapter<Identifiable> by lazy {
BaseAdapter() BaseAdapter(allowDuplicateEntries = true)
} }
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@ -536,8 +536,10 @@ class PlayerFragment :
if (update.success == true && update.rating is HeartRating) { if (update.success == true && update.rating is HeartRating) {
if (update.rating.isHeart) { if (update.rating.isHeart) {
starMenuItem.setIcon(fullStar) starMenuItem.setIcon(fullStar)
starMenuItem.setTitle(R.string.download_menu_unstar)
} else { } else {
starMenuItem.setIcon(hollowStar) starMenuItem.setIcon(hollowStar)
starMenuItem.setTitle(R.string.download_menu_star)
} }
} else if (update.success == false) { } else if (update.success == false) {
Toast.makeText(context, "Setting rating failed", Toast.LENGTH_SHORT) Toast.makeText(context, "Setting rating failed", Toast.LENGTH_SHORT)

View File

@ -316,7 +316,6 @@ open class TrackCollectionFragment(
append = append, append = append,
playNext = false, playNext = false,
autoPlay = !append, autoPlay = !append,
shuffle = false,
playlistName = null, playlistName = null,
fragment = this fragment = this
) )
@ -616,7 +615,6 @@ open class TrackCollectionFragment(
append = true, append = true,
playNext = true, playNext = true,
autoPlay = false, autoPlay = false,
shuffle = false,
playlistName = navArgs.playlistName, playlistName = navArgs.playlistName,
fragment = this@TrackCollectionFragment fragment = this@TrackCollectionFragment
) )

View File

@ -429,7 +429,10 @@ class MediaPlayerManager(
when (insertionMode) { when (insertionMode) {
InsertionMode.CLEAR -> clear() InsertionMode.CLEAR -> clear()
InsertionMode.APPEND -> insertAt = mediaItemCount InsertionMode.APPEND -> insertAt = mediaItemCount
InsertionMode.AFTER_CURRENT -> insertAt = currentMediaItemIndex + 1 InsertionMode.AFTER_CURRENT -> {
// Must never be larger than the count of items (especially when empty)
insertAt = (currentMediaItemIndex + 1).coerceAtMost(mediaItemCount)
}
} }
val mediaItems: List<MediaItem> = songs.map { val mediaItems: List<MediaItem> = songs.map {
@ -437,10 +440,13 @@ class MediaPlayerManager(
result result
} }
if (shuffle) isShufflePlayEnabled = true
Timber.w("Adding ${mediaItems.size} media items") Timber.w("Adding ${mediaItems.size} media items")
controller?.addMediaItems(insertAt, mediaItems) controller?.addMediaItems(insertAt, mediaItems)
// There is a bug in media3 ( https://github.com/androidx/media/issues/480 ),
// so we must first add the tracks, and then enable shuffle
if (shuffle) isShufflePlayEnabled = true
prepare() prepare()
// Playback doesn't start correctly when the player is in STATE_ENDED. // Playback doesn't start correctly when the player is in STATE_ENDED.

View File

@ -98,7 +98,7 @@ class DownloadHandler(
isDirectory: Boolean = true, isDirectory: Boolean = true,
append: Boolean, append: Boolean,
autoPlay: Boolean, autoPlay: Boolean,
shuffle: Boolean, shuffle: Boolean = false,
playNext: Boolean, playNext: Boolean,
isArtist: Boolean = false isArtist: Boolean = false
) { ) {
@ -141,7 +141,7 @@ class DownloadHandler(
append: Boolean, append: Boolean,
playNext: Boolean, playNext: Boolean,
autoPlay: Boolean, autoPlay: Boolean,
shuffle: Boolean, shuffle: Boolean = false,
playlistName: String? = null, playlistName: String? = null,
fragment: Fragment fragment: Fragment
) { ) {

View File

@ -12,7 +12,7 @@
<item <item
a:id="@+id/menu_item_star" a:id="@+id/menu_item_star"
a:icon="@drawable/ic_star_hollow" a:icon="@drawable/ic_star_hollow"
app:showAsAction="ifRoom|withText" app:showAsAction="always"
a:title="@string/download.menu_star"/> a:title="@string/download.menu_star"/>
<item <item

View File

@ -7,6 +7,6 @@
a:icon="@drawable/ic_menu_search" a:icon="@drawable/ic_menu_search"
a:title="@string/button_bar.search" a:title="@string/button_bar.search"
app:actionViewClass="androidx.appcompat.widget.SearchView" app:actionViewClass="androidx.appcompat.widget.SearchView"
app:showAsAction="always|collapseActionView" /> app:showAsAction="ifRoom|collapseActionView" />
</menu> </menu>

View File

@ -44,7 +44,7 @@
<string name="common.play_next">Reproducir a continuación</string> <string name="common.play_next">Reproducir a continuación</string>
<string name="common.play_now">Reproducir ahora</string> <string name="common.play_now">Reproducir ahora</string>
<string name="common.play_shuffled">Reproducción aleatoria</string> <string name="common.play_shuffled">Reproducción aleatoria</string>
<string name="common.public">Public</string> <string name="common.public">Público</string>
<string name="common.save">Guardar</string> <string name="common.save">Guardar</string>
<string name="common.select_all">Seleccionar todo</string> <string name="common.select_all">Seleccionar todo</string>
<string name="common.title">Título</string> <string name="common.title">Título</string>
@ -57,14 +57,14 @@
<string name="download.bookmark_set_at_position" formatted="false">Marcador añadido a %s.</string> <string name="download.bookmark_set_at_position" formatted="false">Marcador añadido a %s.</string>
<string name="download.empty">Nada se esta descargando</string> <string name="download.empty">Nada se esta descargando</string>
<string name="playlist.empty">La lista de reproducción esta vacía</string> <string name="playlist.empty">La lista de reproducción esta vacía</string>
<string name="download.jukebox_not_authorized">El control remoto no esta habilitado. Por favor habilita el modo jukebox en <b>Configuración &gt; Usuarios</b> en tu servidor de Subsonic.</string> <string name="download.jukebox_not_authorized">El control remoto no esta habilitado. Por favor habilita el modo gramola (jukebox) en <b>Configuración &gt; Usuarios</b> en tu servidor de Subsonic.</string>
<string name="download.jukebox_off">Control remoto apagado. La música se reproduce en tu dispositivo.</string> <string name="download.jukebox_off">Control remoto apagado. La música se reproduce en tu dispositivo.</string>
<string name="download.jukebox_offline">Control remoto no disponible en modo fuera de línea.</string> <string name="download.jukebox_offline">Control remoto no disponible en modo fuera de línea.</string>
<string name="download.jukebox_on">Control remoto encendido. La música se reproduce en el servidor.</string> <string name="download.jukebox_on">Control remoto encendido. La música se reproduce en el servidor.</string>
<string name="download.jukebox_server_too_old">Control remoto no soportado. Por favor actualiza tu servidor de Subsonic.</string> <string name="download.jukebox_server_too_old">Control remoto no soportado. Por favor actualiza tu servidor de Subsonic.</string>
<string name="download.menu_equalizer">Ecualizador</string> <string name="download.menu_equalizer">Ecualizador</string>
<string name="download.menu_jukebox_off">Apagar Jukebox</string> <string name="download.menu_jukebox_off">Apagar gramola</string>
<string name="download.menu_jukebox_on">Encender Jukebox</string> <string name="download.menu_jukebox_on">Encender gramola</string>
<string name="download.menu_lyrics">Letras</string> <string name="download.menu_lyrics">Letras</string>
<string name="download.menu_save">Guardar lista de reproducción</string> <string name="download.menu_save">Guardar lista de reproducción</string>
<string name="download.menu_screen_off">Pantalla apagada</string> <string name="download.menu_screen_off">Pantalla apagada</string>
@ -87,7 +87,7 @@
<string name="equalizer.label">Ecualizador</string> <string name="equalizer.label">Ecualizador</string>
<string name="equalizer.preset">Seleccionar preajuste</string> <string name="equalizer.preset">Seleccionar preajuste</string>
<string name="error.label">Error</string> <string name="error.label">Error</string>
<string name="jukebox.is_default">Jukebox por defecto</string> <string name="jukebox.is_default">Gramola por defecto</string>
<string name="lyrics.nomatch">No se encontraron letras</string> <string name="lyrics.nomatch">No se encontraron letras</string>
<string name="language.default">Predeterminado del sistema</string> <string name="language.default">Predeterminado del sistema</string>
<string name="language.zh_CN">Chino (China)</string> <string name="language.zh_CN">Chino (China)</string>
@ -446,7 +446,7 @@
<string name="settings.preload_100">100 canciones</string> <string name="settings.preload_100">100 canciones</string>
<string name="settings.preload_50">50 canciones</string> <string name="settings.preload_50">50 canciones</string>
<string name="settings.preload_1000">1000 canciones</string> <string name="settings.preload_1000">1000 canciones</string>
<string name="jukebox">Jukebox</string> <string name="jukebox">Gramola</string>
<string name="settings.preload_500">500 canciones</string> <string name="settings.preload_500">500 canciones</string>
<string name="supported_server_features">Funciones soportadas</string> <string name="supported_server_features">Funciones soportadas</string>
<string name="foreground_exception_title">No se puede reanudar la reproducción</string> <string name="foreground_exception_title">No se puede reanudar la reproducción</string>
@ -454,4 +454,5 @@
<string name="settings.max_bitrate_pinning">Tasa de bits máxima: al fijar una canción de forma permanente</string> <string name="settings.max_bitrate_pinning">Tasa de bits máxima: al fijar una canción de forma permanente</string>
<string name="shortcut_play_random_songs_short">Canciones aleatorias</string> <string name="shortcut_play_random_songs_short">Canciones aleatorias</string>
<string name="shortcut_play_random_songs_long">Reproducir las canciones aleatoriamente</string> <string name="shortcut_play_random_songs_long">Reproducir las canciones aleatoriamente</string>
<string name="download.menu_unstar">No me gusta</string>
</resources> </resources>

View File

@ -24,4 +24,40 @@
<string name="button_bar.podcasts">Podcast</string> <string name="button_bar.podcasts">Podcast</string>
<string name="button_bar.search">Buscar</string> <string name="button_bar.search">Buscar</string>
<string name="chat.send_a_message">Enviar unha mensaxe</string> <string name="chat.send_a_message">Enviar unha mensaxe</string>
<string name="button_bar.bookmarks">Marcadores</string>
<string name="chat.send_button">Enviar</string>
<string name="chat.user_avatar">Imaxe do avatar</string>
<string name="common.album">Álbum</string>
<string name="common.comment">Comentar</string>
<string name="common.confirm">Confirmar</string>
<string name="common.delete">Borrar</string>
<string name="common.download">Descargar</string>
<string name="common.info">Detalles</string>
<string name="common.multiple_genres">Múltiples xéneros</string>
<string name="common.name">Nome</string>
<string name="common.ok">OK</string>
<string name="common.play_next">Reproducir a continuación</string>
<string name="common.play_now">Reproducir agora</string>
<string name="common.play_shuffled">Reproducir aleatoriamente</string>
<string name="common.public">Público</string>
<string name="common.save">Gardar</string>
<string name="common.select_all">Seleccionar todo</string>
<string name="common.unpin">Desancorar</string>
<string name="common.unpin_selection_confirmation">¿Realmente queres desancorar a selección actual\?</string>
<string name="common.appname">Ultrasonic</string>
<string name="common.title">Título</string>
<string name="common.artist">Artista</string>
<string name="common.cancel">Cancelar</string>
<string name="common.play_last">Reproducir última</string>
<string name="common.pin">Ancorar</string>
<string name="settings.show_confirmation_dialog_summary">Mostra un cadro de diálogo de confirmación antes de eliminar ou desancorar as cancións</string>
<plurals name="select_album_n_songs_pinned">
<item quantity="one">%d canción seleccionada para ser ancorada</item>
<item quantity="other">%d cancións seleccionadas para ser ancoradas</item>
</plurals>
<string name="settings.max_bitrate_pinning">Tasa de bits máxima: ao fixar unha canción de forma permanente</string>
<plurals name="select_album_n_songs_unpinned">
<item quantity="one">%d canción desancorada</item>
<item quantity="other">%d cancións desancoradas</item>
</plurals>
</resources> </resources>

View File

@ -346,6 +346,7 @@
<string name="download.bookmark_set">Set Bookmark</string> <string name="download.bookmark_set">Set Bookmark</string>
<string name="download.bookmark_delete">Delete Bookmark</string> <string name="download.bookmark_delete">Delete Bookmark</string>
<string name="download.menu_star">Star</string> <string name="download.menu_star">Star</string>
<string name="download.menu_unstar">Unstar</string>
<string name="download.menu_clear_playlist">Clear Playlist</string> <string name="download.menu_clear_playlist">Clear Playlist</string>
<string name="button_bar.shares">Shares</string> <string name="button_bar.shares">Shares</string>
<string name="select_share.empty">No shares available on server</string> <string name="select_share.empty">No shares available on server</string>