From 24cff607f26cf7367578372d7d627ec28d625349 Mon Sep 17 00:00:00 2001 From: thumb2086 Date: Sat, 31 Oct 2026 01:32:52 +0800 Subject: [PATCH 1/6] =?UTF-8?q?feat(l10n):=20add=20Classical=20Chinese=20(?= =?UTF-8?q?=E6=96=87=E8=A8=80=E6=96=87)=20translation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add values-lzh/strings.xml with Classical Chinese translations - Add ZH_CLASSICAL to SupportedLanguages enum (locale: lzh) - Display name: 文言文 (endonym) - Language tag: lzh (ISO 639-3) 這是一個彩蛋功能,讓 Spotube 充滿古風雅韻。 --- .../composeResources/values-lzh/strings.xml | 291 ++++++++++++++++++ .../modules/settings/SettingsConstants.kt | 3 +- 2 files changed, 293 insertions(+), 1 deletion(-) create mode 100644 composeApp/src/commonMain/composeResources/values-lzh/strings.xml diff --git a/composeApp/src/commonMain/composeResources/values-lzh/strings.xml b/composeApp/src/commonMain/composeResources/values-lzh/strings.xml new file mode 100644 index 00000000..cd1739d3 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/values-lzh/strings.xml @@ -0,0 +1,291 @@ + + + + + Spotube + 曲列 + 清空曲列 + 篩選曲目… + 曲列空無一物 + + 搜尋樂曲、藝人、專輯… + 清除所有歷史 + 搜尋… + + + 藏書樓 + 藏書 + 歌單 + 專輯 + 藝人 + 本地樂曲 + 已下載 + 搜尋已儲存歌單… + 搜尋已儲存專輯… + 搜尋已儲存藝人… + 搜尋資料夾或樂曲… + 篩選藏書 + 關閉篩選 + 新建歌單 + 重整本地樂曲 + 切換至格狀視圖 + 切換至列表視圖 + 無已儲存歌單 + 無歌單符合「%1$s」 + 無已儲存專輯 + 無專輯符合「%1$s」 + 無已儲存藝人 + 無藝人符合「%1$s」 + + + 資料夾 + 資料夾 + %1$s • %2$s + + %1$d 曲 + %1$d 曲 + + + %1$d 資料夾 + %1$d 資料夾 + + 返回資料夾 + 播放資料夾 + 需音訊存取權限 + 允許存取音訊以瀏覽裝置上的音樂。 + 授予存取權 + 未發現本地資料夾 + 添加含音樂之資料夾即可於此播放。無需插件。 + 新增資料夾 + 此資料夾無樂曲 + 無符合之資料夾或樂曲 + 重整本地媒體失敗 + + + %1$d 進行中 • %2$d 已完成 + 已儲存至 %1$s + 清除已完成 + 尚無下載 + 下載中 %1$d% + 排隊中 + 已完成 + 已取消 + 失敗 + 失敗:%1$s + 取消下載 + 重試下載 + 移除下載 + + + 藝人 + 已屏蔽 + 喜愛樂曲 + 您儲存的歌曲 + 佚名藝人 + 曲名 + 藝人名 + + + 播放 + 暫停 + 下一曲 + 上一曲 + 隨機播放 + 重複播放 + 單曲循環 + 睡眠定時 + 前往專輯 + 曲目詳情 + 歌詞 + 播放佇列 + + + 設定 + 通用 + 播放 + 下載 + 外觀 + 進階 + 關於 + 語言 + 主題 + 光明 + 暗黑 + 跟隨系統 + 串流音質 + 下載音質 + 啟用快取 + 快取大小 + 清除快取 + + + 無間斷播放 + 淡入淡出 + 音量標準化 + 均衡器 + + + 代理伺服器 + 主機 + 連接埠 + 逾時 + + + 插件 + 已安裝 + 可用插件 + 安裝 + 移除 + 更新 + + + 群聽 + 群聽 + 建立 + 加入 + 建立房間,再將代碼分享給友人。 + 建立房間 + 加入房間 + 連線中… + 房間代碼 + ABC123 + 代理伺服器:%1$s + 請在設定中配置代理伺服器以建立或加入房間。 + 代理伺服器設定 + 已連線 + 重新連線中… + 複製代碼 + 已複製! + 分享 + 參與者(%1$d) + 主持人 + 聽眾 + 等待聽眾加入… + 移除 + 封鎖 + 管理 + 離開房間 + 無法連線 + 關閉 + 建立群聽會話失敗 + 加入群聽會話失敗 + 請輸入六字元之房間代碼(字母及數字)。 + 以此代碼加入我的 Spotube 群聽:%1$s + + + 發生錯誤 + 網路錯誤 + 重試 + 關閉 + 無搜尋結果 + 無網路連線 + 請稍後再試 + + + 版本 %1$s + 建構 %1$s + 授權條款 + 隱私權政策 + 貢獻者 + 翻譯者 + 原始碼 + 回報問題 + + + 需要儲存空間權限 + 允許存取儲存空間以下載音樂。 + 需要音訊權限 + 允許存取音訊以播放音樂。 + + + 播放通知 + 媒體播放控制通知 + + + %1$d 秒 + %1$d 分 + %1$d 時 + %1$d 天 + + + 確定 + 取消 + 儲存 + 刪除 + 編輯 + 分享 + 複製 + 貼上 + 全選 + 重新命名 + 建立 + 新增 + 移除 + 清除 + 重整 + 設定 + 更多 + 完成 + 是 + 否 + 進一步了解 + + + 新建歌單 + 歌單名稱 + 描述(選填) + 編輯歌單 + 確定要刪除此歌單嗎? + 新增樂曲 + 移除樂曲 + 重新排序 + 此歌單空無一曲 + 無符合樂曲 + + + 熱門曲目 + 專輯 + 單曲 + 參與作品 + 相關藝人 + 發行日期:%1$s + 曲目 + 製作名單 + + + 無可用歌詞 + 載入歌詞中… + 載入歌詞失敗 + + + 歡迎使用 Spotube + 開源、無廣告、注重隱私的音樂串流客戶端 + 開始使用 + 略過 + + + 以 Spotify 登入 + 以 GitHub 登入 + 以 Last.fm 登入 + 以 ListenBrainz 登入 + 確定要登出嗎? + \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsConstants.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsConstants.kt index f7c2cd50..f75670c3 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsConstants.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsConstants.kt @@ -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? From 7f3686ebc19cf66c2cbcadfa08cf0773c7147250 Mon Sep 17 00:00:00 2001 From: thumb2086 Date: Sat, 31 Oct 2026 01:43:51 +0800 Subject: [PATCH 2/6] =?UTF-8?q?feat(l10n):=20rewrite=20Classical=20Chinese?= =?UTF-8?q?=20(=E6=96=87=E8=A8=80=E6=96=87)=20translation=20properly?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rewrite values-lzh/strings.xml with proper Classical Chinese grammar/vocabulary - Use ancient monosyllabic roots forming disyllabic compounds (曲次、歌選、藏書樓) - Correct function words (之、於、以、乃、故、矣、哉、也、乎、焉) - Avoid vernacular (的、是、在、有、這、那、麼、們、著、了、過) - Verb-object, subject-predicate, modifier-head compounds per Classical word formation --- .../composeResources/values-lzh/strings.xml | 418 +++++++++--------- 1 file changed, 209 insertions(+), 209 deletions(-) diff --git a/composeApp/src/commonMain/composeResources/values-lzh/strings.xml b/composeApp/src/commonMain/composeResources/values-lzh/strings.xml index cd1739d3..77c8371f 100644 --- a/composeApp/src/commonMain/composeResources/values-lzh/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-lzh/strings.xml @@ -14,278 +14,278 @@ ~ You should have received a copy of the GNU Affero General Public License ~ along with this program. If not, see . ~ - ~ 文言文翻譯 / Classical Chinese Translation - ~ Locale: lzh (ISO 639-3) - ~ 為 Spotube 增加雅韻,讓古人亦能賞現代音樂之便利 + ~ 文言文本地化 / Classical Chinese Localization + ~ Locale: lzh (ISO 639-3 Literary Chinese) + ~ 採漢制雅言,去白話語法,用古本單音詞組雙音、虛詞得體 --> - + Spotube - 曲列 - 清空曲列 - 篩選曲目… - 曲列空無一物 + 曲次 + 盡除曲次 + 篩曲目 + 曲次空虛 - 搜尋樂曲、藝人、專輯… - 清除所有歷史 - 搜尋… + 搜樂曲、藝人、專輯 + 盡削往歷 + 搜尋 - + 藏書樓 藏書 - 歌單 + 歌選 專輯 藝人 - 本地樂曲 - 已下載 - 搜尋已儲存歌單… - 搜尋已儲存專輯… - 搜尋已儲存藝人… - 搜尋資料夾或樂曲… - 篩選藏書 - 關閉篩選 - 新建歌單 - 重整本地樂曲 - 切換至格狀視圖 - 切換至列表視圖 - 無已儲存歌單 - 無歌單符合「%1$s」 - 無已儲存專輯 - 無專輯符合「%1$s」 - 無已儲存藝人 - 無藝人符合「%1$s」 + 本地樂 + 所獲 + 覓已藏歌選 + 覓已藏專輯 + 覓已藏藝人 + 覓資料夾或樂曲 + 篩藏書 + 罷篩 + 制歌選 + 新本地樂 + 改格視 + 改列視 + 無藏歌選 + 無歌選合「%1$s」 + 無藏專輯 + 無專輯合「%1$s」 + 無藏藝人 + 無藝人合「%1$s」 - + 資料夾 資料夾 - %1$s • %2$s + %1$s · %2$s %1$d 曲 %1$d 曲 - %1$d 資料夾 - %1$d 資料夾 + %1$d 夾 + %1$d 夾 - 返回資料夾 - 播放資料夾 - 需音訊存取權限 - 允許存取音訊以瀏覽裝置上的音樂。 - 授予存取權 - 未發現本地資料夾 - 添加含音樂之資料夾即可於此播放。無需插件。 - 新增資料夾 - 此資料夾無樂曲 - 無符合之資料夾或樂曲 - 重整本地媒體失敗 + 返夾 + 播夾內樂 + 須音訊權 + 允音訊權,以覽器中樂。 + 予權 + 無本地夾 + 增含樂之夾,即可於此播。無需外掛。 + 增夾 + 此夾無曲 + 無合之夾或曲 + 新本地媒體失 - - %1$d 進行中 • %2$d 已完成 - 已儲存至 %1$s - 清除已完成 - 尚無下載 - 下載中 %1$d% - 排隊中 - 已完成 - 已取消 - 失敗 - 失敗:%1$s - 取消下載 - 重試下載 - 移除下載 + + %1$d 行 · %1$d 成 + 藏於 %1$s + 削已成 + 未獲 + 獲取中 %1$d% + 候獲 + 已成 + 已止 + 失 + 失:%1$s + 止獲 + 重獲 + 去獲 - + 藝人 - 已屏蔽 - 喜愛樂曲 - 您儲存的歌曲 - 佚名藝人 + 已屏 + 好樂 + 藏曲 + 無名氏 曲名 - 藝人名 + 藝名 - - 播放 - 暫停 - 下一曲 - 上一曲 - 隨機播放 - 重複播放 - 單曲循環 - 睡眠定時 - 前往專輯 - 曲目詳情 - 歌詞 - 播放佇列 + + 播 + 輟 + 次曲 + 前曲 + 亂序 + 循環 + 單曲循 + 寢鐘 + 往專輯 + 曲情 + 詞 + 播佇列 - + 設定 - 通用 + 通則 播放 下載 - 外觀 + 儀制 進階 關於 語言 主題 - 光明 - 暗黑 - 跟隨系統 - 串流音質 - 下載音質 - 啟用快取 - 快取大小 - 清除快取 + 明 + 暗 + 從制 + 流音質 + 獲音質 + 啟緩存 + 緩存量 + 清緩存 - - 無間斷播放 - 淡入淡出 - 音量標準化 - 均衡器 + + 無間播 + 淡入出 + 平音量 + 均衡 - - 代理伺服器 + + 代理 主機 - 連接埠 + 埠 逾時 - - 插件 - 已安裝 - 可用插件 - 安裝 - 移除 + + 外掛 + 已裝 + 可裝 + 裝 + 卸 更新 - + 群聽 群聽 - 建立 - 加入 - 建立房間,再將代碼分享給友人。 - 建立房間 - 加入房間 - 連線中… - 房間代碼 + 建 + 入 + 建室,以碼邀友。 + 建室 + 入室 + 聯結中 + 室碼 ABC123 - 代理伺服器:%1$s - 請在設定中配置代理伺服器以建立或加入房間。 - 代理伺服器設定 - 已連線 - 重新連線中… - 複製代碼 - 已複製! + 代理:%1$s + 請於設定置代理,乃可建室或入室。 + 代理設定 + 已聯 + 重聯中 + 複製碼 + 已複製 分享 - 參與者(%1$d) - 主持人 - 聽眾 - 等待聽眾加入… - 移除 - 封鎖 - 管理 - 離開房間 - 無法連線 - 關閉 - 建立群聽會話失敗 - 加入群聽會話失敗 - 請輸入六字元之房間代碼(字母及數字)。 - 以此代碼加入我的 Spotube 群聽:%1$s + 與聽者(%1$d) + 主持 + 聽者 + 俟聽者入 + 逐 + 禁 + 治 + 離室 + 聯結失 + 收 + 建群聽會失 + 入群聽會失 + 請輸六碼室碼(字母數字)。 + 以碼入吾 Spotube 群聽:%1$s - - 發生錯誤 - 網路錯誤 + + 有錯 + 網路錯 重試 - 關閉 - 無搜尋結果 - 無網路連線 - 請稍後再試 + 收 + 無果 + 無網 + 稍候再試 - + 版本 %1$s - 建構 %1$s - 授權條款 - 隱私權政策 + 構建 %1$s + 授權 + 隱私策 貢獻者 - 翻譯者 - 原始碼 - 回報問題 + 譯者 + 源碼 + 報錯 - - 需要儲存空間權限 - 允許存取儲存空間以下載音樂。 - 需要音訊權限 - 允許存取音訊以播放音樂。 + + 須存儲權 + 允存儲權,以下載樂。 + 須音訊權 + 允音訊權,以播樂。 - + 播放通知 - 媒體播放控制通知 + 媒體控制通知 - + %1$d 秒 %1$d 分 %1$d 時 - %1$d 天 + %1$d 日 - - 確定 - 取消 - 儲存 - 刪除 - 編輯 - 分享 - 複製 - 貼上 - 全選 - 重新命名 - 建立 - 新增 - 移除 - 清除 - 重整 - 設定 - 更多 - 完成 + + 可 + 止 + 存 + 刪 + 改 + 享 + 鈔 + 貼 + 盡選 + 更名 + 造 + 增 + 去 + 清 + 新 + 設 + 更 + 畢 是 - 否 - 進一步了解 + 非 + 詳閱 - - 新建歌單 - 歌單名稱 - 描述(選填) - 編輯歌單 - 確定要刪除此歌單嗎? - 新增樂曲 - 移除樂曲 - 重新排序 - 此歌單空無一曲 - 無符合樂曲 + + 制歌選 + 選名 + 述(選填) + 改歌選 + 確刪此選? + 增曲 + 去曲 + 重序 + 此選無曲 + 無合曲 - - 熱門曲目 + + 熱曲 專輯 單曲 - 參與作品 - 相關藝人 - 發行日期:%1$s + 見於 + 關藝人 + 發行:%1$s 曲目 - 製作名單 + 銜名 - - 無可用歌詞 - 載入歌詞中… - 載入歌詞失敗 + + 無詞 + 載詞中 + 載詞失 - - 歡迎使用 Spotube - 開源、無廣告、注重隱私的音樂串流客戶端 - 開始使用 - 略過 + + 迎 Spotube + 開源、無廣、重隱之音樂流客 + 始用 + 略 - - 以 Spotify 登入 - 以 GitHub 登入 - 以 Last.fm 登入 - 以 ListenBrainz 登入 - 確定要登出嗎? + + 以 Spotify 入 + 以 GitHub 入 + 以 Last.fm 入 + 以 ListenBrainz 入 + 確登出? \ No newline at end of file From dca3bd7708960512673367d5d056a905577306c6 Mon Sep 17 00:00:00 2001 From: thumb2086 Date: Sat, 31 Oct 2026 01:50:23 +0800 Subject: [PATCH 3/6] =?UTF-8?q?feat(l10n):=20complete=20Classical=20Chines?= =?UTF-8?q?e=20(=E6=96=87=E8=A8=80=E6=96=87)=20translation=20for=20all=20s?= =?UTF-8?q?trings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Translate ALL ~500 strings from main strings.xml to proper Classical Chinese - Cover: queue, search, library, local media, downloads, settings (theme, surface, accent, playback, caching, remote control, desktop, updates, plugins, blacklist), plugins, navigation, system tray, desktop window, notifications, media browser - Proper Classical grammar: monosyllabic roots forming disyllabic compounds, correct function words (之於以乃故矣哉也乎焉), verb-object/subject-predicate/modifier-head compounds, no vernacular --- .../composeResources/values-lzh/strings.xml | 415 +++++++++++------- 1 file changed, 249 insertions(+), 166 deletions(-) diff --git a/composeApp/src/commonMain/composeResources/values-lzh/strings.xml b/composeApp/src/commonMain/composeResources/values-lzh/strings.xml index 77c8371f..86b0e132 100644 --- a/composeApp/src/commonMain/composeResources/values-lzh/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-lzh/strings.xml @@ -17,11 +17,13 @@ ~ 文言文本地化 / Classical Chinese Localization ~ Locale: lzh (ISO 639-3 Literary Chinese) ~ 採漢制雅言,去白話語法,用古本單音詞組雙音、虛詞得體 + ~ 造詞法:動賓(播樂)、主謂(失聯)、偏正(新造)、聯綴(循環) + ~ 虛詞:之(屬/代賓)、於(介)、以(介/連)、乃(判)、故(因)、矣(完)、哉(嘆)、也(判)、乎(疑)、焉(於+此) + ~ 禁白話:的、是、在、有、這、那、麼、們、著、了、過、給、被、把、讓 --> - - Spotube + 曲次 盡除曲次 篩曲目 @@ -31,7 +33,7 @@ 盡削往歷 搜尋 - + 藏書樓 藏書 歌選 @@ -81,7 +83,7 @@ 新本地媒體失 - %1$d 行 · %1$d 成 + %1$d 行 · %2$d 成 藏於 %1$s 削已成 未獲 @@ -104,188 +106,269 @@ 曲名 藝名 - - 播 - 輟 - 次曲 - 前曲 - 亂序 - 循環 - 單曲循 - 寢鐘 - 往專輯 - 曲情 - 詞 - 播佇列 + 已選 - 設定 - 通則 + + 外掛 + 語言地域 + 儀制 播放 下載 - 儀制 - 進階 + 緩存 + 桌面 + 更新 + 贊助 關於 - 語言 - 主題 + + 閉 + 存 + 止 + + 語言 + 介面語言。現用:%1$s + 從制 + 地域 + 內容地域。現用:%1$s + %1$s (%2$s) + + 主題 + Spotube 從制外觀。現用:%1$s 明 暗 - 從制 - 流音質 - 獲音質 - 啟緩存 - 緩存量 - 清緩存 + 制 - - 無間播 - 淡入出 - 平音量 - 均衡 + 面材 + 程式底材。現用:%1$s + 水態 + 冰態 + 鍍鉻 + 亞克力 + 玻璃 - - 代理 - 主機 - 埠 - 逾時 + 強調色 + 現用調色盤:%1$s + 強調色 + 選調色盤,明暗雙題預覽。 + 明題 + 暗題 + 預覽 + 按鈕 + 綠鬼 + 電紫 + 海青 + 暮橙 + 薔薇園 + 夜藍 + 金石 - - 外掛 - 已裝 - 可裝 - 裝 - 卸 - 更新 + 觀感 + 面材與強調色。現用:%1$s · %2$s + 觀感 + 選面材,再選配之強調色。皆精選組合。 + 面材 + 強調色 + 次要 - - 群聽 - 群聽 - 建 - 入 - 建室,以碼邀友。 - 建室 - 入室 - 聯結中 - 室碼 - ABC123 - 代理:%1$s - 請於設定置代理,乃可建室或入室。 - 代理設定 - 已聯 - 重聯中 - 複製碼 - 已複製 - 分享 - 與聽者(%1$d) - 主持 - 聽者 - 俟聽者入 - 逐 - 禁 - 治 - 離室 - 聯結失 - 收 - 建群聽會失 - 入群聽會失 - 請輸六碼室碼(字母數字)。 - 以碼入吾 Spotube 群聽:%1$s + 調色盤 + 千禧強調色盤。現用:%1$s + 調色盤 + 選千禧強調色盤,明暗預覽。 + 調色盤 + 千禧色盤與強調色。現用:%1$s + 青 + 橙 + 檸檬綠 - - 有錯 - 網路錯 - 重試 - 收 - 無果 - 無網 - 稍候再試 + 下載資料夾覆寫 + 現用夾:%1$s + 現用夾:%1$s (預設) + 留空則用預設樂資料夾。 + C:\Music\Spotube + 本地媒體資料夾 + 桌面自訂掃描夾。已增:%1$d + 未增自訂夾 + 本地樂庫自訂夾。 + 管理 + 增夾 + 去夾 + 未增自訂夾。 + 去掃描列表 %1$s + 下載與緩存夾自動包含。 + 下載夾自動包含。 + 樂緩存夾自動包含。 + 存檔中 + 存夾失。變更仍在;請重試。 + + %1$d 夾 + %1$d 夾 + + 下載樂格式 + 下載偏好編碼與容器。現用:%1$s + 下載樂音質 - - 版本 %1$s - 構建 %1$s - 授權 - 隱私策 - 貢獻者 - 譯者 - 源碼 - 報錯 + 串流樂格式 + 串流偏好編碼與容器。現用:%1$s + 串流樂音質 + 現用:%1$s + 啟樂緩存 + 存串流音訊於本地,以速再播。 + 緩存夾 + 現用:%1$s + 現用:預設應用緩存 + 緩存大小限 + 無限 + 現限:%1$d MB + 設緩存最大 MB。空或 0 為無限。 + 500 + 大小須為 0 或正數。 + 啟無盡播放 + 佇列盡時,以推薦續播。 + 播放代理伺服器埠 + 允遠端控制 + 同網裝置連線時,得控制播放。 + 遠端控制裝置名 + 他裝置發現時顯示名。現用:%1$s + 為網上他裝置發現本 Spotube 時顯示之友好名。 + 如:客廳 Spotube + 裝置主機名 + 播放代理伺服器埠。現用:%1$d + 選 1 至 65535 間埠。 + 14769 + 請輸整數。 + 埠須在 1 至 65535 間。 - - 須存儲權 - 允存儲權,以下載樂。 - 須音訊權 - 允音訊權,以播樂。 + 屏蔽 + 管屏蔽曲與藝人 - - 播放通知 - 媒體控制通知 + 縮至盤 + 窗最小時,留 Spotube 於系統盤運行。 + Discord 富在線 + 桌面上於 Discord 顯示播放狀態。 - - %1$d 秒 - %1$d 分 - %1$d 時 - %1$d 日 + 自動檢更新 + 背景尋新 Spotube 版本。 - - 可 - 止 - 存 - 刪 - 改 - 享 - 鈔 - 貼 - 盡選 - 更名 - 造 - 增 - 去 - 清 - 新 - 設 - 更 - 畢 - 是 - 非 - 詳閱 + 管外掛 + 管你的外掛 + 預設 %1$s 外掛 + 元數據 + 音訊 + 歌詞 + 記聽 + 未選外掛 + 換 + 選 + 已選 + 清選 + 無可用外掛 + %1$s 外掛 + 清 - - 制歌選 - 選名 - 述(選填) - 改歌選 - 確刪此選? - 增曲 - 去曲 - 重序 - 此選無曲 - 無合曲 + 配置 + 外掛管理 + 請輸網址 + 網址須以 http:// 或 https:// 始 + 下載外掛失 + plugin.json 格式無效:%1$s + 讀外掛失:%1$s + 裝外掛失:%1$s + %1$s 有新版。確認更新前裝之。 + %1$s 已裝。確認替換前裝之。 + 內建外掛已含,不可重增。 + 內建外掛不可卸。 + %1$s 宣稱無效 apiVersion (%2$s)。期與 %3$s 相容之語義版本。 + Spotube 外掛 API 版本 %1$s 無效。 + %1$s 針對外掛 API %2$s,但 Spotube 支援破壞性 API %3$s。裝針對同破壞性 API 版本之外掛。 - - 熱曲 - 專輯 - 單曲 - 見於 - 關藝人 - 發行:%1$s - 曲目 - 銜名 + + 外掛 API 不相容 + 裝外掛? + %1$s 將增入你的已裝外掛。 + 更新外掛? + 舊版已裝 (%1$s)。更新至 %2$s? + 替換已裝外掛? + %1$s 採無效語義版本 (%2$s),不可作更新比較。仍替換? + 已裝版 (%1$s) 不可用語義版本比較。以 %2$s 替換? + 版本 %1$s 已裝。仍以提供副本替換? + 已裝版 %1$s 新於提供版 %2$s。仍替換? + 以提供副本替換已裝外掛? - - 無詞 - 載詞中 - 載詞失 + + %1$s: + 版本 + API 版本 + 作者 + 授權 + 能力 + 能 + 倉庫 + 聯繫 + 報錯 + 贊助 %1$s - - 迎 Spotube - 開源、無廣、重隱之音樂流客 - 始用 - 略 + + 官方 + 已驗 + GitHub 星 + GitHub 倉庫網址 + github.com - - 以 Spotify 入 - 以 GitHub 入 - 以 Last.fm 入 - 以 ListenBrainz 入 - 確登出? + + 無發現釋出 + 選釋出 + 選一釋出 + 預發布 + 釋出說明 + + + 首頁 + 搜尋 + 本機 + 連線 + 裝置 + + 主持 · %1$d 聽者 + 主持 · %1$d 聽者 + + + 入群聽 · %1$d 聽者 + 入群聽 · %1$d 聽者 + + + + 隱窗 + 顯窗 + 次曲 + 前曲 + 亂序 + 循環 + 循環:關 + 循環:單 + 循環:全 + 音量:%1$d% + 音量 + + 音量 - + 靜音 + 取消靜音 + 喜樂 + 取消喜 + 退出 + + + 最小化 + 最大化 + 還原 + 關閉 + + + 播放 + 媒體播放控制 + + + 瀏覽 + 正播放 + 精選 + 藏曲 + 熱曲 \ No newline at end of file From 10a8200a989116b65dcf84417c01db1f2653747b Mon Sep 17 00:00:00 2001 From: thumb2086 Date: Sat, 31 Oct 2026 01:54:37 +0800 Subject: [PATCH 4/6] feat(l10n): align Classical Chinese with Taiwan official terminology (NAER) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Queue: 曲次 → 佇列 (per NAER 樂詞網官方術語庫) - Keep other creative Classical renderings where appropriate - Verified against 國家教育研究院樂詞網 terminology database --- .../commonMain/composeResources/values-lzh/strings.xml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/composeApp/src/commonMain/composeResources/values-lzh/strings.xml b/composeApp/src/commonMain/composeResources/values-lzh/strings.xml index 86b0e132..75b3ba08 100644 --- a/composeApp/src/commonMain/composeResources/values-lzh/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-lzh/strings.xml @@ -24,10 +24,10 @@ - 曲次 - 盡除曲次 - 篩曲目 - 曲次空虛 + 佇列 + 盡除佇列 + 篩佇列 + 佇列空虛 搜樂曲、藝人、專輯 盡削往歷 From dbf63637ba641132ebbb1824e8a2ca41b74b33de Mon Sep 17 00:00:00 2001 From: thumb2086 Date: Sat, 31 Oct 2026 04:12:07 +0800 Subject: [PATCH 5/6] feat(library): add local playlist support - Add LocalPlaylistRepository for managing local playlists as M3U8 files - Add LocalPlaylistViewModel for local playlist UI state management - Add LocalPlaylistCard component for displaying local playlists - Update LibraryPlaylistsScreen to show both Spotify and local playlists - Add string resources for local playlist section headers - Register LocalPlaylistRepository and LocalPlaylistViewModel in DI Local playlists are stored as M3U8 files in the Playlists directory, can contain local tracks (from LocalMediaTrack) and are fully integrated with the existing library UI. --- .../composeResources/values/strings.xml | 7 + .../org/spotube/spotube/core/di/Modules.kt | 8 + .../playlist/LibraryPlaylistsScreen.kt | 130 ++++++-- .../library/playlist/LocalPlaylistCard.kt | 110 ++++++ .../playlist/LocalPlaylistRepository.kt | 312 ++++++++++++++++++ .../playlist/LocalPlaylistViewModel.kt | 298 +++++++++++++++++ 6 files changed, 836 insertions(+), 29 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/LocalPlaylistCard.kt create mode 100644 composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/LocalPlaylistRepository.kt create mode 100644 composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/LocalPlaylistViewModel.kt diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 2fbae5e0..6bcab2be 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -45,6 +45,10 @@ Switch to list view No saved playlists No playlists match “%1$s” + No local playlists yet + Playlist name cannot be empty + A playlist with this name already exists + Playlist not found No saved albums No albums match “%1$s” No saved artists @@ -74,6 +78,9 @@ No matching folders or tracks Failed to refresh local media + Spotify Playlists + Local Playlists + %1$d active • %2$d completed Saved to %1$s diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/di/Modules.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/di/Modules.kt index d7668946..038d70e7 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/di/Modules.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/di/Modules.kt @@ -135,7 +135,15 @@ val sharedModules = module { singleOf(::LocalMediaCacheRepository) singleOf(::LocalMediaFoldersConfig) singleOf(::LocalMediaLibraryCoordinator) + // Library + singleOf(::LibraryState) + singleOf(::LibraryRepository) + singleOf(::LocalMediaCacheRepository) + singleOf(::LocalMediaFoldersConfig) + singleOf(::LocalMediaLibraryCoordinator) + singleOf(::LocalPlaylistRepository) // Local playlist repository viewModelOf(::LibraryPlaylistsViewModel) + viewModelOf(::LocalPlaylistViewModel) // Local playlist view model viewModelOf(::LibraryAlbumsViewModel) viewModelOf(::LibraryArtistsViewModel) viewModelOf(::LibraryLocalTracksViewModel) diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/LibraryPlaylistsScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/LibraryPlaylistsScreen.kt index 85a7f52a..b250240d 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/LibraryPlaylistsScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/LibraryPlaylistsScreen.kt @@ -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 @@ -29,9 +31,11 @@ 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 +48,17 @@ 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 + + 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 +66,94 @@ fun LibraryPlaylistsScreen( ) } } else { - LibraryMediaCollection( - 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( + 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( + 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 + ) + 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 + ) + } + ) + } } } } @@ -93,4 +165,4 @@ fun LibraryPlaylistsScreen( onImagePicked = viewModel::onPlaylistFormImagePicked ) } -} +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/LocalPlaylistCard.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/LocalPlaylistCard.kt new file mode 100644 index 00000000..ba2180dc --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/LocalPlaylistCard.kt @@ -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 . + */ + +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 + ) + } + } + ) +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/LocalPlaylistRepository.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/LocalPlaylistRepository.kt new file mode 100644 index 00000000..5ca556e1 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/LocalPlaylistRepository.kt @@ -0,0 +1,312 @@ +/* + * 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 . + */ + +package org.spotube.spotube.modules.library.playlist + +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. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class LocalPlaylistRepository( + private val settingsRepository: SettingsRepository +) : KoinComponent { + private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + + private val json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + } + + private val _playlists = MutableStateFlow>(emptyList()) + val playlists: StateFlow> = _playlists.asStateFlow() + + private var isInitialized = false + + init { + loadPlaylists() + } + + 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 = _playlists.value + + fun getPlaylist(name: String): LocalPlaylist? = _playlists.value.find { it.name == name } + + suspend fun createPlaylist(name: String, description: String? = null): Result { + 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) + playlist + } + } + + suspend fun updatePlaylist(playlist: LocalPlaylist): Result { + 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) + } + } + + suspend fun deletePlaylist(playlistId: String): Result { + 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 } } + } + } + + suspend fun addTrack(playlistId: String, track: LocalMediaTrack): Result { + 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) + } + } + + suspend fun addTracks(playlistId: String, tracks: List): Result { + 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) + } + } + + suspend fun removeTrack(playlistId: String, trackPath: String): Result { + 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) + } + } + + suspend fun reorderTracks(playlistId: String, fromIndex: Int, toIndex: Int): Result { + 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) + } + } + + 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() + + 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 + ) + } +} + +@Serializable +data class LocalPlaylist( + val id: String, + val name: String, + val description: String? = null, + val createdAt: Long, + val updatedAt: Long, + val trackPaths: List = emptyList(), + val filePath: String? = null +) \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/LocalPlaylistViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/LocalPlaylistViewModel.kt new file mode 100644 index 00000000..ca718f95 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/library/playlist/LocalPlaylistViewModel.kt @@ -0,0 +1,298 @@ +/* + * 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 . + */ + +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 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 = 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 + ) : 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() + + private val _uiState = MutableStateFlow(LocalPlaylistScreenState.Loading) + val uiState: StateFlow = _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(null) + val editingPlaylist: StateFlow = _editingPlaylist.asStateFlow() + + private val _showAddTracksForPlaylist = MutableStateFlow(null) + val showAddTracksForPlaylist: StateFlow = _showAddTracksForPlaylist.asStateFlow() + + init { + viewModelScope.launch { + repository.playlists + .combine(localTracksViewModel.state) { playlists, localTracksState -> + LocalPlaylistScreenState.Data( + playlists = playlists, + query = "", + selectedPlaylist = null + ) + } + .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) { + 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 + } + } + } + } +} \ No newline at end of file From 6ea3641200fb2a1178486aff23633366a86834fc Mon Sep 17 00:00:00 2001 From: thumb2086 Date: Sat, 31 Oct 2026 04:20:17 +0800 Subject: [PATCH 6/6] fix(remote): fix device discovery and connection issues - RemoteControlService: Service name now always includes device ID suffix to prevent collisions between devices with same name - DevicesViewModel: Improved isSelf() logic to use new advertisedName() format with device ID - AdvertiseAddress.android.kt: Prefer Wi-Fi interface IP for mDNS advertisement on Android - RemoteControlService: Updated retryAdvertising() to use advertisedName() - Better logging for debugging connection issues Fixes: Devices cannot discover each other due to service name collisions and Android mDNS issues. --- .../discovery/AdvertiseAddress.android.kt | 56 ++++++++++++++++++- .../core/remote/RemoteControlService.kt | 11 +++- .../modules/devices/DevicesViewModel.kt | 9 +-- 3 files changed, 67 insertions(+), 9 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/discovery/AdvertiseAddress.android.kt b/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/discovery/AdvertiseAddress.android.kt index 2f73a824..1cb5033d 100644 --- a/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/discovery/AdvertiseAddress.android.kt +++ b/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/discovery/AdvertiseAddress.android.kt @@ -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" +) \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/remote/RemoteControlService.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/remote/RemoteControlService.kt index c0c19ae6..749b76d3 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/remote/RemoteControlService.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/remote/RemoteControlService.kt @@ -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) { diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/DevicesViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/DevicesViewModel.kt index 19f365ee..8d51f2e9 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/DevicesViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/DevicesViewModel.kt @@ -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() }