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?