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/composeResources/values-lzh/strings.xml b/composeApp/src/commonMain/composeResources/values-lzh/strings.xml
new file mode 100644
index 00000000..75b3ba08
--- /dev/null
+++ b/composeApp/src/commonMain/composeResources/values-lzh/strings.xml
@@ -0,0 +1,374 @@
+
+
+
+
+ 佇列
+ 盡除佇列
+ 篩佇列
+ 佇列空虛
+
+ 搜樂曲、藝人、專輯
+ 盡削往歷
+ 搜尋
+
+
+ 藏書樓
+ 藏書
+ 歌選
+ 專輯
+ 藝人
+ 本地樂
+ 所獲
+ 覓已藏歌選
+ 覓已藏專輯
+ 覓已藏藝人
+ 覓資料夾或樂曲
+ 篩藏書
+ 罷篩
+ 制歌選
+ 新本地樂
+ 改格視
+ 改列視
+ 無藏歌選
+ 無歌選合「%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
+ 止獲
+ 重獲
+ 去獲
+
+
+ 藝人
+ 已屏
+ 好樂
+ 藏曲
+ 無名氏
+ 曲名
+ 藝名
+
+ 已選
+
+ 設定
+
+ 外掛
+ 語言地域
+ 儀制
+ 播放
+ 下載
+ 緩存
+ 桌面
+ 更新
+ 贊助
+ 關於
+
+ 閉
+ 存
+ 止
+
+ 語言
+ 介面語言。現用:%1$s
+ 從制
+ 地域
+ 內容地域。現用:%1$s
+ %1$s (%2$s)
+
+ 主題
+ Spotube 從制外觀。現用:%1$s
+ 明
+ 暗
+ 制
+
+ 面材
+ 程式底材。現用:%1$s
+ 水態
+ 冰態
+ 鍍鉻
+ 亞克力
+ 玻璃
+
+ 強調色
+ 現用調色盤:%1$s
+ 強調色
+ 選調色盤,明暗雙題預覽。
+ 明題
+ 暗題
+ 預覽
+ 按鈕
+ 綠鬼
+ 電紫
+ 海青
+ 暮橙
+ 薔薇園
+ 夜藍
+ 金石
+
+ 觀感
+ 面材與強調色。現用:%1$s · %2$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$d MB
+ 設緩存最大 MB。空或 0 為無限。
+ 500
+ 大小須為 0 或正數。
+ 啟無盡播放
+ 佇列盡時,以推薦續播。
+ 播放代理伺服器埠
+ 允遠端控制
+ 同網裝置連線時,得控制播放。
+ 遠端控制裝置名
+ 他裝置發現時顯示名。現用:%1$s
+ 為網上他裝置發現本 Spotube 時顯示之友好名。
+ 如:客廳 Spotube
+ 裝置主機名
+ 播放代理伺服器埠。現用:%1$d
+ 選 1 至 65535 間埠。
+ 14769
+ 請輸整數。
+ 埠須在 1 至 65535 間。
+
+ 屏蔽
+ 管屏蔽曲與藝人
+
+ 縮至盤
+ 窗最小時,留 Spotube 於系統盤運行。
+ Discord 富在線
+ 桌面上於 Discord 顯示播放狀態。
+
+ 自動檢更新
+ 背景尋新 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 版本之外掛。
+
+
+ 外掛 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
+
+
+ 官方
+ 已驗
+ GitHub 星
+ GitHub 倉庫網址
+ github.com
+
+
+ 無發現釋出
+ 選釋出
+ 選一釋出
+ 預發布
+ 釋出說明
+
+
+ 首頁
+ 搜尋
+ 本機
+ 連線
+ 裝置
+
+ - 主持 · %1$d 聽者
+ - 主持 · %1$d 聽者
+
+
+ - 入群聽 · %1$d 聽者
+ - 入群聽 · %1$d 聽者
+
+
+
+ 隱窗
+ 顯窗
+ 次曲
+ 前曲
+ 亂序
+ 循環
+ 循環:關
+ 循環:單
+ 循環:全
+ 音量:%1$d%
+ 音量 +
+ 音量 -
+ 靜音
+ 取消靜音
+ 喜樂
+ 取消喜
+ 退出
+
+
+ 最小化
+ 最大化
+ 還原
+ 關閉
+
+
+ 播放
+ 媒體播放控制
+
+
+ 瀏覽
+ 正播放
+ 精選
+ 藏曲
+ 熱曲
+
\ No newline at end of file
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/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()
}
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
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?