From 9dee9fe4b4fcc6b23e60e1f3aebd4402fa152c41 Mon Sep 17 00:00:00 2001 From: adlifarizi Date: Thu, 23 Apr 2026 18:04:08 +0700 Subject: [PATCH 1/7] feat: add Simplified Chinese to language selector and refactor more hardcoded strings - **Internationalization**: - Added Simplified Chinese (`zh-CN`) to supported languages in `AppLanguage`. - Implemented full Simplified Chinese translations for settings, batch actions, and library states. - Updated Spanish translations for new settings and empty state strings. - **UI/UX**: - Refactored `LibraryEmptyState` and `PlaylistContainer` to use localized string resources instead of hardcoded English text. - Added comprehensive "Empty State" messaging for Songs, Albums, Artists, Folders, and Playlists, with specific variants for local and cloud filters. - Added localized labels for "Minimum Tracks per Album" and "Album Art Cache Limit" in the settings screen. - **Settings**: - Added a toggle for Simplified Chinese in the language selection settings. - Localized the "Minimum Tracks per Album" slider label. --- .../pixelplay/data/preferences/AppLanguage.kt | 3 +- .../components/PlaylistContainer.kt | 4 +- .../presentation/screens/LibraryEmptyState.kt | 65 ++++++++++--------- .../screens/SettingsCategoryScreen.kt | 2 +- app/src/main/res/values-es/strings.xml | 2 +- .../strings_presentation_batch_c.xml | 30 +++++++++ .../strings_presentation_batch_e.xml | 4 ++ .../main/res/values-es/strings_settings.xml | 4 ++ .../strings_presentation_batch_c.xml | 30 +++++++++ .../strings_presentation_batch_e.xml | 4 ++ .../res/values-zh-rCN/strings_settings.xml | 2 + .../values/strings_presentation_batch_c.xml | 30 +++++++++ .../values/strings_presentation_batch_e.xml | 4 ++ app/src/main/res/values/strings_settings.xml | 2 + 14 files changed, 149 insertions(+), 37 deletions(-) diff --git a/app/src/main/java/com/theveloper/pixelplay/data/preferences/AppLanguage.kt b/app/src/main/java/com/theveloper/pixelplay/data/preferences/AppLanguage.kt index fad453b55..774c35c96 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/preferences/AppLanguage.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/preferences/AppLanguage.kt @@ -9,8 +9,9 @@ object AppLanguage { const val SPANISH = "es" const val FRENCH = "fr" const val RUSSIAN = "ru" + const val CHINESE = "zh-CN" - val supportedLanguageTags: Set = setOf(SYSTEM, ENGLISH, SPANISH, FRENCH, RUSSIAN) + val supportedLanguageTags: Set = setOf(SYSTEM, ENGLISH, SPANISH, CHINESE, FRENCH, RUSSIAN) fun getLanguageOptions(context: Context): Map { return mapOf( diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/PlaylistContainer.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/PlaylistContainer.kt index a9dbe6c61..4cbdeab48 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/PlaylistContainer.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/PlaylistContainer.kt @@ -153,12 +153,12 @@ fun PlaylistContainer( ) Spacer(Modifier.height(8.dp)) Text( - "No playlist has been created.", + stringResource(R.string.presentation_batch_e_no_playlist_created), style = MaterialTheme.typography.titleMedium ) Spacer(Modifier.height(6.dp)) Text( - "Touch the 'New Playlist' button to start.", + stringResource(R.string.presentation_batch_e_new_playlist_hint), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/LibraryEmptyState.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/LibraryEmptyState.kt index 7249b80c1..ddf2355a3 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/LibraryEmptyState.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/LibraryEmptyState.kt @@ -17,6 +17,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp @@ -29,8 +30,8 @@ import com.theveloper.pixelplay.ui.theme.GoogleSansRounded private data class LibraryEmptySpec( val iconRes: Int, - val title: String, - val subtitle: String + val titleRes: Int, + val subtitleRes: Int ) private fun libraryEmptySpec( @@ -41,85 +42,85 @@ private fun libraryEmptySpec( LibraryTabId.SONGS -> when (storageFilter) { StorageFilter.ALL -> LibraryEmptySpec( iconRes = R.drawable.rounded_music_off_24, - title = "No songs yet", - subtitle = "Add music to your device or sync a cloud source to start listening." + titleRes = R.string.lib_empty_songs_all_title, + subtitleRes = R.string.lib_empty_songs_all_subtitle ) StorageFilter.OFFLINE -> LibraryEmptySpec( iconRes = R.drawable.rounded_music_off_24, - title = "No local songs found", - subtitle = "Try another source filter or rescan your device library." + titleRes = R.string.lib_empty_songs_offline_title, + subtitleRes = R.string.lib_empty_songs_offline_subtitle ) StorageFilter.ONLINE -> LibraryEmptySpec( iconRes = R.drawable.rounded_music_off_24, - title = "No cloud songs found", - subtitle = "Sync Telegram or Netease songs, or switch to local source." + titleRes = R.string.lib_empty_songs_online_title, + subtitleRes = R.string.lib_empty_songs_online_subtitle ) } LibraryTabId.ALBUMS -> when (storageFilter) { StorageFilter.ALL -> LibraryEmptySpec( iconRes = R.drawable.rounded_album_24, - title = "No albums available", - subtitle = "Albums will appear here as soon as your library has grouped tracks." + titleRes = R.string.lib_empty_albums_all_title, + subtitleRes = R.string.lib_empty_albums_all_subtitle ) StorageFilter.OFFLINE -> LibraryEmptySpec( iconRes = R.drawable.rounded_album_24, - title = "No local albums found", - subtitle = "Local songs are required to build local album groups." + titleRes = R.string.lib_empty_albums_offline_title, + subtitleRes = R.string.lib_empty_albums_offline_subtitle ) StorageFilter.ONLINE -> LibraryEmptySpec( iconRes = R.drawable.rounded_album_24, - title = "No cloud albums found", - subtitle = "Cloud songs with album data will appear here after sync." + titleRes = R.string.lib_empty_albums_online_title, + subtitleRes = R.string.lib_empty_albums_online_subtitle ) } LibraryTabId.ARTISTS -> when (storageFilter) { StorageFilter.ALL -> LibraryEmptySpec( iconRes = R.drawable.rounded_artist_24, - title = "No artists available", - subtitle = "Artists are shown after songs are indexed from any source." + titleRes = R.string.lib_empty_artists_all_title, + subtitleRes = R.string.lib_empty_artists_all_subtitle ) StorageFilter.OFFLINE -> LibraryEmptySpec( iconRes = R.drawable.rounded_artist_24, - title = "No local artists found", - subtitle = "No artist metadata is available for local songs right now." + titleRes = R.string.lib_empty_artists_offline_title, + subtitleRes = R.string.lib_empty_artists_offline_subtitle ) StorageFilter.ONLINE -> LibraryEmptySpec( iconRes = R.drawable.rounded_artist_24, - title = "No cloud artists found", - subtitle = "Cloud artist entries appear when remote songs are synced." + titleRes = R.string.lib_empty_artists_online_title, + subtitleRes = R.string.lib_empty_artists_online_subtitle ) } LibraryTabId.LIKED -> when (storageFilter) { StorageFilter.ALL -> LibraryEmptySpec( iconRes = R.drawable.rounded_favorite_24, - title = "No liked songs yet", - subtitle = "Tap the heart icon while playing a song to save it here." + titleRes = R.string.lib_empty_liked_all_title, + subtitleRes = R.string.lib_empty_liked_all_subtitle ) StorageFilter.OFFLINE -> LibraryEmptySpec( iconRes = R.drawable.rounded_favorite_24, - title = "No liked local songs", - subtitle = "Switch source filter or like songs from your device." + titleRes = R.string.lib_empty_liked_offline_title, + subtitleRes = R.string.lib_empty_liked_offline_subtitle ) StorageFilter.ONLINE -> LibraryEmptySpec( iconRes = R.drawable.rounded_favorite_24, - title = "No liked cloud songs", - subtitle = "Like Telegram or Netease tracks to see them in this view." + titleRes = R.string.lib_empty_liked_online_title, + subtitleRes = R.string.lib_empty_liked_online_subtitle ) } LibraryTabId.FOLDERS -> LibraryEmptySpec( iconRes = R.drawable.ic_folder, - title = "No folders found", - subtitle = "Internal storage folders with music will appear here." + titleRes = R.string.lib_empty_folders_title, + subtitleRes = R.string.lib_empty_folders_subtitle ) LibraryTabId.PLAYLISTS -> LibraryEmptySpec( iconRes = R.drawable.rounded_playlist_play_24, - title = "No playlists yet", - subtitle = "Create your first playlist to organize your library." + titleRes = R.string.lib_empty_playlists_title, + subtitleRes = R.string.lib_empty_playlists_subtitle ) } } @@ -171,14 +172,14 @@ internal fun LibraryExpressiveEmptyState( verticalArrangement = Arrangement.spacedBy(6.dp) ) { Text( - text = spec.title, + text = stringResource(spec.titleRes), style = MaterialTheme.typography.titleLarge, fontFamily = GoogleSansRounded, fontWeight = FontWeight.SemiBold, textAlign = TextAlign.Center ) Text( - text = spec.subtitle, + text = stringResource(spec.subtitleRes), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SettingsCategoryScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SettingsCategoryScreen.kt index 9ad9fee49..6c7aa3d2d 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SettingsCategoryScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SettingsCategoryScreen.kt @@ -434,7 +434,7 @@ fun SettingsCategoryScreen( valueText = { value -> "${(value / 1000).toInt()}s" } ) SliderSettingsItem( - label = "Minimum Tracks Per Album", + label = stringResource(R.string.setcat_min_tracks_per_album), value = minTracksPerAlbumDraft, valueRange = 1f..5f, steps = 3, // 1, 2, 3, 4, 5 diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 9743ccaa1..3a8c810ef 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -1,4 +1,4 @@ - + PixelPlayer Cambio de nombre de la app Hemos cambiado el nombre de nuestra app de PixelPlay a PixelPlayer debido a un problema de marca registrada. ¡Sigue reproduciendo! diff --git a/app/src/main/res/values-es/strings_presentation_batch_c.xml b/app/src/main/res/values-es/strings_presentation_batch_c.xml index 3a7f9e263..acc07559e 100644 --- a/app/src/main/res/values-es/strings_presentation_batch_c.xml +++ b/app/src/main/res/values-es/strings_presentation_batch_c.xml @@ -49,4 +49,34 @@ Generando… Listo para reproducir Generar lista + + + Aún no hay canciones + Añade música a tu dispositivo o sincroniza una fuente en la nube para empezar a escuchar. + No se encontraron canciones locales + Prueba con otro filtro de fuente o vuelve a escanear la biblioteca del dispositivo. + No se encontraron canciones en la nube + Sincroniza canciones de Telegram o Netease, o cambia a la fuente local. + No hay álbumes disponibles + Los álbumes aparecerán aquí en cuanto tu biblioteca tenga pistas agrupadas. + No se encontraron álbumes locales + Se necesitan canciones locales para crear grupos de álbumes locales. + No se encontraron álbumes en la nube + Las canciones en la nube con datos de álbum aparecerán aquí tras la sincronización. + No hay artistas disponibles + Los artistas se muestran después de que las canciones se indexen desde cualquier fuente. + No se encontraron artistas locales + No hay metadatos de artista disponibles para las canciones locales en este momento. + No se encontraron artistas en la nube + Las entradas de artistas en la nube aparecerán cuando se sincronicen las canciones remotas. + Aún no hay canciones favoritas + Toca el icono del corazón mientras escuchas una canción para guardarla aquí. + No hay canciones favoritas locales + Cambia el filtro de fuente o marca canciones de tu dispositivo como favoritas. + No hay canciones favoritas en la nube + Marca canciones de Telegram o Netease como favoritas para verlas aquí. + No se encontraron carpetas + Las carpetas de almacenamiento interno con música aparecerán aquí. + Aún no hay listas de reproducción + Crea tu primera lista de reproducción para organizar tu biblioteca. diff --git a/app/src/main/res/values-es/strings_presentation_batch_e.xml b/app/src/main/res/values-es/strings_presentation_batch_e.xml index eeda77302..ddab1ba49 100644 --- a/app/src/main/res/values-es/strings_presentation_batch_e.xml +++ b/app/src/main/res/values-es/strings_presentation_batch_e.xml @@ -36,6 +36,10 @@ %d canciones seleccionadas + + No se ha creado ninguna lista de reproducción. + Toca el botón \'Nueva lista\' para empezar. + Crear lista Elige cómo quieres crearla. diff --git a/app/src/main/res/values-es/strings_settings.xml b/app/src/main/res/values-es/strings_settings.xml index d22ac707f..c8a810b5e 100644 --- a/app/src/main/res/values-es/strings_settings.xml +++ b/app/src/main/res/values-es/strings_settings.xml @@ -45,6 +45,9 @@ Análisis de varios artistas y organización. Filtrado Duración mínima de canción + Número mínimo de pistas por álbum + Límite de caché de carátulas de álbum + Tamaño máximo de caché antes de que las imágenes más antiguas se eliminen automáticamente Sincronización y escaneo Reescaneo completo en curso Sincronización de la biblioteca terminada @@ -69,6 +72,7 @@ Español Francés Ruso + Chino simplificado Tema de la app Cambia entre claro, oscuro o seguir el sistema. Tema claro diff --git a/app/src/main/res/values-zh-rCN/strings_presentation_batch_c.xml b/app/src/main/res/values-zh-rCN/strings_presentation_batch_c.xml index 296770ed5..768edf8b2 100644 --- a/app/src/main/res/values-zh-rCN/strings_presentation_batch_c.xml +++ b/app/src/main/res/values-zh-rCN/strings_presentation_batch_c.xml @@ -49,4 +49,34 @@ 生成中… 可立即播放 生成播放列表 + + + 暂无歌曲 + 向设备中添加音乐或同步云端来源以开始聆听。 + 未找到本地歌曲 + 尝试其他来源筛选或重新扫描设备媒体库。 + 未找到云端歌曲 + 同步 Telegram 或网易云音乐歌曲,或切换到本地来源。 + 暂无专辑 + 当媒体库中有已分组的曲目时,专辑会显示在这里。 + 未找到本地专辑 + 构建本地专辑分组需要本地歌曲。 + 未找到云端专辑 + 同步后,带有专辑数据的云端歌曲会显示在这里。 + 暂无艺术家 + 歌曲从任何来源索引后都会显示艺术家。 + 未找到本地艺术家 + 目前没有本地歌曲的艺术家元数据。 + 未找到云端艺术家 + 远程歌曲同步后,云端艺术家条目会显示在这里。 + 暂无收藏歌曲 + 播放歌曲时点击爱心图标即可将其保存到这里。 + 未找到本地收藏歌曲 + 切换来源筛选或收藏设备中的歌曲。 + 未找到云端收藏歌曲 + 收藏 Telegram 或网易云音乐曲目即可在此视图中查看。 + 未找到文件夹 + 包含音乐的内部存储文件夹将显示在这里。 + 暂无播放列表 + 创建你的第一个播放列表以整理媒体库。 diff --git a/app/src/main/res/values-zh-rCN/strings_presentation_batch_e.xml b/app/src/main/res/values-zh-rCN/strings_presentation_batch_e.xml index aaae90bdc..c5ec73f14 100644 --- a/app/src/main/res/values-zh-rCN/strings_presentation_batch_e.xml +++ b/app/src/main/res/values-zh-rCN/strings_presentation_batch_e.xml @@ -36,6 +36,10 @@ 已选择 %d 首歌曲 + + 尚未创建任何播放列表。 + 点击“新建播放列表”按钮开始创建。 + 创建播放列表 选择创建方式。 diff --git a/app/src/main/res/values-zh-rCN/strings_settings.xml b/app/src/main/res/values-zh-rCN/strings_settings.xml index 9f8e519e7..df3c0348c 100644 --- a/app/src/main/res/values-zh-rCN/strings_settings.xml +++ b/app/src/main/res/values-zh-rCN/strings_settings.xml @@ -45,6 +45,7 @@ 多艺术家解析与组织选项。 筛选 最短歌曲时长 + 每张专辑的最少歌曲数量 专辑封面缓存上限 超过该缓存大小后,最旧的图片会被自动删除 同步与扫描 @@ -69,6 +70,7 @@ 跟随系统 英语 西班牙语 + 简体中文 应用主题 切换浅色、深色,或跟随系统外观。 浅色主题 diff --git a/app/src/main/res/values/strings_presentation_batch_c.xml b/app/src/main/res/values/strings_presentation_batch_c.xml index 4c2c95a81..4e9b56361 100644 --- a/app/src/main/res/values/strings_presentation_batch_c.xml +++ b/app/src/main/res/values/strings_presentation_batch_c.xml @@ -49,4 +49,34 @@ Generating… Ready to Play Generate Playlist + + + No songs yet + Add music to your device or sync a cloud source to start listening. + No local songs found + Try another source filter or rescan your device library. + No cloud songs found + Sync Telegram or Netease songs, or switch to local source. + No albums available + Albums will appear here as soon as your library has grouped tracks. + No local albums found + Local songs are required to build local album groups. + No cloud albums found + Cloud songs with album data will appear here after sync. + No artists available + Artists are shown after songs are indexed from any source. + No local artists found + No artist metadata is available for local songs right now. + No cloud artists found + Cloud artist entries appear when remote songs are synced. + No liked songs yet + Tap the heart icon while playing a song to save it here. + No liked local songs + Switch source filter or like songs from your device. + No liked cloud songs + Like Telegram or Netease tracks to see them in this view. + No folders found + Internal storage folders with music will appear here. + No playlists yet + Create your first playlist to organize your library. diff --git a/app/src/main/res/values/strings_presentation_batch_e.xml b/app/src/main/res/values/strings_presentation_batch_e.xml index 0bc8e7675..55de3eb2a 100644 --- a/app/src/main/res/values/strings_presentation_batch_e.xml +++ b/app/src/main/res/values/strings_presentation_batch_e.xml @@ -36,6 +36,10 @@ %d songs selected + + No playlist has been created. + Touch the \'New Playlist\' button to start. + Create playlist Choose the creation flow. diff --git a/app/src/main/res/values/strings_settings.xml b/app/src/main/res/values/strings_settings.xml index 833650ae8..e80b32061 100644 --- a/app/src/main/res/values/strings_settings.xml +++ b/app/src/main/res/values/strings_settings.xml @@ -45,6 +45,7 @@ Multi-artist parsing and organization options. Filtering Minimum Song Duration + Minimum Tracks per Album Album Art Cache Limit Max cache size before oldest images are auto-deleted Sync and Scanning @@ -71,6 +72,7 @@ Español Français Русский + Simplified Chinese App Theme Switch between light, dark, or follow system appearance. Light Theme From b78d3aef5842bd151fd57fb3e666c93705d6d735 Mon Sep 17 00:00:00 2001 From: adlifarizi Date: Fri, 24 Apr 2026 07:11:53 +0700 Subject: [PATCH 2/7] feat: add Indonesian language support and localization resources --- .../pixelplay/data/preferences/AppLanguage.kt | 7 +- .../main/res/values-es/strings_settings.xml | 3 +- .../main/res/values-fr/strings_settings.xml | 2 + app/src/main/res/values-in/plurals.xml | 39 ++ app/src/main/res/values-in/strings.xml | 225 +++++++++ app/src/main/res/values-in/strings_auth.xml | 73 +++ .../main/res/values-in/strings_components.xml | 160 ++++++ .../strings_presentation_batch_a.xml | 21 + .../strings_presentation_batch_b.xml | 84 ++++ .../strings_presentation_batch_c.xml | 82 ++++ .../strings_presentation_batch_d.xml | 131 +++++ .../strings_presentation_batch_e.xml | 151 ++++++ .../strings_presentation_batch_f.xml | 229 +++++++++ .../strings_presentation_batch_g.xml | 464 ++++++++++++++++++ .../strings_presentation_batch_h.xml | 15 + .../main/res/values-in/strings_screens.xml | 239 +++++++++ .../main/res/values-in/strings_settings.xml | 301 ++++++++++++ .../main/res/values-ru/strings_settings.xml | 2 + .../res/values-zh-rCN/strings_settings.xml | 3 + app/src/main/res/values/strings_settings.xml | 3 +- 20 files changed, 2230 insertions(+), 4 deletions(-) create mode 100644 app/src/main/res/values-in/plurals.xml create mode 100644 app/src/main/res/values-in/strings.xml create mode 100644 app/src/main/res/values-in/strings_auth.xml create mode 100644 app/src/main/res/values-in/strings_components.xml create mode 100644 app/src/main/res/values-in/strings_presentation_batch_a.xml create mode 100644 app/src/main/res/values-in/strings_presentation_batch_b.xml create mode 100644 app/src/main/res/values-in/strings_presentation_batch_c.xml create mode 100644 app/src/main/res/values-in/strings_presentation_batch_d.xml create mode 100644 app/src/main/res/values-in/strings_presentation_batch_e.xml create mode 100644 app/src/main/res/values-in/strings_presentation_batch_f.xml create mode 100644 app/src/main/res/values-in/strings_presentation_batch_g.xml create mode 100644 app/src/main/res/values-in/strings_presentation_batch_h.xml create mode 100644 app/src/main/res/values-in/strings_screens.xml create mode 100644 app/src/main/res/values-in/strings_settings.xml diff --git a/app/src/main/java/com/theveloper/pixelplay/data/preferences/AppLanguage.kt b/app/src/main/java/com/theveloper/pixelplay/data/preferences/AppLanguage.kt index 774c35c96..e596d1a5b 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/preferences/AppLanguage.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/preferences/AppLanguage.kt @@ -10,8 +10,9 @@ object AppLanguage { const val FRENCH = "fr" const val RUSSIAN = "ru" const val CHINESE = "zh-CN" + const val INDONESIAN = "in" - val supportedLanguageTags: Set = setOf(SYSTEM, ENGLISH, SPANISH, CHINESE, FRENCH, RUSSIAN) + val supportedLanguageTags: Set = setOf(SYSTEM, ENGLISH, SPANISH, CHINESE, INDONESIAN, FRENCH, RUSSIAN) fun getLanguageOptions(context: Context): Map { return mapOf( @@ -19,7 +20,9 @@ object AppLanguage { ENGLISH to context.getString(R.string.setcat_language_english), SPANISH to context.getString(R.string.setcat_language_spanish), FRENCH to context.getString(R.string.setcat_language_french), - RUSSIAN to context.getString(R.string.setcat_language_russian) + RUSSIAN to context.getString(R.string.setcat_language_russian), + CHINESE to context.getString(R.string.setcat_language_chinese), + INDONESIAN to context.getString(R.string.setcat_language_indonesian) ) } diff --git a/app/src/main/res/values-es/strings_settings.xml b/app/src/main/res/values-es/strings_settings.xml index c8a810b5e..6816152e4 100644 --- a/app/src/main/res/values-es/strings_settings.xml +++ b/app/src/main/res/values-es/strings_settings.xml @@ -72,7 +72,8 @@ Español Francés Ruso - Chino simplificado + Chino (simplificado) + Indonesio Tema de la app Cambia entre claro, oscuro o seguir el sistema. Tema claro diff --git a/app/src/main/res/values-fr/strings_settings.xml b/app/src/main/res/values-fr/strings_settings.xml index 33c0f0460..05e3db0e7 100644 --- a/app/src/main/res/values-fr/strings_settings.xml +++ b/app/src/main/res/values-fr/strings_settings.xml @@ -9,4 +9,6 @@ Espagnol Français Russe + Chinois (simplifié) + Indonésien \ No newline at end of file diff --git a/app/src/main/res/values-in/plurals.xml b/app/src/main/res/values-in/plurals.xml new file mode 100644 index 000000000..4160783f2 --- /dev/null +++ b/app/src/main/res/values-in/plurals.xml @@ -0,0 +1,39 @@ + + + + Berbagi %d playlist + Berbagi %d playlist + + + Mengekspor %1$d playlist ke %2$s + Mengekspor %1$d playlist ke %2$s + + + %d lagu ditambahkan ke antrean + %d lagu ditambahkan ke antrean + + + %d lagu akan diputar berikutnya + %d lagu akan diputar berikutnya + + + %d lagu ditambahkan ke favorit + %d lagu ditambahkan ke favorit + + + %d lagu dihapus dari favorit + %d lagu dihapus dari favorit + + + %d file dihapus + %d file dihapus + + + Hapus %d lagu? + Hapus %d lagu? + + + %d kali + %d kali + + diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml new file mode 100644 index 000000000..02493daaa --- /dev/null +++ b/app/src/main/res/values-in/strings.xml @@ -0,0 +1,225 @@ + + PixelPlayer + Perubahan Nama Aplikasi + Kami telah mengubah nama aplikasi kami dari PixelPlay menjadi PixelPlayer karena masalah terkait merek dagang. Teruslah mendengarkan! + Jangan tampilkan lagi + Tutup + Izin Khusus Diperlukan + Untuk mengedit metadata lagu (file .mp3), PixelPlayer memerlukan akses khusus ke semua file. Ini memungkinkan kami untuk mengubah tag lagu secara langsung. Mohon berikan izin ini di layar berikutnya untuk mengaktifkan pengeditan metadata. + Berikan Izin + Akses Semua File + Kesalahan + OK + Batal + Impor + Cari + + Lirik + Tutup Panel Lirik + Memuat lirik… + Tidak dapat menemukan lirik untuk lagu ini. + Lirik disediakan oleh + https://lrclib.net/ + Lirik tidak ditemukan + Apakah Anda ingin mencari lirik secara online? + Kami tidak dapat menemukan lirik secara otomatis. Anda dapat mengedit judul atau artis dan mencoba mencari secara manual. + Gagal mencari lirik + Gagal mengambil lirik dari server + Koneksi habis waktu. Silakan periksa koneksi internet Anda. + Kesalahan jaringan. Silakan periksa koneksi internet Anda. + Kesalahan server (kode %d). Silakan coba lagi nanti. + Ditemukan %d kecocokan + Mencari \"%s\" + Mencari lirik… + Lirik sudah tersedia. Pengambilan online dilewati. + Lirik yang tertanam sudah ditemukan. Pengambilan online dilewati. + Lirik lokal (.lrc) sudah ditemukan. Pengambilan online dilewati. + Tampilkan opsi lirik + Selalu buka pemilih alih-alih menerapkan kecocokan pertama secara otomatis + Simpan lirik sebagai .lrc + Simpan Lirik + Pilih versi yang ingin disimpan: + Synced (dengan timestamp) + Biasa (hanya teks) + Lirik berhasil disimpan + Gagal menyimpan lirik + Tidak ada lirik yang tersedia untuk disimpan + Reset lirik yang diimpor + Offset sinkronisasi lirik + %+.1fs + Reset + Lebih awal + Lebih lambat + + Memindai file musik… + Memproses file… + %1$d dari %2$d file + Menyinkronkan pustaka… + Sinkronisasi selesai + Menunggu… + Menyinkronkan pustaka… + Lagu tidak dikenal + Artis tidak dikenal + Album tidak dikenal + Pilih Artis + Buka artis mana pun yang dikreditkan untuk trek ini. + 1 artis + %1$d artis + Artis utama + Halaman artis + Putar cepat + Tidak dapat membuka file audio tersebut. + Buka pemutar penuh + Tutup pemutar melayang + Tutup pemutar + Trek sebelumnya + Trek berikutnya + Jeda putaran + Putar + Playlist tidak ditemukan. + + Silakan konfigurasi API key yang valid untuk penyedia AI yang dipilih di Pengaturan. + Kesalahan AI: %s + Penyedia AI yang dipilih menolak permintaan karena akun tidak memiliki kredit atau kuota yang tersedia. + Model AI yang dipilih tidak lagi tersedia. PixelPlayer mencoba beralih ke model yang didukung secara otomatis. + AI tidak dapat menemukan lagu apa pun untuk permintaan Anda. + Tulis ide untuk Daily Mix Anda + Daily Mix diperbarui dengan AI + AI tidak dapat menemukan lagu untuk mix ini + + Acak + Acak semua lagu + Playlist + Playlist terakhir diputar + + Acak Semua + Playlist Terakhir + Tidak ada playlist yang tersedia untuk dibuka + + ID album tidak valid + ID album tidak ditemukan + Kesalahan memuat data album: %s + Album tidak ditemukan + Tidak dapat memperbarui: %s + ID artis tidak valid + ID artis tidak ditemukan + Kesalahan memuat data artis: %s + Tidak dapat menemukan artis + Tidak ditemukan lagu yang valid untuk diputar + + Widget responsif yang beradaptasi dengan ukurannya + Bilah pemutar ringkas + Kontrol penuh dengan acak dan ulangi + Pemutar kotak minimalis + Memproses tindakan pemutaran… + + + Tidak ada playlist untuk dibagikan + Bagikan playlist + Gagal berbagi: %1$s + Tidak ada playlist untuk diekspor + Gagal mengekspor: %1$s + Musik/Ekspor PixelPlayer + Silakan konfigurasi API key Gemini Anda di Pengaturan. + Kesalahan tidak dikenal + + + Mengirim %1$d lagu ke jam tangan + Mengirim ke jam tangan + Transfer selesai + Transfer gagal + Transfer dibatalkan + Menyiapkan transfer jam tangan + %1$d transfer + Memulai transfer… + Beberapa transfer aktif + Menyiapkan transfer… + Mentransfer + Selesai + Gagal + Dibatalkan + Menyiapkan + Memulai + Transfer jam tangan + Menampilkan progres langsung untuk transfer musik dari ponsel ke jam tangan + + + Server media Cast + Mentransmisikan ke perangkat + Menyajikan media ke perangkat Cast + %1$s: %2$s + + + Cadangan tidak valid: %1$s + Menyiapkan pemulihan + Memulai tugas pemulihan. + Menyiapkan cadangan + Memulai tugas pencadangan. + Cadangan berhasil dipulihkan + Pemulihan selesai dengan beberapa masalah yang belum terselesaikan. + Pemulihan tidak dapat diselesaikan: %1$s + Pemulihan gagal: %1$s + Data berhasil diekspor + Ekspor gagal: %1$s + Data berhasil dipulihkan + Pemulihan selesai dengan masalah yang belum terselesaikan. Gagal: %1$s + Gagal memuat model + Crash uji coba dipicu dari Opsi Pengembang - Ini disengaja untuk menguji sistem pelaporan crash + + + Lagu tidak ditemukan dalam daftar saat ini + Tidak dapat menemukan lokasi lagu + Tidak ada lagu ditemukan di pustaka + Pemutaran berhenti: %1$s selesai (Akhir Trek). + Trek + Tidak ada lagu untuk diacak. + Album Terpilih + Tidak ditemukan lagu yang dapat diputar di album terpilih + Hanya %1$d album pertama yang dimasukkan ke antrean + %1$d album dimasukkan ke antrean (%2$d lagu) + Tidak dapat memasukkan album terpilih ke antrean + Semua lagu sudah ada di favorit + Tidak ada lagu yang ada di favorit + Membuat file ZIP… + Gagal berbagi: %1$s + Tidak dapat menghapus lagu yang sedang diputar + %1$d file dihapus (%2$d dilewati - sedang diputar) + %1$d dari %2$d file dihapus + Gagal menghapus file + File dihapus + Tidak dapat menghapus file atau file tidak ditemukan + Penghapusan dibatalkan + Izin ditolak – tidak dapat mengedit file + Izin ditolak – tidak dapat menyimpan lirik + Izin ditolak – tidak dapat mengedit file ini + Metadata berhasil diperbarui + Memperbarui %1$d lagu… + Berhasil memperbarui %1$d lagu! + Diperbarui %1$d lagu. Gagal: %2$d + Daftar putar dipulihkan + Lagu-lagu ini akan dihapus secara permanen dari perangkat Anda dan tidak dapat dipulihkan. + Hapus + + + %1$d menit + Akhir trek + Timer diatur untuk %1$d menit. + Timer dibatalkan. + Tidak dapat mengaktifkan akhir trek: tidak ada lagu aktif. + Timer akhir trek dinonaktifkan: lagu berubah dari %1$s ke %2$s. + Pemutaran akan berhenti di akhir trek. + Trek sebelumnya + Trek saat ini + Sleep Timer + Timer + Akhir trek saat ini + Waktu kustom + Batalkan timer + Atur durasi kustom + Jumlah putaran: %1$s + 1 kali + Nyalakan + %1$d%% + v%1$d + %1$s %2$s + diff --git a/app/src/main/res/values-in/strings_auth.xml b/app/src/main/res/values-in/strings_auth.xml new file mode 100644 index 000000000..9c5d23ccc --- /dev/null +++ b/app/src/main/res/values-in/strings_auth.xml @@ -0,0 +1,73 @@ + + + + Kembali + Tampilkan kata sandi + Sembunyikan kata sandi + Menghubungkan… + Hubungkan + Detail koneksi + Masukkan URL server dan kredensial akun Anda. + URL Server + Nama Pengguna + Kata Sandi + Masukkan kata sandi + admin + Selamat datang, %1$s! + + + Subsonic / Navidrome + Hubungkan ke server musik mandiri Anda + Mendukung Navidrome, Airsonic, Gonic, Ampache, dan server lain yang kompatibel dengan API Subsonic. + https://musik.contoh.com + Gunakan alamat dasar https:// lengkap dari server Anda. + Ini adalah nama akun Subsonic atau Navidrome Anda. + Kata sandi aplikasi juga berfungsi jika server Anda mendukungnya. + Isi otomatis https:// + Kompatibel dengan Navidrome, Gonic, Airsonic, dan server lain yang kompatibel dengan Subsonic + Navidrome + Subsonic + + + Jellyfin + Terhubung ke server Jellyfin. HTTP dan HTTPS didukung untuk akses jaringan lokal. + Hubungkan ke server media Jellyfin Anda + Masukkan URL server Jellyfin dan kredensial akun Anda. + http://192.168.1.100:8096 + URL lengkap server Jellyfin Anda, termasuk port. + Nama pengguna akun Jellyfin Anda. + Kata sandi akun Jellyfin Anda. + Isi otomatis http:// + Terhubung ke server Jellyfin untuk mengalirkan pustaka musik Anda + Jellyfin + + + Google Drive terhubung! + Google Drive + + + Keluar dari login NetEase? + Keluar dari login QQ Music? + Anda dapat kembali lagi nanti. Status halaman saat ini akan dibuang saat menutup. + Keluar + Tetap di sini + Login ke NetEase + Login ke QQ Music + Web kembali + Web maju + Muat ulang + Buka beranda + Menyimpan… + Selesai + Coba lagi + + + Pemuatan halaman habis waktu. Anda dapat mencoba lagi tanpa kehilangan progres Anda. + Tidak dapat membaca cookie sesi. + Halaman memakan waktu terlalu lama untuk dimuat. Gunakan muat ulang atau coba jaringan lain. + Pemuatan WebView gagal. + HTTP %1$d saat memuat NetEase. + HTTP %1$d saat memuat QQ Music. + Tidak ditemukan cookie. Login terlebih dahulu. + Login belum terdeteksi. Selesaikan login NetEase sebelum menekan Selesai. + Login belum terdeteksi. Selesaikan login QQ Music sebelum menekan Selesai. + diff --git a/app/src/main/res/values-in/strings_components.xml b/app/src/main/res/values-in/strings_components.xml new file mode 100644 index 000000000..fe5a01476 --- /dev/null +++ b/app/src/main/res/values-in/strings_components.xml @@ -0,0 +1,160 @@ + + + Ketuk untuk membuka + Sampul album + Placeholder sampul album + Favorit + Putar + Jeda + Ketuk untuk memutar + Judul lagu + Artis + Ulangi + Bilah progres, %1$d persen + + + Tampilan + Posisi teks + Kontrol + Reset lirik? + Apakah Anda yakin ingin mereset lirik untuk lagu ini? + Sembunyikan kontrol sinkronisasi + Sesuaikan sinkronisasi + Tampilkan romanisasi + Tampilkan terjemahan + Nonaktifkan imersif (sekali) + Rata kiri lirik + Rata tengah lirik + Rata kanan lirik + + + Tidak ada koneksi internet + Konten ini memerlukan koneksi internet. Silakan periksa pengaturan jaringan Anda dan coba lagi. + Anda sedang offline + Silakan periksa koneksi internet Anda dan coba lagi untuk mengakses konten ini. + + + Simpan preset kustom + Masukkan nama untuk preset equalizer kustom. + Nama preset + Ubah nama preset + Nama tidak boleh kosong + Simpan + Ubah Nama + + + Berhasil ditandai! + Metadata AI + Berkonsultasi dengan panduan Daily Mix… + Tinjau dan sempurnakan detail yang dihasilkan + Judul + Artis + Album + Genre + Coba lagi + Terapkan perubahan + + + Mengedit metadata lagu + Mengedit metadata lagu dapat memengaruhi cara lagu ditampilkan dan diatur dalam pustaka Anda. Perubahan bersifat permanen dan mungkin tidak dapat dibatalkan. + Mengerti + Informasi + Edit lagu + Gunakan Gemini AI + Tampilkan informasi + Nomor track + Nomor disc + ReplayGain track (dB) + ReplayGain album (dB) + -6.50 + -8.20 + ReplayGain track + ReplayGain album + Judul + Nomor track + Nomor disc + Cari lirik di lrclib.net + Sampul album + Pilih gambar persegi dan sesuaikan agar sampul album Anda terlihat bagus di seluruh aplikasi. + Ubah sampul album + Hapus sampul album + Preview sampul album baru + Sampul album lagu saat ini + Sesuaikan sampul album Anda + Cubit dan geser untuk menyesuaikan framing. + Terapkan sampul album + Tidak dapat memuat gambar yang dipilih + + + Bagikan file lagu melalui + Putar lagu + Bagikan file lagu + Tambahkan ke antrean + Putar berikutnya dalam antrean + Tambahkan ke playlist + Tambahkan ke antrean + Berikutnya + Memeriksa jam tangan + Mentransfer %1$d%% + Mentransfer ke jam tangan + Transfer sedang berlangsung + Kirim ke jam tangan + Jam tangan tidak tersedia + Kirim lagu ke jam tangan + Jam tangan tidak tersedia + Durasi + Info lagu + Durasi + Genre + Album + Artis + Format audio + Penyedia + File + Edit metadata lagu + Hapus dari favorit + Tambahkan ke favorit + Opsi + OPSI + Detail + INFO + Detail + + + %1$d LAGU + terpilih + Putar semua + Putar semua + Sukai semua + Batal sukai semua + Bagikan semua sebagai ZIP + Tambahkan semua ke antrean + Hapus semua + Hapus semua + + Playlist ditutup + Urungkan + DJ Mashup + Playlist baru + Nama playlist + Playlist saya + Buat + Tambahkan %1$d lagu ke… + Pilih playlist + Cari playlist… + + %1$d PLAYLIST + Ekspor semua + Gabungkan + Bagikan semua + Ekspor + Gabungkan + + Urutkan ulang tab pustaka + Reset urutan + Reset urutan tab ke default? + Mengurutkan ulang tab… + Drag handle + Reset + Selesai + diff --git a/app/src/main/res/values-in/strings_presentation_batch_a.xml b/app/src/main/res/values-in/strings_presentation_batch_a.xml new file mode 100644 index 000000000..357efd8f5 --- /dev/null +++ b/app/src/main/res/values-in/strings_presentation_batch_a.xml @@ -0,0 +1,21 @@ + + + + Catatan keamanan: kata sandi Anda hanya dimasukkan di halaman web QQ Music. PixelPlayer menyimpan cookie sesi untuk menyinkronkan pustaka Anda. + Catatan keamanan: kata sandi Anda hanya dimasukkan di halaman web NetEase. PixelPlayer menyimpan cookie sesi (MUSIC_U) untuk menyinkronkan pustaka Anda. + Gagal membaca cookie QQ Music: %1$s + Gagal membaca cookie NetEase: %1$s + + + Menyiapkan Google Drive… + Hubungkan Google Drive + Stream file musik langsung dari Google Drive Anda + Masuk dengan Google + Pilih folder musik + Pilih atau buat folder untuk digunakan sebagai sumber musik Anda + Buat \"PixelPlayer Music\" + Buat folder baru di sini untuk musik Anda + Tidak ada folder di sini + Gunakan + Buka folder + diff --git a/app/src/main/res/values-in/strings_presentation_batch_b.xml b/app/src/main/res/values-in/strings_presentation_batch_b.xml new file mode 100644 index 000000000..bbf4308bc --- /dev/null +++ b/app/src/main/res/values-in/strings_presentation_batch_b.xml @@ -0,0 +1,84 @@ + + + + Layanan Tertaut + Akun Terhubung + Kelola penyedia yang tertaut dan jaga agar setiap integrasi berada di bawah kendali Anda. + Aktif + Tersedia + Segera + Terhubung + Buka Layanan + Segera hadir + Keluar… + Belum ada akun tertaut + Hubungkan penyedia untuk mengelolanya dari layar ini. + Hubungkan %1$s + %1$s (Segera hadir) + Telegram + NetEase + + + Urutkan Lagu + Opsi Lainnya + Putar + Tambah lagu + Tambah + Hapus lagu + Urutkan ulang lagu + Urutkan ulang + Urutkan ulang lagu + Playlist ini kosong. + Folder ini tidak berisi lagu. + Ketuk \'Tambah Lagu\' untuk memulai. + Opsi playlist + Edit playlist + Hapus playlist + Atur transisi default + Ekspor Playlist + Hapus playlist? + Apakah Anda yakin ingin menghapus playlist ini? + Ubah Nama Playlist + Nama baru + + + Daily Mix + + + Pilih Lagu + Pilih Genre + Cari lagu + Pilih Semua + Bersihkan + Genre: %1$s + Pilih genre + Isi Cepat + Tambah Kustom + Genre Baru + Tambah Genre Kustom + Nama Genre + Pilih Ikon + + + Baru Diputar + Putar terbaru + Belum ada yang diputar di %1$s + Ubah rentang atau putar lebih banyak lagu untuk mengisi timeline ini. + Baru Diputar + Hari ini + Kemarin + + + Sesuaikan Radius Sudut + Cocokkan sudut navbar dengan sudut fisik perangkat Anda untuk tampilan yang mulus. + Radius Sudut + %1$d dp + + + Acak %1$s + + + %1$d lagu • %2$s + %1$d lagu • %2$s + + diff --git a/app/src/main/res/values-in/strings_presentation_batch_c.xml b/app/src/main/res/values-in/strings_presentation_batch_c.xml new file mode 100644 index 000000000..24d405f95 --- /dev/null +++ b/app/src/main/res/values-in/strings_presentation_batch_c.xml @@ -0,0 +1,82 @@ + + + + Gagal memuat lagu + Gagal memuat album + Gagal memuat artis + Coba lagi + + + Tidak ditemukan lagu di pustaka Anda. + Coba pindai ulang pustaka Anda di pengaturan jika Anda memiliki musik di perangkat Anda. + Tidak ditemukan lagu + + + Baru + Buat playlist baru + Impor playlist M3U + Temukan lagu saat ini + Semua lagu + Online + Offline + Opsi pengurutan + + + SYNCED + Artis (opsional) + + + Tambah lagu + Tambah lagu terpilih + Tambah + Cari lagu… + Gagal memuat lagu + Muat lebih banyak + + + AI + Perfectly Curated + Daily Mix + Perjalanan sonik Anda sudah siap + Generator Playlist AI + Jelaskan getaran, suasana hati, atau aktivitas dan biarkan AI menciptakan playlist yang sempurna dari pustaka Anda. + Ukuran playlist + Lagu min + Lagu maks + misal. Getaran malam santai, energi latihan yang ceria… + Ketuk untuk Mencoba Lagi + Perjalanan sonik disintesis! + Menghasilkan… + Siap Diputar + Hasilkan Playlist + + + Belum ada lagu + Tambahkan musik ke perangkat Anda atau sinkronkan sumber cloud untuk mulai mendengarkan. + Tidak ditemukan lagu lokal + Coba filter sumber lain atau pindai ulang pustaka perangkat Anda. + Tidak ditemukan lagu cloud + Sinkronkan lagu Telegram atau NetEase, atau beralih ke sumber lokal. + Tidak ada album yang tersedia + Album akan muncul di sini segera setelah pustaka Anda memiliki trek yang dikelompokkan. + Tidak ditemukan album lokal + Lagu lokal diperlukan untuk membangun grup album lokal. + Tidak ditemukan album cloud + Lagu cloud dengan data album akan muncul di sini setelah sinkronisasi. + Tidak ada artis yang tersedia + Artis ditampilkan setelah lagu diindeks dari sumber mana pun. + Tidak ditemukan artis lokal + Tidak ada metadata artis yang tersedia untuk lagu lokal saat ini. + Tidak ditemukan artis cloud + Entri artis cloud muncul saat lagu jarak jauh disinkronkan. + Belum ada lagu yang disukai + Ketuk ikon hati saat memutar lagu untuk menyimpannya di sini. + Tidak ada lagu lokal yang disukai + Beralih filter sumber atau sukai lagu dari perangkat Anda. + Tidak ada lagu cloud yang disukai + Sukai trek Telegram atau NetEase untuk melihatnya di tampilan ini. + Tidak ditemukan folder + Folder penyimpanan internal dengan musik akan muncul di sini. + Belum ada playlist + Buat playlist pertama Anda untuk mengatur pustaka Anda. + diff --git a/app/src/main/res/values-in/strings_presentation_batch_d.xml b/app/src/main/res/values-in/strings_presentation_batch_d.xml new file mode 100644 index 000000000..0b97c4bbd --- /dev/null +++ b/app/src/main/res/values-in/strings_presentation_batch_d.xml @@ -0,0 +1,131 @@ + + + + Pustaka + Transfer jam tangan + Pengaturan + Edit + Urutkan ulang tab + Urutkan berdasarkan + Cloud + Tampilan + Saluran Cloud Telegram + Tampilan Playlist + Grid + List + Internal + Kartu SD + Kartu SD tidak tersedia saat ini. + Tampilan Topik + Saluran + Topik + Keduanya + Cloud + Hanya Cloud + Menghasilkan metadata dengan AI… + Anda dapat memilih hingga %1$d album + Folder + Perluas menu + Tab pustaka + Lompat langsung ke tab mana pun atau urutkan ulang. + Urutkan ulang tab + Folder + + + Mengirim ke Jam Tangan + Memulai transfer… + Mentransfer + Selesai + Gagal + Dibatalkan + Menyiapkan + Menyiapkan transfer… + Batalkan transfer + + + Gabungkan Playlist + Masukkan nama untuk playlist gabungan: + Playlist Gabungan + Ini akan menggabungkan %1$d playlist terpilih menjadi satu. + + + Ruang DJ + Memuat… + Dek %1$d + Muat lagu + Tidak ada lagu yang dimuat + + Pemisahan stem belum tersedia. + Volume + Kecepatan + Crossfader + Dek 1 + Dek 2 + Pilih sebuah lagu + + + Ubah mode tampilan + Nonaktifkan equalizer + Aktifkan equalizer + Edit + Edit preset + Preset kustom + Preset + Perbarui + Bass Boost + Virtualizer + Loudness + Tidak didukung + Tidak didukung di perangkat ini + Volume + Respons Frekuensi + Hz + Bass + Low Mids + High Mids + Treble + Bass / Low + Mid / High + Halaman %1$d + Reset durasi + + + Menggunakan default global + Perubahan berhasil disimpan + Aturan playlist + Transisi global + Simpan + Konfigurasi perilaku default untuk playlist spesifik ini. + Konfigurasi ini berlaku untuk semua sumber pemutaran kecuali ditimpa. + Status Aktif + Default Global + Mengikuti Global + Timpa Kustom + Default Playlist + Timpa Kustom + Aktifkan untuk mengatur aturan spesifik bagi playlist ini. + Gaya Transisi + Cara trek berbaur bersama + Crossfade + Tidak ada + Durasi Transisi + Tumpang tindih total %1$ddtk + Reset + Lagu Saat Ini + Lagu Berikutnya + Trek akan tumpang tindih selama %1$ddtk + Kurva Volume + Sempurnakan kemiringan audio + Fade Out + Fade In + + + Putar %1$s + Ciutkan %1$s + Perluas %1$s + Edit gambar artis + Ubah foto + Reset ke default + Putar acak artis + Artis + diff --git a/app/src/main/res/values-in/strings_presentation_batch_e.xml b/app/src/main/res/values-in/strings_presentation_batch_e.xml new file mode 100644 index 000000000..4e52bcd59 --- /dev/null +++ b/app/src/main/res/values-in/strings_presentation_batch_e.xml @@ -0,0 +1,151 @@ + + + + Antrean kosong. + Tindakan antrean + Bersihkan antrean + Simpan sebagai playlist + Antrean %1$s + Antrean saat ini + dihapus + Bersihkan antrean + Apakah Anda yakin ingin menghapus semua lagu dari antrean kecuali yang sedang diputar? + Berikutnya + Antrean saat ini kosong. + Antrean + Alihkan acak + Alihkan ulangi + Sleep timer + Simpan sebagai playlist + Batalkan pilihan semua + Nama playlist + Cari lagu untuk disertakan… + Simpan sebagai: %1$s + Masukkan nama playlist + Tidak ada lagu yang cocok dengan \"%1$s\" + Tutup lagu + Hapus dari playlist + Opsi lainnya untuk %1$s + + + 1 trek telah siap. + %d trek telah siap. + + + %d lagu terpilih + %d lagu terpilih + + + + Belum ada playlist yang dibuat. + Sentuh tombol \'Playlist Baru\' untuk memulai. + + + Buat playlist + Pilih alur pembuatan. + Manual + Desain sampul, ikon, bentuk, dan pilih lagu sendiri. + Dengan AI + Hasilkan playlist yang dikurasi dengan kontrol lanjutan. + Memerlukan API key Gemini yang dikonfigurasi di pengaturan. + Siapkan API key + + + Lab Playlist AI + Reset + Menghasilkan… + Hasilkan + Tujuan + Nama playlist (opsional) + Seperti apa suasana playlist ini? + Contoh: berkendara saat matahari terbenam dengan synth hangat + Arahan + Suasana Hati + Aktivitas + Era + Mesin kurasi + Energi + Mengontrol intensitas dan tempo lagu. 1 = tenang/lambat, 5 = energi tinggi/cepat. + Penemuan + Mengontrol seberapa familiar pilihannya. 1 = favorit yang paling sering diputar, 5 = lagu jarang diputar. + Lagu min + Lagu maks + Filter + Prioritaskan genre (opsional) + misal. synthwave, indie pop + Hindari genre (opsional) + misal. metal, hard trap + Bahasa pilihan (opsional) + misal. Inggris, Spanyol, instrumental + Prioritaskan favorit + Hindari lirik eksplisit + Pratinjau prompt + Prompt akhir Anda akan muncul di sini setelah Anda menambahkan preferensi. + Kurasi dengan presisi + Tentukan suasana hati, aktivitas, batasan, dan kedalaman. + AI hanya akan menggunakan lagu dari pustaka lokal Anda. + Tambahkan setidaknya satu instruksi untuk AI. + Atur rentang lagu yang valid. + %1$d/5 + Kustom… + Masukkan nilai kustom + Masukkan nilai kustom Anda + + + Era apa pun + Permintaan utama: %1$s. + Target suasana hati: %1$s. + Konteks aktivitas: %1$s. + Fokus era: %1$s. + Prioritaskan genre: %1$s. + Hindari genre: %1$s. + Bahasa pilihan: %1$s. + Target tingkat energi: %1$d/5. + Target penemuan: %1$d/5 di mana 1 adalah familiar dan 5 adalah lagu jarang diputar. + Prioritaskan lagu yang mendekati favorit pendengar jika memungkinkan. + Hindari lirik eksplisit jika ada alternatif. + Jaga transisi tetap mulus dan hindari pengelompokan artis yang berulang. + + + Santai + Energik + Bahagia + Gelap + Romantis + Melankolis + + + Olahraga + Fokus + Perjalanan jauh + Pesta + Belajar + Larut malam + + + @string/presentation_batch_e_ai_era_any + 70-an + 80-an + 90-an + 2000-an + 2010-an + 2020-an + + + + Reset preset + Ini akan mengembalikan urutan preset dan visibilitas default. Lanjutkan? + Kelola preset + Seret untuk mengurutkan ulang • Ketuk mata untuk menampilkan atau menyembunyikan + Reset ke default + Terlihat + Tersembunyi + + + Bagaimana Daily Mix Anda dibuat + Daily Mix Anda dibuat dari lagu-lagu favorit dan yang paling sering Anda putar. Kami juga menambahkan lagu dari artis dan genre yang Anda suka supaya Anda bisa menemukan musik baru. + Beri tahu AI apa yang ingin Anda dengarkan hari ini + Kami menggunakan sampel kecil untuk menjaga biaya tetap rendah + Memperbarui… + Perbarui Daily Mix + diff --git a/app/src/main/res/values-in/strings_presentation_batch_f.xml b/app/src/main/res/values-in/strings_presentation_batch_f.xml new file mode 100644 index 000000000..d70c52e11 --- /dev/null +++ b/app/src/main/res/values-in/strings_presentation_batch_f.xml @@ -0,0 +1,229 @@ + + + + Terpilih + Refresh Pustaka + Pindai seluruh pustaka untuk file baru dan yang dimodifikasi. + Pindai Ulang Penuh + Bangun Ulang Database + Menyiapkan sinkronisasi + Membaca MediaStore + Memproses trek + Menyimpan ke database + Memindai file lirik + Menyelesaikan sinkronisasi + %1$s • %2$d%% (%3$d/%4$d) + %1$s… + Segarkan Lirik + Ambil lirik secara otomatis untuk semua lagu menggunakan lrclib. + Segarkan lirik + Memproses %1$d dari %2$d lagu + Masukkan API Key + Simpan + Tersimpan! + Prompt Preset + Masukkan prompt sistem… + Reset + Kurator Profesional + Anda adalah \'Vibe-Engine\', kurator musik kelas dunia dan ahli dalam alur sonik. Tujuan Anda adalah membangun pengalaman mendengarkan yang mulus dan berkualitas tinggi. Prioritaskan kompatibilitas harmonik, transisi BPM yang logis, dan keseimbangan canggih antara favorit yang sudah dikenal dan penemuan yang berselera tinggi serta didorong oleh logika. + Creative Maverick + Anda adalah penjelajah musik avant-garde yang berspesialisasi dalam \'kohesi yang tak terduga\'. Misi Anda adalah mendobrak batas genre konvensional dengan mengidentifikasi paralel sonik yang tidak jelas. Prioritaskan lagu-lagu langka, tekstur eksperimental, dan kebaruan artistik sambil tetap mempertahankan logika transisi yang mengejutkan namun tidak terbantahkan. + Pustakawan Ketat + Anda adalah arsitek database musik yang sangat teliti. Logika Anda didorong oleh presisi metadata absolut dan kepatuhan kategoris yang kaku. Minimalkan penemuan algoritmik demi konsistensi genre yang ketat, pencocokan tingkat energi, dan memaksimalkan pengambilan preferensi pengguna yang sangat spesifik. + Pemandu Atmosfer + Anda adalah ahli tekstur ambien dan alur berenergi rendah. Fokuslah secara eksklusif pada trek yang memfasilitasi keadaan \'fokus dalam\' atau \'ketenangan\'. Prioritaskan kehangatan akustik, aransemen minimalis, dan transisi yang lembut, sambil menghindari suara transien tinggi atau pergeseran mendadak dalam rentang dinamis. + Antusias Sonik + Anda adalah analis audiophile yang berfokus pada kompleksitas produksi dan instrumentasi. Prioritaskan trek yang ditandai dengan rentang dinamis tinggi, poliritme yang rumit, dan kualitas soundstage yang unggul. Utamakan karya-karya yang menuntut pendengaran aktif yang menghargai pendengar karena memperhatikan kesetiaan teknis dan detail aransemen. + Katalis Energi + Anda adalah penghasil ritme bermomentum tinggi. Filosofi Anda berpusat pada bassline yang kuat, intensitas perkusi, dan alunan yang menular. Prioritaskan lagu yang cocok untuk klub dengan BPM tinggi, energi sinkopasi, dan ketegangan ritme yang berkelanjutan untuk menjaga detak jantung dan motivasi pendengar tetap di tingkat puncak. + + + Playlist pintar baru + Playlist baru + Tambah Lagu + Kembali atau Batal + Berikutnya + Buat + Edit Playlist + Tutup + Konfirmasi Potong + Kolase yang dihasilkan otomatis + Tambah Foto + Pilih Gambar + Nama Playlist + Campuran luar biasa saya + Manual + Pintar + Hasilkan dengan AI + Aturan Pintar + Default + Gambar + Ikon + Warna Latar Belakang + Ikon Simbol + Gaya Bentuk + Parameter bentuk + Radius Sudut + Kehalusan + Sisi + Lengkungan + Rotasi + Skala + Paling Sering Diputar + Trek yang paling sering Anda putar. + Baru-baru ini Diputar + Lagu yang terakhir Anda dengarkan. + Favorit yang Terlupakan + Trek favorit yang sudah lama tidak Anda putar. + Permata Baru + Trek yang baru saja ditambahkan dengan jumlah putaran rendah. + + + Gaya palet + Pilih warna album untuk UI pemutar. + Warna + Terapkan + Seimbang dan tenang. + Aksen saturasi tinggi. + Pergeseran rona dan kontras yang berani. + Aksen rotasi yang ceria. + Tonal Spot + Vibrant + Expressive + Fruit Salad + Akurasi warna + 0 mempertahankan penyetelan saat ini. Nilai yang lebih tinggi akan tetap lebih dekat dengan rona dominan sampul album. + Saat Ini + Lebih akurat + 0 • Saat Ini + %1$d • Halus + %1$d • Seimbang + %1$d • Presisi + + + Penyesuaian pemuatan PlayerUI + Lirik Animasi (Perangkat kelas atas) + Menggunakan animasi spring dan efek visual untuk lirik. Mungkin menyebabkan penurunan frame pada perangkat kelas rendah. + Efek Blur Lirik + Menerapkan blur kedalaman bidang (depth-of-field) pada lirik yang tidak aktif. + Kekuatan Blur + Sesuaikan intensitas efek blur. + %1$.1fx + Langkah 1 · Pilih apa yang akan ditunda + Tunda semuanya + Tahan seluruh konten pemutar sampai latar belakang panel mengembang sepenuhnya. + Karousel album + Tunda sampul album dan karousel sampai panel mengembang. + Metadata lagu + Tunda judul, artis, dan tindakan lirik/antrean. + Bilah progres + Tunda lini masa dan label waktu sampai ekspansi selesai. + Kontrol pemutaran + Tunda kontrol putar/jeda, cari, dan favorit. + Semua komponen yang ditunda sudah aktif. Nonaktifkan \"Tunda semuanya\" untuk menyesuaikan setiap bagian. + Langkah 2 · Konfigurasikan perilaku placeholder + Gunakan placeholder untuk item yang ditunda + Jaga stabilitas tata letak dengan merender placeholder ringan saat komponen menunggu ekspansi. + Langkah 3 · Pilih kapan placeholder beralih ke konten asli + Pilih satu mode. Mode Ambang Batas menggunakan slider; Mode Lepas Seret menunggu sampai Anda melepaskan gerakan panel. + Aktifkan setidaknya satu komponen yang ditunda untuk membuka mode pemicu. + Ambang Batas + Menggunakan persentase ekspansi. + Lepas seret + Beralih hanya setelah gerakan dilepaskan. + Ambang batas ekspansi + Seberapa jauh panel harus mengembang sebelum komponen yang ditunda menjadi terlihat. + %1$d%% ekspansi + Juga terapkan saat pemutar ditutup + Gunakan ambang batas penutupan untuk beralih kembali ke placeholder saat mengempis. + Ambang batas penutupan + Seberapa banyak pengecilan yang diperlukan sebelum placeholder mengambil alih kembali. + Placeholder muncul setelah %1$d%% pengecilan + Mode lepas seret mengabaikan ambang batas dan perilaku penutupan. Pertukaran hanya terjadi saat gerakan seret panel berakhir. + Buat placeholder transparan + Placeholder tetap menempati ruang tata letak tetapi menjadi tidak terlihat. + Kualitas Visual + Resolusi Sampul Album + Eksperimental + Rendah (256px) - Performa lebih baik + Sedang (512px) - Seimbang + Tinggi (800px) - Kualitas terbaik + Asli - Kualitas maksimum + + + %1$d%% + %1$s • %2$s + · %1$s + ? + + + Login Telegram + Anda sedang mengedit nomor Anda. Mengirim kode lagi akan menggantikan yang sebelumnya. + Bekerja… + Menginisialisasi Telegram… + Keluar… + Menutup sesi… + Sesi ditutup. Buka kembali login untuk melanjutkan. + Menyiapkan sesi Telegram yang aman… + Menunggu respons Telegram… + Hubungkan Telegram + Login dengan penanganan kesalahan yang kuat, kontrol batas waktu, dan langkah-languar yang dapat diedit. + Nomor Telepon + Masukkan nomor Telegram Anda. Anda dapat kembali dan mengeditnya nanti. + Nomor telepon + 1 + 8123456789 + Kirim Kode + Kode Verifikasi + Masukkan kode dari Telegram. Jika nomornya salah, kembali dan edit. + Kode + 12345 + Edit telepon + Kirim ulang kode + Verifikasi Kode + Kata Sandi Dua Langkah + Masukkan kata sandi Telegram Anda. Anda masih bisa kembali untuk memperbaiki nomor Anda. + Kata Sandi + Verifikasi Kata Sandi + Harap tunggu… + + + Saluran Telegram + Tambah Saluran + Saluran Telegram publik + Menyinkronkan + Sinkronkan sekarang + Ciutkan topik + Tampilkan topik + Opsi saluran + Topik + Menyinkronkan saluran + Memperbarui lagu dari Telegram + Ambil lagu terbaru dari saluran ini + Hapus saluran + Berhenti menyinkronkan dan hapus lagu yang dicache + Tidak Ada Saluran yang Disinkronkan + Tambahkan saluran Telegram publik untuk menyinkronkan\npustaka musik Anda + Tambah saluran + Belum pernah disinkronkan + Disinkronkan %1$s + + + Tambah Saluran + Cari saluran Telegram publik untuk menyinkronkan musiknya + \@namasaluran atau tautan + Cari + Mencari… + Cari saluran + Masukkan username atau tautan saluran publik\nuntuk menyinkronkan file audionya + Selesai + + + %d lagu + %d lagu + + + %d topik + %d topik + + diff --git a/app/src/main/res/values-in/strings_presentation_batch_g.xml b/app/src/main/res/values-in/strings_presentation_batch_g.xml new file mode 100644 index 000000000..60d974882 --- /dev/null +++ b/app/src/main/res/values-in/strings_presentation_batch_g.xml @@ -0,0 +1,464 @@ + + + Hari ini + Minggu Berjalan + Bulan Berjalan + Tahun Berjalan + Semua Waktu + Statistik Mendengarkan + Segarkan statistik mendengarkan + Mendengarkan + Putaran + + Kebiasaan mendengarkan + Belum ada kebiasaan + Kami akan menampilkan kebiasaan mendengarkan Anda setelah kami mengenal Anda lebih baik. + Total sesi + Rata-rata sesi + Sesi terlama + Sesi/hari + Hari paling aktif + Belum ada pemutaran + Slot lini masa puncak + Waktu mendengarkan + Total waktu mendengarkan dalam rentang yang dipilih. + Jumlah putaran + Berapa banyak sesi yang Anda selesaikan per segmen. + Rata-rata sesi + Rata-rata durasi mendengarkan untuk setiap segmen. + %1$d putaran + Lini masa mendengarkan + Belum ada data mendengarkan + Tekan putar untuk mulai membangun lini masa mendengarkan Anda + Ritme harian + Ritme mingguan + Ritme bulanan + Tahun sekilas + Progres semua waktu + Dikelompokkan dalam segmen 4 jam + Dikelompokkan berdasarkan hari dalam seminggu + Dikelompokkan berdasarkan minggu dalam sebulan + Dikelompokkan berdasarkan bulan + Dikelompokkan berdasarkan tahun + Segmen puncak + Dibagi menjadi jendela 4 jam untuk mengungkapkan ritme harian Anda. + Bilah harian memudahkan perbandingan kebiasaan dari minggu ke minggu. + Bilah mingguan menunjukkan tren bulan ini. + Bilah bulanan menunjukkan musiman sepanjang tahun. + Bilah tahunan merangkum riwayat lengkap Anda. + Kategori teratas + Bandingkan cara Anda mendengarkan lintas genre, artis, album, dan lagu. + %1$d putaran • %2$d artis + %1$d putaran • %2$d trek + Genre + Artis + Album + Lagu + Mendengarkan berdasarkan genre + Mendengarkan berdasarkan artis + Mendengarkan berdasarkan album + Mendengarkan berdasarkan lagu + Belum ada data kategori + Tekan putar untuk menampilkan sorotan mendengarkan Anda + Artis teratas + Tidak ada artis teratas + Teruslah mendengarkan dan artis favorit Anda akan muncul di sini. + %1$d. %2$s + Album teratas + Tidak ada album teratas + Album yang sering Anda kunjungi kembali akan muncul di sini. + %1$d. %2$s + Trek dalam rentang ini + Trek yang paling sering diputar untuk rentang waktu yang dipilih. + Tidak ada trek teratas + Dengarkan favorit Anda untuk melihatnya disorot di sini. + Ciutkan trek + Tampilkan semua trek + Konsentrasi trek + Bagaimana waktu mendengarkan Anda didistribusikan ke seluruh trek teratas Anda. + Belum ada data konsentrasi + Putar lebih banyak trek untuk melihat seberapa terfokus pendengaran Anda. + Top 1 + Top 2-3 + Lainnya + %1$d%% + Konsentrasi mendengarkan + Top 3 trek menyumbang %1$d%% dari waktu mendengarkan Anda. + Rata-rata putaran/trek + Trek unik + Top 3 share + ? + Info Perangkat + Codec Audio yang Didukung + Output Audio + ExoPlayer Engine + Sample Rate + Frame Per Buffer + Low Latency Support + Pro Audio Support + Versi + Renderer Aktif + Penghitung Dekoder + %1$d Hz + Ya + Tidak + Hardware Accelerated + Manufaktur + Model + Merek + Perangkat + Versi Android + Versi SDK + Perangkat Keras + Input + Output + Pemikiran + %1$s: %2$s + dd MMM, HH:mm + Penguraian Multi-Artis + Pemisah Karakter + Saat ini: %1$s + Pemisah Kata + Tidak ada + Saat ini: %1$s + + Konfigurasikan + Ekstrak Artis dari Judul + Deteksi feat., ft., with dalam judul lagu + Pengorganisasian Pustaka + Kelompokkan berdasarkan Artis Album + Tampilkan album kolaborasi di bawah artis utama + Tentang Penguraian Multi-Artis + PixelPlayer membagi tag artis menggunakan pemisah karakter (/, ;, &) dan pemisah kata (feat., ft., vs., x). Pemisah kata dicocokkan tanpa membedakan huruf besar-kecil. +\n\n"Ekstrak Artis dari Judul" mendeteksi pola seperti (feat. Artis) dalam judul lagu. +\n\nBackslash (\\) dapat digunakan untuk mengabaikan pemisah karakter. + + Contoh + \"Artis1/Artis2\" + Artis1, Artis2 + \"Drake feat. Rihanna\" + Drake, Rihanna + \"Marshmello x Bastille\" + Marshmello, Bastille + \"Lagu (ft. B)\" oleh A + A, B + \"AC\\DC\" + AC/DC (diabaikan) + Artis + Pemindaian Ulang Diperlukan + Pengaturan artis telah berubah. Pindai ulang pustaka Anda untuk menerapkannya. + Memindai… + Pindai Ulang + β + Beta + Telegram + Log Perubahan + Pengaturan + Synced + Statis + Opsi lirik + Cloud Streaming + Stream musik dari akun cloud Anda + Sumber + Urutan + Menurun + Menaik + Urutan Asli + Ketuk untuk beralih ke menaik + Ketuk untuk beralih ke menurun + Pengurutan ini mempertahankan urutan aslinya + Switch is on + Tutup + Segarkan + Selesai + Selesai + Semuanya diizinkan secara default. Ketuk folder untuk menandainya sebagai dikecualikan dari pemindaian. + Tidak ada subfolder di sini + Navigasi ke atas + Ke root + Daily Mix + DAILY MIX + Berdasarkan Riwayat + Lihat semua Daily Mix + lagu terpilih + lagu terpilih + Bagikan terpilih + Sukai terpilih + Putar + Semua + Batal Pilih + Opsi lainnya + Opsi + +%1$d + %1$s • %2$s + Terpilih + Opsi lainnya untuk %1$s + Sampul album untuk %1$s + Memutar + %1$d%% + Statistik mendengarkan + Total putaran + Rata-rata per hari + Trek teratas + %1$s • %2$d putaran + Baru Diputar + −0,5 + −0,1 + +0,1 + +0,5 + 0 d + + %1$+.1f d + Buka Play Store + Lanjutkan beta + Tautan Play Store akan diaktifkan dari konfigurasi GitHub. + PixelPlayer kini tersedia di Google Play + Gunakan saluran stabil di Google Play untuk pembaruan rilis sementara kami tetap mengaktifkan build beta. + PixelPlayer + Pengumuman rilis + Segera hadir + Urutkan & Putar + Acak + Urutkan Berdasarkan + Artis + Album + Judul + Terpilih + Log Perubahan + Lihat di GitHub + Preset Tersimpan + Belum ada preset kustom yang tersimpan. + Lepas semat + Sematkan + Ubah nama + Hapus + Beta 0.6.0 + Selamat datang di PixelPlayer 0.6.0-beta + Beta ini sekarang difokuskan pada stabilitas, performa, dan pemutaran lintas perangkat sambil merilis integrasi baru yang besar. + Apa yang diharapkan + Penggunaan harian yang lebih cepat: startup, navigasi, dan interaksi pemutar yang lebih lancar di seluruh aplikasi. + Dukungan perangkat yang lebih luas: Android Auto, peningkatan Wear OS, dan keandalan Cast yang lebih kuat. + Ekosistem cloud yang diperluas: daftar putar Telegram, sinkronisasi NetEase, QQ Music, dan pembaruan streaming Google Drive. + Peningkatan keandalan besar-besaran: logika antrean/acak, perilaku pemutaran latar belakang, dan banyak perbaikan UI. + Laporkan masalah + Bagikan langkah-langkah untuk mereproduksi, hasil yang diharapkan, hasil aktual, dan detail perangkat/OS Anda. Rekaman layar singkat sangat membantu. + Buka issue GitHub + Laporkan bug + Peningkatan Beta 0.5.0 + Instal bersih direkomendasikan + Jika Anda datang dari beta 0.5.0, pembaruan ini mungkin memerlukan data pustaka baru alih-alih status cache lama. + Jika metadata atau entri pustaka tampak salah + Metadata lagu yang salah, artis atau album yang tidak cocok, atau entri yang tampak duplikat biasanya berarti instal bersih adalah solusinya. + Jangan tampilkan lagi + Mengerti + %1$d ALBUM + terpilih + Antrekan + putar menghormati urutan pilihan Anda. + Batas: %1$d album per pilihan. + Tambah ke Antrean & Putar + PixelPlayer + Pemutar Musik + TERTINGGI %1$d + Tutup + SKOR + LVL %1$d + NYAWA + LEVEL SELESAI! + PERMAINAN BERAKHIR + Skor: %1$d + Coba Lagi? + Level Berikutnya + Mulai Ulang Permainan + KETUK UNTUK MELUNCURKAN KEMBALI + Putar Musik Acak + Brick Breaker + SKOR TERTINGGI %1$d + Main + Seret untuk menggerakkan pemukul + Pulihkan Modul + Memulihkan + Pulihkan Terpilih + Detail Cadangan + Dibuat + Versi Aplikasi + Skema + Perangkat + Tidak diketahui + %1$d dari %2$d modul terpilih + Transfer sedang berlangsung… + Pilih semua + Bersihkan pilihan + %1$d entri · Akan menggantikan data saat ini + Cloud stream + Ciutkan pemutar + Cast + Bluetooth + Pemutaran lokal + Menghubungkan… + Antrean + Lirik + Sesi casting + Menghubungkan + Terhubung + Ponsel ini + Audio Bluetooth + Pemutaran lokal + Memutar + Dijeda + Bersiaplah untuk terhubung + Izinkan PixelPlayer untuk melihat perangkat terdekat dan Wi‑Fi saat ini agar kami dapat menjaga sinkronisasi cast, audio Bluetooth, dan speaker Anda. + Perangkat terdekat + Diperlukan untuk membaca dan mengontrol perangkat audio Bluetooth Anda yang terhubung. + Lokasi untuk Wi‑Fi + Android memerlukan Lokasi untuk membagikan jaringan Wi‑Fi (SSID) yang Anda gunakan agar kami dapat menemukan perangkat cast yang kompatibel. + Izinkan akses + Kami hanya menggunakan izin ini untuk interkonektivitas perangkat — casting, mengontrol speaker terdekat, dan menjaga sinkronisasi audio. + Hubungkan perangkat + Memindai perangkat sekitar + Kontrol + Perangkat + Konektivitas + Aktifkan Wi-Fi atau Bluetooth + Kelola radio aktif dan pindai ulang + Segarkan koneksi + Segarkan perangkat + Perangkat terdekat + Ketuk untuk menghubungkan + Belum ada perangkat + Putuskan + Volume perangkat + Volume ponsel + Mencari perangkat… + Pastikan TV atau speaker Anda menyala dan menggunakan jaringan Wi‑Fi yang sama. + Terhubung + Tersedia untuk dihubungkan + Menghubungkan + Tersedia + Tingkat baterai + Tingkat volume + Wi-Fi + Mati + Terhubung + Nyala + Bluetooth + Terhubung + Nyala + Mati + Koneksi mati + Aktifkan Wi‑Fi atau Bluetooth untuk menemukan perangkat terdekat + Aktifkan Wi‑Fi + Buka Bluetooth + Putuskan + Menghubungkan... + + Apa yang Baru + Peningkatan + Perbaikan + Sorotan + Apa yang baru + Ditambahkan + Diubah + Diperbaiki + + Dukungan Android Auto kini tersedia untuk pemutaran di dalam mobil. + Dukungan Wear OS telah hadir, termasuk kontrol pemutaran jam-ke-ponsel yang lebih baik. + Integrasi cloud diperluas dengan peningkatan Telegram, NetEase, QQ Music, dan Google Drive. + Baru-baru ini Diputar dan pemulihan antrean persisten menjaga sesi mendengarkan Anda tetap siap. + Cadangkan & Pulihkan v3 dan alat manajemen akun kini disertakan. + Lirik menjadi lebih pintar dengan pencarian manual cadangan dan peningkatan penyimpanan. + + + Peningkatan performa besar-besaran pada startup, pustaka, antrean, dan interaksi pemutar. + Tampilan Pemutar, Cast, Lirik, Artis, dan Genre didesain ulang untuk penggunaan yang lebih lancar. + Alur navigasi dan pencarian lebih andal, dengan penanganan rute yang lebih aman. + Kompatibilitas pemutaran audio ditingkatkan untuk lebih banyak perangkat dan format. + Alur kerja multi-pilihan diperluas untuk lagu, album, dan daftar putar. + + + Perilaku antrean dan acak kini lebih stabil dan mudah diprediksi. + Beberapa kasus tepi pada pemutaran latar belakang dan casting telah diperbaiki. + Masalah pada Sleep Timer, navigasi tab File, dan crash pada artis album telah diperbaiki. + Pemuatan widget dan stabilitas layanan ditingkatkan untuk mengurangi masalah panas berlebih/memori. + Perbaikan bug umum dan penghalusan UI di seluruh aplikasi. + + + Pembaruan UI Material 3 Expressive + Equalizer 10-band & Efek + Alur Sinkronisasi Pustaka Baru + Integrasi AI (Model Gemini) + Impor/Ekspor Daftar Putar M3U + Integrasi Sampul Artis Deezer + Sampul Daftar Putar Kustom + + + Refaktor Arsitektur Pengaturan + Animasi Antrean & Pemutar + Profil Dasar & Performa + Sistem Lirik Lebih Baik dengan Offset Sinkronisasi + + + Peningkatan Stabilitas Casting + Stabilitas Panel Pemutar + Perbaikan Bug Umum & Pembersihan + + + Redesain navigasi utama + Penjelajah file baru untuk memilih direktori sumber + Fungsionalitas Konektivitas dan casting baru + Kontinuitas mulus antara perangkat jarak jauh + Transisi tanpa jeda antar lagu + Kontrol Crossfade + Fitur Transisi Kustom Baru (hanya untuk daftar putar) + Tetap putar setelah menutup aplikasi + Optimalisasi UI + Fitur statistik yang ditingkatkan + Kontrol Antrean yang didesain ulang dengan lebih banyak fitur + Peningkatan dukungan berbagai tipe file untuk pemutaran dan pengeditan metadata + Peningkatan pengontrol izin + Perbaikan bug minor + + + Memperkenalkan hub statistik mendengarkan yang lebih kaya dengan wawasan lebih dalam tentang sesi Anda. + Meluncurkan pemutar cepat melayang untuk membuka dan melihat pratinjau file lokal secara instan. + Menambahkan tab folder dengan navigator gaya pohon dan tampilan siap-daftar-putar. + + + Menyempurnakan UI Material 3 secara keseluruhan untuk pengalaman yang lebih bersih dan kohesif. + Metadata editing now supports cover art change. + Menghaluskan animasi dan transisi di seluruh aplikasi untuk navigasi yang lebih cair. + Meningkatkan tata letak layar artis dengan detail dan polesan yang lebih kaya. + Meningkatkan pembuatan DailyMix dan YourMix dengan pilihan yang lebih pintar dan beragam. + Memperkuat pembuatan daftar putar AI. + Meningkatkan relevansi pencarian dan presentasi untuk penemuan yang lebih cepat. + Memperluas dukungan untuk rentang format file audio yang lebih luas. + + + Menyelesaikan masalah metadata sehingga detail lagu tetap akurat di mana saja. + Memulihkan pintasan notifikasi sehingga melompat kembali ke pemutaran dengan andal. + + + Dukungan Chromecast untuk melakukan cast audio dari perangkat Anda. + Log perubahan dalam aplikasi untuk membuat Anda tetap terinformasi tentang fitur terbaru. + Dukungan untuk file .LRC, baik yang tertanam maupun eksternal. + Dukungan lirik offline. + Lirik yang disinkronkan (sinkron dengan lagu). + Layar baru untuk melihat antrean lengkap. + Urutkan ulang dan hapus lagu dari antrean. + Gerakan mini-player (geser ke bawah untuk menutup). + Menambahkan lebih banyak animasi material. + Pengaturan baru untuk menyesuaikan tampilan dan nuansa. + Pengaturan baru untuk membersihkan cache. + + + Redesain total antarmuka pengguna. + Redesain total pemutar. + Peningkatan performa di pustaka. + Peningkatan kecepatan startup aplikasi. + AI kini memberikan hasil yang lebih baik. + + + Memperbaiki berbagai bug di editor tag. + Memperbaiki bug di mana notifikasi pemutaran tidak bisa dihapus. + Memperbaiki beberapa bug yang menyebabkan aplikasi crash. + + diff --git a/app/src/main/res/values-in/strings_presentation_batch_h.xml b/app/src/main/res/values-in/strings_presentation_batch_h.xml new file mode 100644 index 000000000..95cc09833 --- /dev/null +++ b/app/src/main/res/values-in/strings_presentation_batch_h.xml @@ -0,0 +1,15 @@ + + + + %1$d/%2$d + + %1$s · %2$s + + + + x%1$.2f + + β + + %1$s / %2$s + diff --git a/app/src/main/res/values-in/strings_screens.xml b/app/src/main/res/values-in/strings_screens.xml new file mode 100644 index 000000000..c2cdc4802 --- /dev/null +++ b/app/src/main/res/values-in/strings_screens.xml @@ -0,0 +1,239 @@ + + + + Kesalahan: ID Genre hilang + Terima kasih telah menggunakan PixelPlayer! + + + Pemisah Kata Saat Ini + Kata kunci ini memisahkan nama artis jika dikelilingi spasi. Tidak peka huruf besar-kecil. Ketuk untuk menghapus. + Tidak ada pemisah kata yang dikonfigurasi + Tambah Pemisah Kata Baru + misalnya, feat. atau ft. + Cara Kerja Pemisah Kata + Pemisah kata dicocokkan tanpa peka huruf besar-kecil dengan spasi di sekelilingnya.\n\nPemisah satu karakter (seperti \"x\") memerlukan spasi di kedua sisi untuk menghindari pencocokan yang salah.\n\nContoh:\n \"Drake feat. Rihanna\" -> Drake, Rihanna\n \"Marshmello x Bastille\" -> Marshmello, Bastille\n \"A vs. B\" -> A, B + Pemisah Kata + Reset Pemisah Kata? + Ini akan menghapus semua pemisah kata kustom Anda dan mengembalikan kata kunci default. Tindakan ini tidak dapat dibatalkan. + Pemisah kata ditambahkan + Sudah ada atau tidak valid + Pemisah kata direset ke default + Reset + + + Pemisah Saat Ini + Ketuk pemisah untuk menghapusnya. Setidaknya satu pemisah diperlukan. + Tambah Pemisah Baru + misalnya, / atau ; + Pemisah Default + Reset Pemisah? + Ini akan menghapus semua pemisah kustom Anda dan mengembalikan default. Tindakan ini tidak dapat dibatalkan. + Pemisah direset ke default + Setidaknya satu pemisah diperlukan + Pemisah ditambahkan + Pemisah sudah ada atau tidak valid + Pemisah + Spasi + Tambah pemisah + + + Google Drive akan segera hadir. + Tidak dapat membuka layar ini sekarang. + + + Selamat datang di + β + Beta + Mari kita siapkan segalanya untuk Anda. + Memeriksa paket cadangan… + Tema Aplikasi + Pilih tampilan yang Anda inginkan sebelum mulai menjelajahi pustaka Anda. + Anda dapat mengubah ini nanti di Pengaturan > Tampilan > Tema Aplikasi. + Direkomendasikan + Tata Letak Pustaka + Pilih cara yang Anda sukai untuk menavigasi pustaka Anda. + Mode Ringkas + Anda dapat mengubah ini nanti di Pengaturan > Tampilan > Navigasi Pustaka. + Pustaka + LAGU + ALBUM + ARTIS + Semua Sudah Siap! + Anda siap untuk menikmati musik Anda. + Pulihkan Cadangan + Tinjau apa yang ingin Anda impor sebelum menyelesaikan penyiapan. + %1$d dari %2$d modul terpilih + Dibuat %1$s + Cadangan dari %1$s + Versi tidak dikenal + Mari Mulai! + Langkah %1$d dari %2$d + Navigasi Aplikasi + Pilih gaya bilah navigasi bawah. + Gaya Default + Anda dapat mengubah ini nanti di Pengaturan > Tampilan > Gaya Navbar. + Lewati untuk sekarang + Lewati / Tidak sekarang + Memulihkan + Pulihkan yang terpilih + Sesuaikan Radius Sudut + Mohon berikan izin yang diperlukan terlebih dahulu. + Mohon berikan semua izin yang diperlukan. + Berikan izin penyimpanan terlebih dahulu + Tidak dapat membuka pengaturan baterai + + + Perluas menu + Berikutnya + Selesai + Tutup + Hapus + Tambah pemisah kata + Reset default + + + Folder yang dikecualikan + Semua folder dipindai secara default. Pilih lokasi mana pun yang ingin Anda abaikan saat membangun pustaka. + Pilih folder untuk diabaikan + Izin Media + PixelPlayer memerlukan akses ke file audio Anda untuk membangun pustaka musik Anda. + Izin Diberikan + Berikan Izin Media + Notifikasi + Aktifkan notifikasi untuk mengontrol musik Anda dari layar kunci dan panel notifikasi. + Aktifkan Notifikasi + Alarm & Pengingat + Opsional, tetapi direkomendasikan jika Anda menggunakan Timer Tidur dan ingin PixelPlayer berhenti memutar tepat waktu. + Berikan Izin + Apakah Anda memiliki cadangan? + Jika Anda sudah memiliki cadangan PixelPlayer, pulihkan sekarang dan lewati sebagian besar penyiapan yang tersisa di perangkat ini. + Memeriksa cadangan + Memulihkan cadangan + Impor cadangan + Gelap + Tampilan gelap Material 3 default untuk PixelPlayer. + Terang + Tampilan Material 3 yang lebih cerah di seluruh aplikasi. + Ikuti sistem + Cocokkan dengan pengaturan tampilan ponsel Anda saat ini. + Menggunakan navigasi pil minimal + Menggunakan baris tab standar + Lagu + Optimalisasi Baterai + Beberapa perangkat Android secara agresif mematikan aplikasi latar belakang. Nonaktifkan optimalisasi baterai untuk PixelPlayer untuk mencegah gangguan pemutaran yang tidak terduga. + Nonaktifkan Optimalisasi + Pil melayang dengan sudut membulat + Bilah lebar penuh standar + + + Hapus lagu? + \"%1$s\" oleh %2$s\n\nLagu ini akan dihapus secara permanen dari perangkat Anda dan tidak dapat dipulihkan. + + + Your\nMix + Putar Acak + Sampul album untuk %1$s + Opsi + Isi Cepat Genre + Artis umum + Putar album + Putar acak album + Sampul %1$s + %1$s · %2$s + Putar/Jeda + Sampul lagu + + + Ups! Terjadi kesalahan + Aplikasi berhenti mendadak selama sesi terakhir Anda. Bantu kami memperbaikinya dengan membagikan laporan crash. + Tanggal: %1$s + Kesalahan: + Stack trace (pratinjau): + Log crash + Log crash disalin ke papan klip + Laporan crash PixelPlayer + Bagikan laporan crash + Salin + Bagikan + + + Cari… + Cari + Bersihkan pencarian + Pencarian terbaru + Hapus semua + Riwayat + Hapus item riwayat pencarian + Tidak ada hasil + Tidak ada hasil untuk \"%1$s\" + Tidak ditemukan apa pun + Coba istilah pencarian yang berbeda atau periksa filter Anda. + Tidak ditemukan hasil. + + + Telusuri berdasarkan genre + Tidak ada genre yang tersedia. + + + Tidak ditemukan kontributor saat ini. Silakan coba lagi nanti. + PixelPlayer + Pemutar musik open source yang dibangun bersama komunitasnya. + Versi v%1$s + %1$d kontrib. + Tentang + Pemelihara + Orang di balik PixelPlayer. + Sorotan komunitas + Penghargaan untuk kolaborator dengan dampak besar. + Kontributor open source + Daftar kontributor langsung dari GitHub. + Open source + Community-first + Material 3 expressive + Buka profil GitHub + Buka Telegram + Avatar %1$s + Ikon %1$s + + + Subsonic + %1$d playlist disinkronkan + %1$d folder disinkronkan + Playlist + Folder musik + Sinkronisasi + Belum ada playlist yang disinkronkan + Ketuk sinkronisasi untuk mengambil playlist Anda + Ketuk sinkronisasi untuk mengambil playlist Jellyfin Anda + Belum ada folder yang ditambahkan + Ketuk + untuk menambahkan folder Drive + Tindakan cepat + Kelola Navidrome, Airsonic, dan server lain yang kompatibel dengan Subsonic. + Kelola koneksi server Jellyfin Anda. + Menyinkronkan + Sinkronkan pustaka + Putuskan sambungan + %1$d lagu + Sinkronisasi + Sinkronkan semua + Tambah folder + Keluar + NetEase Cloud Music + QQ Music + Sinkronkan semua playlist + Kesalahan: %1$s + Menyinkronkan… + Pilih tipe playlist + Pilih playlist yang ingin disinkronkan: + Semua playlist + Dibuat & dikumpulkan + Playlist yang dibuat + Playlist yang dikumpulkan + Avatar pengguna + Playlist berhasil dibuat + Atur API key penyedia AI Anda terlebih dahulu + Atur API key Gemini Anda terlebih dahulu + Ditambahkan ke antrean + Diputar berikutnya + Tidak dapat membagikan lagu: %1$s + diff --git a/app/src/main/res/values-in/strings_settings.xml b/app/src/main/res/values-in/strings_settings.xml new file mode 100644 index 000000000..1d513e48b --- /dev/null +++ b/app/src/main/res/values-in/strings_settings.xml @@ -0,0 +1,301 @@ + + + + Pengaturan + Akun + Kelola Telegram, Google Drive, NetEase, dan layanan lainnya + + + Manajemen Musik + Kelola folder, segarkan pustaka, opsi penguraian + Tampilan + Tema, tata letak, dan gaya visual + Pemutaran + Perilaku audio, crossfade, dan putar latar belakang + Perilaku + Gerakan, haptik, dan perilaku navigasi + Integrasi AI (β) + Penyedia AI, kunci API, dan pengaturan model + Cadangkan & Pulihkan + Ekspor dan pulihkan data aplikasi pribadi Anda + Opsi Pengembang + Fitur eksperimental dan debugging + Equalizer + Sesuaikan frekuensi audio dan preset + Kemampuan Perangkat + Spek audio, codec, dan info dekoder + Tentang + Info aplikasi, versi, dan kredit + + + Nyala + Mati + Diaktifkan + Dinonaktifkan + Buka + Pilih semua + Bersihkan pilihan + Tutup pemberitahuan + + + Struktur Pustaka + Direktori yang Dikecualikan + Folder di sini akan dilewati saat memindai pustaka Anda. + Artis + Opsi penguraian dan pengorganisasian multi-artis. + Penyaringan + Durasi Lagu Minimum + Jumlah Trek Minimum per Album + Batas Cache Sampul Album + Ukuran cache maksimum sebelum gambar terlama dihapus otomatis + Sinkronisasi dan Pemindaian + Menjalankan pemindaian ulang penuh + Sinkronisasi pustaka selesai + Pemindaian ulang penuh dimulai… + Pindai otomatis file .lrc + Secara otomatis memindai dan menetapkan file .lrc di folder yang sama selama sinkronisasi pustaka. + Manajemen Lirik + Prioritas Sumber Lirik + Pilih sumber mana yang akan dicoba terlebih dahulu saat mengambil lirik. + Tertanam Terlebih Dahulu + Online Terlebih Dahulu + Lokal (.lrc) Terlebih Dahulu + Reset Lirik yang Diimpor + Hapus semua lirik yang diimpor dari database. + + + Tema Global + Bahasa Aplikasi + Pilih bahasa yang digunakan di seluruh antarmuka aplikasi. + Default sistem + Inggris + Spanyol + Prancis + Rusia + Tionghoa (Sederhana) + Indonesia + Tema Aplikasi + Beralih antara terang, gelap, atau ikuti tampilan sistem. + Tema Terang + Tema Gelap + Ikuti Sistem + Gunakan Sudut Halus + Gunakan sudut berbentuk kompleks yang secara efektif meningkatkan estetika tetapi mungkin memengaruhi performa pada perangkat berspesifikasi rendah + Sedang Diputar + Tema Pemutar + Pilih tampilan untuk pemutar melayang. + Sampul Album + Dinamis Sistem + Tampilkan info file pemutar + Tampilkan codec, bitrate, dan sample rate di bagian progres pemutar. + Gaya Palet Sampul Album + Saat ini: %1$s. Buka pratinjau langsung dan pilih gaya. + Gaya Karousel + Pilih tampilan untuk karousel album. + Tanpa Intip + Satu Intip + Kolase Beranda + Pola Kolase + Pilih susunan bentuk untuk kolase Mix Anda. + Putar Pola Otomatis + Berganti-ganti pola kolase setiap kali Anda mengunjungi Beranda. + Bilah Navigasi + Gaya NavBar + Pilih tampilan untuk bilah navigasi. + Default + Lebar Penuh + Mode ringkas + Tampilkan hanya ikon dan kurangi tinggi navbar. + Radius Sudut NavBar + Sesuaikan radius sudut bilah navigasi. + Layar Lirik + Lirik Imersif + Sembunyikan kontrol otomatis dan perbesar teks. + Penundaan Sembunyi Otomatis + Waktu sebelum kontrol disembunyikan. + 3d + 4d + 5d + 6d + Navigasi Aplikasi + Tab Default + Pilih tab peluncuran Default. + Beranda + Navigasi Pustaka + Pilih cara berpindah antar tab Pustaka. + Baris tab (default) + Pil ringkas & kisi + + + Pemutaran Latar Belakang + Tetap putar setelah ditutup + Jika mati, menghapus aplikasi dari daftar terbaru akan menghentikan pemutaran. + Optimalisasi Baterai + Nonaktifkan optimalisasi baterai untuk mencegah gangguan pemutaran. + Optimalisasi baterai sudah dinonaktifkan + Normalisasi Volume (ReplayGain) + Aktifkan ReplayGain + Normalkan tingkat volume menggunakan metadata ReplayGain dari file audio. + Mode Gain + Trek: normalkan setiap lagu. Album: normalkan per album. + Trek + Album + Cast + Putar otomatis saat cast terhubung/terputus + Mulai memutar segera setelah beralih koneksi cast. + Headphone + Lanjutkan saat headphone terhubung kembali + Jika pemutaran dijeda karena headphone dilepas, lanjutkan secara otomatis saat terhubung kembali. + Antrean dan Transisi + Crossfade + Aktifkan transisi halus antar lagu. + Durasi Crossfade + Mode Hi-Fi + Output audio Float 32-bit. Nonaktifkan jika pemutaran tersendat di perangkat Anda. + Tidak didukung di perangkat ini (PCM_FLOAT AudioTrack tidak tersedia). + Acak Persisten + Ingat pengaturan acak bahkan setelah menutup aplikasi. + Tampilkan riwayat antrean + Tampilkan lagu yang diputar sebelumnya di antrean. + + + Folder + Gerakan kembali mengontrol folder + Di tab Folder, tombol kembali sistem menavigasi tumpukan folder sebelum meninggalkan Pustaka. + Gerakan Pemutar + Ketuk latar belakang menutup pemutar + Ketuk latar belakang yang kabur untuk menutup panel pemutar. + Haptic + Haptic feedback + Aktifkan haptic feedback di seluruh aplikasi. + + + Penyedia AI + Penyedia + Pilih penyedia AI Anda + Mode Token Aman + NYALA — Cepat & murah. Mengirim data minimal (~1K token) ke AI. + MATI — Konteks mendalam. Mengirim profil mendengarkan lengkap (~8K token) untuk hasil yang lebih kaya. + Kredensial + API Key %1$s + Dapatkan dari %1$s + Google AI Studio (aistudio.google.com) + DeepSeek Platform (api.deepseek.com) + Groq Console (console.groq.com) + Mistral AI Platform (console.mistral.ai) + NVIDIA Build (build.nvidia.com) + Moonshot AI Platform (platform.moonshot.cn) + Zhipu AI Open Platform (bigmodel.cn) + OpenAI Platform (platform.openai.com) + Pemilihan Model + Memuat model yang tersedia… + Model AI + Pilih sebuah model. + Perilaku Prompt + Prompt Sistem + Sesuaikan cara AI berperilaku. + Laporan Penggunaan AI + Total Konsumsi + Pelacakan %1$s token\nPrompt: %2$s | Output: %3$s | Pemikiran: %4$s + + + Buat Cadangan + Ekspor Cadangan + %1$s Membuat file cadangan .pxpl. + Pulihkan Cadangan + Impor Cadangan + Telusuri atau pilih dari cadangan terbaru. Data yang dipilih akan menggantikan data saat ini. + Eksperimen + Eksperimental + Eksperimen pemuatan UI pemutar dan tombol pengalih. + Uji Alur Penyiapan + Luncurkan layar penyiapan orientasi untuk pengujian. + Pemeliharaan + Paksa Regenerasi Daily Mix + Membuat ulang daftar putar daily mix segera. + Paksa Regenerasi Statistik + Bersihkan cache dan hitung ulang statistik pemutaran. + Paksa Regenerasi Palet Album + Diagnostik + Picu Crash Uji Coba + Simulasikan crash untuk menguji sistem pelaporan crash. + Aplikasi + Tentang PixelPlayer + Versi aplikasi, kredit, dan lainnya. + + Tidak ada bagian yang dipilih. + Semua bagian dipilih. + Terpilih %1$d dari %2$d bagian. + Cara kerja pencadangan + Pilih bagian, ekspor file .pxpl, dan impor nanti. Pemulihan hanya menggantikan bagian yang Anda pilih. + Pilih dengan tepat apa yang ingin Anda sertakan dalam paket cadangan. + Ekspor .pxpl + %1$d dari %2$d bagian terpilih + Transfer sedang berlangsung… + Mengekspor + Mengimpor + Membuat Cadangan + Memulihkan Cadangan + Langkah %1$d dari %2$d + %1$d entri · Akan menggantikan data saat ini + + Palet diregenerasi untuk %1$s + Tidak dapat meregenerasi palet untuk %1$s + Meregenerasi palet album… + Regenerasi semua palet album? + Membangun kembali varian palet yang dicache untuk %1$d sampul album unik. Ini mungkin memakan waktu lama pada pustaka besar. + Ini akan membersihkan data tema yang dicache dan membangun kembali semua gaya palet untuk %1$d sampul album unik. + %1$d dari %2$d selesai + Bekerja… + Regenerasi + Berhasil meregenerasi %1$d palet sampul album + Meregenerasi %1$d dari %2$d palet sampul album + + Reset lirik yang diimpor? + Tindakan ini tidak dapat dibatalkan. + Konfirmasi + Bangun kembali database? + Ini akan sepenuhnya membangun kembali pustaka musik Anda dari awal. Semua lirik yang diimpor, favorit, dan metadata kustom akan hilang. Tindakan ini tidak dapat dibatalkan. + Bangun Ulang + Membangun kembali database + Membangun kembali database… + Regenerasi Daily Mix? + Ini akan membuang mix saat ini dan menghasilkan yang baru berdasarkan kebiasaan mendengarkan baru-baru ini. + Regenerasi Daily Mix dimulai + Regenerasi Statistik? + Ini akan menghapus cache statistik dan memaksa perhitungan ulang dari riwayat database. + Regenerasi statistik dimulai + PixelPlayer_Cadangan_%1$d.pxpl + + Regenerasi Daily Mix + Regenerasi Statistik + Tidak ditemukan lagu dengan sampul album. + Bangun kembali semua varian palet yang dicache untuk setiap sampul album, atau pilih satu saja untuk disegarkan. + Regenerasi Semua + Meregenerasi… + Pilih Lagu + + Bersihkan Log + Log Aktivitas AI (%1$d) + Tampilkan + Sembunyikan + Pilih & Ekspor + Pilih & Pulihkan + + + Impor Cadangan + Memeriksa… + Telusuri file + Pilih file cadangan .pxpl untuk diperiksa. Anda akan memilih bagian mana yang ingin dipulihkan pada langkah berikutnya. + Cadangan Terbaru + Tidak ada cadangan terbaru + Cadangan yang diimpor sebelumnya akan muncul di sini. + Paksa Regenerasi Palet Album + Pilih lagu untuk membersihkan data tema yang dicache dan meregenerasi semua gaya palet dari sampul album. + Cari berdasarkan judul, artis, atau album + Meregenerasi palet… + Tidak ada lagu yang cocok dengan pencarian Anda. + Hapus dari riwayat + Bersihkan pencarian + %1$d modul · v%2$s · schema v%3$d + diff --git a/app/src/main/res/values-ru/strings_settings.xml b/app/src/main/res/values-ru/strings_settings.xml index 0dbf178d4..51be0e027 100644 --- a/app/src/main/res/values-ru/strings_settings.xml +++ b/app/src/main/res/values-ru/strings_settings.xml @@ -9,4 +9,6 @@ Испанский Французский Русский + Китайский (упрощённый) + Индонезийский \ No newline at end of file diff --git a/app/src/main/res/values-zh-rCN/strings_settings.xml b/app/src/main/res/values-zh-rCN/strings_settings.xml index df3c0348c..45738c5dd 100644 --- a/app/src/main/res/values-zh-rCN/strings_settings.xml +++ b/app/src/main/res/values-zh-rCN/strings_settings.xml @@ -70,7 +70,10 @@ 跟随系统 英语 西班牙语 + 法语 + 俄语 简体中文 + 印尼语 应用主题 切换浅色、深色,或跟随系统外观。 浅色主题 diff --git a/app/src/main/res/values/strings_settings.xml b/app/src/main/res/values/strings_settings.xml index e80b32061..15a5bc879 100644 --- a/app/src/main/res/values/strings_settings.xml +++ b/app/src/main/res/values/strings_settings.xml @@ -72,7 +72,8 @@ Español Français Русский - Simplified Chinese + 简体中文 + Bahasa Indonesia App Theme Switch between light, dark, or follow system appearance. Light Theme From 5e39051d7256c0ea5f65e2d4ecdd4ae529a2f391 Mon Sep 17 00:00:00 2001 From: adlifarizi Date: Fri, 24 Apr 2026 10:22:47 +0700 Subject: [PATCH 3/7] chore: refine Indonesian localization strings for brevity and clarity --- app/src/main/res/values-in/strings_components.xml | 8 ++++---- .../main/res/values-in/strings_presentation_batch_f.xml | 2 +- .../main/res/values-in/strings_presentation_batch_g.xml | 8 ++++---- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/app/src/main/res/values-in/strings_components.xml b/app/src/main/res/values-in/strings_components.xml index fe5a01476..5d8e655f9 100644 --- a/app/src/main/res/values-in/strings_components.xml +++ b/app/src/main/res/values-in/strings_components.xml @@ -82,17 +82,17 @@ Sampul album lagu saat ini Sesuaikan sampul album Anda Cubit dan geser untuk menyesuaikan framing. - Terapkan sampul album + Terapkan Tidak dapat memuat gambar yang dipilih Bagikan file lagu melalui Putar lagu Bagikan file lagu - Tambahkan ke antrean + Tambah ke antrean Putar berikutnya dalam antrean - Tambahkan ke playlist - Tambahkan ke antrean + Tambah ke playlist + Tambah ke antrean Berikutnya Memeriksa jam tangan Mentransfer %1$d%% diff --git a/app/src/main/res/values-in/strings_presentation_batch_f.xml b/app/src/main/res/values-in/strings_presentation_batch_f.xml index d70c52e11..67c22bfd1 100644 --- a/app/src/main/res/values-in/strings_presentation_batch_f.xml +++ b/app/src/main/res/values-in/strings_presentation_batch_f.xml @@ -75,7 +75,7 @@ Lagu yang terakhir Anda dengarkan. Favorit yang Terlupakan Trek favorit yang sudah lama tidak Anda putar. - Permata Baru + Temuan Baru Trek yang baru saja ditambahkan dengan jumlah putaran rendah. diff --git a/app/src/main/res/values-in/strings_presentation_batch_g.xml b/app/src/main/res/values-in/strings_presentation_batch_g.xml index 60d974882..d829fe2c7 100644 --- a/app/src/main/res/values-in/strings_presentation_batch_g.xml +++ b/app/src/main/res/values-in/strings_presentation_batch_g.xml @@ -5,8 +5,8 @@ Bulan Berjalan Tahun Berjalan Semua Waktu - Statistik Mendengarkan - Segarkan statistik mendengarkan + Statistik + Segarkan statistik Mendengarkan Putaran @@ -196,7 +196,7 @@ Sampul album untuk %1$s Memutar %1$d%% - Statistik mendengarkan + Statistik Total putaran Rata-rata per hari Trek teratas @@ -418,7 +418,7 @@ Perbaikan bug minor - Memperkenalkan hub statistik mendengarkan yang lebih kaya dengan wawasan lebih dalam tentang sesi Anda. + Memperkenalkan hub statistik yang lebih kaya dengan wawasan lebih dalam tentang sesi Anda. Meluncurkan pemutar cepat melayang untuk membuka dan melihat pratinjau file lokal secara instan. Menambahkan tab folder dengan navigator gaya pohon dan tampilan siap-daftar-putar. From ebf599d4da6b8a3214efed702e1214b116c65090 Mon Sep 17 00:00:00 2001 From: theo Date: Thu, 23 Apr 2026 21:28:36 -0300 Subject: [PATCH 4/7] Fixed overheating issues --- .../pixelplay/data/service/MusicService.kt | 6 ++- .../data/service/player/DualPlayerEngine.kt | 46 ++++++++++++++++--- .../service/player/TransitionController.kt | 7 +-- .../data/telegram/TelegramStreamProxy.kt | 24 +++++++--- .../presentation/components/LyricsSheet.kt | 6 ++- .../components/subcomps/PlayingEqIcon.kt | 7 ++- .../viewmodel/PlaybackStateHolder.kt | 6 ++- .../viewmodel/exts/DeckController.kt | 2 +- 8 files changed, 81 insertions(+), 23 deletions(-) diff --git a/app/src/main/java/com/theveloper/pixelplay/data/service/MusicService.kt b/app/src/main/java/com/theveloper/pixelplay/data/service/MusicService.kt index 234ae67a4..5d654377e 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/service/MusicService.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/service/MusicService.kt @@ -180,7 +180,10 @@ class MusicService : MediaLibraryService() { const val ACTION_SLEEP_TIMER_EXPIRED = "com.theveloper.pixelplay.ACTION_SLEEP_TIMER_EXPIRED" const val EXTRA_FORCE_FOREGROUND_ON_START = "com.theveloper.pixelplay.extra.FORCE_FOREGROUND_ON_START" - private const val PLAYBACK_SNAPSHOT_DEBOUNCE_MS = 350L + // Queue/index/flags snapshot is only used for restore on process death. A full-queue + // JSON+DataStore rewrite on every Media3 event (track transition fires 3-4 listeners + // within ~200ms) is unnecessary work. 1500ms coalesces those without harming restore. + private const val PLAYBACK_SNAPSHOT_DEBOUNCE_MS = 1500L private const val FORCED_WIDGET_STATE_DEBOUNCE_MS = 90L private const val MEDIA_SESSION_BUTTON_DEBOUNCE_MS = 90L private val pendingMediaButtonForegroundStarts = AtomicInteger(0) @@ -1049,7 +1052,6 @@ class MusicService : MediaLibraryService() { } requestWidgetAndWearRefreshWithFollowUp() mediaSession?.let { refreshMediaSessionUiWithFollowUp(it) } - mediaSession?.let { refreshMediaSessionUi(it) } schedulePlaybackSnapshotPersist() } diff --git a/app/src/main/java/com/theveloper/pixelplay/data/service/player/DualPlayerEngine.kt b/app/src/main/java/com/theveloper/pixelplay/data/service/player/DualPlayerEngine.kt index 5400c89b2..1f9e0c505 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/service/player/DualPlayerEngine.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/service/player/DualPlayerEngine.kt @@ -72,6 +72,7 @@ class DualPlayerEngine @Inject constructor( private companion object { private const val AUDIO_OFFLOAD_BUFFERING_FALLBACK_MS = 4_000L private val LOCAL_MEDIA_SCHEMES = setOf("content", "file", "android.resource") + private val REMOTE_MEDIA_SCHEMES = setOf("http", "https", "telegram", "netease", "qqmusic", "navidrome", "jellyfin", "gdrive") } private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) @@ -167,7 +168,9 @@ class DualPlayerEngine @Inject constructor( // Limpieza para canciones que no son de Telegram telegramCacheManager.setActivePlayback(null) } - // Wake mode is configured once in buildPlayer(). + // Upgrade/downgrade wake policy so local playback does not keep the radio awake + // and cloud/remote playback still holds the network wake lock. + applyWakeModeForCurrentItem() // --- Pre-Resolve Next/Prev Tracks para Performance --- try { @@ -337,6 +340,33 @@ class DualPlayerEngine @Inject constructor( return scheme == null || scheme in LOCAL_MEDIA_SCHEMES } + /** + * Selects the cheapest wake policy that still keeps playback reliable. + * Network wake is only needed when the current queue item actually talks to the network — + * local file playback keeps only CPU awake so the device can sleep the radio. + */ + private fun wakeModeFor(mediaItem: MediaItem?): Int { + val scheme = mediaItem?.localConfiguration?.uri?.scheme?.lowercase() + return if (scheme != null && scheme in REMOTE_MEDIA_SCHEMES) { + C.WAKE_MODE_NETWORK + } else { + C.WAKE_MODE_LOCAL + } + } + + private fun applyWakeModeForCurrentItem() { + if (!::playerA.isInitialized) return + val mode = wakeModeFor(playerA.currentMediaItem) + try { + playerA.setWakeMode(mode) + if (::playerB.isInitialized) { + playerB.setWakeMode(mode) + } + } catch (e: Exception) { + Timber.tag("DualPlayerEngine").w(e, "Failed to update wake mode") + } + } + private fun shouldDisableAudioOffloadByDefault(): Boolean { val manufacturer = Build.MANUFACTURER.lowercase() val brand = Build.BRAND.lowercase() @@ -388,6 +418,7 @@ class DualPlayerEngine @Inject constructor( playerA.shuffleModeEnabled = shuffleMode playerA.prepare() playerA.playWhenReady = desiredPlayWhenReady + applyWakeModeForCurrentItem() } _activeAudioSessionId.value = playerA.audioSessionId @@ -510,10 +541,11 @@ class DualPlayerEngine @Inject constructor( .build() ) setHandleAudioBecomingNoisy(true) // Force player to pause automatically when audio is rerouted from a headset to device speakers - // Cloud sources are proxied through localhost, but the proxy still depends on - // upstream network access. Keep both CPU and network awake so background - // playback does not stall when the screen turns off or the app is backgrounded. - setWakeMode(C.WAKE_MODE_NETWORK) + // Default to CPU-only wake. Upgraded to WAKE_MODE_NETWORK dynamically when the + // current item is a remote/cloud source via applyWakeModeForCurrentItem(). + // Keeping local playback on WAKE_MODE_LOCAL lets the radio sleep, which is one + // of the biggest heat savings for long sessions on weaker phones. + setWakeMode(C.WAKE_MODE_LOCAL) // Explicitly keep both players live so they can overlap without affecting each other playWhenReady = false Timber.tag("DualPlayerEngine").d("Built player with audio offload %s", if (audioOffloadEnabled) "enabled" else "disabled") @@ -933,7 +965,9 @@ class DualPlayerEngine @Inject constructor( // playerB is now the Outgoing/Aux. val duration = settings.durationMs.toLong().coerceAtLeast(500L) - val stepMs = 16L + // 30Hz volume ramp is indistinguishable from 60Hz at the AudioTrack frame boundary + // for a multi-second crossfade; halves wake-ups during overlap. + val stepMs = 32L var elapsed = 0L var lastLog = 0L diff --git a/app/src/main/java/com/theveloper/pixelplay/data/service/player/TransitionController.kt b/app/src/main/java/com/theveloper/pixelplay/data/service/player/TransitionController.kt index cb6f050ed..c5e01b1f7 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/service/player/TransitionController.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/service/player/TransitionController.kt @@ -250,13 +250,14 @@ class TransitionController @Inject constructor( return@collectLatest } - // Wait loop with adaptive sleep + // Wait loop with adaptive sleep. 250ms near-end cadence still lands the crossfade + // within ±125ms of the target — imperceptible for a multi-second overlap, and 5× + // fewer wakeups in the last second of every track. while (player.currentPosition < transitionPoint && isActive) { val remaining = transitionPoint - player.currentPosition val sleep = when { remaining > 5000 -> 1000L - remaining > 1000 -> 250L - else -> 50L // Tight loop near the end + else -> 250L } if (remaining < 2000 && remaining % 500 < 50) { Timber.tag("TransitionDebug").v("Countdown: %d ms to transition", remaining) diff --git a/app/src/main/java/com/theveloper/pixelplay/data/telegram/TelegramStreamProxy.kt b/app/src/main/java/com/theveloper/pixelplay/data/telegram/TelegramStreamProxy.kt index 6350c710f..cb0867b26 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/telegram/TelegramStreamProxy.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/telegram/TelegramStreamProxy.kt @@ -195,9 +195,16 @@ class TelegramStreamProxy @Inject constructor( var currentPos = start val buffer = ByteArray(64 * 1024) // Increased to 64KB for smoother streaming var noDataCount = 0 - + // Exponential backoff while the reader is waiting for TDLib to + // deliver more bytes. The previous fixed 50ms delay combined with + // a per-iteration getFile() call kept the IO thread and TDLib + // database churning during any stall, which showed up as sustained + // CPU heat on weaker devices during cloud playback. + var stallDelayMs = 50L + val maxStallDelayMs = 400L + raf.seek(currentPos) - + var cachedDownloadedPrefixSize = fileInfo?.local?.downloadedPrefixSize?.toLong() ?: 0L while (true) { @@ -218,14 +225,19 @@ class TelegramStreamProxy @Inject constructor( // If size is different than expected, we still stop because we can't get more. break } - + // Verify cancellation/failure if (updatedInfo?.local?.isDownloadingCompleted == false && !updatedInfo.local.canBeDownloaded) { break // Failed/Cancelled } - - delay(50) // Wait for more data + + delay(stallDelayMs) + stallDelayMs = (stallDelayMs * 2).coerceAtMost(maxStallDelayMs) continue + } else { + // New data arrived — reset the backoff so we stay + // responsive once the download catches up. + stallDelayMs = 50L } } @@ -233,7 +245,7 @@ class TelegramStreamProxy @Inject constructor( // Read min of: buffer size, remaining in range, remaining valid bytes val remainingValid = cachedDownloadedPrefixSize - currentPos val toRead = min(buffer.size.toLong(), min(remaining, remainingValid)).toInt() - + val read = raf.read(buffer, 0, toRead) if (read > 0) { writeFully(buffer, 0, read) diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/LyricsSheet.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/LyricsSheet.kt index 836ad3cf5..08023daf7 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/LyricsSheet.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/LyricsSheet.kt @@ -1769,11 +1769,13 @@ private fun LyricsTrackInfo( LaunchedEffect(isPlaying) { if (isPlaying) { - // Spin forever + // Spin forever. 8s per revolution halves the effective per-second animation work + // vs the original 4s cadence — visually still clearly a rotating "vinyl", but + // drives fewer Compose invalidations during long listening sessions. while (true) { currentRotation.animateTo( targetValue = currentRotation.value + 360f, - animationSpec = tween(4000, easing = LinearEasing) + animationSpec = tween(8000, easing = LinearEasing) ) } } else { diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/subcomps/PlayingEqIcon.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/subcomps/PlayingEqIcon.kt index 6ed9743f3..47a32afe9 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/subcomps/PlayingEqIcon.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/subcomps/PlayingEqIcon.kt @@ -27,8 +27,11 @@ fun PlayingEqIcon( bars: Int = 3, minHeightFraction: Float = 0.28f, maxHeightFraction: Float = 1.0f, - phaseDurationMillis: Int = 2400, // ciclo más lento - wanderDurationMillis: Int = 8000, // patrón más largo + // Slower cycles mean fewer animation frames / Canvas redraws per second while the icon + // is visible. With many current-song indicators potentially on screen (home, queue, + // lyrics sheet), this noticeably lowers screen-on CPU on weaker devices. + phaseDurationMillis: Int = 3600, // ciclo más lento + wanderDurationMillis: Int = 12000, // patrón más largo gapFraction: Float = 0.30f ) { val fullRotation = (2f * PI).toFloat() diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlaybackStateHolder.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlaybackStateHolder.kt index ca7913b7c..17cb10b09 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlaybackStateHolder.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlaybackStateHolder.kt @@ -42,7 +42,11 @@ class PlaybackStateHolder @Inject constructor( companion object { private const val TAG = "PlaybackStateHolder" private const val DURATION_MISMATCH_TOLERANCE_MS = 1500L - private const val FOREGROUND_PROGRESS_TICK_MS = 250L + // 500 ms keeps the progress bar/time display smooth to the eye (it still updates twice + // per second) while halving Compose recomposition pressure vs. the old 250 ms tick. + // The visual slider uses frame-clock interpolation on top of this, so the animation + // stays fluid even at a slower underlying cadence. + private const val FOREGROUND_PROGRESS_TICK_MS = 500L private const val BACKGROUND_PROGRESS_TICK_MS = 1000L /** * Threshold above which we skip per-item moveMediaItem calls and use diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/exts/DeckController.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/exts/DeckController.kt index ab78cea37..2cb1dbf71 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/exts/DeckController.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/exts/DeckController.kt @@ -73,7 +73,7 @@ class DeckController( .build() ) setHandleAudioBecomingNoisy(true) - setWakeMode(C.WAKE_MODE_NETWORK) + setWakeMode(C.WAKE_MODE_LOCAL) } } From 62b244749f4adf18bf7c3726c8e702e467298a3d Mon Sep 17 00:00:00 2001 From: theo Date: Fri, 24 Apr 2026 11:47:17 -0300 Subject: [PATCH 5/7] Redesign DeviceCapabilitiesScreen.kt --- .../pixelplay/data/database/MusicDao.kt | 23 + .../screens/DeviceCapabilitiesScreen.kt | 1372 ++++++++++++----- .../viewmodel/DeviceCapabilitiesViewModel.kt | 457 +++++- .../viewmodel/PlaybackStateHolder.kt | 11 +- .../strings_presentation_batch_g.xml | 91 +- .../values/strings_presentation_batch_g.xml | 53 + 6 files changed, 1576 insertions(+), 431 deletions(-) diff --git a/app/src/main/java/com/theveloper/pixelplay/data/database/MusicDao.kt b/app/src/main/java/com/theveloper/pixelplay/data/database/MusicDao.kt index 3567e3a6b..32114b7a9 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/database/MusicDao.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/database/MusicDao.kt @@ -67,6 +67,16 @@ private const val SONG_LIST_PROJECTION = """ telegram_file_id, artists_json, source_type """ +data class DeviceCapabilitySongRow( + val filePath: String, + val contentUriString: String, + val mimeType: String?, + val duration: Long, + val bitrate: Int?, + val sampleRate: Int?, + val sourceType: Int +) + @Dao interface MusicDao { @@ -442,6 +452,19 @@ interface MusicDao { @Query("SELECT COUNT(*) FROM songs") suspend fun getSongCountOnce(): Int + @Query(""" + SELECT + file_path AS filePath, + content_uri_string AS contentUriString, + mime_type AS mimeType, + duration, + bitrate, + sample_rate AS sampleRate, + source_type AS sourceType + FROM songs + """) + suspend fun getDeviceCapabilitySongRows(): List + /** * Returns random songs for efficient shuffle without loading all songs into memory. * Uses SQLite RANDOM() for true randomness. diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/DeviceCapabilitiesScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/DeviceCapabilitiesScreen.kt index 9736d7b64..e51a062ae 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/DeviceCapabilitiesScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/DeviceCapabilitiesScreen.kt @@ -1,47 +1,55 @@ package com.theveloper.pixelplay.presentation.screens +import android.text.format.Formatter import androidx.annotation.OptIn import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.Spring import androidx.compose.animation.core.spring -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.CheckCircle +import androidx.compose.material.icons.rounded.ErrorOutline +import androidx.compose.material.icons.rounded.GraphicEq +import androidx.compose.material.icons.rounded.Headphones import androidx.compose.material.icons.rounded.Info import androidx.compose.material.icons.rounded.Memory import androidx.compose.material.icons.rounded.Speaker -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults +import androidx.compose.material.icons.rounded.Storage +import androidx.compose.material.icons.rounded.Warning import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon +import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -50,26 +58,37 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.media3.common.util.UnstableApi import androidx.navigation.NavController -import androidx.compose.ui.res.stringResource import com.theveloper.pixelplay.R import com.theveloper.pixelplay.presentation.components.CollapsibleCommonTopBar import com.theveloper.pixelplay.presentation.components.MiniPlayerHeight -import com.theveloper.pixelplay.presentation.viewmodel.CodecInfo +import com.theveloper.pixelplay.presentation.viewmodel.AudioCapabilities +import com.theveloper.pixelplay.presentation.viewmodel.AudioOutputCategory +import com.theveloper.pixelplay.presentation.viewmodel.DeviceCapabilitiesState import com.theveloper.pixelplay.presentation.viewmodel.DeviceCapabilitiesViewModel +import com.theveloper.pixelplay.presentation.viewmodel.ExoPlayerInfo +import com.theveloper.pixelplay.presentation.viewmodel.FormatSupportInfo +import com.theveloper.pixelplay.presentation.viewmodel.LocalMusicStorageSummary +import com.theveloper.pixelplay.presentation.viewmodel.MemorySummary +import com.theveloper.pixelplay.presentation.viewmodel.PlaybackCompatibilitySummary import com.theveloper.pixelplay.presentation.viewmodel.PlayerViewModel import kotlinx.coroutines.launch import racra.compose.smooth_corner_rect_library.AbsoluteSmoothCornerShape @@ -80,27 +99,26 @@ import kotlin.math.roundToInt fun DeviceCapabilitiesScreen( navController: NavController, viewModel: DeviceCapabilitiesViewModel = hiltViewModel(), - playerViewModel: PlayerViewModel // Kept for consistency if needed for player sheet handling + playerViewModel: PlayerViewModel ) { val state by viewModel.state.collectAsStateWithLifecycle() - - // Top Bar Logic (Reused Pattern) + val density = LocalDensity.current val coroutineScope = rememberCoroutineScope() val lazyListState = rememberLazyListState() - + val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() val minTopBarHeight = 64.dp + statusBarHeight - val maxTopBarHeight = 180.dp - + val maxTopBarHeight = 188.dp val minTopBarHeightPx = with(density) { minTopBarHeight.toPx() } val maxTopBarHeightPx = with(density) { maxTopBarHeight.toPx() } - + val topBarHeight = remember { Animatable(maxTopBarHeightPx) } - var collapseFraction by remember { mutableStateOf(0f) } + var collapseFraction by remember { mutableFloatStateOf(0f) } LaunchedEffect(topBarHeight.value) { - collapseFraction = 1f - ((topBarHeight.value - minTopBarHeightPx) / (maxTopBarHeightPx - minTopBarHeightPx)).coerceIn(0f, 1f) + collapseFraction = 1f - ((topBarHeight.value - minTopBarHeightPx) / (maxTopBarHeightPx - minTopBarHeightPx)) + .coerceIn(0f, 1f) } val nestedScrollConnection = remember { @@ -126,7 +144,7 @@ fun DeviceCapabilitiesScreen( } } } - + LaunchedEffect(lazyListState.isScrollInProgress) { if (!lazyListState.isScrollInProgress) { val shouldExpand = topBarHeight.value > (minTopBarHeightPx + maxTopBarHeightPx) / 2 @@ -138,333 +156,667 @@ fun DeviceCapabilitiesScreen( } } - Box(modifier = Modifier.nestedScroll(nestedScrollConnection).fillMaxSize()) { + Box( + modifier = Modifier + .nestedScroll(nestedScrollConnection) + .fillMaxSize() + ) { val currentTopBarHeightDp = with(density) { topBarHeight.value.toDp() } - + if (state.isLoading) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - CircularProgressIndicator() - } + Box( + modifier = Modifier + .fillMaxSize() + .padding(top = currentTopBarHeightDp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } } else { - val supportedCodecs = state.audioCapabilities?.supportedCodecs.orEmpty() - LazyColumn( - state = lazyListState, - contentPadding = PaddingValues( - top = currentTopBarHeightDp + 8.dp, - start = 16.dp, - end = 16.dp, - bottom = MiniPlayerHeight + WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 16.dp - ), - verticalArrangement = Arrangement.spacedBy(14.dp), + DeviceCapabilitiesContent( + state = state, + lazyListState = lazyListState, + topPadding = currentTopBarHeightDp, modifier = Modifier.fillMaxSize() - ) { - // Device Info Section - item { - DeviceInfoExpressiveSection(deviceInfo = state.deviceInfo) - } - - // Audio Capabilities - item { - state.audioCapabilities?.let { audio -> - CapabilitySection(title = stringResource(R.string.presentation_batch_g_device_section_audio_output), icon = Icons.Rounded.Speaker) { - InfoRow(stringResource(R.string.presentation_batch_g_device_label_sample_rate), stringResource(R.string.presentation_batch_g_device_value_hz, audio.outputSampleRate)) - InfoRow(stringResource(R.string.presentation_batch_g_device_label_frames_per_buffer), "${audio.outputFramesPerBuffer}") - InfoRow(stringResource(R.string.presentation_batch_g_device_label_low_latency), if (audio.isLowLatencySupported) stringResource(R.string.presentation_batch_g_yes) else stringResource(R.string.presentation_batch_g_no)) - InfoRow(stringResource(R.string.presentation_batch_g_device_label_pro_audio), if (audio.isProAudioSupported) stringResource(R.string.presentation_batch_g_yes) else stringResource(R.string.presentation_batch_g_no)) - } - } - } - - // ExoPlayer Info - item { - state.exoPlayerInfo?.let { exo -> - CapabilitySection(title = stringResource(R.string.presentation_batch_g_device_section_exoplayer), icon = Icons.Rounded.Memory) { - InfoRow(stringResource(R.string.presentation_batch_g_device_label_version), exo.version) - InfoRow(stringResource(R.string.presentation_batch_g_device_label_active_renderers), exo.renderers) - InfoRow(stringResource(R.string.presentation_batch_g_device_label_decoder_counters), exo.decoderCounters) - } - } - } - - // Codecs Header - item { - Text( - text = stringResource(R.string.presentation_batch_g_device_supported_codecs_title), - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(top = 8.dp, bottom = 2.dp, start = 4.dp) - ) - } - - // Codec List - if (supportedCodecs.isNotEmpty()) { - item { - SegmentedCodecList(codecs = supportedCodecs) - } - } - } - } - - // Top Bar + ) + } + CollapsibleCommonTopBar( title = stringResource(R.string.settings_category_device_capabilities_title), collapseFraction = collapseFraction, headerHeight = currentTopBarHeightDp, onBackClick = { navController.popBackStack() }, expandedTitleStartPadding = 20.dp, - collapsedTitleStartPadding = 68.dp + collapsedTitleStartPadding = 68.dp, + maxLines = 2 ) } } @Composable -fun CapabilitySection( - title: String, - icon: androidx.compose.ui.graphics.vector.ImageVector, - content: @Composable () -> Unit +private fun DeviceCapabilitiesContent( + state: DeviceCapabilitiesState, + lazyListState: LazyListState, + topPadding: Dp, + modifier: Modifier = Modifier ) { - val sectionShape = AbsoluteSmoothCornerShape(28.dp, 60) - Card( - shape = sectionShape, - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), - modifier = Modifier.fillMaxWidth(), - elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) + LazyColumn( + state = lazyListState, + contentPadding = PaddingValues( + top = topPadding + 8.dp, + start = 16.dp, + end = 16.dp, + bottom = MiniPlayerHeight + WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 16.dp + ), + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = modifier ) { - Box( - modifier = Modifier - .fillMaxWidth() - .background( - brush = Brush.linearGradient( - colors = listOf( - MaterialTheme.colorScheme.primary.copy(alpha = 0.12f), - MaterialTheme.colorScheme.tertiary.copy(alpha = 0.08f), - MaterialTheme.colorScheme.surfaceContainer - ) + item { + PlaybackReadinessCard( + deviceInfo = state.deviceInfo, + audioCapabilities = state.audioCapabilities, + storageSummary = state.storageSummary, + playbackCompatibility = state.playbackCompatibility + ) + } + + state.storageSummary?.let { storage -> + item { + LocalMusicStorageCard(storageSummary = storage) + } + } + + state.audioCapabilities?.let { audio -> + item { + PlaybackPathCard( + audioCapabilities = audio, + memorySummary = state.memorySummary, + exoPlayerInfo = state.exoPlayerInfo + ) + } + } + + if (state.formatSupport.isNotEmpty()) { + item { + FormatCompatibilityCard( + formats = state.formatSupport, + playbackCompatibility = state.playbackCompatibility + ) + } + } + + state.playbackCompatibility?.let { compatibility -> + item { + PlaybackFindingsCard(compatibility = compatibility) + } + } + + item { + DeviceInfoPanel(deviceInfo = state.deviceInfo) + } + } +} + +@Composable +private fun PlaybackReadinessCard( + deviceInfo: Map, + audioCapabilities: AudioCapabilities?, + storageSummary: LocalMusicStorageSummary?, + playbackCompatibility: PlaybackCompatibilitySummary?, + modifier: Modifier = Modifier +) { + val needsReview = playbackCompatibility?.let { + it.unsupportedLibrarySongCount > 0 || it.resampledLocalSongCount > 0 + } ?: false + val containerColor = if (needsReview) { + MaterialTheme.colorScheme.errorContainer + } else { + MaterialTheme.colorScheme.primaryContainer + } + val contentColor = if (needsReview) { + MaterialTheme.colorScheme.onErrorContainer + } else { + MaterialTheme.colorScheme.onPrimaryContainer + } + Surface( + modifier = modifier.fillMaxWidth(), + shape = AbsoluteSmoothCornerShape(32.dp, 60), + color = containerColor + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + StatusIcon( + icon = if (needsReview) Icons.Rounded.Warning else Icons.Rounded.CheckCircle, + containerColor = contentColor.copy(alpha = 0.12f), + contentColor = contentColor + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = if (needsReview) { + stringResource(R.string.device_capabilities_review_title) + } else { + stringResource(R.string.device_capabilities_ready_title) + }, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.SemiBold, + color = contentColor, + modifier = Modifier.semantics { heading() } ) + } + } + + Row( + modifier = Modifier.height(IntrinsicSize.Min), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + HeroMetricTile( + label = stringResource(R.string.device_capabilities_metric_formats), + value = audioCapabilities?.supportedCodecs + ?.flatMap { it.supportedTypes } + ?.distinct() + ?.size + ?.toString() + ?: stringResource(R.string.device_capabilities_unknown_short), + modifier = Modifier.weight(1f), + containerColor = contentColor.copy(alpha = 0.10f), + contentColor = contentColor ) + HeroMetricTile( + label = stringResource(R.string.device_capabilities_metric_hw_decoders), + value = audioCapabilities?.supportedCodecs + ?.count { it.isHardwareAccelerated } + ?.toString() + ?: stringResource(R.string.device_capabilities_unknown_short), + modifier = Modifier.weight(1f), + containerColor = contentColor.copy(alpha = 0.10f), + contentColor = contentColor + ) + HeroMetricTile( + label = stringResource(R.string.device_capabilities_metric_local_music), + value = storageSummary?.localSongCount?.toString() + ?: stringResource(R.string.device_capabilities_unknown_short), + modifier = Modifier.weight(1f), + containerColor = contentColor.copy(alpha = 0.10f), + contentColor = contentColor + ) + } + } + } +} + +@Composable +private fun LocalMusicStorageCard( + storageSummary: LocalMusicStorageSummary, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + val musicSize = remember(storageSummary.localMusicBytes) { + Formatter.formatShortFileSize(context, storageSummary.localMusicBytes) + } + val availableSize = remember(storageSummary.deviceAvailableBytes) { + Formatter.formatShortFileSize(context, storageSummary.deviceAvailableBytes) + } + val totalSize = remember(storageSummary.deviceTotalBytes) { + Formatter.formatShortFileSize(context, storageSummary.deviceTotalBytes) + } + val musicPercent = storagePercentLabel(storageSummary.localMusicStorageFraction) + val usedPercent = storagePercentLabel(storageSummary.deviceUsedFraction) + + CapabilityCard( + title = stringResource(R.string.device_capabilities_storage_title), + icon = Icons.Rounded.Storage, + modifier = modifier + ) { + Row( + modifier = Modifier.height(IntrinsicSize.Min), + horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - Column(modifier = Modifier.padding(16.dp)) { - Row(verticalAlignment = Alignment.CenterVertically) { - Surface( - shape = AbsoluteSmoothCornerShape(16.dp, 60), - color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.9f) - ) { - Icon( - imageVector = icon, - contentDescription = null, - tint = MaterialTheme.colorScheme.onPrimaryContainer, - modifier = Modifier.padding(10.dp) + InfoTile( + label = stringResource(R.string.device_capabilities_storage_music_size), + value = musicSize, + supporting = stringResource( + R.string.device_capabilities_storage_music_count, + storageSummary.localSongCount + ), + modifier = Modifier.weight(1f) + ) + InfoTile( + label = stringResource(R.string.device_capabilities_storage_available), + value = availableSize, + supporting = stringResource(R.string.device_capabilities_storage_total, totalSize), + modifier = Modifier.weight(1f) + ) + } + + Column( + modifier = Modifier.padding(start = 6.dp, end = 6.dp, bottom = 6.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + ProgressReadout( + label = stringResource(R.string.device_capabilities_storage_music_footprint), + value = musicPercent, + progress = storageSummary.localMusicStorageFraction + ) + ProgressReadout( + label = stringResource(R.string.device_capabilities_storage_device_used), + value = usedPercent, + progress = storageSummary.deviceUsedFraction, + color = MaterialTheme.colorScheme.secondary + ) + } + + if (storageSummary.cloudSongCount > 0 || storageSummary.unavailableLocalFileCount > 0) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + if (storageSummary.cloudSongCount > 0) { + TonalChip( + text = stringResource( + R.string.device_capabilities_storage_cloud_count, + storageSummary.cloudSongCount ) - } - Spacer(Modifier.width(12.dp)) - Text( - text = title, - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface ) } - Spacer(Modifier.height(12.dp)) -// HorizontalDivider( -// color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.32f) -// ) - Spacer(Modifier.height(12.dp)) - content() + if (storageSummary.unavailableLocalFileCount > 0) { + TonalChip( + text = stringResource( + R.string.device_capabilities_storage_unavailable_count, + storageSummary.unavailableLocalFileCount + ), + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer + ) + } } } } } @Composable -fun InfoRow(label: String, value: String) { - Surface( - shape = AbsoluteSmoothCornerShape(16.dp, 60), - color = MaterialTheme.colorScheme.surfaceContainerLow +private fun PlaybackPathCard( + audioCapabilities: AudioCapabilities, + memorySummary: MemorySummary?, + exoPlayerInfo: ExoPlayerInfo?, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + val availableRam = memorySummary?.availableRamBytes?.let { + Formatter.formatShortFileSize(context, it) + } ?: stringResource(R.string.device_capabilities_unknown_short) + val totalRam = memorySummary?.totalRamBytes?.let { + Formatter.formatShortFileSize(context, it) + } ?: stringResource(R.string.device_capabilities_unknown_short) + + CapabilityCard( + title = stringResource(R.string.device_capabilities_playback_path_title), + icon = Icons.Rounded.Speaker, + modifier = modifier ) { Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 14.dp, vertical = 10.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically + modifier = Modifier.height(IntrinsicSize.Min), + horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - Text( - text = label, - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.weight(0.44f) + InfoTile( + label = stringResource(R.string.presentation_batch_g_device_label_sample_rate), + value = stringResource(R.string.presentation_batch_g_device_value_hz, audioCapabilities.outputSampleRate), + supporting = stringResource( + R.string.device_capabilities_buffer_frames, + audioCapabilities.outputFramesPerBuffer + ), + modifier = Modifier.weight(1f) + ) + InfoTile( + label = stringResource(R.string.device_capabilities_hifi_pcm_float), + value = yesNo(audioCapabilities.isPcmFloatSupported), + supporting = stringResource(R.string.device_capabilities_hifi_pcm_float_supporting), + modifier = Modifier.weight(1f) + ) + } + + Row( + modifier = Modifier.height(IntrinsicSize.Min), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + InfoTile( + label = stringResource(R.string.presentation_batch_g_device_label_low_latency), + value = yesNo(audioCapabilities.isLowLatencySupported), + supporting = stringResource(R.string.presentation_batch_g_device_label_pro_audio) + + ": " + yesNo(audioCapabilities.isProAudioSupported), + modifier = Modifier.weight(1f) ) + InfoTile( + label = stringResource(R.string.device_capabilities_memory_title), + value = availableRam, + supporting = stringResource(R.string.device_capabilities_memory_available_of, totalRam), + modifier = Modifier.weight(1f) + ) + } + + SectionLabel(text = stringResource(R.string.device_capabilities_offload_title)) + ChipRow( + emptyText = stringResource(R.string.device_capabilities_offload_empty), + chips = audioCapabilities.offloadSupportedFormats + ) + + SectionLabel(text = stringResource(R.string.device_capabilities_outputs_title)) + if (audioCapabilities.outputRoutes.isEmpty()) { Text( - text = value, + text = stringResource(R.string.device_capabilities_outputs_empty), style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurface, - textAlign = TextAlign.End, - modifier = Modifier.weight(0.56f) + color = MaterialTheme.colorScheme.onSurfaceVariant ) + } else { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + audioCapabilities.outputRoutes.take(5).forEach { route -> + OutputRouteRow( + name = route.name, + category = route.category + ) + } + } + } + + exoPlayerInfo?.let { exo -> + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)) + Row( + modifier = Modifier.height(IntrinsicSize.Min), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + InfoTile( + label = stringResource(R.string.presentation_batch_g_device_section_exoplayer), + value = exo.version, + supporting = stringResource(R.string.device_capabilities_renderers_count, exo.renderers), + icon = Icons.Rounded.Memory, + modifier = Modifier.weight(1f) + ) + } } } - Spacer(Modifier.height(8.dp)) } @Composable -fun DeviceInfoExpressiveSection(deviceInfo: Map) { - val orderedEntries = remember(deviceInfo) { orderedDeviceInfoEntries(deviceInfo) } - val lManufacturer = stringResource(R.string.presentation_batch_g_device_key_manufacturer) - val lModel = stringResource(R.string.presentation_batch_g_device_key_model) - val lBrand = stringResource(R.string.presentation_batch_g_device_key_brand) - val lDevice = stringResource(R.string.presentation_batch_g_device_key_device) - val lAndroid = stringResource(R.string.presentation_batch_g_device_key_android_version) - val lSdk = stringResource(R.string.presentation_batch_g_device_key_sdk_version) - val lHardware = stringResource(R.string.presentation_batch_g_device_key_hardware) - val localized = remember( - orderedEntries, - lManufacturer, - lModel, - lBrand, - lDevice, - lAndroid, - lSdk, - lHardware +private fun FormatCompatibilityCard( + formats: List, + playbackCompatibility: PlaybackCompatibilitySummary?, + modifier: Modifier = Modifier +) { + CapabilityCard( + title = stringResource(R.string.device_capabilities_formats_title), + icon = Icons.Rounded.GraphicEq, + verticalSpacing = 0.dp, + modifier = modifier ) { - orderedEntries.map { (k, v) -> - val label = when (k) { - "Manufacturer" -> lManufacturer - "Model" -> lModel - "Brand" -> lBrand - "Device" -> lDevice - "Android Version" -> lAndroid - "SDK Version" -> lSdk - "Hardware" -> lHardware - else -> k + val rows = formats.chunked(2) + Spacer( + modifier = Modifier.height(12.dp) + ) + rows.forEachIndexed { index, rowFormats -> + Row( + modifier = Modifier.height(IntrinsicSize.Min), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + rowFormats.forEach { format -> + FormatSupportTile( + format = format, + modifier = Modifier.weight(1f) + ) + } + if (rowFormats.size == 1) { + Spacer(modifier = Modifier.weight(1f)) + } + } + if (index != rows.lastIndex) { + Spacer(Modifier.height(8.dp)) + } + } + + Spacer( + modifier = Modifier.height(12.dp) + ) + + playbackCompatibility?.let { compatibility -> + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TonalChip( + text = stringResource( + R.string.device_capabilities_formats_supported_count, + compatibility.supportedLibrarySongCount + ), + leadingIcon = Icons.Rounded.CheckCircle + ) + if (compatibility.unknownFormatSongCount > 0) { + TonalChip( + text = stringResource( + R.string.device_capabilities_formats_unknown_count, + compatibility.unknownFormatSongCount + ), + leadingIcon = Icons.Rounded.Info + ) + } } - label to v } } - val heroEntries = localized.take(2) - val detailEntries = localized.drop(2) - val sectionShape = AbsoluteSmoothCornerShape(30.dp, 60) +} - Card( - shape = sectionShape, - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), - modifier = Modifier.fillMaxWidth(), - elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) +@Composable +private fun PlaybackFindingsCard( + compatibility: PlaybackCompatibilitySummary, + modifier: Modifier = Modifier +) { + val hasFindings = compatibility.unsupportedLibrarySongCount > 0 || + compatibility.unknownFormatSongCount > 0 || + compatibility.resampledLocalSongCount > 0 + + CapabilityCard( + title = stringResource(R.string.device_capabilities_findings_title), + icon = if (hasFindings) Icons.Rounded.Warning else Icons.Rounded.CheckCircle, + modifier = modifier ) { - Box( - modifier = Modifier - .fillMaxWidth() - .background( - brush = Brush.linearGradient( - colors = listOf( - MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.52f), - MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.34f), - MaterialTheme.colorScheme.surfaceContainer - ) - ) - ) - ) { - Column( - modifier = Modifier.padding(horizontal = 16.dp, vertical = 16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) + if (!hasFindings) { + FindingRow( + icon = Icons.Rounded.CheckCircle, + title = stringResource(R.string.device_capabilities_finding_clear_title), + body = stringResource(R.string.device_capabilities_finding_clear_body), + tone = FindingTone.Success + ) + return@CapabilityCard + } + + if (compatibility.unsupportedLibrarySongCount > 0) { + FindingRow( + icon = Icons.Rounded.ErrorOutline, + title = stringResource( + R.string.device_capabilities_finding_unsupported_title, + compatibility.unsupportedLibrarySongCount + ), + body = stringResource( + R.string.device_capabilities_finding_unsupported_body, + compatibility.unsupportedFormats.take(4).joinToString(", ") + ), + tone = FindingTone.Error + ) + } + + if (compatibility.resampledLocalSongCount > 0) { + Spacer(Modifier.height(8.dp)) + FindingRow( + icon = Icons.Rounded.Warning, + title = stringResource( + R.string.device_capabilities_finding_resample_title, + compatibility.resampledLocalSongCount + ), + body = stringResource( + R.string.device_capabilities_finding_resample_body, + compatibility.maxLocalSampleRate ?: 0 + ), + tone = FindingTone.Warning + ) + } + + if (compatibility.unknownFormatSongCount > 0) { + Spacer(Modifier.height(8.dp)) + FindingRow( + icon = Icons.Rounded.Info, + title = stringResource( + R.string.device_capabilities_finding_unknown_title, + compatibility.unknownFormatSongCount + ), + body = stringResource(R.string.device_capabilities_finding_unknown_body), + tone = FindingTone.Info + ) + } + } +} + +@Composable +private fun DeviceInfoPanel( + deviceInfo: Map, + modifier: Modifier = Modifier +) { + val orderedEntries = remember(deviceInfo) { orderedDeviceInfoEntries(deviceInfo) } + val localized = localizedDeviceInfoEntries(orderedEntries) + + CapabilityCard( + title = stringResource(R.string.presentation_batch_g_device_info_title), + icon = Icons.Rounded.Info, + verticalSpacing = 0.dp, + enableTopSpacer = true, + modifier = modifier + ) { + val rows = localized.chunked(2) + rows.forEachIndexed { index, entries -> + Row( + modifier = Modifier.height(IntrinsicSize.Min), + horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - Row( - verticalAlignment = Alignment.CenterVertically - ) { - Surface( - shape = AbsoluteSmoothCornerShape(16.dp, 60), - color = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.94f) - ) { - Icon( - imageVector = Icons.Rounded.Info, - contentDescription = null, - tint = MaterialTheme.colorScheme.onTertiaryContainer, - modifier = Modifier.padding(10.dp) - ) - } - Spacer(Modifier.width(12.dp)) - Text( - text = stringResource(R.string.presentation_batch_g_device_info_title), - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface + entries.forEach { (label, value) -> + InfoTile( + label = label, + value = value, + modifier = Modifier.weight(1f) ) } - //HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.24f)) - if (heroEntries.isNotEmpty()) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - heroEntries.forEach { (label, value) -> - DeviceInfoHeroTile( - label = label, - value = value, - modifier = Modifier.weight(1f) - ) - } - if (heroEntries.size == 1) { - Spacer(modifier = Modifier.weight(1f)) - } - } - } - detailEntries.chunked(2).forEach { rowEntries -> - if (rowEntries.size == 2) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - DeviceInfoStatTile( - label = rowEntries[0].first, - value = rowEntries[0].second, - modifier = Modifier.weight(1f) - ) - DeviceInfoStatTile( - label = rowEntries[1].first, - value = rowEntries[1].second, - modifier = Modifier.weight(1f) - ) - } - } else { - DeviceInfoStatTile( - label = rowEntries[0].first, - value = rowEntries[0].second, - modifier = Modifier.fillMaxWidth() - ) - } + if (entries.size == 1) { + Spacer(modifier = Modifier.weight(1f)) } } + if (index != rows.lastIndex) { + Spacer(Modifier.height(8.dp)) + } } } } @Composable -private fun DeviceInfoHeroTile( +private fun CapabilityCard( + modifier: Modifier = Modifier, + title: String, + icon: ImageVector, + enableTopSpacer: Boolean = false, + verticalSpacing: Dp = 10.dp, + content: @Composable ColumnScope.() -> Unit +) { + Surface( + modifier = modifier.fillMaxWidth(), + shape = AbsoluteSmoothCornerShape(28.dp, 60), + color = MaterialTheme.colorScheme.surfaceContainer, + tonalElevation = 0.dp + ) { + Column( + modifier = Modifier.padding(14.dp), + verticalArrangement = Arrangement.spacedBy(verticalSpacing) + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + StatusIcon( + icon = icon, + containerColor = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer + ) + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier + .weight(1f) + .semantics { heading() } + ) + } + + if (enableTopSpacer) { + Spacer( + modifier = Modifier.height(12.dp) + ) + } + + content() + } + } +} + +@Composable +private fun StatusIcon( + icon: ImageVector, + containerColor: Color, + contentColor: Color, + modifier: Modifier = Modifier +) { + Surface( + modifier = modifier.size(44.dp), + shape = CircleShape, + color = containerColor + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = icon, + contentDescription = null, + tint = contentColor, + modifier = Modifier.size(24.dp) + ) + } + } +} + +@Composable +private fun HeroMetricTile( label: String, value: String, + containerColor: Color, + contentColor: Color, modifier: Modifier = Modifier ) { Surface( - shape = AbsoluteSmoothCornerShape(22.dp, 60), - color = MaterialTheme.colorScheme.surfaceContainerLow, modifier = modifier + .fillMaxHeight() + .defaultMinSize(minHeight = 82.dp), + shape = AbsoluteSmoothCornerShape(18.dp, 60), + color = containerColor ) { Column( - modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp), - verticalArrangement = Arrangement.spacedBy(4.dp) + modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + horizontalAlignment = Alignment.CenterHorizontally ) { - Text( - text = label, - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) Text( text = value, - style = MaterialTheme.typography.headlineSmall, + style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface, + color = contentColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = contentColor.copy(alpha = 0.76f), maxLines = 2, + textAlign = TextAlign.Center, overflow = TextOverflow.Ellipsis ) } @@ -472,25 +824,44 @@ private fun DeviceInfoHeroTile( } @Composable -private fun DeviceInfoStatTile( +private fun InfoTile( label: String, value: String, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + supporting: String? = null, + icon: ImageVector? = null ) { Surface( - shape = AbsoluteSmoothCornerShape(14.dp, 60), - color = MaterialTheme.colorScheme.surfaceContainerLow, modifier = modifier + .fillMaxHeight() + .defaultMinSize(minHeight = 86.dp), + shape = AbsoluteSmoothCornerShape(18.dp, 60), + color = MaterialTheme.colorScheme.surfaceContainerLow ) { Column( modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), verticalArrangement = Arrangement.spacedBy(2.dp) ) { - Text( - text = label, - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + icon?.let { + Icon( + imageVector = it, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(16.dp) + ) + } + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } Text( text = value, style = MaterialTheme.typography.titleMedium, @@ -499,86 +870,186 @@ private fun DeviceInfoStatTile( maxLines = 2, overflow = TextOverflow.Ellipsis ) + supporting?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } } } } @Composable -fun SegmentedCodecList(codecs: List) { - val listShape = RoundedCornerShape(18.dp) - Column( - modifier = Modifier - .fillMaxWidth() - .clip(listShape), - verticalArrangement = Arrangement.spacedBy(4.dp) +private fun FormatSupportTile( + format: FormatSupportInfo, + modifier: Modifier = Modifier +) { + val statusColor = when { + !format.isDecoderAvailable -> MaterialTheme.colorScheme.errorContainer + format.isHardwareAccelerated -> MaterialTheme.colorScheme.primaryContainer + else -> MaterialTheme.colorScheme.tertiaryContainer + } + val statusContentColor = when { + !format.isDecoderAvailable -> MaterialTheme.colorScheme.onErrorContainer + format.isHardwareAccelerated -> MaterialTheme.colorScheme.onPrimaryContainer + else -> MaterialTheme.colorScheme.onTertiaryContainer + } + + Surface( + modifier = modifier + .fillMaxHeight() + .defaultMinSize(minHeight = 112.dp), + shape = AbsoluteSmoothCornerShape(18.dp, 60), + color = MaterialTheme.colorScheme.surfaceContainerLow ) { - codecs.forEachIndexed { index, codec -> - CodecCard( - codec = codec, - shape = settingsSegmentShape( - index = index, - count = codecs.size, - outerCorner = 18.dp, - innerCorner = 8.dp + Column( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = format.label, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) ) + Surface( + shape = CircleShape, + color = statusColor + ) { + Icon( + imageVector = if (format.isDecoderAvailable) Icons.Rounded.CheckCircle else Icons.Rounded.ErrorOutline, + contentDescription = null, + tint = statusContentColor, + modifier = Modifier + .padding(4.dp) + .size(16.dp) + ) + } + } + Text( + text = when { + !format.isDecoderAvailable -> stringResource(R.string.device_capabilities_format_unsupported) + format.isHardwareAccelerated -> stringResource(R.string.device_capabilities_format_hardware) + else -> stringResource(R.string.device_capabilities_format_software) + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant ) + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + if (format.isOffloadSupported) { + TonalChip( + text = stringResource(R.string.device_capabilities_format_offload), + compact = true + ) + } + if (format.librarySongCount > 0) { + TonalChip( + text = stringResource(R.string.device_capabilities_format_library_count, format.librarySongCount), + compact = true + ) + } + } } } } @Composable -fun CodecCard( - codec: CodecInfo, - shape: Shape = RoundedCornerShape(8.dp) +private fun ProgressReadout( + label: String, + value: String, + progress: Float, + modifier: Modifier = Modifier, + color: Color = MaterialTheme.colorScheme.primary ) { - Card( - shape = shape, - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow), - modifier = Modifier.fillMaxWidth(), - elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) - ) { - Box( + Column(modifier = modifier) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = label, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = value, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface + ) + } + Spacer(Modifier.height(6.dp)) + LinearProgressIndicator( + progress = { progress.visibleProgress() }, modifier = Modifier .fillMaxWidth() - .background( - brush = Brush.horizontalGradient( - colors = listOf( - MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.36f), - MaterialTheme.colorScheme.surfaceContainerLow - ) - ) - ) + .height(8.dp) + .clip(CircleShape), + color = color, + trackColor = MaterialTheme.colorScheme.surfaceContainerHighest + ) + } +} + +@Composable +private fun storagePercentLabel(fraction: Float): String { + val percent = fraction * 100f + return when { + fraction <= 0f -> stringResource(R.string.device_capabilities_storage_percent, 0) + percent < 1f -> stringResource(R.string.device_capabilities_storage_less_than_one_percent) + else -> stringResource(R.string.device_capabilities_storage_percent, percent.roundToInt()) + } +} + +private fun Float.visibleProgress(): Float { + val clamped = coerceIn(0f, 1f) + return if (clamped > 0f && clamped < 0.01f) 0.01f else clamped +} + +@Composable +private fun OutputRouteRow( + name: String, + category: AudioOutputCategory, + modifier: Modifier = Modifier +) { + Surface( + modifier = modifier.fillMaxWidth(), + shape = AbsoluteSmoothCornerShape(16.dp, 60), + color = MaterialTheme.colorScheme.surfaceContainerLow + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically ) { - Column(modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp)) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = codec.name, - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.weight(1f) - ) - if (codec.isHardwareAccelerated) { - Surface( - shape = CircleShape, - color = MaterialTheme.colorScheme.primaryContainer - ) { - Icon( - imageVector = Icons.Rounded.CheckCircle, - contentDescription = stringResource(R.string.presentation_batch_g_device_cd_hw_accelerated), - tint = MaterialTheme.colorScheme.onPrimaryContainer, - modifier = Modifier.padding(4.dp).size(16.dp) - ) - } - } - } - Spacer(Modifier.height(6.dp)) + Icon( + imageVector = if (category == AudioOutputCategory.BuiltIn) Icons.Rounded.Speaker else Icons.Rounded.Headphones, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp) + ) + Column(modifier = Modifier.weight(1f)) { Text( - text = codec.supportedTypes.joinToString(", "), + text = name, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = audioOutputCategoryLabel(category), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -587,27 +1058,192 @@ fun CodecCard( } } -private fun settingsSegmentShape( - index: Int, - count: Int, - outerCorner: Dp, - innerCorner: Dp -): RoundedCornerShape { - return when { - count <= 1 -> RoundedCornerShape(outerCorner) - index == 0 -> RoundedCornerShape( - topStart = outerCorner, - topEnd = outerCorner, - bottomStart = innerCorner, - bottomEnd = innerCorner - ) - index == count - 1 -> RoundedCornerShape( - topStart = innerCorner, - topEnd = innerCorner, - bottomStart = outerCorner, - bottomEnd = outerCorner +private enum class FindingTone { + Success, + Warning, + Error, + Info +} + +@Composable +private fun FindingRow( + icon: ImageVector, + title: String, + body: String, + tone: FindingTone, + modifier: Modifier = Modifier +) { + val containerColor = when (tone) { + FindingTone.Success -> MaterialTheme.colorScheme.primaryContainer + FindingTone.Warning -> MaterialTheme.colorScheme.tertiaryContainer + FindingTone.Error -> MaterialTheme.colorScheme.errorContainer + FindingTone.Info -> MaterialTheme.colorScheme.secondaryContainer + } + val contentColor = when (tone) { + FindingTone.Success -> MaterialTheme.colorScheme.onPrimaryContainer + FindingTone.Warning -> MaterialTheme.colorScheme.onTertiaryContainer + FindingTone.Error -> MaterialTheme.colorScheme.onErrorContainer + FindingTone.Info -> MaterialTheme.colorScheme.onSecondaryContainer + } + + Surface( + modifier = modifier.fillMaxWidth(), + shape = AbsoluteSmoothCornerShape(18.dp, 60), + color = containerColor + ) { + Row( + modifier = Modifier.padding(12.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.Top + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = contentColor, + modifier = Modifier.size(22.dp) + ) + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = contentColor + ) + Text( + text = body, + style = MaterialTheme.typography.bodySmall, + color = contentColor.copy(alpha = 0.78f) + ) + } + } + } +} + +@Composable +private fun TonalChip( + text: String, + modifier: Modifier = Modifier, + leadingIcon: ImageVector? = null, + compact: Boolean = false, + containerColor: Color = MaterialTheme.colorScheme.surfaceContainerHighest, + contentColor: Color = MaterialTheme.colorScheme.onSurfaceVariant +) { + Surface( + modifier = modifier, + shape = CircleShape, + color = containerColor + ) { + Row( + modifier = Modifier.padding( + horizontal = if (compact) 8.dp else 10.dp, + vertical = if (compact) 5.dp else 7.dp + ), + horizontalArrangement = Arrangement.spacedBy(5.dp), + verticalAlignment = Alignment.CenterVertically + ) { + leadingIcon?.let { + Icon( + imageVector = it, + contentDescription = null, + tint = contentColor, + modifier = Modifier.size(if (compact) 14.dp else 16.dp) + ) + } + Text( + text = text, + style = if (compact) MaterialTheme.typography.labelSmall else MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Medium, + color = contentColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } +} + +@Composable +private fun ChipRow( + chips: List, + emptyText: String, + modifier: Modifier = Modifier +) { + if (chips.isEmpty()) { + Text( + text = emptyText, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = modifier ) - else -> RoundedCornerShape(innerCorner) + return + } + + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + chips.take(3).forEach { + TonalChip(text = it) + } + if (chips.size > 3) { + TonalChip(text = stringResource(R.string.device_capabilities_more_count, chips.size - 3)) + } + } +} + +@Composable +private fun SectionLabel(text: String) { + Text( + text = text, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.semantics { heading() } + ) +} + +@Composable +private fun yesNo(value: Boolean): String { + return if (value) { + stringResource(R.string.presentation_batch_g_yes) + } else { + stringResource(R.string.presentation_batch_g_no) + } +} + +@Composable +private fun audioOutputCategoryLabel(category: AudioOutputCategory): String { + return when (category) { + AudioOutputCategory.BuiltIn -> stringResource(R.string.device_capabilities_output_builtin) + AudioOutputCategory.Bluetooth -> stringResource(R.string.device_capabilities_output_bluetooth) + AudioOutputCategory.Usb -> stringResource(R.string.device_capabilities_output_usb) + AudioOutputCategory.Wired -> stringResource(R.string.device_capabilities_output_wired) + AudioOutputCategory.Cast -> stringResource(R.string.device_capabilities_output_digital) + AudioOutputCategory.Other -> stringResource(R.string.device_capabilities_output_other) + } +} + +@Composable +private fun localizedDeviceInfoEntries(entries: List>): List> { + val lManufacturer = stringResource(R.string.presentation_batch_g_device_key_manufacturer) + val lModel = stringResource(R.string.presentation_batch_g_device_key_model) + val lBrand = stringResource(R.string.presentation_batch_g_device_key_brand) + val lDevice = stringResource(R.string.presentation_batch_g_device_key_device) + val lAndroid = stringResource(R.string.presentation_batch_g_device_key_android_version) + val lSdk = stringResource(R.string.presentation_batch_g_device_key_sdk_version) + val lHardware = stringResource(R.string.presentation_batch_g_device_key_hardware) + + return entries.map { (key, value) -> + val label = when (key) { + "Manufacturer" -> lManufacturer + "Model" -> lModel + "Brand" -> lBrand + "Device" -> lDevice + "Android Version" -> lAndroid + "SDK Version" -> lSdk + "Hardware" -> lHardware + else -> key + } + label to value } } diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/DeviceCapabilitiesViewModel.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/DeviceCapabilitiesViewModel.kt index 67edaaea5..73c69acb8 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/DeviceCapabilitiesViewModel.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/DeviceCapabilitiesViewModel.kt @@ -1,21 +1,35 @@ package com.theveloper.pixelplay.presentation.viewmodel +import android.app.ActivityManager import android.content.Context +import android.content.pm.PackageManager +import android.media.AudioDeviceInfo +import android.media.AudioFormat import android.media.AudioManager -import android.media.MediaCodecList -import android.media.MediaFormat +import android.net.Uri import android.os.Build +import android.os.Environment +import android.os.StatFs +import android.provider.OpenableColumns +import androidx.annotation.OptIn import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import androidx.media3.common.util.UnstableApi +import com.theveloper.pixelplay.data.database.DeviceCapabilitySongRow +import com.theveloper.pixelplay.data.database.MusicDao +import com.theveloper.pixelplay.data.database.SourceType import com.theveloper.pixelplay.data.service.player.DualPlayerEngine +import com.theveloper.pixelplay.data.service.player.HiFiCapabilityChecker import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext +import java.io.File +import java.util.Locale +import javax.inject.Inject +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch -import javax.inject.Inject -import androidx.media3.common.util.UnstableApi -import androidx.media3.exoplayer.DecoderCounters +import kotlinx.coroutines.withContext data class CodecInfo( val name: String, @@ -24,14 +38,86 @@ data class CodecInfo( val maxSupportedInstances: Int ) +data class AudioOutputInfo( + val name: String, + val category: AudioOutputCategory +) + +enum class AudioOutputCategory { + BuiltIn, + Bluetooth, + Usb, + Wired, + Cast, + Other +} + data class AudioCapabilities( val outputSampleRate: Int, val outputFramesPerBuffer: Int, val isLowLatencySupported: Boolean, val isProAudioSupported: Boolean, + val isPcmFloatSupported: Boolean, + val offloadSupportedFormats: List, + val outputRoutes: List, val supportedCodecs: List ) +data class FormatSupportInfo( + val label: String, + val mimeType: String, + val isDecoderAvailable: Boolean, + val isHardwareAccelerated: Boolean, + val isOffloadSupported: Boolean, + val librarySongCount: Int +) + +data class LocalMusicStorageSummary( + val localSongCount: Int, + val cloudSongCount: Int, + val knownLocalFileCount: Int, + val unavailableLocalFileCount: Int, + val localMusicBytes: Long, + val deviceAvailableBytes: Long, + val deviceTotalBytes: Long +) { + val deviceUsedBytes: Long + get() = (deviceTotalBytes - deviceAvailableBytes).coerceAtLeast(0L) + + val localMusicStorageFraction: Float + get() = if (deviceTotalBytes <= 0L) { + 0f + } else { + (localMusicBytes.toDouble() / deviceTotalBytes.toDouble()).coerceIn(0.0, 1.0).toFloat() + } + + val deviceUsedFraction: Float + get() = if (deviceTotalBytes <= 0L) { + 0f + } else { + (deviceUsedBytes.toDouble() / deviceTotalBytes.toDouble()).coerceIn(0.0, 1.0).toFloat() + } +} + +data class PlaybackCompatibilitySummary( + val supportedLibrarySongCount: Int, + val unsupportedLibrarySongCount: Int, + val unknownFormatSongCount: Int, + val unsupportedFormats: List, + val localHiResSongCount: Int, + val resampledLocalSongCount: Int, + val maxLocalSampleRate: Int?, + val maxLocalBitrate: Int? +) + +data class MemorySummary( + val availableRamBytes: Long, + val totalRamBytes: Long, + val memoryClassMb: Int, + val isLowRamDevice: Boolean, + val isSystemLowMemory: Boolean +) + data class ExoPlayerInfo( val version: String, val renderers: String, @@ -42,13 +128,24 @@ data class DeviceCapabilitiesState( val deviceInfo: Map = emptyMap(), val audioCapabilities: AudioCapabilities? = null, val exoPlayerInfo: ExoPlayerInfo? = null, + val storageSummary: LocalMusicStorageSummary? = null, + val playbackCompatibility: PlaybackCompatibilitySummary? = null, + val formatSupport: List = emptyList(), + val memorySummary: MemorySummary? = null, val isLoading: Boolean = true ) +private data class AudioFormatCandidate( + val label: String, + val mimeType: String, + val offloadEncoding: Int? +) + @HiltViewModel class DeviceCapabilitiesViewModel @Inject constructor( @ApplicationContext private val context: Context, - private val engine: DualPlayerEngine + private val engine: DualPlayerEngine, + private val musicDao: MusicDao ) : ViewModel() { private val _state = MutableStateFlow(DeviceCapabilitiesState()) @@ -60,16 +157,29 @@ class DeviceCapabilitiesViewModel @Inject constructor( private fun loadCapabilities() { viewModelScope.launch { - val deviceInfo = getDeviceInfo() - val audioCaps = getAudioCapabilities() val exoInfo = getExoPlayerInfo() + val loadedState = withContext(Dispatchers.IO) { + val deviceInfo = getDeviceInfo() + val audioCaps = getAudioCapabilities() + val libraryRows = musicDao.getDeviceCapabilitySongRows() + val storage = getLocalMusicStorageSummary(libraryRows) + val playback = getPlaybackCompatibilitySummary(libraryRows, audioCaps) + val formatSupport = getFormatSupport(libraryRows, audioCaps) + val memorySummary = getMemorySummary() - _state.value = DeviceCapabilitiesState( - deviceInfo = deviceInfo, - audioCapabilities = audioCaps, - exoPlayerInfo = exoInfo, - isLoading = false - ) + DeviceCapabilitiesState( + deviceInfo = deviceInfo, + audioCapabilities = audioCaps, + exoPlayerInfo = exoInfo, + storageSummary = storage, + playbackCompatibility = playback, + formatSupport = formatSupport, + memorySummary = memorySummary, + isLoading = false + ) + } + + _state.value = loadedState } } @@ -87,11 +197,12 @@ class DeviceCapabilitiesViewModel @Inject constructor( private fun getAudioCapabilities(): AudioCapabilities { val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager - val sampleRate = audioManager.getProperty(AudioManager.PROPERTY_OUTPUT_SAMPLE_RATE)?.toIntOrNull() ?: 44100 + val sampleRate = audioManager.getProperty(AudioManager.PROPERTY_OUTPUT_SAMPLE_RATE)?.toIntOrNull() ?: 44_100 val framesPerBuffer = audioManager.getProperty(AudioManager.PROPERTY_OUTPUT_FRAMES_PER_BUFFER)?.toIntOrNull() ?: 256 - val hasLowLatency = context.packageManager.hasSystemFeature(android.content.pm.PackageManager.FEATURE_AUDIO_LOW_LATENCY) - val hasProAudio = context.packageManager.hasSystemFeature(android.content.pm.PackageManager.FEATURE_AUDIO_PRO) - + val packageManager = context.packageManager + val hasLowLatency = packageManager.hasSystemFeature(PackageManager.FEATURE_AUDIO_LOW_LATENCY) + val hasProAudio = packageManager.hasSystemFeature(PackageManager.FEATURE_AUDIO_PRO) + val offloadSupportedFormats = getOffloadSupportedFormats() val supportedCodecs = getSupportedAudioCodecs() return AudioCapabilities( @@ -99,30 +210,36 @@ class DeviceCapabilitiesViewModel @Inject constructor( outputFramesPerBuffer = framesPerBuffer, isLowLatencySupported = hasLowLatency, isProAudioSupported = hasProAudio, + isPcmFloatSupported = HiFiCapabilityChecker.isSupported(), + offloadSupportedFormats = offloadSupportedFormats, + outputRoutes = getOutputRoutes(audioManager), supportedCodecs = supportedCodecs ) } private fun getSupportedAudioCodecs(): List { - val codecList = MediaCodecList(MediaCodecList.ALL_CODECS) + val codecList = android.media.MediaCodecList(android.media.MediaCodecList.ALL_CODECS) val codecs = mutableListOf() for (codecInfo in codecList.codecInfos) { - if (codecInfo.isEncoder) continue // Skip encorders + if (codecInfo.isEncoder) continue - val types = codecInfo.supportedTypes.filter { it.startsWith("audio/") } + val types = codecInfo.supportedTypes + .filter { it.startsWith("audio/") } + .map { normalizeMimeType(it) } + .distinct() if (types.isEmpty()) continue - var isHardware = false - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - isHardware = codecInfo.isHardwareAccelerated + val isHardware = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + codecInfo.isHardwareAccelerated + } else { + false } - // Estimate instances (not always available/accurate via public API directly without capabilities, but usually safe default) val instances = try { - val caps = codecInfo.getCapabilitiesForType(types[0]) - caps.maxSupportedInstances - } catch (e: Exception) { + codecInfo.getCapabilitiesForType(codecInfo.supportedTypes.first { it.startsWith("audio/") }) + .maxSupportedInstances + } catch (_: Exception) { -1 } @@ -138,24 +255,286 @@ class DeviceCapabilitiesViewModel @Inject constructor( return codecs.sortedBy { it.name } } - @androidx.annotation.OptIn(UnstableApi::class) + private fun getLocalMusicStorageSummary(rows: List): LocalMusicStorageSummary { + val localRows = rows.filter { it.sourceType == SourceType.LOCAL } + val cloudCount = rows.count { it.sourceType != SourceType.LOCAL } + var totalBytes = 0L + var knownFiles = 0 + var unavailableFiles = 0 + + localRows.forEach { row -> + val bytes = resolveLocalFileSize(row) + if (bytes > 0L) { + totalBytes += bytes + knownFiles += 1 + } else { + unavailableFiles += 1 + } + } + + val storageStats = getDeviceStorageStats() + + return LocalMusicStorageSummary( + localSongCount = localRows.size, + cloudSongCount = cloudCount, + knownLocalFileCount = knownFiles, + unavailableLocalFileCount = unavailableFiles, + localMusicBytes = totalBytes, + deviceAvailableBytes = storageStats.first, + deviceTotalBytes = storageStats.second + ) + } + + private fun getPlaybackCompatibilitySummary( + rows: List, + audioCapabilities: AudioCapabilities + ): PlaybackCompatibilitySummary { + val supportedTypes = audioCapabilities.supportedCodecs.flatMap { it.supportedTypes }.toSet() + val localRows = rows.filter { it.sourceType == SourceType.LOCAL } + var supportedCount = 0 + var unsupportedCount = 0 + var unknownCount = 0 + val unsupportedFormats = linkedSetOf() + + rows.forEach { row -> + val mimeType = row.mimeType?.let(::normalizeMimeType) + when { + mimeType.isNullOrBlank() -> unknownCount += 1 + isMimeTypeSupported(mimeType, supportedTypes) -> supportedCount += 1 + else -> { + unsupportedCount += 1 + unsupportedFormats += mimeType + } + } + } + + val localSampleRates = localRows.mapNotNull { it.sampleRate }.filter { it > 0 } + val maxSampleRate = localSampleRates.maxOrNull() + val maxBitrate = localRows.mapNotNull { it.bitrate }.filter { it > 0 }.maxOrNull() + val outputRate = audioCapabilities.outputSampleRate.coerceAtLeast(1) + val hiResSongCount = localSampleRates.count { it > 48_000 } + val resampledSongCount = localSampleRates.count { it > outputRate } + + return PlaybackCompatibilitySummary( + supportedLibrarySongCount = supportedCount, + unsupportedLibrarySongCount = unsupportedCount, + unknownFormatSongCount = unknownCount, + unsupportedFormats = unsupportedFormats.toList(), + localHiResSongCount = hiResSongCount, + resampledLocalSongCount = resampledSongCount, + maxLocalSampleRate = maxSampleRate, + maxLocalBitrate = maxBitrate + ) + } + + private fun getFormatSupport( + rows: List, + audioCapabilities: AudioCapabilities + ): List { + val supportedTypes = audioCapabilities.supportedCodecs.flatMap { it.supportedTypes }.toSet() + val hardwareTypes = audioCapabilities.supportedCodecs + .filter { it.isHardwareAccelerated } + .flatMap { it.supportedTypes } + .toSet() + val offloadFormats = audioCapabilities.offloadSupportedFormats.toSet() + val libraryCountsByMime = rows + .mapNotNull { it.mimeType?.let(::normalizeMimeType) } + .groupingBy { it } + .eachCount() + + return audioFormatCandidates().map { candidate -> + val acceptedMimes = compatibleMimeTypes(candidate.mimeType) + FormatSupportInfo( + label = candidate.label, + mimeType = candidate.mimeType, + isDecoderAvailable = acceptedMimes.any { isMimeTypeSupported(it, supportedTypes) }, + isHardwareAccelerated = acceptedMimes.any { isMimeTypeSupported(it, hardwareTypes) }, + isOffloadSupported = candidate.label in offloadFormats, + librarySongCount = acceptedMimes.sumOf { libraryCountsByMime[it] ?: 0 } + ) + } + } + + private fun getMemorySummary(): MemorySummary { + val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager + val memoryInfo = ActivityManager.MemoryInfo() + activityManager.getMemoryInfo(memoryInfo) + return MemorySummary( + availableRamBytes = memoryInfo.availMem, + totalRamBytes = memoryInfo.totalMem, + memoryClassMb = activityManager.memoryClass, + isLowRamDevice = activityManager.isLowRamDevice, + isSystemLowMemory = memoryInfo.lowMemory + ) + } + + private fun getOutputRoutes(audioManager: AudioManager): List { + return audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS) + .map { device -> + AudioOutputInfo( + name = device.productName?.toString()?.takeIf { it.isNotBlank() } + ?: device.type.toAudioOutputCategory().name, + category = device.type.toAudioOutputCategory() + ) + } + .distinctBy { it.category to it.name.lowercase(Locale.US) } + .sortedBy { it.category.ordinal } + } + + private fun getOffloadSupportedFormats(): List { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return emptyList() + + val attributes = android.media.AudioAttributes.Builder() + .setUsage(android.media.AudioAttributes.USAGE_MEDIA) + .setContentType(android.media.AudioAttributes.CONTENT_TYPE_MUSIC) + .build() + + return audioFormatCandidates() + .mapNotNull { candidate -> + val encoding = candidate.offloadEncoding ?: return@mapNotNull null + val isSupported = runCatching { + val audioFormat = AudioFormat.Builder() + .setEncoding(encoding) + .setSampleRate(44_100) + .setChannelMask(AudioFormat.CHANNEL_OUT_STEREO) + .build() + AudioManager.isOffloadedPlaybackSupported(audioFormat, attributes) + }.getOrDefault(false) + + if (isSupported) candidate.label else null + } + } + + private fun getDeviceStorageStats(): Pair { + return runCatching { + val statFs = StatFs(Environment.getExternalStorageDirectory().path) + statFs.availableBytes to statFs.totalBytes + }.getOrElse { 0L to 0L } + } + + private fun resolveLocalFileSize(row: DeviceCapabilitySongRow): Long { + val pathSize = row.filePath + .takeIf { it.isNotBlank() } + ?.let { path -> + runCatching { + val file = File(path) + if (file.exists() && file.isFile) file.length() else 0L + }.getOrDefault(0L) + } + ?: 0L + + if (pathSize > 0L) return pathSize + + val contentSize = resolveContentUriSize(row.contentUriString) + if (contentSize > 0L) return contentSize + + return estimateFileSizeFromMetadata(row) + } + + private fun resolveContentUriSize(contentUriString: String): Long { + val uri = runCatching { Uri.parse(contentUriString) }.getOrNull() ?: return 0L + if (uri.scheme != "content") return 0L + + return runCatching { + context.contentResolver.query(uri, arrayOf(OpenableColumns.SIZE), null, null, null)?.use { cursor -> + if (cursor.moveToFirst()) { + val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE) + if (sizeIndex >= 0 && !cursor.isNull(sizeIndex)) cursor.getLong(sizeIndex) else 0L + } else { + 0L + } + } ?: 0L + }.getOrDefault(0L) + } + + private fun estimateFileSizeFromMetadata(row: DeviceCapabilitySongRow): Long { + val bitrate = row.bitrate?.takeIf { it > 0 }?.toLong() ?: return 0L + val durationMs = row.duration.takeIf { it > 0 } ?: return 0L + return (bitrate * durationMs / 8_000L).coerceAtLeast(0L) + } + + @OptIn(UnstableApi::class) private fun getExoPlayerInfo(): ExoPlayerInfo { val player = engine.masterPlayer - - // This is a basic info string, expanding it would require deeper reflection or ExoPlayer specific listeners - // For now, we return version and renderer count. - val version = androidx.media3.common.MediaLibraryInfo.VERSION val exoPlayer = player as? androidx.media3.exoplayer.ExoPlayer - val renderers = "${exoPlayer?.rendererCount ?: 0} Active Renderers" - - // We can't easily get internal decoder counters without a listener, - // but we can show what we know. - + val renderers = "${exoPlayer?.rendererCount ?: 0}" + return ExoPlayerInfo( version = version, renderers = renderers, - decoderCounters = "N/A (Requires Debug Listener)" + decoderCounters = "N/A" ) } } + +private fun Int.toAudioOutputCategory(): AudioOutputCategory { + return when (this) { + AudioDeviceInfo.TYPE_BUILTIN_EARPIECE, + AudioDeviceInfo.TYPE_BUILTIN_SPEAKER, + AudioDeviceInfo.TYPE_BUILTIN_SPEAKER_SAFE -> AudioOutputCategory.BuiltIn + AudioDeviceInfo.TYPE_BLUETOOTH_A2DP, + AudioDeviceInfo.TYPE_BLUETOOTH_SCO, + AudioDeviceInfo.TYPE_BLE_HEADSET, + AudioDeviceInfo.TYPE_BLE_SPEAKER, + AudioDeviceInfo.TYPE_BLE_BROADCAST -> AudioOutputCategory.Bluetooth + AudioDeviceInfo.TYPE_USB_ACCESSORY, + AudioDeviceInfo.TYPE_USB_DEVICE, + AudioDeviceInfo.TYPE_USB_HEADSET -> AudioOutputCategory.Usb + AudioDeviceInfo.TYPE_WIRED_HEADPHONES, + AudioDeviceInfo.TYPE_WIRED_HEADSET, + AudioDeviceInfo.TYPE_LINE_ANALOG, + AudioDeviceInfo.TYPE_LINE_DIGITAL -> AudioOutputCategory.Wired + AudioDeviceInfo.TYPE_HDMI, + AudioDeviceInfo.TYPE_HDMI_ARC, + AudioDeviceInfo.TYPE_HDMI_EARC -> AudioOutputCategory.Cast + else -> AudioOutputCategory.Other + } +} + +private fun audioFormatCandidates(): List { + return buildList { + add(AudioFormatCandidate("MP3", "audio/mpeg", AudioFormat.ENCODING_MP3)) + add(AudioFormatCandidate("AAC", "audio/mp4a-latm", AudioFormat.ENCODING_AAC_LC)) + add(AudioFormatCandidate("FLAC", "audio/flac", null)) + add(AudioFormatCandidate("Vorbis", "audio/vorbis", null)) + add(AudioFormatCandidate("WAV", "audio/wav", AudioFormat.ENCODING_PCM_16BIT)) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + add(AudioFormatCandidate("Opus", "audio/opus", AudioFormat.ENCODING_OPUS)) + } else { + add(AudioFormatCandidate("Opus", "audio/opus", null)) + } + add(AudioFormatCandidate("ALAC", "audio/alac", null)) + } +} + +private fun normalizeMimeType(mimeType: String): String { + return when (mimeType.lowercase(Locale.US).substringBefore(";").trim()) { + "audio/mp3", "audio/x-mp3" -> "audio/mpeg" + "audio/x-wav", "audio/wave" -> "audio/wav" + "audio/aac", "audio/x-aac", "audio/m4a", "audio/mp4" -> "audio/mp4a-latm" + "audio/x-flac" -> "audio/flac" + "audio/ogg" -> "audio/vorbis" + "audio/x-ms-wma" -> "audio/wma" + else -> mimeType.lowercase(Locale.US).substringBefore(";").trim() + } +} + +private fun compatibleMimeTypes(mimeType: String): Set { + val normalized = normalizeMimeType(mimeType) + return when (normalized) { + "audio/mpeg" -> setOf("audio/mpeg", "audio/mp3", "audio/x-mp3") + "audio/mp4a-latm" -> setOf("audio/mp4a-latm", "audio/aac", "audio/x-aac", "audio/m4a", "audio/mp4") + "audio/flac" -> setOf("audio/flac", "audio/x-flac") + "audio/wav" -> setOf("audio/wav", "audio/x-wav", "audio/wave") + "audio/vorbis" -> setOf("audio/vorbis", "audio/ogg") + else -> setOf(normalized) + } +} + +private fun isMimeTypeSupported( + mimeType: String, + supportedTypes: Set +): Boolean { + return compatibleMimeTypes(mimeType).any { normalizeMimeType(it) in supportedTypes } +} diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlaybackStateHolder.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlaybackStateHolder.kt index 17cb10b09..f73d42ec0 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlaybackStateHolder.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlaybackStateHolder.kt @@ -42,11 +42,12 @@ class PlaybackStateHolder @Inject constructor( companion object { private const val TAG = "PlaybackStateHolder" private const val DURATION_MISMATCH_TOLERANCE_MS = 1500L - // 500 ms keeps the progress bar/time display smooth to the eye (it still updates twice - // per second) while halving Compose recomposition pressure vs. the old 250 ms tick. - // The visual slider uses frame-clock interpolation on top of this, so the animation - // stays fluid even at a slower underlying cadence. - private const val FOREGROUND_PROGRESS_TICK_MS = 500L + // 250 ms keeps the slider/time display visibly smooth. We tried 500 ms to lower + // Compose recomposition pressure, but the smooth-progress sampler does not actually + // interpolate between source samples — it polls — so a 500 ms source cadence made the + // slider stutter in half-second jumps. Background tick is throttled to 1 s since the + // screen is off and no slider is visible. + private const val FOREGROUND_PROGRESS_TICK_MS = 250L private const val BACKGROUND_PROGRESS_TICK_MS = 1000L /** * Threshold above which we skip per-item moveMediaItem calls and use diff --git a/app/src/main/res/values-es/strings_presentation_batch_g.xml b/app/src/main/res/values-es/strings_presentation_batch_g.xml index a87dafdb8..6ffbf3a3e 100644 --- a/app/src/main/res/values-es/strings_presentation_batch_g.xml +++ b/app/src/main/res/values-es/strings_presentation_batch_g.xml @@ -88,28 +88,81 @@ Unique tracks Top 3 share ? - Device Info - Supported Audio Codecs - Audio Output - ExoPlayer Engine - Sample Rate - Frames Per Buffer - Low Latency Support - Pro Audio Support - Version - Active Renderers - Decoder Counters + Información del dispositivo + Códecs de audio compatibles + Salida de audio + Motor ExoPlayer + Frecuencia de muestreo + Frames por búfer + Baja latencia + Audio Pro + Versión + Renderizadores activos + Contadores del decodificador %1$d Hz - Yes + No - Hardware accelerated - Manufacturer - Model - Brand - Device - Android Version - SDK Version + Acelerado por hardware + Fabricante + Modelo + Marca + Dispositivo + Versión de Android + Versión SDK Hardware + Este dispositivo + -- + Listo para reproducir música + La reproducción necesita revisión + Formatos + Decod. HW + Canciones locales + Almacenamiento de música local + Tamaño de música + %1$d canciones locales + Disponible + %1$s total + Huella de música + Dispositivo usado + %1$d%% + <1% + %1$d canciones en la nube + %1$d archivos no legibles + Ruta de reproducción + %1$d frames por búfer + Hi-Fi PCM Float + Salida float de 32 bits + Memoria + disponibles de %1$s + Formatos con offload + Android no informó offload por hardware para formatos comprimidos. + Salidas detectadas + Android no informó rutas de salida. + %1$s renderizadores + Compatibilidad de formatos + %1$d pistas compatibles + %1$d formato desconocido + Sin decodificador informado + Decodificador por hardware + Decodificador por software + Offload + %1$d en biblioteca + Hallazgos de compatibilidad + Sin incompatibilidades importantes + Las pistas indexadas coinciden con los decodificadores que Android informa en este dispositivo. + %1$d pistas podrían no decodificar nativamente + Formatos a revisar: %1$s. + %1$d pistas locales podrían remuestrearse + La biblioteca llega a %1$d Hz, por encima de la frecuencia actual de salida. + %1$d pistas tienen metadatos desconocidos + Un reescaneo completo puede completar MIME, bitrate y frecuencia de muestreo. + +%1$d más + Salida integrada + Audio Bluetooth + Audio USB + Audio cableado + Salida digital + Otra salida Input Output Thought diff --git a/app/src/main/res/values/strings_presentation_batch_g.xml b/app/src/main/res/values/strings_presentation_batch_g.xml index d98a066c2..c32a50899 100644 --- a/app/src/main/res/values/strings_presentation_batch_g.xml +++ b/app/src/main/res/values/strings_presentation_batch_g.xml @@ -110,6 +110,59 @@ Android Version SDK Version Hardware + This device + -- + Ready for playback + Playback needs review + Formats + HW decoders + Local songs + Local Music Storage + Music size + %1$d local songs + Available + %1$s total + Music footprint + Device used + %1$d%% + <1% + %1$d cloud songs + %1$d files not readable + Playback Path + %1$d frames per buffer + Hi-Fi PCM Float + 32-bit float output path + Memory + available of %1$s + Offload-ready formats + No compressed format reported hardware offload support. + Detected outputs + No output routes were reported by Android. + %1$s renderers + Format Compatibility + %1$d supported tracks + %1$d unknown format + No decoder reported + Hardware decoder + Software decoder + Offload + %1$d in library + Compatibility Findings + No major incompatibilities + Your indexed tracks match the decoders Android reports on this device. + %1$d tracks may not decode natively + Formats to review: %1$s. + %1$d local tracks may be resampled + The library reaches %1$d Hz, above the current output sample rate. + %1$d tracks have unknown metadata + A full library rescan can fill missing MIME, bitrate, and sample-rate data. + +%1$d more + Built-in output + Bluetooth audio + USB audio + Wired audio + Digital output + Other output Input Output Thought From e7156f1ea6dde6f8b8d6bcac8311a24f5a2c3f74 Mon Sep 17 00:00:00 2001 From: theo Date: Fri, 24 Apr 2026 15:12:08 -0300 Subject: [PATCH 6/7] Fixed file info on player not updating. --- .../components/player/FullPlayerContent.kt | 24 +++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/player/FullPlayerContent.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/player/FullPlayerContent.kt index 182075d50..ea9f046ea 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/player/FullPlayerContent.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/player/FullPlayerContent.kt @@ -1209,14 +1209,30 @@ private fun FullPlayerProgressSection( loadingTweaks: FullPlayerLoadingTweaks ) { val isMetadataForCurrentSong = playbackMetadataMediaId == song.id + val audioMimeType = if (isMetadataForCurrentSong) { + playbackMetadataMimeType ?: song.mimeType + } else { + song.mimeType + } + val audioBitrate = if (isMetadataForCurrentSong) { + playbackMetadataBitrate ?: song.bitrate + } else { + song.bitrate + } + val audioSampleRate = if (isMetadataForCurrentSong) { + playbackMetadataSampleRate ?: song.sampleRate + } else { + song.sampleRate + } + PlayerProgressBarSection( songId = song.id, currentPositionProvider = currentPositionProvider, totalDurationValue = totalDurationValue, songDurationHintMs = song.duration, - audioMimeType = if (isMetadataForCurrentSong) playbackMetadataMimeType else null, - audioBitrate = if (isMetadataForCurrentSong) playbackMetadataBitrate else null, - audioSampleRate = if (isMetadataForCurrentSong) playbackMetadataSampleRate else null, + audioMimeType = audioMimeType, + audioBitrate = audioBitrate, + audioSampleRate = audioSampleRate, showAudioFileInfo = showPlayerFileInfo, onSeek = onSeek, expansionFractionProvider = expansionFractionProvider, @@ -1644,7 +1660,7 @@ private fun PlayerProgressBarSection( null } } - var displayAudioMetaLabel by remember { mutableStateOf(null) } + var displayAudioMetaLabel by remember(songId) { mutableStateOf(null) } LaunchedEffect(songId, audioMetaLabel, showAudioFileInfo) { if (!showAudioFileInfo) { displayAudioMetaLabel = null From c5c4f6606b99cf9d934c83a765b32c5101970a27 Mon Sep 17 00:00:00 2001 From: adlifarizi Date: Sun, 26 Apr 2026 19:46:50 +0700 Subject: [PATCH 7/7] feat: add device capabilities strings and update StatsScreen layout for improved text alignment --- .../presentation/screens/StatsScreen.kt | 5 +- .../strings_presentation_batch_g.xml | 53 +++++++++++++++++++ .../strings_presentation_batch_g.xml | 53 +++++++++++++++++++ 3 files changed, 109 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/StatsScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/StatsScreen.kt index 178dc5200..4300de471 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/StatsScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/StatsScreen.kt @@ -500,13 +500,14 @@ private fun StatsEmptyState( text = title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurface + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center ) Text( text = subtitle, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = androidx.compose.ui.text.style.TextAlign.Center + textAlign = TextAlign.Center ) } } diff --git a/app/src/main/res/values-in/strings_presentation_batch_g.xml b/app/src/main/res/values-in/strings_presentation_batch_g.xml index d829fe2c7..2473f0bdf 100644 --- a/app/src/main/res/values-in/strings_presentation_batch_g.xml +++ b/app/src/main/res/values-in/strings_presentation_batch_g.xml @@ -110,6 +110,59 @@ Versi Android Versi SDK Perangkat Keras + Perangkat ini + -- + Siap untuk pemutaran + Pemutaran perlu ditinjau + Format + Dekoder HW + Lagu lokal + Penyimpanan Musik Lokal + Ukuran musik + %1$d lagu lokal + Tersedia + Total %1$s + Penggunaan ruang musik + Penggunaan perangkat + %1$d%% + <1% + %1$d lagu cloud + %1$d file tidak dapat dibaca + Jalur Pemutaran + %1$d frame per buffer + PCM Float Hi-Fi + Jalur output float 32-bit + Memori + tersedia dari %1$s + Format siap offload + Tidak ada format terkompresi yang dilaporkan mendukung offload perangkat keras. + Output terdeteksi + Tidak ada jalur output yang dilaporkan oleh Android. + %1$s renderer + Kompatibilitas Format + %1$d trek didukung + %1$d format tidak diketahui + Tidak ada dekoder tersedia + Dekoder perangkat keras + Dekoder perangkat lunak + Offload + %1$d di perpustakaan + Hasil Analisis Kompatibilitas + Tidak ada ketidakcocokan besar + Trek yang diindeks sesuai dengan dekoder yang dilaporkan Android pada perangkat ini. + %1$d trek mungkin tidak dapat diputar secara native + Format yang perlu ditinjau: %1$s. + %1$d trek lokal mungkin akan di-resample + Perpustakaan mencapai %1$d Hz, melebihi sample rate output saat ini. + %1$d trek memiliki metadata tidak diketahui + Pemindaian ulang penuh perpustakaan dapat melengkapi data MIME, bitrate, dan sample rate yang hilang. + +%1$d lainnya + Output bawaan + Audio Bluetooth + Audio USB + Audio kabel + Output digital + Output lainnya Input Output Pemikiran diff --git a/app/src/main/res/values-zh-rCN/strings_presentation_batch_g.xml b/app/src/main/res/values-zh-rCN/strings_presentation_batch_g.xml index ef5c21ace..3c3160d20 100644 --- a/app/src/main/res/values-zh-rCN/strings_presentation_batch_g.xml +++ b/app/src/main/res/values-zh-rCN/strings_presentation_batch_g.xml @@ -110,6 +110,59 @@ Android 版本 SDK 版本 硬件 + 此设备 + -- + 可播放 + 播放需要检查 + 格式 + 硬件解码器 + 本地歌曲 + 本地音乐存储 + 音乐大小 + %1$d 首本地歌曲 + 可用 + 总计 %1$s + 音乐占用空间 + 设备已用 + %1$d%% + <1% + %1$d 首云端歌曲 + %1$d 个文件无法读取 + 播放路径 + %1$d 帧/缓冲区 + Hi-Fi PCM 浮点 + 32 位浮点输出路径 + 内存 + 可用 / 总计 %1$s + 支持卸载的格式 + 没有压缩格式报告支持硬件卸载。 + 检测到的输出 + Android 未报告任何输出路径。 + %1$s 个渲染器 + 格式兼容性 + %1$d 个受支持的音轨 + %1$d 个未知格式 + 未报告解码器 + 硬件解码器 + 软件解码器 + 卸载 + 库中 %1$d 个 + 兼容性分析结果 + 没有重大不兼容 + 已索引的音轨与此设备上 Android 报告的解码器匹配。 + %1$d 个音轨可能无法原生解码 + 需要检查的格式:%1$s。 + %1$d 个本地音轨可能会被重新采样 + 媒体库达到 %1$d Hz,高于当前输出采样率。 + %1$d 个音轨的元数据未知 + 重新扫描整个媒体库可以补全缺失的 MIME、比特率和采样率数据。 + +%1$d 更多 + 内置输出 + 蓝牙音频 + USB 音频 + 有线音频 + 数字输出 + 其他输出 输入 输出 思考