This commit is contained in:
thumb2086 2026-10-31 10:00:02 +06:00 committed by GitHub
commit 00d6be2084
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 1602 additions and 39 deletions

View File

@ -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"
)

View File

@ -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>

View File

@ -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>

View File

@ -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)

View File

@ -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) {

View File

@ -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()
}

View File

@ -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
)
}
)
}
}
}
}

View File

@ -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
)
}
}
)
}

View File

@ -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
)

View File

@ -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
}
}
}
}
}

View File

@ -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?