diff --git a/composeApp/src/androidJvmMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeService.androidJvm.kt b/composeApp/src/androidJvmMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeService.androidJvm.kt index 39cf7930..7e4311dc 100644 --- a/composeApp/src/androidJvmMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeService.androidJvm.kt +++ b/composeApp/src/androidJvmMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeService.androidJvm.kt @@ -19,6 +19,8 @@ package org.spotube.spotube.core.newpipe import org.spotube.plugin_interfaces.plugin_apis.audio.AudioStream import org.spotube.plugin_interfaces.plugin_apis.audio.StreamProtocol +import co.touchlab.kermit.Logger +import org.spotube.spotube.core.server.describeForLog import io.ktor.http.Url import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -30,6 +32,8 @@ import org.schabi.newpipe.extractor.stream.StreamInfoItem private fun secondsToMs(seconds: Long): Long = if (seconds > 0) seconds * 1000 else 0 actual class NewPipeService { + private val logger = Logger.withTag("NewPipeService") + actual suspend fun searchVideos(query: String, kind: SearchKind): List { return withContext(Dispatchers.IO) { val extractor = YouTube.getSearchExtractor( @@ -66,6 +70,22 @@ actual class NewPipeService { val extractor = YouTube.getStreamExtractor("https://www.youtube.com/watch?v=$id") extractor.fetchPage() + // Raw extractor output, before any mapping/filtering, so stream-selection issues can be + // traced back to what YouTube actually offered for this video. + logger.i { + "[Streams $id] '${extractor.name}' raw audioStreams=${extractor.audioStreams.size}:\n" + + extractor.audioStreams.joinToString("\n") { stream -> + " itag=${stream.itag} format=${stream.format?.name} mime=${stream.format?.mimeType} " + + "suffix=${stream.format?.suffix} codec=${stream.codec} bitrate=${stream.bitrate} " + + "avgBitrate=${stream.averageBitrate} delivery=${stream.deliveryMethod} isUrl=${stream.isUrl} " + + "trackType=${stream.audioTrackType}" + } + } + logger.i { + "[Streams $id] dashMpdUrl=${extractor.dashMpdUrl.isNotBlank()} hlsUrl=${extractor.hlsUrl.isNotBlank()} " + + "raw videoStreams=${extractor.videoStreams.size} videoOnlyStreams=${extractor.videoOnlyStreams.size}" + } + val audioStreams = extractor.audioStreams .mapNotNull { stream -> AudioStream.Lossy( @@ -103,6 +123,10 @@ actual class NewPipeService { bitrate = stream.bitrate, container = stream.format!!.suffix) } + logger.i { + "[Streams $id] mapped audio candidates (${audioStreams.size}):\n" + + audioStreams.joinToString("\n") { " ${it.describeForLog()}" } + } VideoInfo( id = id, title = extractor.name, @@ -112,10 +136,18 @@ actual class NewPipeService { thumbnailUrl = extractor.thumbnails.first().url, // fallback to video streams if no audio streams are available audioStreams = audioStreams.ifEmpty { + logger.w { + "[Streams $id] no audio streams; FALLING BACK TO ${videoStreams.size} VIDEO streams " + + "(muxed video+audio, downloads will be video files):\n" + + extractor.videoStreams.joinToString("\n") { + " itag=${it.itag} format=${it.format?.name} res=${it.resolution} " + + "codec=${it.codec} videoOnly=${it.isVideoOnly} isUrl=${it.isUrl}" + } + } videoStreams }, videoStreams = videoStreams ) } } -} \ No newline at end of file +} diff --git a/composeApp/src/androidMain/kotlin/org/spotube/spotube/modules/downloads/MediaLibrary.android.kt b/composeApp/src/androidMain/kotlin/org/spotube/spotube/modules/downloads/MediaLibrary.android.kt new file mode 100644 index 00000000..672d70f4 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/org/spotube/spotube/modules/downloads/MediaLibrary.android.kt @@ -0,0 +1,27 @@ +/* + * 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.downloads + +import android.content.Context +import android.media.MediaScannerConnection +import org.koin.core.context.GlobalContext + +internal actual fun addToMediaLibrary(path: String) { + val context = GlobalContext.get().get() + MediaScannerConnection.scanFile(context, arrayOf(path), null, null) +} diff --git a/composeApp/src/commonMain/composeResources/values-ar/strings.xml b/composeApp/src/commonMain/composeResources/values-ar/strings.xml index af2a5da6..febc7e6a 100644 --- a/composeApp/src/commonMain/composeResources/values-ar/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-ar/strings.xml @@ -179,7 +179,6 @@ تخصيص مجلد التنزيل المجلد الحالي: %1$s - المجلد الحالي: مجلد الموسيقى الافتراضي اتركه فارغًا لاستخدام مجلد الموسيقى الافتراضي. C:\Music\Spotube مجلدات الوسائط المحلية diff --git a/composeApp/src/commonMain/composeResources/values-bn/strings.xml b/composeApp/src/commonMain/composeResources/values-bn/strings.xml index cf54df53..ca7c78cf 100644 --- a/composeApp/src/commonMain/composeResources/values-bn/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-bn/strings.xml @@ -171,7 +171,6 @@ ডাউনলোড ফোল্ডার পরিবর্তন বর্তমান ফোল্ডার: %1$s - বর্তমান ফোল্ডার: ডিফল্ট মিউজিক ফোল্ডার ডিফল্ট মিউজিক ফোল্ডার ব্যবহার করতে এটি ফাঁকা রাখুন। C:\Music\Spotube লোকাল মিডিয়া ফোল্ডার diff --git a/composeApp/src/commonMain/composeResources/values-de/strings.xml b/composeApp/src/commonMain/composeResources/values-de/strings.xml index 22f864c8..ec3d8da3 100644 --- a/composeApp/src/commonMain/composeResources/values-de/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-de/strings.xml @@ -171,7 +171,6 @@ Eigener Download-Ordner Aktueller Ordner: %1$s - Aktueller Ordner: Standard-Musikordner Leer lassen, um den Standard-Musikordner zu verwenden. C:\Music\Spotube Lokale Medienordner diff --git a/composeApp/src/commonMain/composeResources/values-es/strings.xml b/composeApp/src/commonMain/composeResources/values-es/strings.xml index 10092442..4fec22e5 100644 --- a/composeApp/src/commonMain/composeResources/values-es/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-es/strings.xml @@ -173,7 +173,6 @@ Carpeta de descargas personalizada Carpeta actual: %1$s - Carpeta actual: carpeta de música predeterminada Déjalo vacío para usar la carpeta de música predeterminada. C:\Music\Spotube Carpetas de contenido local diff --git a/composeApp/src/commonMain/composeResources/values-fa/strings.xml b/composeApp/src/commonMain/composeResources/values-fa/strings.xml index 84c45926..679313d5 100644 --- a/composeApp/src/commonMain/composeResources/values-fa/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-fa/strings.xml @@ -171,7 +171,6 @@ پوشهٔ دانلود سفارشی پوشهٔ فعلی: %1$s - پوشهٔ فعلی: پوشهٔ پیش‌فرض موسیقی برای استفاده از پوشهٔ پیش‌فرض موسیقی، این قسمت را خالی بگذارید. C:\Music\Spotube پوشه‌های رسانهٔ محلی diff --git a/composeApp/src/commonMain/composeResources/values-fr/strings.xml b/composeApp/src/commonMain/composeResources/values-fr/strings.xml index b5be84d8..644cb9f9 100644 --- a/composeApp/src/commonMain/composeResources/values-fr/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-fr/strings.xml @@ -173,7 +173,6 @@ Dossier de téléchargement personnalisé Dossier actuel : %1$s - Dossier actuel : dossier Musique par défaut Laissez vide pour utiliser le dossier Musique par défaut. C:\Music\Spotube Dossiers de médias locaux diff --git a/composeApp/src/commonMain/composeResources/values-hi/strings.xml b/composeApp/src/commonMain/composeResources/values-hi/strings.xml index 15344dcd..4deeb649 100644 --- a/composeApp/src/commonMain/composeResources/values-hi/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-hi/strings.xml @@ -170,7 +170,6 @@ डाउनलोड फ़ोल्डर बदलें मौजूदा फ़ोल्डर: %1$s - मौजूदा फ़ोल्डर: डिफ़ॉल्ट म्यूज़िक फ़ोल्डर डिफ़ॉल्ट म्यूज़िक फ़ोल्डर इस्तेमाल करने के लिए इसे खाली छोड़ें। C:\Music\Spotube लोकल मीडिया फ़ोल्डर diff --git a/composeApp/src/commonMain/composeResources/values-id/strings.xml b/composeApp/src/commonMain/composeResources/values-id/strings.xml index f189a383..27c80b88 100644 --- a/composeApp/src/commonMain/composeResources/values-id/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-id/strings.xml @@ -169,7 +169,6 @@ Ganti folder unduhan Folder saat ini: %1$s - Folder saat ini: folder musik bawaan Biarkan kosong untuk memakai folder musik bawaan. C:\Music\Spotube Folder media lokal diff --git a/composeApp/src/commonMain/composeResources/values-it/strings.xml b/composeApp/src/commonMain/composeResources/values-it/strings.xml index 67ce22bd..36196910 100644 --- a/composeApp/src/commonMain/composeResources/values-it/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-it/strings.xml @@ -173,7 +173,6 @@ Cartella di download personalizzata Cartella attuale: %1$s - Cartella attuale: cartella Musica predefinita Lascia vuoto per usare la cartella Musica predefinita. C:\Music\Spotube Cartelle multimediali locali diff --git a/composeApp/src/commonMain/composeResources/values-ja/strings.xml b/composeApp/src/commonMain/composeResources/values-ja/strings.xml index c01033a0..c6b4195d 100644 --- a/composeApp/src/commonMain/composeResources/values-ja/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-ja/strings.xml @@ -169,7 +169,6 @@ ダウンロードフォルダの変更 現在のフォルダ: %1$s - 現在のフォルダ: デフォルトの音楽フォルダ 空欄にするとデフォルトの音楽フォルダを使用します。 C:\Music\Spotube ローカルメディアフォルダ diff --git a/composeApp/src/commonMain/composeResources/values-ko/strings.xml b/composeApp/src/commonMain/composeResources/values-ko/strings.xml index 1db8b5e5..cad8b072 100644 --- a/composeApp/src/commonMain/composeResources/values-ko/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-ko/strings.xml @@ -169,7 +169,6 @@ 다운로드 폴더 지정 현재 폴더: %1$s - 현재 폴더: 기본 음악 폴더 기본 음악 폴더를 사용하려면 비워 두세요. C:\Music\Spotube 로컬 미디어 폴더 diff --git a/composeApp/src/commonMain/composeResources/values-nl/strings.xml b/composeApp/src/commonMain/composeResources/values-nl/strings.xml index b6ab0848..3206d0d6 100644 --- a/composeApp/src/commonMain/composeResources/values-nl/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-nl/strings.xml @@ -171,7 +171,6 @@ Andere downloadmap Huidige map: %1$s - Huidige map: standaard muziekmap Laat dit leeg om de standaard muziekmap te gebruiken. C:\Music\Spotube Lokale mediamappen diff --git a/composeApp/src/commonMain/composeResources/values-pl/strings.xml b/composeApp/src/commonMain/composeResources/values-pl/strings.xml index 56dda439..fa6bcfb6 100644 --- a/composeApp/src/commonMain/composeResources/values-pl/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-pl/strings.xml @@ -175,7 +175,6 @@ Własny folder pobierania Obecny folder: %1$s - Obecny folder: domyślny folder muzyki Pozostaw puste, aby używać domyślnego folderu muzyki. C:\Music\Spotube Foldery lokalnych multimediów diff --git a/composeApp/src/commonMain/composeResources/values-pt/strings.xml b/composeApp/src/commonMain/composeResources/values-pt/strings.xml index 95dcb260..f7c11447 100644 --- a/composeApp/src/commonMain/composeResources/values-pt/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-pt/strings.xml @@ -173,7 +173,6 @@ Pasta de download personalizada Pasta atual: %1$s - Pasta atual: pasta de músicas padrão Deixe em branco para usar a pasta de músicas padrão. C:\Music\Spotube Pastas de mídia local diff --git a/composeApp/src/commonMain/composeResources/values-ru/strings.xml b/composeApp/src/commonMain/composeResources/values-ru/strings.xml index 2ce7ca5a..28b5450e 100644 --- a/composeApp/src/commonMain/composeResources/values-ru/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-ru/strings.xml @@ -175,7 +175,6 @@ Своя папка для загрузок Текущая папка: %1$s - Текущая папка: стандартная папка «Музыка» Оставьте пустым, чтобы использовать стандартную папку «Музыка». C:\Music\Spotube Папки с локальной музыкой diff --git a/composeApp/src/commonMain/composeResources/values-th/strings.xml b/composeApp/src/commonMain/composeResources/values-th/strings.xml index 246fb93e..2b14b08d 100644 --- a/composeApp/src/commonMain/composeResources/values-th/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-th/strings.xml @@ -169,7 +169,6 @@ กำหนดโฟลเดอร์ดาวน์โหลดเอง โฟลเดอร์ปัจจุบัน: %1$s - โฟลเดอร์ปัจจุบัน: โฟลเดอร์เพลงเริ่มต้น เว้นว่างไว้เพื่อใช้โฟลเดอร์เพลงเริ่มต้น C:\Music\Spotube โฟลเดอร์สื่อในเครื่อง diff --git a/composeApp/src/commonMain/composeResources/values-tr/strings.xml b/composeApp/src/commonMain/composeResources/values-tr/strings.xml index 9024bbca..123d18cd 100644 --- a/composeApp/src/commonMain/composeResources/values-tr/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-tr/strings.xml @@ -171,7 +171,6 @@ Özel indirme klasörü Geçerli klasör: %1$s - Geçerli klasör: varsayılan müzik klasörü Varsayılan müzik klasörünü kullanmak için boş bırak. C:\Music\Spotube Yerel medya klasörleri diff --git a/composeApp/src/commonMain/composeResources/values-uk/strings.xml b/composeApp/src/commonMain/composeResources/values-uk/strings.xml index cb9bf14f..0fbd6d0e 100644 --- a/composeApp/src/commonMain/composeResources/values-uk/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-uk/strings.xml @@ -175,7 +175,6 @@ Власна папка для завантажень Поточна папка: %1$s - Поточна папка: стандартна папка «Музика» Залиште порожнім, щоб використовувати стандартну папку «Музика». C:\Music\Spotube Папки з локальною музикою diff --git a/composeApp/src/commonMain/composeResources/values-vi/strings.xml b/composeApp/src/commonMain/composeResources/values-vi/strings.xml index 6a6b75fb..241c50f4 100644 --- a/composeApp/src/commonMain/composeResources/values-vi/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-vi/strings.xml @@ -169,7 +169,6 @@ Thư mục tải xuống tùy chỉnh Thư mục hiện tại: %1$s - Thư mục hiện tại: thư mục nhạc mặc định Để trống để dùng thư mục nhạc mặc định. C:\Music\Spotube Thư mục nhạc trên máy diff --git a/composeApp/src/commonMain/composeResources/values-zh-rTW/strings.xml b/composeApp/src/commonMain/composeResources/values-zh-rTW/strings.xml index 7aaee59d..e10e720e 100644 --- a/composeApp/src/commonMain/composeResources/values-zh-rTW/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-zh-rTW/strings.xml @@ -169,7 +169,6 @@ 自訂下載資料夾 目前資料夾:%1$s - 目前資料夾:預設音樂資料夾 留空即使用預設音樂資料夾。 C:\Music\Spotube 本機媒體資料夾 diff --git a/composeApp/src/commonMain/composeResources/values-zh/strings.xml b/composeApp/src/commonMain/composeResources/values-zh/strings.xml index 1caaa3c2..b247fac7 100644 --- a/composeApp/src/commonMain/composeResources/values-zh/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-zh/strings.xml @@ -169,7 +169,6 @@ 自定义下载文件夹 当前文件夹:%1$s - 当前文件夹:默认音乐文件夹 留空则使用默认音乐文件夹。 C:\Music\Spotube 本地媒体文件夹 diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index f7acd6a9..9cfcb895 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -76,6 +76,7 @@ %1$d active • %2$d completed + Saved to %1$s Clear completed No downloads yet Downloading %1$d% @@ -173,7 +174,7 @@ Download folder override Current folder: %1$s - Current folder: default music folder + Current folder: %1$s (default) Leave this empty to use the default music folder. C:\Music\Spotube Local media folders @@ -519,6 +520,7 @@ Resolving... Connected Available devices + This device Connected Open remote Search again @@ -925,6 +927,14 @@ On Device Connect Devices + + Hosting · %1$d listener + Hosting · %1$d listeners + + + In a Jam · %1$d listener + In a Jam · %1$d listeners + Hide Window 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 cd8e2742..5022fcd5 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 @@ -49,6 +49,7 @@ import org.spotube.spotube.modules.blacklist.BlacklistRepository import org.spotube.spotube.modules.blacklist.BlacklistViewModel import org.spotube.spotube.modules.devices.DevicesViewModel import org.spotube.spotube.modules.devices.RemoteControlViewModel +import org.spotube.spotube.modules.shell.ConnectStatusViewModel import org.spotube.spotube.modules.jam.JamViewModel import org.spotube.spotube.modules.downloads.DownloadManager import org.spotube.spotube.modules.downloads.DownloadsViewModel @@ -219,6 +220,7 @@ val sharedModules = module { viewModelOf(::BlacklistViewModel) viewModel { DevicesViewModel(get()) } viewModelOf(::RemoteControlViewModel) + viewModelOf(::ConnectStatusViewModel) viewModel { JamViewModel( jamRoomService = get(), diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamingUrlRepository.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamingUrlRepository.kt index 3375f2d2..3a20b697 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamingUrlRepository.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamingUrlRepository.kt @@ -102,10 +102,19 @@ class StreamingUrlRepository( logger.w { "No audio plugin selected while resolving stream for track $trackId" } return null } + // NOTE: always the *streaming* preferences, also when called by the downloader. + logger.i { + "[Resolve $trackId] '${track.title}' plugin=${audioPlugin.pluginId} forceRefresh=$forceRefresh " + + "prefs: format=${preferredFormat.codec}/${preferredFormat.container} quality=$preferredQuality " + + "(streamingMusicFormat/streamingMusicQuality)" + } if (!forceRefresh) { getCachedStreamUrl(trackId, preferredFormat, preferredQuality, audioPlugin.pluginId)?.let { cached -> - logger.v { "Using cached stream URL for track $trackId" } + logger.i { + "[Resolve $trackId] cache hit: codec=${cached.codec} container=${cached.container} " + + "protocol=${cached.protocol} ${urlSummary(cached.url)}" + } return cached } } @@ -130,6 +139,7 @@ class StreamingUrlRepository( stream.streams, preferredFormat, preferredQuality) + logSelection(trackId, "matched source ${source.id}", stream.streams, preferredFormat, preferredQuality, resolvedStream) if (resolvedStream != null) { val info = resolvedStream.toStreamInfo() cacheStreamInfo(trackId, info, preferredFormat, preferredQuality, stream, resolvedStream, audioPlugin.pluginId) @@ -189,6 +199,7 @@ class StreamingUrlRepository( stream.streams, preferredFormat, preferredQuality) + logSelection(trackId, "track lookup -> ${stream.id}", stream.streams, preferredFormat, preferredQuality, resolvedStream) if (resolvedStream != null) { val info = resolvedStream.toStreamInfo() cacheStreamInfo(trackId, info, preferredFormat, preferredQuality, stream, resolvedStream, audioPlugin.pluginId) @@ -197,6 +208,38 @@ class StreamingUrlRepository( return null } + /** Logs every candidate with its ranking key so it's obvious *why* a stream won. */ + private fun logSelection( + trackId: String, + origin: String, + streams: List, + preferredFormat: AudioFormat, + preferredQuality: AudioQuality, + selected: AudioStream?) { + logger.i { + "[Resolve $trackId] selecting from ${streams.size} candidates ($origin), " + + "want ${preferredFormat.codec}/${preferredFormat.container} $preferredQuality " + + "(rank key = formatMismatch, qualityTypeMismatch, qualityDistance, channelDistance; lowest wins):\n" + + streams.joinToString("\n") { stream -> + val marker = if (stream === selected) " -> " else " " + "$marker${stream.describeForLog()} key=(${formatMismatchCount(stream, preferredFormat)}, " + + "${if (stream.matchesQualityType(preferredQuality)) 0 else 1}, " + + "${stream.qualityDistance(preferredQuality).let { if (it == Long.MAX_VALUE) "inf" else "$it" }}, " + + "${stream.channelDistance(preferredQuality)}) ${urlSummary(stream.url)}" + } + } + when { + selected == null -> logger.w { "[Resolve $trackId] no stream selected" } + formatMismatchCount(selected, preferredFormat) > 0 -> logger.w { + "[Resolve $trackId] FORMAT FALLBACK: wanted ${preferredFormat.codec}/${preferredFormat.container}, " + + "got ${selected.codec}/${selected.container} (${selected.protocol})" + } + selected.qualityDistance(preferredQuality) != 0L -> logger.w { + "[Resolve $trackId] QUALITY FALLBACK: wanted $preferredQuality, got ${selected.describeForLog()}" + } + } + } + private fun AudioStream.toStreamInfo() = StreamInfo( url = normalizeManifestUrl(url, protocol), protocol = protocol, @@ -353,3 +396,20 @@ private fun AudioStream.channelDistance(preferredQuality: AudioQuality): Int = w else -> 0 } + +internal fun AudioStream.describeForLog(): String = when (this) { + is AudioStream.Lossy -> "lossy codec=$codec container=$container bitrate=$bitrate protocol=$protocol" + is AudioStream.Lossless -> + "lossless codec=$codec container=$container sampleRate=$sampleRate channels=$channels protocol=$protocol" +} + +/** + * Host plus the query params that identify a YouTube stream (itag, mime, clen = byte size, dur), + * instead of the full signed URL. + */ +internal fun urlSummary(url: String): String = runCatching { + val parsed = io.ktor.http.Url(url) + val keys = listOf("itag", "mime", "clen", "dur") + val params = keys.mapNotNull { key -> parsed.parameters[key]?.let { "$key=$it" } } + "url=${parsed.host}${parsed.encodedPath.take(40)}" + if (params.isEmpty()) "" else " ${params.joinToString(" ")}" +}.getOrDefault("url=") diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/DevicesScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/DevicesScreen.kt index aef1a96f..58067ff9 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/DevicesScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/DevicesScreen.kt @@ -58,8 +58,10 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.spotube.spotube.core.discovery.rememberLocalNetworkPermissionRequester import org.spotube.spotube.core.ui.component.ApplicationMainBar +import org.spotube.spotube.modules.settings.SettingsViewModel import org.spotube.spotube.modules.settings.components.SettingCardItem import org.spotube.spotube.modules.settings.sections.SettingsItemIcon +import org.spotube.spotube.modules.settings.sections.remoteControlSettingCards import org.spotube.spotube.modules.settings.sections.settingsSectionCard import org.spotube.spotube.modules.settings.sections.settingsSectionHeader import org.spotube.spotube.modules.shell.LocalAppShellBottomInset @@ -83,6 +85,7 @@ import spotube.composeapp.generated.resources.devices_searching import spotube.composeapp.generated.resources.devices_searching_more import spotube.composeapp.generated.resources.devices_section_available import spotube.composeapp.generated.resources.devices_section_connected +import spotube.composeapp.generated.resources.devices_section_this_device import spotube.composeapp.generated.resources.devices_status_connected import spotube.composeapp.generated.resources.devices_title import spotube.composeapp.generated.resources.devices_unknown_device @@ -92,9 +95,11 @@ private val ContentMaxWidth = 720.dp @Composable fun DevicesScreen( - viewModel: DevicesViewModel = koinViewModel() + viewModel: DevicesViewModel = koinViewModel(), + settingsViewModel: SettingsViewModel = koinViewModel() ) { val state by viewModel.uiState.collectAsStateWithLifecycle() + val settings by settingsViewModel.settingsState.collectAsStateWithLifecycle() val requestLocalNetworkPermission = rememberLocalNetworkPermissionRequester() val shellBottomInset = LocalAppShellBottomInset.current @@ -209,6 +214,15 @@ fun DevicesScreen( ) } } + + // Same cards as Settings > Playback, so making this device controllable (or renaming + // it) doesn't require a detour through Settings. + settings?.let { current -> + settingsSectionHeader(Res.string.devices_section_this_device) + settingsSectionCard( + items = remoteControlSettingCards(current, settingsViewModel, requestLocalNetworkPermission) + ) + } } } } @@ -347,11 +361,11 @@ private fun LazyItemScope.EmptyState( isDiscovering: Boolean, onSearchAgain: () -> Unit ) { + // Fixed padding rather than filling the viewport, so the "This device" section below stays visible. Box( modifier = Modifier - .fillParentMaxHeight(0.8f) .fillParentMaxWidth() - .padding(24.dp), + .padding(horizontal = 24.dp, vertical = 48.dp), contentAlignment = Alignment.Center ) { Column( diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/DownloadManager.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/DownloadManager.kt index d2261217..d2f91aae 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/DownloadManager.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/DownloadManager.kt @@ -22,6 +22,8 @@ import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import org.spotube.spotube.core.paths.Paths import org.spotube.spotube.core.server.StreamInfo import org.spotube.spotube.core.server.StreamingUrlRepository +import org.spotube.spotube.core.server.urlSummary +import org.spotube.plugin_interfaces.plugin_apis.audio.StreamProtocol import org.spotube.spotube.modules.settings.SettingsRepository import io.ktor.client.HttpClient import io.ktor.client.plugins.HttpTimeout @@ -31,6 +33,7 @@ import io.ktor.client.request.headers import io.ktor.client.statement.bodyAsChannel import io.ktor.http.HttpHeaders import io.ktor.http.contentLength +import io.ktor.http.isSuccess import io.ktor.utils.io.readRemaining import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope @@ -112,6 +115,11 @@ class DownloadManager( private val cachedDownloadFolder = MutableStateFlow(null) + /** Where downloads are saved right now: the user's override, else the platform default. */ + val downloadFolder: StateFlow = cachedDownloadFolder + .map { it?.takeIf(String::isNotBlank) ?: paths.getUserDownloadsDirPath() } + .stateIn(scope, SharingStarted.Eagerly, paths.getUserDownloadsDirPath()) + private val activeJobs = mutableMapOf() init { @@ -135,6 +143,13 @@ class DownloadManager( track = track) downloadsMap.update { it + (id to item) } + val settings = settingsRepository.userSettings.value + logger.i { + "[Download $id] enqueued '${track.title}' by $artistNames (track=${track.id}) " + + "downloadPrefs=${settings.downloadMusicFormat.codec}/${settings.downloadMusicFormat.container} " + + "${settings.downloadMusicQuality} streamingPrefs=${settings.streamingMusicFormat.codec}/" + + "${settings.streamingMusicFormat.container} ${settings.streamingMusicQuality} folder=${downloadFolder.value}" + } scope.launch { processQueue() } } @@ -210,6 +225,7 @@ class DownloadManager( } try { + logger.i { "[Download ${item.id}] resolving stream for '${track.title}'" } val streamInfo = streamingUrlRepository.resolveStreamInfo(track) if (streamInfo == null) { logger.w { "Could not resolve stream URL for ${track.title}" } @@ -226,6 +242,7 @@ class DownloadManager( } val url = streamInfo.url + logResolvedStream(item, streamInfo) val filename = buildFilename(item, streamInfo) @@ -235,15 +252,28 @@ class DownloadManager( val tempDir = FileSystem.SYSTEM_TEMPORARY_DIRECTORY / "spotube_downloads".toPath() fileSystem.createDirectories(tempDir) - val (totalSize, supportsRanges) = probeUrl(url) + val (totalSize, supportsRanges) = probeUrl(item.id, url) if (totalSize > 0 && supportsRanges) { + logger.i { "[Download ${item.id}] multi-segment download ($SEGMENT_COUNT segments, $totalSize bytes) -> ${downloadDir / filename}" } executeMultiSegmentDownload(url, filename, item, tempDir, downloadDir, totalSize) } else { + logger.i { "[Download ${item.id}] single-connection download (size=$totalSize ranges=$supportsRanges) -> ${downloadDir / filename}" } executeSingleConnectionDownload(url, filename, item, tempDir, downloadDir) } + val finalPath = downloadDir / filename.toPath() + val writtenBytes = fileSystem.metadataOrNull(finalPath)?.size + logger.i { "[Download ${item.id}] wrote $finalPath ($writtenBytes bytes, expected $totalSize)" } + if (totalSize > 0 && writtenBytes != null && writtenBytes != totalSize) { + logger.w { "[Download ${item.id}] SIZE MISMATCH: wrote $writtenBytes bytes, server reported $totalSize" } + } + applyMetadata(item, filename, downloadDir) + // Without this Android music players and the Files app's Audio view don't see the + // file until the next full media scan. + addToMediaLibrary((downloadDir / filename.toPath()).toString()) + logger.i { "[Download ${item.id}] completed '${item.title}'" } downloadsMap.update { map -> map[item.id]?.let { @@ -251,6 +281,7 @@ class DownloadManager( } ?: map } } catch (e: CancellationException) { + logger.i { "[Download ${item.id}] cancelled" } downloadsMap.update { map -> map[item.id]?.let { map + (item.id to it.copy(status = DownloadStatus.Cancelled)) @@ -258,7 +289,7 @@ class DownloadManager( } throw e } catch (e: Exception) { - logger.e(e) { "Download failed: ${item.title}" } + logger.e(e) { "[Download ${item.id}] FAILED '${item.title}': ${e.message}" } val failureMessage = e.message ?: getString(Res.string.common_unknown_error) downloadsMap.update { map -> map[item.id]?.let { @@ -322,6 +353,15 @@ class DownloadManager( append(HttpHeaders.Range, "bytes=${segment.start}-${segment.end}") } } + logger.d { + "[Download ${item.id}] segment ${segment.index} bytes=${segment.start}-${segment.end} -> " + + "HTTP ${response.status.value} type=${response.headers[HttpHeaders.ContentType]} " + + "range=${response.headers[HttpHeaders.ContentRange]}" + } + // expectSuccess = false, so an error page would otherwise be saved as audio. + if (!response.status.isSuccess()) { + logger.w { "[Download ${item.id}] segment ${segment.index} got HTTP ${response.status}; its body will be written as audio" } + } val channel = response.bodyAsChannel() fileSystem.sink(outputPath).buffer().use { sink -> @@ -344,6 +384,13 @@ class DownloadManager( val response = httpClient.get(url) val totalSize = response.contentLength() ?: 0L + logger.i { + "[Download ${item.id}] GET -> HTTP ${response.status.value} " + + "type=${response.headers[HttpHeaders.ContentType]} length=$totalSize" + } + if (!response.status.isSuccess()) { + logger.w { "[Download ${item.id}] GET got HTTP ${response.status}; its body will be written as audio" } + } val channel = response.bodyAsChannel() fileSystem.sink(tempFile).buffer().use { sink -> @@ -375,22 +422,55 @@ class DownloadManager( } } - private suspend fun probeUrl(url: String): Pair { + private suspend fun probeUrl(id: String, url: String): Pair { return try { val response = httpClient.head(url) val contentLength = response.contentLength() ?: 0L val acceptRanges = response.headers[HttpHeaders.AcceptRanges] val supportsRanges = acceptRanges?.equals("bytes", ignoreCase = true) == true + val contentType = response.headers[HttpHeaders.ContentType] + logger.i { + "[Download $id] HEAD -> HTTP ${response.status.value} type=$contentType " + + "length=$contentLength acceptRanges=$acceptRanges" + } + if (contentType?.startsWith("video/") == true) { + logger.w { "[Download $id] server says this is VIDEO ($contentType), not audio" } + } Pair(contentLength, supportsRanges) } catch (e: Exception) { - logger.w(e) { "HEAD request failed, falling back to single connection" } + logger.w(e) { "[Download $id] HEAD request failed, falling back to single connection" } Pair(0L, false) } } + private fun logResolvedStream(item: DownloadItem, info: StreamInfo) { + val settings = settingsRepository.userSettings.value + val wanted = settings.downloadMusicFormat + logger.i { + "[Download ${item.id}] resolved stream codec=${info.codec} container=${info.container} " + + "protocol=${info.protocol} ${urlSummary(info.url)}" + } + if (info.protocol != StreamProtocol.PROGRESSIVE) { + logger.w { + "[Download ${item.id}] stream is a ${info.protocol} MANIFEST; a plain GET saves the manifest, not audio" + } + } + if (!info.codec.equals(wanted.codec, ignoreCase = true) || !info.container.equals(wanted.container, ignoreCase = true)) { + logger.w { + "[Download ${item.id}] DOWNLOAD FORMAT NOT RESPECTED: download setting is ${wanted.codec}/${wanted.container} " + + "${settings.downloadMusicQuality}, got ${info.codec}/${info.container} " + + "(resolver used streaming prefs ${settings.streamingMusicFormat.codec}/${settings.streamingMusicFormat.container})" + } + } + } + private suspend fun applyMetadata(item: DownloadItem, filename: String, downloadDir: Path) { val filePath = downloadDir / filename.toPath() - if (!fileSystem.exists(filePath)) return + if (!fileSystem.exists(filePath)) { + logger.w { "[Download ${item.id}] skipping metadata: $filePath does not exist" } + return + } + logger.d { "[Download ${item.id}] writing metadata to $filePath" } val track = item.track ?: return @@ -421,18 +501,11 @@ class DownloadManager( discNumber = track.discNumber, coverBytes = coverBytes) } catch (e: Exception) { - logger.w(e) { "Failed to write audio metadata for ${item.title}" } + logger.w(e) { "[Download ${item.id}] failed to write audio metadata for ${item.title}" } } } - private fun resolveDownloadDir(): Path { - val folder = cachedDownloadFolder.value - return if (!folder.isNullOrBlank()) { - folder.toPath() - } else { - paths.getUserDownloadsDirPath().toPath() - } - } + private fun resolveDownloadDir(): Path = downloadFolder.value.toPath() private fun cleanupTempFiles(id: String) { try { diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/DownloadsScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/DownloadsScreen.kt index 8d9c610c..8bcee0fa 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/DownloadsScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/DownloadsScreen.kt @@ -100,11 +100,18 @@ internal fun DownloadsToolbarTitle() { @Composable internal fun DownloadsToolbarSummary(state: DownloadsUiState) { - if (state.items.isNotEmpty()) Text( - stringResource(Res.string.downloads_summary, state.activeCount, state.completedCount), - modifier = Modifier.padding(start = 28.dp), - style = AppTheme.typography.bodySmall, color = AppTheme.colors.onSurfaceVariant - ) + Column(Modifier.padding(start = 28.dp)) { + if (state.items.isNotEmpty()) Text( + stringResource(Res.string.downloads_summary, state.activeCount, state.completedCount), + style = AppTheme.typography.bodySmall, color = AppTheme.colors.onSurfaceVariant + ) + // Always visible (empty list included), so users know where their files end up. + if (state.folder.isNotBlank()) Text( + stringResource(Res.string.downloads_saved_to, state.folder), + style = AppTheme.typography.bodySmall, color = MillenniumTheme.colors.muted, + maxLines = 2, overflow = TextOverflow.Ellipsis + ) + } } @Composable diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/DownloadsViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/DownloadsViewModel.kt index 68ab8d7d..8ee69171 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/DownloadsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/DownloadsViewModel.kt @@ -23,7 +23,7 @@ import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.combine enum class DownloadRowAction { Cancel, Retry, Remove } @@ -43,10 +43,12 @@ data class DownloadsUiState( val items: List = emptyList(), val activeCount: Int = 0, val completedCount: Int = 0, - val canClearCompleted: Boolean = false + val canClearCompleted: Boolean = false, + /** Folder new downloads are saved to, shown so users can find their files. */ + val folder: String = "" ) -internal fun downloadsUiState(items: List): DownloadsUiState { +internal fun downloadsUiState(items: List, folder: String = ""): DownloadsUiState { val completedCount = items.count { it.status == DownloadStatus.Completed } return DownloadsUiState( items = items.map { item -> @@ -66,7 +68,8 @@ internal fun downloadsUiState(items: List): DownloadsUiState { }, activeCount = items.count { it.status == DownloadStatus.Downloading || it.status == DownloadStatus.Queued }, completedCount = completedCount, - canClearCompleted = completedCount > 0 + canClearCompleted = completedCount > 0, + folder = folder ) } @@ -79,7 +82,7 @@ class DownloadsViewModel( emptyList() ) - val uiState = downloadManager.downloadsFlow.map(::downloadsUiState) + val uiState = combine(downloadManager.downloadsFlow, downloadManager.downloadFolder, ::downloadsUiState) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), DownloadsUiState()) fun downloadTrack(track: MetadataTrack) { diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/MediaLibrary.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/MediaLibrary.kt new file mode 100644 index 00000000..75340bb2 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/MediaLibrary.kt @@ -0,0 +1,21 @@ +/* + * 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.downloads + +/** Tells the OS media index about a finished download so other apps can see it right away. */ +internal expect fun addToMediaLibrary(path: String) diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/home/HomeScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/home/HomeScreen.kt index f3e32ebd..2a8a8944 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/home/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/home/HomeScreen.kt @@ -75,13 +75,14 @@ import org.spotube.spotube.core.ui.component.dragScrollable import org.spotube.spotube.core.ui.misc.SkeletonTree import org.spotube.spotube.core.navigation.NavigationCommands import org.spotube.spotube.core.navigation.Routes +import org.spotube.spotube.modules.shell.ConnectStatusBanner +import org.spotube.spotube.resources.iconsax.IconsaxSoundTwotone import org.spotube.spotube.getPlatform import org.spotube.spotube.modules.shell.LocalAppShellBottomInset import org.spotube.spotube.modules.support.LocalSupportPrompt import org.spotube.spotube.resources.iconsax.Iconsax import org.spotube.spotube.resources.iconsax.IconsaxHeart2 import org.spotube.spotube.resources.iconsax.IconsaxMirroringScreen -import org.spotube.spotube.resources.iconsax.User import org.jetbrains.compose.resources.stringResource import org.koin.compose.koinInject import spotube.composeapp.generated.resources.Res @@ -93,6 +94,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import org.jetbrains.compose.resources.Font import org.spotube.millennium.adaptive.WindowBreakpoints +import org.spotube.spotube.resources.iconsax.IconsaxMirrorScreenRegular import spotube.composeapp.generated.resources.cookie_regular @Composable @@ -161,7 +163,7 @@ fun HomeScreen(viewModel: HomeScreenViewModel) { style = MillenniumIconButtonStyle.Quiet ) { MillenniumIcon( - imageVector = Iconsax.IconsaxMirroringScreen, + imageVector = Iconsax.IconsaxMirrorScreenRegular, contentDescription = stringResource(Res.string.devices_title) ) } @@ -170,7 +172,7 @@ fun HomeScreen(viewModel: HomeScreenViewModel) { style = MillenniumIconButtonStyle.Quiet ) { MillenniumIcon( - imageVector = Iconsax.User, + imageVector = Iconsax.IconsaxSoundTwotone, contentDescription = stringResource(Res.string.jam_screen_title) ) } @@ -186,6 +188,12 @@ fun HomeScreen(viewModel: HomeScreenViewModel) { } } }) + if (isSmallScreen) { + ConnectStatusBanner( + onOpenRemote = { navigationCommands.navigateTo(Routes.RemoteControl) }, + onOpenJam = { navigationCommands.navigateTo(Routes.Jam) } + ) + } Box( modifier = Modifier .fillMaxSize() diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsViewModel.kt index 8fd6741a..c629111a 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsViewModel.kt @@ -23,16 +23,21 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import org.spotube.spotube.core.paths.Paths interface SettingsProvider { val settingsState: StateFlow } class SettingsViewModel( - private val repository: SettingsRepository + private val repository: SettingsRepository, + private val paths: Paths ) : ViewModel(), SettingsProvider { override val settingsState = repository.userSettings + /** Where downloads go when no override folder is set. */ + val defaultDownloadFolder: String = paths.getUserDownloadsDirPath() + fun updateSettings(transform: UserSettings.() -> UserSettings) { viewModelScope.launch { // Get current state, apply transform, and save diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/DownloadsSection.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/DownloadsSection.kt index 37b7bf71..fb5289d4 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/DownloadsSection.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/DownloadsSection.kt @@ -46,7 +46,7 @@ import io.github.vinceglb.filekit.path import org.jetbrains.compose.resources.stringResource import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.settings_download_folder_current -import spotube.composeapp.generated.resources.settings_download_folder_default +import spotube.composeapp.generated.resources.settings_download_folder_default_path import spotube.composeapp.generated.resources.settings_download_folder_title import spotube.composeapp.generated.resources.settings_download_format_subtitle_current import spotube.composeapp.generated.resources.settings_download_format_title @@ -77,6 +77,7 @@ internal fun LazyListScope.downloadsSection( { DownloadFolderSettingCard( folder = settings.overloadedDownloadFolder, + defaultFolder = settingsViewModel.defaultDownloadFolder, onFolderSelected = { folder -> settingsViewModel.updateSettings { copy(overloadedDownloadFolder = folder) @@ -142,6 +143,7 @@ internal fun LazyListScope.downloadsSection( @Composable private fun DownloadFolderSettingCard( folder: String?, + defaultFolder: String, onFolderSelected: (String?) -> Unit ) { val pickerLauncher = rememberDirectoryPickerLauncher { directory -> @@ -157,7 +159,7 @@ private fun DownloadFolderSettingCard( title = stringResource(Res.string.settings_download_folder_title), subtitle = folder?.let { stringResource(Res.string.settings_download_folder_current, it) - } ?: stringResource(Res.string.settings_download_folder_default), + } ?: stringResource(Res.string.settings_download_folder_default_path, defaultFolder), icon = { SettingsItemIcon( Iconsax.IconsaxDirectboxReceive, diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/PlaybackSection.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/PlaybackSection.kt index 7688d0b6..c60df146 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/PlaybackSection.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/PlaybackSection.kt @@ -41,9 +41,7 @@ import org.spotube.spotube.modules.settings.components.SwitchSettingCard import org.spotube.spotube.modules.settings.components.TextInputSettingCard import org.spotube.spotube.resources.iconsax.CustomServer import org.spotube.spotube.resources.iconsax.Iconsax -import org.spotube.spotube.resources.iconsax.IconsaxEdit import org.spotube.spotube.resources.iconsax.IconsaxForbidden -import org.spotube.spotube.resources.iconsax.IconsaxMirroringScreen import org.spotube.spotube.resources.iconsax.IconsaxMusicPlay import org.spotube.spotube.resources.iconsax.IconsaxRepeatArrow import org.spotube.spotube.resources.iconsax.IconsaxVerticalSetting @@ -122,51 +120,7 @@ internal fun LazyListScope.playbackSection( } ) } - add { - SwitchSettingCard( - title = stringResource(Res.string.settings_allow_remote_control_title), - subtitle = stringResource(Res.string.settings_allow_remote_control_subtitle), - icon = { - SettingsItemIcon( - Iconsax.IconsaxMirroringScreen, - stringResource(Res.string.settings_allow_remote_control_title) - ) - }, - checked = settings.allowRemoteControl, - onCheckedChange = { enabled -> - settingsViewModel.updateSettings { - copy(allowRemoteControl = enabled) - } - // Request the local network permission when enabling remote control - // so that DNS-SD registration can succeed on Android 16+ - if (enabled) { - requestLocalNetworkPermission() - } - } - ) - } - add { - TextInputSettingCard( - title = stringResource(Res.string.settings_remote_device_name_title), - subtitle = stringResource( - Res.string.settings_remote_device_name_subtitle, - settings.remoteControlDeviceName.ifBlank { stringResource(Res.string.settings_remote_device_name_default) } - ), - icon = { - SettingsItemIcon(Iconsax.IconsaxEdit, stringResource(Res.string.settings_remote_device_name_title)) - }, - value = settings.remoteControlDeviceName, - dialogDescription = stringResource(Res.string.settings_remote_device_name_description), - placeholder = stringResource(Res.string.settings_remote_device_name_placeholder), - normalize = { it.trim() }, - validate = { _ -> null }, - onValueSaved = { value -> - settingsViewModel.updateSettings { - copy(remoteControlDeviceName = value) - } - } - ) - } + addAll(remoteControlSettingCards(settings, settingsViewModel, requestLocalNetworkPermission)) add { val error_whole_number = stringResource(Res.string.settings_error_whole_number) val error_port_range = stringResource(Res.string.settings_error_port_range) diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/RemoteControlSettings.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/RemoteControlSettings.kt new file mode 100644 index 00000000..5485f195 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/RemoteControlSettings.kt @@ -0,0 +1,92 @@ +/* + * 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.settings.sections + +import androidx.compose.runtime.Composable +import org.jetbrains.compose.resources.stringResource +import org.spotube.spotube.modules.settings.SettingsViewModel +import org.spotube.spotube.modules.settings.UserSettings +import org.spotube.spotube.modules.settings.components.SwitchSettingCard +import org.spotube.spotube.modules.settings.components.TextInputSettingCard +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxEdit +import org.spotube.spotube.resources.iconsax.IconsaxMirroringScreen +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.settings_allow_remote_control_subtitle +import spotube.composeapp.generated.resources.settings_allow_remote_control_title +import spotube.composeapp.generated.resources.settings_remote_device_name_default +import spotube.composeapp.generated.resources.settings_remote_device_name_description +import spotube.composeapp.generated.resources.settings_remote_device_name_placeholder +import spotube.composeapp.generated.resources.settings_remote_device_name_subtitle +import spotube.composeapp.generated.resources.settings_remote_device_name_title + +/** + * "Allow remote control" and "Device name" cards. Shared by the Playback settings section and the + * Devices screen so both stay identical. + */ +internal fun remoteControlSettingCards( + settings: UserSettings, + settingsViewModel: SettingsViewModel, + requestLocalNetworkPermission: () -> Unit +): List<@Composable () -> Unit> = listOf( + { + SwitchSettingCard( + title = stringResource(Res.string.settings_allow_remote_control_title), + subtitle = stringResource(Res.string.settings_allow_remote_control_subtitle), + icon = { + SettingsItemIcon( + Iconsax.IconsaxMirroringScreen, + stringResource(Res.string.settings_allow_remote_control_title) + ) + }, + checked = settings.allowRemoteControl, + onCheckedChange = { enabled -> + settingsViewModel.updateSettings { + copy(allowRemoteControl = enabled) + } + // Request the local network permission when enabling remote control + // so that DNS-SD registration can succeed on Android 16+ + if (enabled) { + requestLocalNetworkPermission() + } + } + ) + }, + { + TextInputSettingCard( + title = stringResource(Res.string.settings_remote_device_name_title), + subtitle = stringResource( + Res.string.settings_remote_device_name_subtitle, + settings.remoteControlDeviceName.ifBlank { stringResource(Res.string.settings_remote_device_name_default) } + ), + icon = { + SettingsItemIcon(Iconsax.IconsaxEdit, stringResource(Res.string.settings_remote_device_name_title)) + }, + value = settings.remoteControlDeviceName, + dialogDescription = stringResource(Res.string.settings_remote_device_name_description), + placeholder = stringResource(Res.string.settings_remote_device_name_placeholder), + normalize = { it.trim() }, + validate = { _ -> null }, + onValueSaved = { value -> + settingsViewModel.updateSettings { + copy(remoteControlDeviceName = value) + } + } + ) + } +) diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppSidebar.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppSidebar.kt index a38af3bd..c28cff4b 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppSidebar.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppSidebar.kt @@ -20,6 +20,20 @@ package org.spotube.spotube.modules.shell import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.ui.text.style.TextOverflow +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.composeunstyled.LocalTextStyle +import org.koin.compose.viewmodel.koinViewModel +import org.spotube.millennium.composables.LocalMillenniumSidebar +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.theme.MillenniumControlState +import org.spotube.millennium.theme.MillenniumTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -80,8 +94,12 @@ fun AppSidebar( navigator: Navigator, navigationState: NavigationState, modifier: Modifier = Modifier, - libraryState: LibraryState = koinInject() + libraryState: LibraryState = koinInject(), + connectStatusViewModel: ConnectStatusViewModel = koinViewModel() ) { + val connectStatus by connectStatusViewModel.uiState.collectAsStateWithLifecycle() + val remoteStatus = connectStatus.remote?.let { remoteStatusLabel(it) } + val jamStatus = connectStatus.jam?.let { jamStatusLabel(it) } var expanded by rememberSaveable { mutableStateOf(true) } val groupExpansion = remember { mutableStateMapOf() } val currentLibraryTab by libraryState.currentTab.collectAsState() @@ -144,6 +162,11 @@ fun AppSidebar( SidebarNavItem( item = item, selected = selected, + status = when (item.route) { + Routes.Devices -> remoteStatus + Routes.Jam -> jamStatus + else -> null + }, onClick = { if (item.route == Routes.Library) { (item.data as? LibraryTab)?.let { @@ -194,9 +217,15 @@ fun AppSidebar( private fun SidebarNavItem( item: NavigationItem.Tab<*>, selected: Boolean, - onClick: () -> Unit + onClick: () -> Unit, + /** Live status (e.g. "Playing on Krtx"); when set the row gets a dot and a second line. */ + status: String? = null ) { val title = stringResource(item.title) + if (status != null) { + LiveSidebarNavItem(item, title, status, selected, onClick) + return + } MillenniumSidebarItem( label = title, onClick = onClick, @@ -205,3 +234,44 @@ private fun SidebarNavItem( icon = item.icon ) } + +@Composable +private fun LiveSidebarNavItem( + item: NavigationItem.Tab<*>, + title: String, + status: String, + selected: Boolean, + onClick: () -> Unit +) { + MillenniumSidebarItem( + onClick = onClick, + selected = selected, + contentDescription = "$title, $status" + ) { + // Match the text overload's icon metrics so live rows line up with their siblings. + val metrics = MillenniumTheme.sidebarTheme.item.resolve(MillenniumControlState.Default) + val contentColor = LocalTextStyle.current.color + Box { + MillenniumIcon( + imageVector = item.icon, + contentDescription = null, + tint = contentColor, + modifier = Modifier.size(metrics.iconSize) + ) + LiveDot(Modifier.align(Alignment.TopEnd).offset(x = 3.dp, y = (-3).dp)) + } + if (LocalMillenniumSidebar.current?.collapsed != true) { + Spacer(Modifier.width(metrics.iconLabelSpacing)) + Column(Modifier.weight(1f)) { + Text(title, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text( + text = status, + style = AppTheme.typography.labelSmall, + color = contentColor.copy(alpha = 0.7f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/ConnectStatusBanner.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/ConnectStatusBanner.kt new file mode 100644 index 00000000..b2c14807 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/ConnectStatusBanner.kt @@ -0,0 +1,131 @@ +/* + * 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.shell + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +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.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.composeunstyled.Text +import compose.icons.FeatherIcons +import compose.icons.feathericons.ChevronRight +import org.jetbrains.compose.resources.pluralStringResource +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.viewmodel.koinViewModel +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.theme.MillenniumTheme +import org.spotube.spotube.core.ui.theming.AppTheme +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.connect_jam_hosting +import spotube.composeapp.generated.resources.connect_jam_joined +import spotube.composeapp.generated.resources.devices_remote_playing_on +import spotube.composeapp.generated.resources.devices_status_connected + +/** + * Slim "something is live" strip under the Home header: one row while remote-controlling a device, + * one while in a Group Jam. Renders nothing when neither is active. + */ +@Composable +fun ConnectStatusBanner( + onOpenRemote: () -> Unit, + onOpenJam: () -> Unit, + modifier: Modifier = Modifier, + viewModel: ConnectStatusViewModel = koinViewModel() +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + val remote = state.remote + val jam = state.jam + if (remote == null && jam == null) return + + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + if (remote != null) StatusRow(remoteStatusLabel(remote), onOpenRemote) + if (jam != null) StatusRow(jamStatusLabel(jam), onOpenJam) + } +} + +@Composable +private fun StatusRow(label: String, onClick: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(AppTheme.shapes.medium) + .background(AppTheme.colors.primary.copy(alpha = 0.12f)) + .clickable(onClick = onClick) + .padding(horizontal = 14.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + LiveDot() + Text( + text = label, + style = AppTheme.typography.labelLarge, + color = AppTheme.colors.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + MillenniumIcon( + imageVector = FeatherIcons.ChevronRight, + contentDescription = null, + tint = AppTheme.colors.onSurfaceVariant, + modifier = Modifier.size(18.dp) + ) + } +} + +@Composable +internal fun remoteStatusLabel(status: RemoteConnectStatus): String = + status.deviceName?.let { stringResource(Res.string.devices_remote_playing_on, it) } + ?: stringResource(Res.string.devices_status_connected) + +@Composable +internal fun jamStatusLabel(status: JamConnectStatus): String = pluralStringResource( + if (status.isHost) Res.plurals.connect_jam_hosting else Res.plurals.connect_jam_joined, + status.listenerCount, + status.listenerCount) + +/** Small "something is live" marker, ringed so it reads on any background. */ +@Composable +internal fun LiveDot(modifier: Modifier = Modifier) { + Box( + modifier = modifier + .size(10.dp) + .background(MillenniumTheme.colors.success, CircleShape) + .border(2.dp, MillenniumTheme.colors.surface, CircleShape) + ) +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/ConnectStatusViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/ConnectStatusViewModel.kt new file mode 100644 index 00000000..c83abac5 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/ConnectStatusViewModel.kt @@ -0,0 +1,57 @@ +/* + * 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.shell + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import org.spotube.spotube.core.jam.JamRole +import org.spotube.spotube.core.jam.JamRoomService +import org.spotube.spotube.core.remote.ConnectionState +import org.spotube.spotube.core.remote.RemoteControlClient + +/** Set while this app is remote-controlling another device. */ +data class RemoteConnectStatus(val deviceName: String?) + +/** Set while this app is in a Group Jam room. */ +data class JamConnectStatus(val isHost: Boolean, val listenerCount: Int) + +data class ConnectStatusUiState( + val remote: RemoteConnectStatus? = null, + val jam: JamConnectStatus? = null) + +/** Live remote-control / Group Jam status shown by the Home header and the sidebar. */ +class ConnectStatusViewModel( + remoteControlClient: RemoteControlClient, + jamRoomService: JamRoomService +) : ViewModel() { + val uiState: StateFlow = combine( + remoteControlClient.connectionState, + jamRoomService.role, + jamRoomService.participants + ) { connection, role, participants -> + ConnectStatusUiState( + remote = (connection as? ConnectionState.Connected)?.let { + RemoteConnectStatus(it.deviceName.ifBlank { null }) + }, + jam = role?.let { JamConnectStatus(isHost = it == JamRole.Host, listenerCount = participants.size) }) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ConnectStatusUiState()) +} diff --git a/composeApp/src/iosMain/kotlin/org/spotube/spotube/modules/downloads/MediaLibrary.ios.kt b/composeApp/src/iosMain/kotlin/org/spotube/spotube/modules/downloads/MediaLibrary.ios.kt new file mode 100644 index 00000000..a51ee801 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/org/spotube/spotube/modules/downloads/MediaLibrary.ios.kt @@ -0,0 +1,21 @@ +/* + * 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.downloads + +// No system media index to update; files are picked up from the folder directly. +internal actual fun addToMediaLibrary(path: String) = Unit diff --git a/composeApp/src/jvmMain/kotlin/org/spotube/spotube/modules/downloads/MediaLibrary.jvm.kt b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/modules/downloads/MediaLibrary.jvm.kt new file mode 100644 index 00000000..a51ee801 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/modules/downloads/MediaLibrary.jvm.kt @@ -0,0 +1,21 @@ +/* + * 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.downloads + +// No system media index to update; files are picked up from the folder directly. +internal actual fun addToMediaLibrary(path: String) = Unit