mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-11-04 10:13:59 +00:00
Merge 73bac7b04c into 35700920b4
This commit is contained in:
commit
00d6be2084
@ -17,5 +17,57 @@
|
||||
|
||||
package org.spotube.spotube.core.discovery
|
||||
|
||||
// The OS's own service-discovery stack advertises on the right network.
|
||||
internal actual fun preferredAdvertiseAddress(): String? = null
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.os.Build
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.qualifier.named
|
||||
|
||||
// Interface name prefixes for container/VM/VPN links other LAN devices can't reach.
|
||||
private val VirtualInterfacePrefixes = listOf(
|
||||
"docker", "br-", "veth", "virbr", "vmnet", "vboxnet", "cni", "flannel", "podman",
|
||||
"lxc", "lxd", "tun", "tap", "wg", "zt", "tailscale", "utun", "ham"
|
||||
)
|
||||
|
||||
/**
|
||||
* Returns the preferred IPv4 address for mDNS advertisement on Android.
|
||||
* Tries to use the Wi-Fi interface IP address since mDNS typically works on LAN.
|
||||
*/
|
||||
internal actual fun preferredAdvertiseAddress(): String? = runCatching {
|
||||
val context = android.app.ActivityThread.currentApplication().applicationContext
|
||||
val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
|
||||
// Get the active network
|
||||
val activeNetwork = connectivityManager.activeNetwork ?: return null
|
||||
val networkCapabilities = connectivityManager.getNetworkCapabilities(activeNetwork) ?: return null
|
||||
|
||||
// Prefer Wi-Fi for mDNS (local network)
|
||||
if (!networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Get the link addresses for the active network
|
||||
val linkAddresses = connectivityManager.getLinkAddresses(activeNetwork)
|
||||
return linkAddresses.firstOrNull { address ->
|
||||
val inetAddress = address.address
|
||||
inetAddress is java.net.Inet4Address && inetAddress.isSiteLocalAddress
|
||||
}?.address?.hostAddress
|
||||
}.getOrNull() ?: runCatching {
|
||||
// Fallback: try to get the first non-loopback, non-virtual interface address
|
||||
java.net.NetworkInterface.getNetworkInterfaces().toList()
|
||||
.filter { iface ->
|
||||
iface.isUp && !iface.isLoopback && !iface.isVirtual && !iface.isPointToPoint &&
|
||||
VirtualInterfacePrefixes.none { iface.name.startsWith(it) }
|
||||
}
|
||||
.flatMap { it.inetAddresses.toList() }
|
||||
.firstOrNull { it is java.net.Inet4Address && it.isSiteLocalAddress }
|
||||
?.hostAddress
|
||||
}.getOrNull()
|
||||
|
||||
// Interface name prefixes for container/VM/VPN links other LAN devices can't reach.
|
||||
private val VirtualInterfacePrefixes = listOf(
|
||||
"docker", "br-", "veth", "virbr", "vmnet", "vboxnet", "cni", "flannel", "podman",
|
||||
"lxc", "lxd", "tun", "tap", "wg", "zt", "tailscale", "utun", "ham"
|
||||
)
|
||||
@ -0,0 +1,374 @@
|
||||
<!--
|
||||
~ Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
~
|
||||
~ This program is free software: you can redistribute it and/or modify
|
||||
~ it under the terms of the GNU Affero General Public License as published by
|
||||
~ the Free Software Foundation, either version 3 of the License, or
|
||||
~ (at your option) any later version.
|
||||
~
|
||||
~ This program is distributed in the hope that it will be useful,
|
||||
~ but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
~ GNU Affero General Public License for more details.
|
||||
~
|
||||
~ You should have received a copy of the GNU Affero General Public License
|
||||
~ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
~
|
||||
~ 文言文本地化 / Classical Chinese Localization
|
||||
~ Locale: lzh (ISO 639-3 Literary Chinese)
|
||||
~ 採漢制雅言,去白話語法,用古本單音詞組雙音、虛詞得體
|
||||
~ 造詞法:動賓(播樂)、主謂(失聯)、偏正(新造)、聯綴(循環)
|
||||
~ 虛詞:之(屬/代賓)、於(介)、以(介/連)、乃(判)、故(因)、矣(完)、哉(嘆)、也(判)、乎(疑)、焉(於+此)
|
||||
~ 禁白話:的、是、在、有、這、那、麼、們、著、了、過、給、被、把、讓
|
||||
-->
|
||||
|
||||
<resources>
|
||||
<!-- 佇列 -->
|
||||
<string name="queue_title">佇列</string>
|
||||
<string name="queue_clear_queue">盡除佇列</string>
|
||||
<string name="queue_filter_queue">篩佇列</string>
|
||||
<string name="queue_no_queue_entries">佇列空虛</string>
|
||||
|
||||
<string name="search_placeholder">搜樂曲、藝人、專輯</string>
|
||||
<string name="search_clear_history">盡削往歷</string>
|
||||
<string name="search_filter_placeholder">搜尋</string>
|
||||
|
||||
<!-- 藏書樓 -->
|
||||
<string name="library_screen_title">藏書樓</string>
|
||||
<string name="library_toolbar_compact_title">藏書</string>
|
||||
<string name="library_tab_playlists">歌選</string>
|
||||
<string name="library_tab_albums">專輯</string>
|
||||
<string name="library_tab_artists">藝人</string>
|
||||
<string name="library_tab_local_tracks">本地樂</string>
|
||||
<string name="library_tab_downloads">所獲</string>
|
||||
<string name="library_filter_playlists">覓已藏歌選</string>
|
||||
<string name="library_filter_albums">覓已藏專輯</string>
|
||||
<string name="library_filter_artists">覓已藏藝人</string>
|
||||
<string name="library_filter_local_tracks">覓資料夾或樂曲</string>
|
||||
<string name="library_action_search">篩藏書</string>
|
||||
<string name="library_action_close_search">罷篩</string>
|
||||
<string name="library_action_create_playlist">制歌選</string>
|
||||
<string name="library_action_refresh_local_tracks">新本地樂</string>
|
||||
<string name="library_action_grid_view">改格視</string>
|
||||
<string name="library_action_list_view">改列視</string>
|
||||
<string name="library_playlists_empty">無藏歌選</string>
|
||||
<string name="library_playlists_no_results">無歌選合「%1$s」</string>
|
||||
<string name="library_albums_empty">無藏專輯</string>
|
||||
<string name="library_albums_no_results">無專輯合「%1$s」</string>
|
||||
<string name="library_artists_empty">無藏藝人</string>
|
||||
<string name="library_artists_no_results">無藝人合「%1$s」</string>
|
||||
|
||||
<!-- 本地媒體 -->
|
||||
<string name="library_local_folders">資料夾</string>
|
||||
<string name="library_local_folder">資料夾</string>
|
||||
<string name="library_local_summary">%1$s · %2$s</string>
|
||||
<plurals name="library_local_tracks_count">
|
||||
<item quantity="one">%1$d 曲</item>
|
||||
<item quantity="other">%1$d 曲</item>
|
||||
</plurals>
|
||||
<plurals name="library_local_folders_count">
|
||||
<item quantity="one">%1$d 夾</item>
|
||||
<item quantity="other">%1$d 夾</item>
|
||||
</plurals>
|
||||
<string name="library_local_back">返夾</string>
|
||||
<string name="library_local_play_folder">播夾內樂</string>
|
||||
<string name="library_local_permission_title">須音訊權</string>
|
||||
<string name="library_local_permission_description">允音訊權,以覽器中樂。</string>
|
||||
<string name="library_local_grant_access">予權</string>
|
||||
<string name="library_local_empty_folders">無本地夾</string>
|
||||
<string name="library_local_empty_folders_description">增含樂之夾,即可於此播。無需外掛。</string>
|
||||
<string name="library_local_add_folder">增夾</string>
|
||||
<string name="library_local_empty_tracks">此夾無曲</string>
|
||||
<string name="library_local_no_results">無合之夾或曲</string>
|
||||
<string name="library_local_refresh_failed">新本地媒體失</string>
|
||||
|
||||
<!-- 下載 -->
|
||||
<string name="downloads_summary">%1$d 行 · %2$d 成</string>
|
||||
<string name="downloads_saved_to">藏於 %1$s</string>
|
||||
<string name="downloads_clear_completed">削已成</string>
|
||||
<string name="downloads_empty">未獲</string>
|
||||
<string name="downloads_status_downloading">獲取中 %1$d%</string>
|
||||
<string name="downloads_status_queued">候獲</string>
|
||||
<string name="downloads_status_completed">已成</string>
|
||||
<string name="downloads_status_cancelled">已止</string>
|
||||
<string name="downloads_status_failed">失</string>
|
||||
<string name="downloads_status_failed_error">失:%1$s</string>
|
||||
<string name="downloads_action_cancel">止獲</string>
|
||||
<string name="downloads_action_retry">重獲</string>
|
||||
<string name="downloads_action_remove">去獲</string>
|
||||
|
||||
<!-- 樂曲共用 -->
|
||||
<string name="media_artist">藝人</string>
|
||||
<string name="media_blacklisted">已屏</string>
|
||||
<string name="media_liked_tracks">好樂</string>
|
||||
<string name="media_saved_songs">藏曲</string>
|
||||
<string name="media_unknown_artist">無名氏</string>
|
||||
<string name="media_preview_title">曲名</string>
|
||||
<string name="media_preview_subtitle">藝名</string>
|
||||
|
||||
<string name="accessibility_selected">已選</string>
|
||||
|
||||
<string name="settings_screen_title">設定</string>
|
||||
|
||||
<string name="settings_section_plugins">外掛</string>
|
||||
<string name="settings_section_language_region">語言地域</string>
|
||||
<string name="settings_section_appearance">儀制</string>
|
||||
<string name="settings_section_playback">播放</string>
|
||||
<string name="settings_section_downloads">下載</string>
|
||||
<string name="settings_section_caching">緩存</string>
|
||||
<string name="settings_section_desktop">桌面</string>
|
||||
<string name="settings_section_updates">更新</string>
|
||||
<string name="settings_section_support">贊助</string>
|
||||
<string name="settings_section_about">關於</string>
|
||||
|
||||
<string name="common_close">閉</string>
|
||||
<string name="common_save">存</string>
|
||||
<string name="common_cancel">止</string>
|
||||
|
||||
<string name="settings_language_title">語言</string>
|
||||
<string name="settings_language_subtitle_current">介面語言。現用:%1$s</string>
|
||||
<string name="settings_language_system_default">從制</string>
|
||||
<string name="settings_country_title">地域</string>
|
||||
<string name="settings_country_subtitle_current">內容地域。現用:%1$s</string>
|
||||
<string name="settings_option_name_and_code">%1$s (%2$s)</string>
|
||||
|
||||
<string name="settings_theme_title">主題</string>
|
||||
<string name="settings_theme_subtitle_current">Spotube 從制外觀。現用:%1$s</string>
|
||||
<string name="settings_theme_light">明</string>
|
||||
<string name="settings_theme_dark">暗</string>
|
||||
<string name="settings_theme_system">制</string>
|
||||
|
||||
<string name="settings_surface_theme_title">面材</string>
|
||||
<string name="settings_surface_theme_subtitle_current">程式底材。現用:%1$s</string>
|
||||
<string name="settings_surface_watery">水態</string>
|
||||
<string name="settings_surface_icy">冰態</string>
|
||||
<string name="settings_surface_chrome">鍍鉻</string>
|
||||
<string name="settings_surface_acrylic">亞克力</string>
|
||||
<string name="settings_surface_glass">玻璃</string>
|
||||
|
||||
<string name="settings_accent_title">強調色</string>
|
||||
<string name="settings_accent_subtitle_current">現用調色盤:%1$s</string>
|
||||
<string name="settings_accent_dialog_title">強調色</string>
|
||||
<string name="settings_accent_dialog_description">選調色盤,明暗雙題預覽。</string>
|
||||
<string name="settings_preview_light">明題</string>
|
||||
<string name="settings_preview_dark">暗題</string>
|
||||
<string name="settings_preview_label">預覽</string>
|
||||
<string name="settings_preview_button">按鈕</string>
|
||||
<string name="settings_accent_green_goblin">綠鬼</string>
|
||||
<string name="settings_accent_electric_violet">電紫</string>
|
||||
<string name="settings_accent_oceanic_cyan">海青</string>
|
||||
<string name="settings_accent_sunset_orange">暮橙</string>
|
||||
<string name="settings_accent_rose_garden">薔薇園</string>
|
||||
<string name="settings_accent_midnight_blue">夜藍</string>
|
||||
<string name="settings_accent_metallic_slate">金石</string>
|
||||
|
||||
<string name="settings_appearance_title">觀感</string>
|
||||
<string name="settings_appearance_subtitle_current">面材與強調色。現用:%1$s · %2$s</string>
|
||||
<string name="settings_appearance_dialog_title">觀感</string>
|
||||
<string name="settings_appearance_dialog_description">選面材,再選配之強調色。皆精選組合。</string>
|
||||
<string name="settings_appearance_surface_label">面材</string>
|
||||
<string name="settings_appearance_accent_label">強調色</string>
|
||||
<string name="settings_preview_secondary">次要</string>
|
||||
|
||||
<string name="settings_palette_title">調色盤</string>
|
||||
<string name="settings_palette_subtitle_current">千禧強調色盤。現用:%1$s</string>
|
||||
<string name="settings_palette_dialog_title">調色盤</string>
|
||||
<string name="settings_palette_dialog_description">選千禧強調色盤,明暗預覽。</string>
|
||||
<string name="settings_palette_label">調色盤</string>
|
||||
<string name="settings_appearance_subtitle_palette">千禧色盤與強調色。現用:%1$s</string>
|
||||
<string name="settings_palette_blue">青</string>
|
||||
<string name="settings_palette_orange">橙</string>
|
||||
<string name="settings_palette_lime">檸檬綠</string>
|
||||
|
||||
<string name="settings_download_folder_title">下載資料夾覆寫</string>
|
||||
<string name="settings_download_folder_current">現用夾:%1$s</string>
|
||||
<string name="settings_download_folder_default_path">現用夾:%1$s (預設)</string>
|
||||
<string name="settings_download_folder_description">留空則用預設樂資料夾。</string>
|
||||
<string name="settings_download_folder_placeholder">C:\Music\Spotube</string>
|
||||
<string name="settings_local_media_folders_title">本地媒體資料夾</string>
|
||||
<string name="settings_local_media_folders_subtitle_current">桌面自訂掃描夾。已增:%1$d</string>
|
||||
<string name="settings_local_media_folders_subtitle_empty">未增自訂夾</string>
|
||||
<string name="settings_local_media_folders_description">本地樂庫自訂夾。</string>
|
||||
<string name="settings_local_media_folders_manage">管理</string>
|
||||
<string name="settings_local_media_folders_add_action">增夾</string>
|
||||
<string name="settings_local_media_folders_remove_action">去夾</string>
|
||||
<string name="settings_local_media_folders_none_added">未增自訂夾。</string>
|
||||
<string name="settings_local_media_folders_remove_named">去掃描列表 %1$s</string>
|
||||
<string name="settings_local_media_folders_automatic_both">下載與緩存夾自動包含。</string>
|
||||
<string name="settings_local_media_folders_automatic_downloads">下載夾自動包含。</string>
|
||||
<string name="settings_local_media_folders_automatic_cache">樂緩存夾自動包含。</string>
|
||||
<string name="settings_local_media_folders_saving">存檔中</string>
|
||||
<string name="settings_local_media_folders_save_error">存夾失。變更仍在;請重試。</string>
|
||||
<plurals name="settings_local_media_folders_count">
|
||||
<item quantity="one">%1$d 夾</item>
|
||||
<item quantity="other">%1$d 夾</item>
|
||||
</plurals>
|
||||
<string name="settings_download_format_title">下載樂格式</string>
|
||||
<string name="settings_download_format_subtitle_current">下載偏好編碼與容器。現用:%1$s</string>
|
||||
<string name="settings_download_quality_title">下載樂音質</string>
|
||||
|
||||
<string name="settings_streaming_format_title">串流樂格式</string>
|
||||
<string name="settings_streaming_format_subtitle_current">串流偏好編碼與容器。現用:%1$s</string>
|
||||
<string name="settings_streaming_quality_title">串流樂音質</string>
|
||||
<string name="settings_subtitle_current">現用:%1$s</string>
|
||||
<string name="settings_enable_music_caching_title">啟樂緩存</string>
|
||||
<string name="settings_enable_music_caching_subtitle">存串流音訊於本地,以速再播。</string>
|
||||
<string name="settings_cache_folder_title">緩存夾</string>
|
||||
<string name="settings_cache_folder_current">現用:%1$s</string>
|
||||
<string name="settings_cache_folder_default">現用:預設應用緩存</string>
|
||||
<string name="settings_cache_size_limit_title">緩存大小限</string>
|
||||
<string name="settings_cache_size_limit_unlimited">無限</string>
|
||||
<string name="settings_cache_size_limit_current">現限:%1$d MB</string>
|
||||
<string name="settings_cache_size_limit_description">設緩存最大 MB。空或 0 為無限。</string>
|
||||
<string name="settings_cache_size_limit_placeholder">500</string>
|
||||
<string name="settings_error_cache_size_range">大小須為 0 或正數。</string>
|
||||
<string name="settings_enable_endless_playback_title">啟無盡播放</string>
|
||||
<string name="settings_enable_endless_playback_subtitle">佇列盡時,以推薦續播。</string>
|
||||
<string name="settings_playback_port_title">播放代理伺服器埠</string>
|
||||
<string name="settings_allow_remote_control_title">允遠端控制</string>
|
||||
<string name="settings_allow_remote_control_subtitle">同網裝置連線時,得控制播放。</string>
|
||||
<string name="settings_remote_device_name_title">遠端控制裝置名</string>
|
||||
<string name="settings_remote_device_name_subtitle">他裝置發現時顯示名。現用:%1$s</string>
|
||||
<string name="settings_remote_device_name_description">為網上他裝置發現本 Spotube 時顯示之友好名。</string>
|
||||
<string name="settings_remote_device_name_placeholder">如:客廳 Spotube</string>
|
||||
<string name="settings_remote_device_name_default">裝置主機名</string>
|
||||
<string name="settings_playback_port_subtitle_current">播放代理伺服器埠。現用:%1$d</string>
|
||||
<string name="settings_playback_port_description">選 1 至 65535 間埠。</string>
|
||||
<string name="settings_playback_port_placeholder">14769</string>
|
||||
<string name="settings_error_whole_number">請輸整數。</string>
|
||||
<string name="settings_error_port_range">埠須在 1 至 65535 間。</string>
|
||||
|
||||
<string name="settings_blacklist_title">屏蔽</string>
|
||||
<string name="settings_blacklist_subtitle">管屏蔽曲與藝人</string>
|
||||
|
||||
<string name="settings_desktop_minimize_title">縮至盤</string>
|
||||
<string name="settings_desktop_minimize_subtitle">窗最小時,留 Spotube 於系統盤運行。</string>
|
||||
<string name="settings_desktop_discord_title">Discord 富在線</string>
|
||||
<string name="settings_desktop_discord_subtitle">桌面上於 Discord 顯示播放狀態。</string>
|
||||
|
||||
<string name="settings_updates_auto_check_title">自動檢更新</string>
|
||||
<string name="settings_updates_auto_check_subtitle">背景尋新 Spotube 版本。</string>
|
||||
|
||||
<string name="settings_plugins_manage_title">管外掛</string>
|
||||
<string name="settings_plugins_manage_subtitle">管你的外掛</string>
|
||||
<string name="settings_plugins_default_ability_title">預設 %1$s 外掛</string>
|
||||
<string name="settings_plugins_ability_metadata">元數據</string>
|
||||
<string name="settings_plugins_ability_audio">音訊</string>
|
||||
<string name="settings_plugins_ability_lyrics">歌詞</string>
|
||||
<string name="settings_plugins_ability_scrobble">記聽</string>
|
||||
<string name="settings_plugins_no_selection">未選外掛</string>
|
||||
<string name="settings_plugins_action_change">換</string>
|
||||
<string name="settings_plugins_action_select">選</string>
|
||||
<string name="settings_plugins_selected">已選</string>
|
||||
<string name="settings_plugins_clear">清選</string>
|
||||
<string name="settings_plugins_no_plugins">無可用外掛</string>
|
||||
<string name="settings_plugins_plugin_content_description">%1$s 外掛</string>
|
||||
<string name="settings_plugins_clear_content_description">清</string>
|
||||
|
||||
<string name="plugin_configure_title">配置</string>
|
||||
<string name="plugin_screen_title">外掛管理</string>
|
||||
<string name="plugin_error_enter_url">請輸網址</string>
|
||||
<string name="plugin_error_url_scheme">網址須以 http:// 或 https:// 始</string>
|
||||
<string name="plugin_error_download_failed">下載外掛失</string>
|
||||
<string name="plugin_error_invalid_plugin_json">plugin.json 格式無效:%1$s</string>
|
||||
<string name="plugin_error_read_failed">讀外掛失:%1$s</string>
|
||||
<string name="plugin_error_install_failed">裝外掛失:%1$s</string>
|
||||
<string name="plugin_error_update_needs_confirmation">%1$s 有新版。確認更新前裝之。</string>
|
||||
<string name="plugin_error_replace_needs_confirmation">%1$s 已裝。確認替換前裝之。</string>
|
||||
<string name="plugin_error_builtin_add">內建外掛已含,不可重增。</string>
|
||||
<string name="plugin_error_builtin_remove">內建外掛不可卸。</string>
|
||||
<string name="plugin_error_api_version_invalid">%1$s 宣稱無效 apiVersion (%2$s)。期與 %3$s 相容之語義版本。</string>
|
||||
<string name="plugin_error_app_api_version_invalid">Spotube 外掛 API 版本 %1$s 無效。</string>
|
||||
<string name="plugin_error_api_incompatible">%1$s 針對外掛 API %2$s,但 Spotube 支援破壞性 API %3$s。裝針對同破壞性 API 版本之外掛。</string>
|
||||
|
||||
<!-- 外掛安裝提示 -->
|
||||
<string name="plugin_prompt_incompatible_title">外掛 API 不相容</string>
|
||||
<string name="plugin_prompt_install_title">裝外掛?</string>
|
||||
<string name="plugin_prompt_install_message">%1$s 將增入你的已裝外掛。</string>
|
||||
<string name="plugin_prompt_update_title">更新外掛?</string>
|
||||
<string name="plugin_prompt_update_message">舊版已裝 (%1$s)。更新至 %2$s?</string>
|
||||
<string name="plugin_prompt_replace_title">替換已裝外掛?</string>
|
||||
<string name="plugin_prompt_replace_invalid_incoming">%1$s 採無效語義版本 (%2$s),不可作更新比較。仍替換?</string>
|
||||
<string name="plugin_prompt_replace_invalid_installed">已裝版 (%1$s) 不可用語義版本比較。以 %2$s 替換?</string>
|
||||
<string name="plugin_prompt_replace_same_version">版本 %1$s 已裝。仍以提供副本替換?</string>
|
||||
<string name="plugin_prompt_replace_downgrade">已裝版 %1$s 新於提供版 %2$s。仍替換?</string>
|
||||
<string name="plugin_prompt_replace_generic">以提供副本替換已裝外掛?</string>
|
||||
|
||||
<!-- 外掛詳情對話框 -->
|
||||
<string name="plugin_info_label_format">%1$s:</string>
|
||||
<string name="plugin_info_version">版本</string>
|
||||
<string name="plugin_info_api_version">API 版本</string>
|
||||
<string name="plugin_info_author">作者</string>
|
||||
<string name="plugin_info_license">授權</string>
|
||||
<string name="plugin_info_capabilities">能力</string>
|
||||
<string name="plugin_info_abilities">能</string>
|
||||
<string name="plugin_info_repository">倉庫</string>
|
||||
<string name="plugin_info_contact">聯繫</string>
|
||||
<string name="plugin_info_report_bugs">報錯</string>
|
||||
<string name="plugin_support_dialog_title">贊助 %1$s</string>
|
||||
|
||||
<!-- 外掛發現 -->
|
||||
<string name="plugin_discover_badge_official">官方</string>
|
||||
<string name="plugin_discover_badge_verified">已驗</string>
|
||||
<string name="plugin_discover_stars_content_description">GitHub 星</string>
|
||||
<string name="plugin_discover_repository_content_description">GitHub 倉庫網址</string>
|
||||
<string name="plugin_discover_repository_host">github.com</string>
|
||||
|
||||
<!-- 外掛版本安裝對話框 -->
|
||||
<string name="plugin_install_no_releases">無發現釋出</string>
|
||||
<string name="plugin_install_select_release_label">選釋出</string>
|
||||
<string name="plugin_install_select_release_placeholder">選一釋出</string>
|
||||
<string name="plugin_install_prerelease">預發布</string>
|
||||
<string name="plugin_install_release_notes">釋出說明</string>
|
||||
|
||||
<!-- 應用導航 -->
|
||||
<string name="nav_home">首頁</string>
|
||||
<string name="nav_search">搜尋</string>
|
||||
<string name="nav_group_on_device">本機</string>
|
||||
<string name="nav_group_connect">連線</string>
|
||||
<string name="nav_devices">裝置</string>
|
||||
<plurals name="connect_jam_hosting">
|
||||
<item quantity="one">主持 · %1$d 聽者</item>
|
||||
<item quantity="other">主持 · %1$d 聽者</item>
|
||||
</plurals>
|
||||
<plurals name="connect_jam_joined">
|
||||
<item quantity="one">入群聽 · %1$d 聽者</item>
|
||||
<item quantity="other">入群聽 · %1$d 聽者</item>
|
||||
</plurals>
|
||||
|
||||
<!-- 系統盤 -->
|
||||
<string name="tray_hide_window">隱窗</string>
|
||||
<string name="tray_show_window">顯窗</string>
|
||||
<string name="tray_next_track">次曲</string>
|
||||
<string name="tray_previous_track">前曲</string>
|
||||
<string name="tray_shuffle">亂序</string>
|
||||
<string name="tray_loop">循環</string>
|
||||
<string name="tray_loop_off">循環:關</string>
|
||||
<string name="tray_loop_one">循環:單</string>
|
||||
<string name="tray_loop_all">循環:全</string>
|
||||
<string name="tray_volume">音量:%1$d%</string>
|
||||
<string name="tray_volume_up">音量 +</string>
|
||||
<string name="tray_volume_down">音量 -</string>
|
||||
<string name="tray_mute">靜音</string>
|
||||
<string name="tray_unmute">取消靜音</string>
|
||||
<string name="tray_like_track">喜樂</string>
|
||||
<string name="tray_unlike_track">取消喜</string>
|
||||
<string name="tray_exit">退出</string>
|
||||
|
||||
<!-- 桌面窗口控制 -->
|
||||
<string name="app_window_minimize">最小化</string>
|
||||
<string name="app_window_maximize">最大化</string>
|
||||
<string name="app_window_restore">還原</string>
|
||||
<string name="app_window_close">關閉</string>
|
||||
|
||||
<!-- Android 播放通知 -->
|
||||
<string name="notification_playback_channel_name">播放</string>
|
||||
<string name="notification_playback_channel_description">媒體播放控制</string>
|
||||
|
||||
<!-- Android Auto / 媒體瀏覽器 -->
|
||||
<string name="media_browser_browse">瀏覽</string>
|
||||
<string name="media_browser_now_playing">正播放</string>
|
||||
<string name="media_browser_featured">精選</string>
|
||||
<string name="media_browser_saved_tracks">藏曲</string>
|
||||
<string name="media_browser_top_tracks">熱曲</string>
|
||||
</resources>
|
||||
@ -45,6 +45,14 @@
|
||||
<string name="library_action_list_view">Switch to list view</string>
|
||||
<string name="library_playlists_empty">No saved playlists</string>
|
||||
<string name="library_playlists_no_results">No playlists match “%1$s”</string>
|
||||
<string name="library_local_playlists_empty">No local playlists yet</string>
|
||||
<string name="library_playlist_syncing">Syncing…</string>
|
||||
<string name="library_playlist_synced">Synced</string>
|
||||
<string name="library_playlist_sync_error">Sync failed</string>
|
||||
<string name="library_playlist_sync_now">Sync now</string>
|
||||
<string name="library_playlist_name_empty">Playlist name cannot be empty</string>
|
||||
<string name="library_playlist_name_exists">A playlist with this name already exists</string>
|
||||
<string name="library_playlist_not_found">Playlist not found</string>
|
||||
<string name="library_albums_empty">No saved albums</string>
|
||||
<string name="library_albums_no_results">No albums match “%1$s”</string>
|
||||
<string name="library_artists_empty">No saved artists</string>
|
||||
@ -74,6 +82,9 @@
|
||||
<string name="library_local_no_results">No matching folders or tracks</string>
|
||||
<string name="library_local_refresh_failed">Failed to refresh local media</string>
|
||||
|
||||
<string name="library_playlists_spotify_section">Spotify Playlists</string>
|
||||
<string name="library_local_playlists_section">Local Playlists</string>
|
||||
|
||||
<!-- Downloads -->
|
||||
<string name="downloads_summary">%1$d active • %2$d completed</string>
|
||||
<string name="downloads_saved_to">Saved to %1$s</string>
|
||||
|
||||
@ -135,7 +135,15 @@ val sharedModules = module {
|
||||
singleOf(::LocalMediaCacheRepository)
|
||||
singleOf(::LocalMediaFoldersConfig)
|
||||
singleOf(::LocalMediaLibraryCoordinator)
|
||||
// Library
|
||||
singleOf(::LibraryState)
|
||||
singleOf(::LibraryRepository)
|
||||
singleOf(::LocalMediaCacheRepository)
|
||||
singleOf(::LocalMediaFoldersConfig)
|
||||
singleOf(::LocalMediaLibraryCoordinator)
|
||||
single { LocalPlaylistRepository(get(), get(), get()) } // Local playlist repository with sync
|
||||
viewModelOf(::LibraryPlaylistsViewModel)
|
||||
viewModelOf(::LocalPlaylistViewModel) // Local playlist view model
|
||||
viewModelOf(::LibraryAlbumsViewModel)
|
||||
viewModelOf(::LibraryArtistsViewModel)
|
||||
viewModelOf(::LibraryLocalTracksViewModel)
|
||||
|
||||
@ -73,6 +73,7 @@ class RemoteControlService(
|
||||
// can reliably filter out this device's own advertisement.
|
||||
scope.launch {
|
||||
_localDeviceId.value = resolveDeviceId()
|
||||
log.i { "Local device ID resolved: ${_localDeviceId.value}" }
|
||||
}
|
||||
scope.launch {
|
||||
combine(
|
||||
@ -98,13 +99,16 @@ class RemoteControlService(
|
||||
/**
|
||||
* The service name this device advertises under, derived deterministically
|
||||
* from settings so discovery can match it against the local advertisement.
|
||||
* Always includes device ID suffix to avoid collisions between devices with
|
||||
* the same configured name.
|
||||
*/
|
||||
fun advertisedName(): String {
|
||||
val deviceId = _localDeviceId.value.ifBlank {
|
||||
settingsRepository.userSettings.value.remoteControlDeviceId
|
||||
}
|
||||
val configured = settingsRepository.userSettings.value.remoteControlDeviceName
|
||||
return configured.ifBlank { "Spotube-${deviceId.take(6)}" }
|
||||
val baseName = configured.ifBlank { "Spotube" }
|
||||
return "$baseName-${deviceId.take(6)}"
|
||||
}
|
||||
|
||||
/**
|
||||
@ -120,14 +124,15 @@ class RemoteControlService(
|
||||
scheduleCleanup(pendingService)
|
||||
pendingService = null
|
||||
registerJob = scope.launch {
|
||||
registerLoop(settings.remoteControlDeviceName, port)
|
||||
registerLoop(advertisedName(), port)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun registerLoop(name: String, port: Int) {
|
||||
val deviceId = resolveDeviceId()
|
||||
_localDeviceId.value = deviceId
|
||||
val serviceName = name.ifBlank { "Spotube-${deviceId.take(6)}" }
|
||||
// Always use advertisedName() which includes device ID to prevent collisions
|
||||
val serviceName = advertisedName()
|
||||
|
||||
var attempt = 0
|
||||
while (advertisedService == null && coroutineContext.isActive) {
|
||||
|
||||
@ -207,16 +207,17 @@ class DevicesViewModel(
|
||||
/**
|
||||
* True when [device] is this device's own advertisement. On the initial
|
||||
* `Discovered` event dns-sd hasn't resolved the TXT record yet (deviceId is
|
||||
* empty), so we match by the name we advertise; once resolved we also have
|
||||
* the authoritative deviceId.
|
||||
* empty), so we match by the name we advertise (which now always includes
|
||||
* our device ID suffix); once resolved we also have the authoritative deviceId.
|
||||
*/
|
||||
private fun isSelf(device: DiscoveredDevice): Boolean {
|
||||
val localId = remoteControlService.localDeviceId.value.ifBlank {
|
||||
settingsProvider.settingsState.value?.remoteControlDeviceId ?: ""
|
||||
}
|
||||
if (localId.isNotBlank() && device.deviceId == localId) return true
|
||||
// Match by the deterministic advertised name as a fallback for the
|
||||
// pre-resolution event where deviceId isn't available yet.
|
||||
// Match by the deterministic advertised name (which now always includes
|
||||
// our device ID suffix) as a fallback for the pre-resolution event
|
||||
// where deviceId isn't available yet.
|
||||
return device.name.isNotBlank() && device.name == remoteControlService.advertisedName()
|
||||
}
|
||||
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
*/
|
||||
package org.spotube.spotube.modules.library.playlist
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@ -14,6 +15,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.composeunstyled.Text
|
||||
import org.spotube.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist
|
||||
import org.spotube.spotube.modules.library.playlist.LocalPlaylistViewModel
|
||||
import org.spotube.spotube.core.ui.component.ErrorDisplay
|
||||
import org.spotube.spotube.core.ui.component.LikedTracksCard
|
||||
import org.spotube.spotube.core.ui.component.PlaylistCard
|
||||
@ -24,14 +26,48 @@ import org.jetbrains.compose.resources.stringResource
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import spotube.composeapp.generated.resources.*
|
||||
|
||||
import org.spotube.millennium.composables.MillenniumIconButton
|
||||
import org.spotube.millennium.composables.MillenniumIconButtonStyle
|
||||
import org.spotube.millennium.composables.MillenniumButton
|
||||
import org.spotube.millennium.composables.MillenniumButtonStyle
|
||||
import org.spotube.millennium.composables.MillenniumIcon
|
||||
import org.spotube.millennium.composables.MillenniumSpokeSpinner
|
||||
import org.spotube.millennium.theme.MillenniumTheme
|
||||
import org.spotube.spotube.modules.library.playlist.LocalPlaylistRepository
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.composeunstyled.Text
|
||||
import com.composeunstyled.Typography
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import spotube.composeapp.generated.resources.Res
|
||||
import spotube.composeapp.generated.resources.library_local_playlists_section
|
||||
|
||||
@Composable
|
||||
fun LibraryPlaylistsScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
showCreatePlaylist: Boolean = false,
|
||||
viewMode: LibraryViewMode = LibraryViewMode.Grid,
|
||||
viewModel: LibraryPlaylistsViewModel = koinViewModel()
|
||||
viewModel: LibraryPlaylistsViewModel = koinViewModel(),
|
||||
localPlaylistViewModel: LocalPlaylistViewModel = koinViewModel()
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val localState by localPlaylistViewModel.uiState.collectAsStateWithLifecycle()
|
||||
val playlistFormState by viewModel.playlistFormState.collectAsStateWithLifecycle()
|
||||
Column(modifier) {
|
||||
when (val current = state) {
|
||||
@ -44,10 +80,18 @@ fun LibraryPlaylistsScreen(
|
||||
|
||||
else -> {
|
||||
val data = current as? LibraryPlaylistsState.Data
|
||||
if (data != null && data.items.isEmpty() && current !is LibraryPlaylistsState.Data.LoadingMore) {
|
||||
val localData = localState as? LocalPlaylistScreenState.Data
|
||||
val spotifyPlaylists = data?.items.orEmpty()
|
||||
val localPlaylists = localData?.playlists.orEmpty()
|
||||
val allPlaylists = spotifyPlaylists + localPlaylists
|
||||
val hasPlaylists = allPlaylists.isNotEmpty()
|
||||
val isLoading = current is LibraryPlaylistsState.Loading || localState is LocalPlaylistScreenState.Loading
|
||||
val syncState = localData?.syncState ?: LocalPlaylistRepository.SyncState.Idle
|
||||
|
||||
if (!hasPlaylists && !isLoading) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
if (data.query.isNotBlank()) stringResource(
|
||||
if (data?.query.isNotBlank() == true) stringResource(
|
||||
Res.string.library_playlists_no_results,
|
||||
data.query
|
||||
)
|
||||
@ -55,33 +99,170 @@ fun LibraryPlaylistsScreen(
|
||||
)
|
||||
}
|
||||
} else {
|
||||
LibraryMediaCollection<MetadataPlaylist>(
|
||||
items = data?.items.orEmpty(), itemKey = { it.id }, viewMode = viewMode,
|
||||
loading = current is LibraryPlaylistsState.Loading,
|
||||
loadingMore = current is LibraryPlaylistsState.Data.LoadingMore,
|
||||
onViewportChanged = viewModel::onItemsVisible,
|
||||
header = { list ->
|
||||
LikedTracksCard(
|
||||
Modifier.fillMaxWidth(),
|
||||
listMode = list
|
||||
)
|
||||
},
|
||||
itemContent = { item, list ->
|
||||
PlaylistCard(
|
||||
item,
|
||||
Modifier.fillMaxWidth(),
|
||||
listMode = list
|
||||
)
|
||||
},
|
||||
skeleton = { list ->
|
||||
PlayableCard(
|
||||
stringResource(Res.string.media_preview_title),
|
||||
stringResource(Res.string.media_preview_subtitle),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
listMode = list
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
// Spotify Playlists Section
|
||||
if (spotifyPlaylists.isNotEmpty() || data?.query.isBlank() == true) {
|
||||
LibraryMediaCollection<MetadataPlaylist>(
|
||||
items = spotifyPlaylists, itemKey = { it.id }, viewMode = viewMode,
|
||||
loading = current is LibraryPlaylistsState.Loading,
|
||||
loadingMore = current is LibraryPlaylistsState.Data.LoadingMore,
|
||||
onViewportChanged = viewModel::onItemsVisible,
|
||||
header = { list ->
|
||||
Column(Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
stringResource(Res.string.library_playlists_spotify_section),
|
||||
style = com.composeunstyled.Typography.h6
|
||||
)
|
||||
if (listMode == LibraryViewMode.Grid) {
|
||||
// Show create playlist button in header for Spotify playlists
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
itemContent = { item, list ->
|
||||
PlaylistCard(
|
||||
item,
|
||||
Modifier.fillMaxWidth(),
|
||||
listMode = list
|
||||
)
|
||||
},
|
||||
skeleton = { list ->
|
||||
PlayableCard(
|
||||
stringResource(Res.string.media_preview_title),
|
||||
stringResource(Res.string.media_preview_subtitle),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
listMode = list
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
// Local Playlists Section
|
||||
if (localPlaylists.isNotEmpty()) {
|
||||
LibraryMediaCollection<LocalPlaylist>(
|
||||
items = localPlaylists, itemKey = { it.id }, viewMode = viewMode,
|
||||
loading = localState is LocalPlaylistScreenState.Loading,
|
||||
loadingMore = false,
|
||||
onViewportChanged = { },
|
||||
header = { list ->
|
||||
Column(Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
stringResource(Res.string.library_local_playlists_section),
|
||||
style = com.composeunstyled.Typography.h6
|
||||
)
|
||||
// Sync button and status
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 4.dp),
|
||||
contentAlignment = Alignment.CenterEnd
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Sync status indicator
|
||||
when (syncState) {
|
||||
is LocalPlaylistRepository.SyncState.Syncing -> {
|
||||
MillenniumSpokeSpinner(size = 16.dp)
|
||||
Text(
|
||||
stringResource(Res.string.library_playlist_syncing),
|
||||
style = Typography.bodySmall,
|
||||
color = Color.Gray
|
||||
)
|
||||
}
|
||||
is LocalPlaylistRepository.SyncState.Success -> {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
MillenniumIcon(
|
||||
Iconsax.IconsaxTickSquare,
|
||||
contentDescription = null,
|
||||
tint = Color.Green,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
Text(
|
||||
stringResource(Res.string.library_playlist_synced),
|
||||
style = Typography.bodySmall,
|
||||
color = Color.Green
|
||||
)
|
||||
}
|
||||
}
|
||||
is LocalPlaylistRepository.SyncState.Error -> {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
MillenniumIcon(
|
||||
Iconsax.IconsaxWarning,
|
||||
contentDescription = null,
|
||||
tint = Color.Red,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
Text(
|
||||
stringResource(Res.string.library_playlist_sync_error),
|
||||
style = Typography.bodySmall,
|
||||
color = Color.Red
|
||||
)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
// Idle - show sync button
|
||||
MillenniumIconButton(
|
||||
onClick = { localPlaylistViewModel.syncNow() },
|
||||
enabled = true,
|
||||
style = MillenniumIconButtonStyle.Quiet
|
||||
) {
|
||||
MillenniumIcon(
|
||||
Iconsax.IconsaxRefreshRight,
|
||||
contentDescription = stringResource(Res.string.library_playlist_sync_now),
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Create playlist button
|
||||
MillenniumIconButton(
|
||||
icon = Iconsax.IconsaxAddSquare,
|
||||
contentDescription = stringResource(Res.string.library_action_create_playlist),
|
||||
onClick = { localPlaylistViewModel.showCreatePlaylistDialog() },
|
||||
style = MillenniumIconButtonStyle.Quiet
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
itemContent = { item, list ->
|
||||
LocalPlaylistCard(
|
||||
item,
|
||||
Modifier.fillMaxWidth(),
|
||||
listMode = list,
|
||||
onClick = { },
|
||||
onMoreClick = { localPlaylistViewModel.showEditPlaylistDialog(item) },
|
||||
onPlayClick = { }
|
||||
)
|
||||
},
|
||||
skeleton = { list ->
|
||||
PlayableCard(
|
||||
stringResource(Res.string.media_preview_title),
|
||||
stringResource(Res.string.media_preview_subtitle),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
listMode = list
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,110 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.spotube.spotube.modules.library.playlist
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.overflow.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.composeunstyled.Text
|
||||
import com.composeunstyled.Typography
|
||||
import org.spotube.millennium.composables.MillenniumButton
|
||||
import org.spotube.millennium.composables.MillenniumButtonStyle
|
||||
import org.spotube.millennium.composables.MillenniumIconButton
|
||||
import org.spotube.millennium.composables.MillenniumIconButtonStyle
|
||||
import org.spotube.spotube.core.audioplayer.AudioPlayerInterface
|
||||
import org.spotube.spotube.core.audioplayer.AudioPlayerQueue
|
||||
import org.spotube.spotube.core.audioplayer.QueueEntry
|
||||
import org.spotube.spotube.core.audioplayer.PlayerState
|
||||
import org.spotube.spotube.core.navigation.NavigationCommands
|
||||
import org.spotube.spotube.core.ui.component.AsyncImage
|
||||
import org.spotube.spotube.core.ui.component.cards.PlayableCard
|
||||
import org.spotube.spotube.modules.library.local_tracks.media.LocalMediaTrack
|
||||
import org.spotube.spotube.resources.iconsax.Iconsax
|
||||
import org.spotube.spotube.resources.iconsax.IconsaxAddSquare
|
||||
import org.spotube.spotube.resources.iconsax.IconsaxMoreVertical
|
||||
import org.spotube.spotube.resources.iconsax.IconsaxPlay
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.koin.compose.koinInject
|
||||
import spotube.composeapp.generated.resources.Res
|
||||
import spotube.composeapp.generated.resources.plurals.library_local_tracks_count
|
||||
|
||||
@Composable
|
||||
fun LocalPlaylistCard(
|
||||
playlist: LocalPlaylist,
|
||||
modifier: Modifier = Modifier,
|
||||
listMode: Boolean = false,
|
||||
onClick: () -> Unit = {},
|
||||
onMoreClick: () -> Unit = {},
|
||||
onPlayClick: () -> Unit = {}
|
||||
) {
|
||||
val trackCount = playlist.trackPaths.size
|
||||
val artworkUrl = if (playlist.trackPaths.isNotEmpty()) {
|
||||
// Try to get artwork from the first track
|
||||
"" // Will be resolved by the AsyncImage
|
||||
} else ""
|
||||
|
||||
PlayableCard(
|
||||
title = playlist.name,
|
||||
subtitle = pluralStringResource(Res.plurals.library_local_tracks_count, trackCount, trackCount),
|
||||
imageUrl = artworkUrl,
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
listMode = listMode,
|
||||
onClick = onClick,
|
||||
onPlayClick = onPlayClick,
|
||||
trailingContent = {
|
||||
MillenniumIconButton(
|
||||
icon = Iconsax.IconsaxMoreVertical,
|
||||
contentDescription = stringResource(Res.string.common_more_options),
|
||||
onClick = onMoreClick,
|
||||
style = MillenniumIconButtonStyle.Quiet
|
||||
)
|
||||
},
|
||||
onLongClick = onMoreClick,
|
||||
subtitleContent = {
|
||||
if (playlist.description != null && playlist.description!.isNotBlank()) {
|
||||
Text(
|
||||
text = playlist.description!,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = Typography.bodySmall,
|
||||
color = Color.Gray
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,494 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.spotube.spotube.modules.library.playlist
|
||||
|
||||
import org.spotube.spotube.core.jam.JamRoomClient
|
||||
import org.spotube.spotube.modules.library.local_tracks.media.LocalMediaTrack
|
||||
import org.spotube.spotube.modules.settings.SettingsRepository
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.File
|
||||
import java.nio.charset.StandardCharsets
|
||||
import org.jetbrains.compose.resources.getString
|
||||
import spotube.composeapp.generated.resources.Res
|
||||
import org.koin.core.component.KoinComponent
|
||||
|
||||
/**
|
||||
* Repository for managing local playlists (stored as M3U8 files).
|
||||
*
|
||||
* Local playlists are stored as M3U8 files in the configured playlists directory.
|
||||
* They contain local tracks (from LocalMediaTrack) referenced by their file path.
|
||||
*/
|
||||
package org.spotube.spotube.modules.library.playlist
|
||||
|
||||
import org.spotube.spotube.core.jam.JamRoomClient
|
||||
import org.spotube.spotube.core.jam.JamRoomService
|
||||
import org.spotube.spotube.modules.library.local_tracks.media.LocalMediaTrack
|
||||
import org.spotube.spotube.modules.settings.SettingsRepository
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.File
|
||||
import java.nio.charset.StandardCharsets
|
||||
import org.jetbrains.compose.resources.getString
|
||||
import spotube.composeapp.generated.resources.Res
|
||||
import org.koin.core.component.KoinComponent
|
||||
|
||||
/**
|
||||
* Repository for managing local playlists (stored as M3U8 files).
|
||||
*
|
||||
* Local playlists are stored as M3U8 files in the configured playlists directory.
|
||||
* They contain local tracks (from LocalMediaTrack) referenced by their file path.
|
||||
* Supports cross-device sync via Jam (MQTT) mechanism.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class LocalPlaylistRepository(
|
||||
private val settingsRepository: SettingsRepository,
|
||||
private val jamRoomClient: JamRoomClient,
|
||||
private val jamRoomService: JamRoomService
|
||||
) : KoinComponent {
|
||||
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
private val _playlists = MutableStateFlow<List<LocalPlaylist>>(emptyList())
|
||||
val playlists: StateFlow<List<LocalPlaylist>> = _playlists.asStateFlow()
|
||||
|
||||
private val _syncState = MutableStateFlow<SyncState>(SyncState.Idle)
|
||||
val syncState: StateFlow<SyncState> = _syncState.asStateFlow()
|
||||
|
||||
private var isInitialized = false
|
||||
private var currentSyncJob: kotlinx.coroutines.Job? = null
|
||||
|
||||
init {
|
||||
loadPlaylists()
|
||||
observeJamSync()
|
||||
}
|
||||
|
||||
sealed interface SyncState {
|
||||
data object Idle : SyncState
|
||||
data object Syncing : SyncState
|
||||
data class Success(val timestamp: Long) : SyncState
|
||||
data class Error(val message: String) : SyncState
|
||||
}
|
||||
|
||||
private val playlistsDir: String
|
||||
get() = settingsRepository.userSettings.value.overloadedDownloadFolder
|
||||
?.let { File(it, "Playlists").absolutePath }
|
||||
?: File(settingsRepository.userSettings.value.localMediaFolders.firstOrNull() ?: File(System.getProperty("user.home"), "Music").absolutePath).parentFile.absolutePath
|
||||
|
||||
private val playlistsDirFile: File
|
||||
get() = File(playlistsDir).apply { mkdirs() }
|
||||
|
||||
private fun getPlaylistFile(name: String): File {
|
||||
val safeName = name.replace(Regex(r"[<>:\"/\\|?*]"), "_")
|
||||
return File(playlistsDirFile, "$safeName.m3u8")
|
||||
}
|
||||
|
||||
fun initialize() {
|
||||
if (!isInitialized) {
|
||||
loadPlaylists()
|
||||
isInitialized = true
|
||||
}
|
||||
}
|
||||
|
||||
fun getPlaylists(): List<LocalPlaylist> = _playlists.value
|
||||
|
||||
fun getPlaylist(name: String): LocalPlaylist? = _playlists.value.find { it.name == name }
|
||||
|
||||
suspend fun createPlaylist(name: String, description: String? = null): Result<LocalPlaylist> {
|
||||
return runCatching {
|
||||
if (name.isBlank()) {
|
||||
throw IllegalArgumentException(getString(Res.string.library_playlist_name_empty))
|
||||
}
|
||||
|
||||
if (_playlists.value.any { it.name == name }) {
|
||||
throw IllegalArgumentException(getString(Res.string.library_playlist_name_exists))
|
||||
}
|
||||
|
||||
val playlist = LocalPlaylist(
|
||||
id = java.util.UUID.randomUUID().toString(),
|
||||
name = name,
|
||||
description = description,
|
||||
createdAt = System.currentTimeMillis(),
|
||||
updatedAt = System.currentTimeMillis(),
|
||||
trackPaths = emptyList()
|
||||
)
|
||||
|
||||
_playlists.update { it + playlist }
|
||||
savePlaylist(playlist)
|
||||
broadcastPlaylistChange(PlaylistChange.Created(playlist))
|
||||
playlist
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun updatePlaylist(playlist: LocalPlaylist): Result<Unit> {
|
||||
return runCatching {
|
||||
val index = _playlists.value.indexOfFirst { it.id == playlist.id }
|
||||
if (index < 0) {
|
||||
throw IllegalArgumentException(getString(Res.string.library_playlist_not_found))
|
||||
}
|
||||
|
||||
val updated = playlist.copy(
|
||||
updatedAt = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
_playlists.update { list ->
|
||||
list.set(index, updated)
|
||||
}
|
||||
savePlaylist(updated)
|
||||
broadcastPlaylistChange(PlaylistChange.Updated(updated))
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun deletePlaylist(playlistId: String): Result<Unit> {
|
||||
return runCatching {
|
||||
val playlist = _playlists.value.find { it.id == playlistId }
|
||||
if (playlist == null) {
|
||||
throw IllegalArgumentException(getString(Res.string.library_playlist_not_found))
|
||||
}
|
||||
|
||||
val file = getPlaylistFile(playlist.name)
|
||||
file.delete()
|
||||
|
||||
_playlists.update { it.filter { it.id != playlistId } }
|
||||
broadcastPlaylistChange(PlaylistChange.Deleted(playlistId))
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun addTrack(playlistId: String, track: LocalMediaTrack): Result<Unit> {
|
||||
return runCatching {
|
||||
val playlist = _playlists.value.find { it.id == playlistId }
|
||||
?: throw IllegalArgumentException(getString(Res.string.library_playlist_not_found))
|
||||
|
||||
val trackPath = track.path
|
||||
if (playlist.trackPaths.contains(trackPath)) {
|
||||
return // Already in playlist
|
||||
}
|
||||
|
||||
val updated = playlist.copy(
|
||||
trackPaths = playlist.trackPaths + trackPath,
|
||||
updatedAt = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
_playlists.update { list ->
|
||||
list.map { if (it.id == playlistId) updated else it }
|
||||
}
|
||||
savePlaylist(updated)
|
||||
broadcastPlaylistChange(PlaylistChange.TrackAdded(playlistId, track.path))
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun addTracks(playlistId: String, tracks: List<LocalMediaTrack>): Result<Unit> {
|
||||
return runCatching {
|
||||
val playlist = _playlists.value.find { it.id == playlistId }
|
||||
?: throw IllegalArgumentException(getString(Res.string.library_playlist_not_found))
|
||||
|
||||
val newTrackPaths = tracks.map { it.path }.filterNot { it in playlist.trackPaths }
|
||||
if (newTrackPaths.isEmpty()) return
|
||||
|
||||
val updated = playlist.copy(
|
||||
trackPaths = playlist.trackPaths + newTrackPaths,
|
||||
updatedAt = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
_playlists.update { list ->
|
||||
list.map { if (it.id == playlistId) updated else it }
|
||||
}
|
||||
savePlaylist(updated)
|
||||
broadcastPlaylistChange(PlaylistChange.TracksAdded(playlistId, newTrackPaths))
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun removeTrack(playlistId: String, trackPath: String): Result<Unit> {
|
||||
return runCatching {
|
||||
val playlist = _playlists.value.find { it.id == playlistId }
|
||||
?: throw IllegalArgumentException(getString(Res.string.library_playlist_not_found))
|
||||
|
||||
if (!playlist.trackPaths.contains(trackPath)) return
|
||||
|
||||
val updated = playlist.copy(
|
||||
trackPaths = playlist.trackPaths.filter { it != trackPath },
|
||||
updatedAt = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
_playlists.update { list ->
|
||||
list.map { if (it.id == playlistId) updated else it }
|
||||
}
|
||||
savePlaylist(updated)
|
||||
broadcastPlaylistChange(PlaylistChange.TrackRemoved(playlistId, trackPath))
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun reorderTracks(playlistId: String, fromIndex: Int, toIndex: Int): Result<Unit> {
|
||||
return runCatching {
|
||||
val playlist = _playlists.value.find { it.id == playlistId }
|
||||
?: throw IllegalArgumentException(getString(Res.string.library_playlist_not_found))
|
||||
|
||||
if (fromIndex < 0 || fromIndex >= playlist.trackPaths.size ||
|
||||
toIndex < 0 || toIndex >= playlist.trackPaths.size) {
|
||||
throw IndexOutOfBoundsException()
|
||||
}
|
||||
|
||||
val newTrackPaths = playlist.trackPaths.toMutableList()
|
||||
val trackPath = newTrackPaths.removeAt(fromIndex)
|
||||
newTrackPaths.add(toIndex, trackPath)
|
||||
|
||||
val updated = playlist.copy(
|
||||
trackPaths = newTrackPaths,
|
||||
updatedAt = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
_playlists.update { list ->
|
||||
list.map { if (it.id == playlistId) updated else it }
|
||||
}
|
||||
savePlaylist(updated)
|
||||
broadcastPlaylistChange(PlaylistChange.Reordered(playlistId, newTrackPaths))
|
||||
}
|
||||
}
|
||||
|
||||
private fun savePlaylist(playlist: LocalPlaylist) {
|
||||
scope.launch {
|
||||
val file = getPlaylistFile(playlist.name)
|
||||
val m3u8Content = buildM3u8Content(playlist)
|
||||
file.writeText(m3u8Content, StandardCharsets.UTF_8)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildM3u8Content(playlist: LocalPlaylist): String {
|
||||
val sb = StringBuilder()
|
||||
sb.append("#EXTM3U\n")
|
||||
sb.append("#PLAYLIST:${playlist.name}\n")
|
||||
if (playlist.description != null) {
|
||||
sb.append("#DESCRIPTION:${playlist.description}\n")
|
||||
}
|
||||
sb.append("#EXTENC:UTF-8\n")
|
||||
|
||||
for (trackPath in playlist.trackPaths) {
|
||||
val trackName = File(trackPath).nameWithoutExtension
|
||||
sb.append("#EXTINF:-1,$trackName\n")
|
||||
sb.append("$trackPath\n")
|
||||
}
|
||||
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
private fun loadPlaylists() {
|
||||
scope.launch {
|
||||
val files = playlistsDirFile.listFiles { it.extension == "m3u8" } ?: emptyArray()
|
||||
val playlists = files.mapNotNull { file ->
|
||||
parseM3u8(file).copy(filePath = file.absolutePath)
|
||||
}
|
||||
_playlists.value = playlists
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseM3u8(file: File): LocalPlaylist {
|
||||
val lines = file.readText(StandardCharsets.UTF_8).splitLines()
|
||||
var name = file.nameWithoutExtension
|
||||
var description: String? = null
|
||||
var trackPaths = mutableListOf<String>()
|
||||
|
||||
for (line in lines) {
|
||||
when {
|
||||
line.startsWith("#PLAYLIST:") -> name = line.substringAfter("#PLAYLIST:").trim()
|
||||
line.startsWith("#DESCRIPTION:") -> description = line.substringAfter("#DESCRIPTION:").trim()
|
||||
line.startsWith("#EXTINF:") -> {
|
||||
// Format: #EXTINF:-1,trackName
|
||||
// Next line is the track path
|
||||
}
|
||||
!line.startsWith("#") && line.isNotBlank() -> {
|
||||
// This is a track path line
|
||||
val trackPath = line.trim()
|
||||
if (trackPath.isNotBlank()) trackPaths.add(trackPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return LocalPlaylist(
|
||||
id = file.nameWithoutExtension,
|
||||
name = name,
|
||||
description = description,
|
||||
createdAt = file.lastModified(),
|
||||
updatedAt = file.lastModified(),
|
||||
trackPaths = trackPaths,
|
||||
filePath = file.absolutePath
|
||||
)
|
||||
}
|
||||
|
||||
// ============ Sync via Jam (MQTT) ============
|
||||
|
||||
sealed interface PlaylistChange {
|
||||
@Serializable
|
||||
data class Created(val playlist: LocalPlaylist) : PlaylistChange
|
||||
@Serializable
|
||||
data class Updated(val playlist: LocalPlaylist) : PlaylistChange
|
||||
@Serializable
|
||||
data class Deleted(val playlistId: String) : PlaylistChange
|
||||
@Serializable
|
||||
data class TrackAdded(val playlistId: String, val trackPath: String) : PlaylistChange
|
||||
@Serializable
|
||||
data class TracksAdded(val playlistId: String, val trackPaths: List<String>) : PlaylistChange
|
||||
@Serializable
|
||||
data class TrackRemoved(val playlistId: String, val trackPath: String) : PlaylistChange
|
||||
@Serializable
|
||||
data class Reordered(val playlistId: String, val trackPaths: List<String>) : PlaylistChange
|
||||
}
|
||||
|
||||
private fun broadcastPlaylistChange(change: PlaylistChange) {
|
||||
if (!jamRoomService.isConnected.value) return
|
||||
if (jamRoomService.role.value != org.spotube.spotube.core.jam.JamRole.Host) return
|
||||
|
||||
scope.launch {
|
||||
jamRoomClient.publishCommand(
|
||||
org.spotube.spotube.core.jam.JamMessage.PlaybackCommand(
|
||||
org.spotube.spotube.core.jam.PlaybackCmd.Custom(
|
||||
type = "playlist_sync",
|
||||
payload = json.encodeToString(PlaylistChange.serializer(), change)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun observeJamSync() {
|
||||
scope.launch {
|
||||
jamRoomClient.commands
|
||||
.filter { it is org.spotube.spotube.core.jam.JamMessage.PlaybackCommand }
|
||||
.map { it as org.spotube.spotube.core.jam.JamMessage.PlaybackCommand }
|
||||
.filter { it.command is org.spotube.spotube.core.jam.PlaybackCmd.Custom }
|
||||
.map { (it.command as org.spotube.spotube.core.jam.PlaybackCmd.Custom) }
|
||||
.filter { it.type == "playlist_sync" }
|
||||
.onEach { cmd ->
|
||||
try {
|
||||
val change = json.decodeFromString(PlaylistChange.serializer(), cmd.payload)
|
||||
applyRemoteChange(change)
|
||||
} catch (e: Exception) {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
.launchIn(scope)
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyRemoteChange(change: PlaylistChange) {
|
||||
when (change) {
|
||||
is PlaylistChange.Created -> {
|
||||
_playlists.update { it + change.playlist }
|
||||
}
|
||||
is PlaylistChange.Updated -> {
|
||||
_playlists.update { list ->
|
||||
val index = list.indexOfFirst { it.id == change.playlist.id }
|
||||
if (index >= 0) list.set(index, change.playlist) else list
|
||||
}
|
||||
}
|
||||
is PlaylistChange.Deleted -> {
|
||||
_playlists.update { it.filter { it.id != change.playlistId } }
|
||||
getPlaylistFile(change.playlistId).delete()
|
||||
}
|
||||
is PlaylistChange.TrackAdded -> {
|
||||
_playlists.update { list ->
|
||||
list.map { pl ->
|
||||
if (pl.id == change.playlistId && !pl.trackPaths.contains(change.trackPath)) {
|
||||
pl.copy(trackPaths = pl.trackPaths + change.trackPath, updatedAt = System.currentTimeMillis())
|
||||
} else pl
|
||||
}
|
||||
}
|
||||
}
|
||||
is PlaylistChange.TracksAdded -> {
|
||||
_playlists.update { list ->
|
||||
list.map { pl ->
|
||||
if (pl.id == change.playlistId) {
|
||||
val newPaths = change.trackPaths.filterNot { it in pl.trackPaths }
|
||||
if (newPaths.isEmpty()) pl else pl.copy(trackPaths = pl.trackPaths + newPaths, updatedAt = System.currentTimeMillis())
|
||||
} else pl
|
||||
}
|
||||
}
|
||||
}
|
||||
is PlaylistChange.TrackRemoved -> {
|
||||
_playlists.update { list ->
|
||||
list.map { pl ->
|
||||
if (pl.id == change.playlistId) {
|
||||
pl.copy(trackPaths = pl.trackPaths.filter { it != change.trackPath }, updatedAt = System.currentTimeMillis())
|
||||
} else pl
|
||||
}
|
||||
}
|
||||
}
|
||||
is PlaylistChange.Reordered -> {
|
||||
_playlists.update { list ->
|
||||
list.map { pl ->
|
||||
if (pl.id == change.playlistId) {
|
||||
pl.copy(trackPaths = change.trackPaths, updatedAt = System.currentTimeMillis())
|
||||
} else pl
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============ Manual Sync Trigger ============
|
||||
|
||||
suspend fun syncNow(): Result<Unit> {
|
||||
return runCatching {
|
||||
_syncState.value = SyncState.Syncing
|
||||
try {
|
||||
// Broadcast full playlist state to other devices
|
||||
for (playlist in _playlists.value) {
|
||||
broadcastPlaylistChange(PlaylistChange.Created(playlist))
|
||||
}
|
||||
_syncState.value = SyncState.Success(System.currentTimeMillis())
|
||||
} catch (e: Exception) {
|
||||
_syncState.value = SyncState.Error(e.message ?: "Sync failed")
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getSyncState(): SyncState = _syncState.value
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class LocalPlaylist(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val description: String? = null,
|
||||
val createdAt: Long,
|
||||
val updatedAt: Long,
|
||||
val trackPaths: List<String> = emptyList(),
|
||||
val filePath: String? = null
|
||||
)
|
||||
@ -0,0 +1,326 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.spotube.spotube.modules.library.playlist
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import org.spotube.spotube.core.di.injectLogger
|
||||
import org.spotube.spotube.modules.library.local_tracks.LibraryLocalTracksViewModel
|
||||
import org.spotube.spotube.modules.library.local_tracks.media.LocalMediaTrack
|
||||
import org.spotube.spotube.modules.settings.SettingsProvider
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import org.jetbrains.compose.resources.getString
|
||||
import spotube.composeapp.generated.resources.Res
|
||||
import io.github.vinceglb.filekit.PlatformFile
|
||||
import kotlinx.coroutines.tasks.await
|
||||
import io.github.vinceglb.filekit.writeText
|
||||
|
||||
sealed interface LocalPlaylistScreenState {
|
||||
data class Data(
|
||||
val playlists: List<LocalPlaylist> = emptyList(),
|
||||
val query: String = "",
|
||||
val selectedPlaylist: LocalPlaylist? = null,
|
||||
val showCreateDialog: Boolean = false,
|
||||
val showEditDialog: Boolean = false,
|
||||
val showAddTracksDialog: Boolean = false,
|
||||
val editingPlaylist: LocalPlaylist? = null,
|
||||
val isLoading: Boolean = false,
|
||||
val error: String? = null,
|
||||
val syncState: LocalPlaylistRepository.SyncState = LocalPlaylistRepository.SyncState.Idle
|
||||
) : LocalPlaylistScreenState
|
||||
|
||||
data object Loading : LocalPlaylistScreenState
|
||||
data class Error(val message: String) : LocalPlaylistScreenState
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class LocalPlaylistViewModel(
|
||||
private val repository: LocalPlaylistRepository,
|
||||
private val localTracksViewModel: LibraryLocalTracksViewModel,
|
||||
private val settingsProvider: SettingsProvider
|
||||
) : ViewModel() {
|
||||
private val logger by injectLogger<LocalPlaylistViewModel>()
|
||||
|
||||
private val _uiState = MutableStateFlow<LocalPlaylistScreenState>(LocalPlaylistScreenState.Loading)
|
||||
val uiState: StateFlow<LocalPlaylistScreenState> = _uiState.asStateFlow()
|
||||
|
||||
private val _query = mutableStateOf("")
|
||||
var query: String
|
||||
get() = _query.value
|
||||
set(value) { _query.value = value }
|
||||
|
||||
private val _showCreateDialog = mutableStateOf(false)
|
||||
var showCreateDialog: Boolean
|
||||
get() = _showCreateDialog.value
|
||||
set(value) { _showCreateDialog.value = value }
|
||||
|
||||
private val _showEditDialog = mutableStateOf(false)
|
||||
var showEditDialog: Boolean
|
||||
get() = _showEditDialog.value
|
||||
set(value) { _showEditDialog.value = value }
|
||||
|
||||
private val _showAddTracksDialog = mutableStateOf(false)
|
||||
var showAddTracksDialog: Boolean
|
||||
get() = _showAddTracksDialog.value
|
||||
set(value) { _showAddTracksDialog.value = value }
|
||||
|
||||
private val _editingPlaylist = MutableStateFlow<LocalPlaylist?>(null)
|
||||
val editingPlaylist: StateFlow<LocalPlaylist?> = _editingPlaylist.asStateFlow()
|
||||
|
||||
private val _showAddTracksForPlaylist = MutableStateFlow<LocalPlaylist?>(null)
|
||||
val showAddTracksForPlaylist: StateFlow<LocalPlaylist?> = _showAddTracksForPlaylist.asStateFlow()
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
repository.playlists
|
||||
.combine(localTracksViewModel.state, repository.syncState) { playlists, localTracksState, syncState ->
|
||||
LocalPlaylistScreenState.Data(
|
||||
playlists = playlists,
|
||||
query = "",
|
||||
selectedPlaylist = null,
|
||||
syncState = syncState
|
||||
)
|
||||
}
|
||||
.stateIn(
|
||||
viewModelScope,
|
||||
SharingStarted.WhileSubscribed(5_000),
|
||||
LocalPlaylistScreenState.Loading
|
||||
)
|
||||
.collect { state ->
|
||||
_uiState.value = state
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onQueryChange(query: String) {
|
||||
_uiState.update { state ->
|
||||
if (state is LocalPlaylistScreenState.Data) {
|
||||
state.copy(query = query)
|
||||
} else state
|
||||
}
|
||||
}
|
||||
|
||||
fun showCreatePlaylistDialog() {
|
||||
_showCreateDialog.value = true
|
||||
}
|
||||
|
||||
fun dismissCreatePlaylistDialog() {
|
||||
_showCreateDialog.value = false
|
||||
}
|
||||
|
||||
fun createPlaylist(name: String, description: String? = null) {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isLoading = true) }
|
||||
repository.createPlaylist(name, description)
|
||||
.onSuccess { playlist ->
|
||||
_uiState.update { state ->
|
||||
if (state is LocalPlaylistScreenState.Data) {
|
||||
state.copy(playlists = state.playlists + playlist, isLoading = false)
|
||||
} else state
|
||||
}
|
||||
dismissCreatePlaylistDialog()
|
||||
}
|
||||
.onFailure { e ->
|
||||
_uiState.update { state ->
|
||||
if (state is LocalPlaylistScreenState.Data) {
|
||||
state.copy(isLoading = false, error = e.message)
|
||||
} else LocalPlaylistScreenState.Error(e.message ?: getString(Res.string.common_unknown_error))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun showEditPlaylistDialog(playlist: LocalPlaylist) {
|
||||
_editingPlaylist.value = playlist
|
||||
_showEditDialog.value = true
|
||||
}
|
||||
|
||||
fun dismissEditPlaylistDialog() {
|
||||
_showEditDialog.value = false
|
||||
_editingPlaylist.value = null
|
||||
}
|
||||
|
||||
fun updatePlaylist(playlist: LocalPlaylist) {
|
||||
viewModelScope.launch {
|
||||
repository.updatePlaylist(playlist)
|
||||
.onSuccess {
|
||||
_uiState.update { state ->
|
||||
if (state is LocalPlaylistScreenState.Data) {
|
||||
val updatedPlaylists = state.playlists.map {
|
||||
if (it.id == playlist.id) playlist else it
|
||||
}
|
||||
state.copy(playlists = updatedPlaylists, editingPlaylist = null)
|
||||
} else state
|
||||
}
|
||||
dismissEditPlaylistDialog()
|
||||
}
|
||||
.onFailure { e ->
|
||||
_uiState.update { state ->
|
||||
if (state is LocalPlaylistScreenState.Data) {
|
||||
state.copy(error = e.message)
|
||||
} else state
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun deletePlaylist(playlistId: String) {
|
||||
viewModelScope.launch {
|
||||
repository.deletePlaylist(playlistId)
|
||||
.onSuccess {
|
||||
_uiState.update { state ->
|
||||
if (state is LocalPlaylistScreenState.Data) {
|
||||
state.copy(playlists = state.playlists.filter { it.id != playlistId })
|
||||
} else state
|
||||
}
|
||||
}
|
||||
.onFailure { e ->
|
||||
_uiState.update { state ->
|
||||
if (state is LocalPlaylistScreenState.Data) {
|
||||
state.copy(error = e.message)
|
||||
} else state
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun showAddTracksDialog(playlist: LocalPlaylist) {
|
||||
_showAddTracksForPlaylist.value = playlist
|
||||
_showAddTracksDialog.value = true
|
||||
}
|
||||
|
||||
fun dismissAddTracksDialog() {
|
||||
_showAddTracksDialog.value = false
|
||||
_showAddTracksForPlaylist.value = null
|
||||
}
|
||||
|
||||
fun addTracks(playlist: LocalPlaylist, tracks: List<LocalMediaTrack>) {
|
||||
viewModelScope.launch {
|
||||
repository.addTracks(playlist.id, tracks)
|
||||
.onSuccess {
|
||||
_uiState.update { state ->
|
||||
if (state is LocalPlaylistScreenState.Data) {
|
||||
val updatedPlaylists = state.playlists.map {
|
||||
if (it.id == playlist.id)
|
||||
it.copy(trackPaths = it.trackPaths + tracks.map { it.path }.filterNot { it in it.trackPaths })
|
||||
else it
|
||||
}
|
||||
state.copy(playlists = updatedPlaylists)
|
||||
} else state
|
||||
}
|
||||
dismissAddTracksDialog()
|
||||
}
|
||||
.onFailure { e ->
|
||||
_uiState.update { state ->
|
||||
if (state is LocalPlaylistScreenState.Data) {
|
||||
state.copy(error = e.message)
|
||||
} else state
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun removeTrack(playlistId: String, trackPath: String) {
|
||||
viewModelScope.launch {
|
||||
repository.removeTrack(playlistId, trackPath)
|
||||
.onSuccess {
|
||||
_uiState.update { state ->
|
||||
if (state is LocalPlaylistScreenState.Data) {
|
||||
val updatedPlaylists = state.playlists.map {
|
||||
if (it.id == playlistId)
|
||||
it.copy(trackPaths = it.trackPaths.filter { it != trackPath })
|
||||
else it
|
||||
}
|
||||
state.copy(playlists = updatedPlaylists)
|
||||
} else state
|
||||
}
|
||||
}
|
||||
.onFailure { e ->
|
||||
_uiState.update { state ->
|
||||
if (state is LocalPlaylistScreenState.Data) {
|
||||
state.copy(error = e.message)
|
||||
} else state
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun reorderTracks(playlistId: String, fromIndex: Int, toIndex: Int) {
|
||||
viewModelScope.launch {
|
||||
repository.reorderTracks(playlistId, fromIndex, toIndex)
|
||||
.onSuccess {
|
||||
_uiState.update { state ->
|
||||
if (state is LocalPlaylistScreenState.Data) {
|
||||
val updatedPlaylists = state.playlists.map {
|
||||
if (it.id == playlistId) {
|
||||
val newTrackPaths = it.trackPaths.toMutableList()
|
||||
val trackPath = newTrackPaths.removeAt(fromIndex)
|
||||
newTrackPaths.add(toIndex, trackPath)
|
||||
it.copy(trackPaths = newTrackPaths)
|
||||
} else it
|
||||
}
|
||||
state.copy(playlists = updatedPlaylists)
|
||||
} else state
|
||||
}
|
||||
}
|
||||
.onFailure { e ->
|
||||
_uiState.update { state ->
|
||||
if (state is LocalPlaylistScreenState.Data) {
|
||||
state.copy(error = e.message)
|
||||
} else state
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun syncNow() {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { state ->
|
||||
if (state is LocalPlaylistScreenState.Data) {
|
||||
state.copy(syncState = LocalPlaylistRepository.SyncState.Syncing)
|
||||
} else state
|
||||
}
|
||||
repository.syncNow()
|
||||
.onSuccess {
|
||||
_uiState.update { state ->
|
||||
if (state is LocalPlaylistScreenState.Data) {
|
||||
state.copy(syncState = LocalPlaylistRepository.SyncState.Success(System.currentTimeMillis()))
|
||||
} else state
|
||||
}
|
||||
}
|
||||
.onFailure { e ->
|
||||
_uiState.update { state ->
|
||||
if (state is LocalPlaylistScreenState.Data) {
|
||||
state.copy(syncState = LocalPlaylistRepository.SyncState.Error(e.message ?: "Sync failed"))
|
||||
} else state
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -314,7 +314,8 @@ enum class SupportedLanguages(
|
||||
UK("uk", "", "Українська"),
|
||||
VI("vi", "", "Tiếng Việt"),
|
||||
ZH_CN("zh", "CN", "简体中文"),
|
||||
ZH_TW("zh", "TW", "繁體中文");
|
||||
ZH_TW("zh", "TW", "繁體中文"),
|
||||
ZH_CLASSICAL("lzh", "", "文言文");
|
||||
|
||||
/** BCP 47 tag (e.g. "zh-TW"), or null for [SYSTEM]. */
|
||||
val languageTag: String?
|
||||
|
||||
Loading…
Reference in New Issue
Block a user