feat: download diagnostics, live connect status, remote settings on devices screen

Downloads
- Show the real download folder: "Current folder: <path> (default)" in
  settings instead of the misleading "default music folder", and a
  "Saved to <path>" line on the Downloads screen
- Register finished downloads with Android's MediaStore (MediaScanner) so
  music players and the Files app see them immediately
- Add tagged logs across the download path to diagnose format/quality
  selection: raw NewPipe streams ([Streams]), every selection candidate
  with its rank key and fallback warnings ([Resolve]), and per-download
  enqueue prefs, resolved stream, HEAD/GET status + content type, segment
  responses and written vs expected bytes ([Download])

Connect status
- Mobile Home keeps its Devices / Group Jam / Support icons (Jam now uses
  the sound icon) and shows a slim banner only while remote-controlling a
  device or in a Jam ("Playing on krtx", "Hosting · 3 listeners")
- Sidebar Devices / Group Jam rows get a live dot and status line
- New ConnectStatusViewModel feeds both

Remote control settings
- Extract "Allow remote control" and "Device name" into shared
  remoteControlSettingCards, used by Playback settings and a new
  "This device" section on the Devices screen
This commit is contained in:
Kingkor Roy Tirtho 2026-10-20 15:54:23 +06:00
parent b38a4f6963
commit d4961bb5ed
41 changed files with 695 additions and 106 deletions

View File

@ -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.AudioStream
import org.spotube.plugin_interfaces.plugin_apis.audio.StreamProtocol 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 io.ktor.http.Url
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext 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 private fun secondsToMs(seconds: Long): Long = if (seconds > 0) seconds * 1000 else 0
actual class NewPipeService { actual class NewPipeService {
private val logger = Logger.withTag("NewPipeService")
actual suspend fun searchVideos(query: String, kind: SearchKind): List<VideoSearchResult> { actual suspend fun searchVideos(query: String, kind: SearchKind): List<VideoSearchResult> {
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
val extractor = YouTube.getSearchExtractor( val extractor = YouTube.getSearchExtractor(
@ -66,6 +70,22 @@ actual class NewPipeService {
val extractor = YouTube.getStreamExtractor("https://www.youtube.com/watch?v=$id") val extractor = YouTube.getStreamExtractor("https://www.youtube.com/watch?v=$id")
extractor.fetchPage() 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 val audioStreams = extractor.audioStreams
.mapNotNull { stream -> .mapNotNull { stream ->
AudioStream.Lossy( AudioStream.Lossy(
@ -103,6 +123,10 @@ actual class NewPipeService {
bitrate = stream.bitrate, bitrate = stream.bitrate,
container = stream.format!!.suffix) container = stream.format!!.suffix)
} }
logger.i {
"[Streams $id] mapped audio candidates (${audioStreams.size}):\n" +
audioStreams.joinToString("\n") { " ${it.describeForLog()}" }
}
VideoInfo( VideoInfo(
id = id, id = id,
title = extractor.name, title = extractor.name,
@ -112,6 +136,14 @@ actual class NewPipeService {
thumbnailUrl = extractor.thumbnails.first().url, thumbnailUrl = extractor.thumbnails.first().url,
// fallback to video streams if no audio streams are available // fallback to video streams if no audio streams are available
audioStreams = audioStreams.ifEmpty { 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 = videoStreams videoStreams = videoStreams

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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<Context>()
MediaScannerConnection.scanFile(context, arrayOf(path), null, null)
}

View File

@ -179,7 +179,6 @@
<string name="settings_download_folder_title">تخصيص مجلد التنزيل</string> <string name="settings_download_folder_title">تخصيص مجلد التنزيل</string>
<string name="settings_download_folder_current">المجلد الحالي: %1$s</string> <string name="settings_download_folder_current">المجلد الحالي: %1$s</string>
<string name="settings_download_folder_default">المجلد الحالي: مجلد الموسيقى الافتراضي</string>
<string name="settings_download_folder_description">اتركه فارغًا لاستخدام مجلد الموسيقى الافتراضي.</string> <string name="settings_download_folder_description">اتركه فارغًا لاستخدام مجلد الموسيقى الافتراضي.</string>
<string name="settings_download_folder_placeholder">C:\Music\Spotube</string> <string name="settings_download_folder_placeholder">C:\Music\Spotube</string>
<string name="settings_local_media_folders_title">مجلدات الوسائط المحلية</string> <string name="settings_local_media_folders_title">مجلدات الوسائط المحلية</string>

View File

@ -171,7 +171,6 @@
<string name="settings_download_folder_title">ডাউনলোড ফোল্ডার পরিবর্তন</string> <string name="settings_download_folder_title">ডাউনলোড ফোল্ডার পরিবর্তন</string>
<string name="settings_download_folder_current">বর্তমান ফোল্ডার: %1$s</string> <string name="settings_download_folder_current">বর্তমান ফোল্ডার: %1$s</string>
<string name="settings_download_folder_default">বর্তমান ফোল্ডার: ডিফল্ট মিউজিক ফোল্ডার</string>
<string name="settings_download_folder_description">ডিফল্ট মিউজিক ফোল্ডার ব্যবহার করতে এটি ফাঁকা রাখুন।</string> <string name="settings_download_folder_description">ডিফল্ট মিউজিক ফোল্ডার ব্যবহার করতে এটি ফাঁকা রাখুন।</string>
<string name="settings_download_folder_placeholder">C:\Music\Spotube</string> <string name="settings_download_folder_placeholder">C:\Music\Spotube</string>
<string name="settings_local_media_folders_title">লোকাল মিডিয়া ফোল্ডার</string> <string name="settings_local_media_folders_title">লোকাল মিডিয়া ফোল্ডার</string>

View File

@ -171,7 +171,6 @@
<string name="settings_download_folder_title">Eigener Download-Ordner</string> <string name="settings_download_folder_title">Eigener Download-Ordner</string>
<string name="settings_download_folder_current">Aktueller Ordner: %1$s</string> <string name="settings_download_folder_current">Aktueller Ordner: %1$s</string>
<string name="settings_download_folder_default">Aktueller Ordner: Standard-Musikordner</string>
<string name="settings_download_folder_description">Leer lassen, um den Standard-Musikordner zu verwenden.</string> <string name="settings_download_folder_description">Leer lassen, um den Standard-Musikordner zu verwenden.</string>
<string name="settings_download_folder_placeholder">C:\Music\Spotube</string> <string name="settings_download_folder_placeholder">C:\Music\Spotube</string>
<string name="settings_local_media_folders_title">Lokale Medienordner</string> <string name="settings_local_media_folders_title">Lokale Medienordner</string>

View File

@ -173,7 +173,6 @@
<string name="settings_download_folder_title">Carpeta de descargas personalizada</string> <string name="settings_download_folder_title">Carpeta de descargas personalizada</string>
<string name="settings_download_folder_current">Carpeta actual: %1$s</string> <string name="settings_download_folder_current">Carpeta actual: %1$s</string>
<string name="settings_download_folder_default">Carpeta actual: carpeta de música predeterminada</string>
<string name="settings_download_folder_description">Déjalo vacío para usar la carpeta de música predeterminada.</string> <string name="settings_download_folder_description">Déjalo vacío para usar la carpeta de música predeterminada.</string>
<string name="settings_download_folder_placeholder">C:\Music\Spotube</string> <string name="settings_download_folder_placeholder">C:\Music\Spotube</string>
<string name="settings_local_media_folders_title">Carpetas de contenido local</string> <string name="settings_local_media_folders_title">Carpetas de contenido local</string>

View File

@ -171,7 +171,6 @@
<string name="settings_download_folder_title">پوشهٔ دانلود سفارشی</string> <string name="settings_download_folder_title">پوشهٔ دانلود سفارشی</string>
<string name="settings_download_folder_current">پوشهٔ فعلی: %1$s</string> <string name="settings_download_folder_current">پوشهٔ فعلی: %1$s</string>
<string name="settings_download_folder_default">پوشهٔ فعلی: پوشهٔ پیش‌فرض موسیقی</string>
<string name="settings_download_folder_description">برای استفاده از پوشهٔ پیش‌فرض موسیقی، این قسمت را خالی بگذارید.</string> <string name="settings_download_folder_description">برای استفاده از پوشهٔ پیش‌فرض موسیقی، این قسمت را خالی بگذارید.</string>
<string name="settings_download_folder_placeholder">C:\Music\Spotube</string> <string name="settings_download_folder_placeholder">C:\Music\Spotube</string>
<string name="settings_local_media_folders_title">پوشه‌های رسانهٔ محلی</string> <string name="settings_local_media_folders_title">پوشه‌های رسانهٔ محلی</string>

View File

@ -173,7 +173,6 @@
<string name="settings_download_folder_title">Dossier de téléchargement personnalisé</string> <string name="settings_download_folder_title">Dossier de téléchargement personnalisé</string>
<string name="settings_download_folder_current">Dossier actuel : %1$s</string> <string name="settings_download_folder_current">Dossier actuel : %1$s</string>
<string name="settings_download_folder_default">Dossier actuel : dossier Musique par défaut</string>
<string name="settings_download_folder_description">Laissez vide pour utiliser le dossier Musique par défaut.</string> <string name="settings_download_folder_description">Laissez vide pour utiliser le dossier Musique par défaut.</string>
<string name="settings_download_folder_placeholder">C:\Music\Spotube</string> <string name="settings_download_folder_placeholder">C:\Music\Spotube</string>
<string name="settings_local_media_folders_title">Dossiers de médias locaux</string> <string name="settings_local_media_folders_title">Dossiers de médias locaux</string>

View File

@ -170,7 +170,6 @@
<string name="settings_download_folder_title">डाउनलोड फ़ोल्डर बदलें</string> <string name="settings_download_folder_title">डाउनलोड फ़ोल्डर बदलें</string>
<string name="settings_download_folder_current">मौजूदा फ़ोल्डर: %1$s</string> <string name="settings_download_folder_current">मौजूदा फ़ोल्डर: %1$s</string>
<string name="settings_download_folder_default">मौजूदा फ़ोल्डर: डिफ़ॉल्ट म्यूज़िक फ़ोल्डर</string>
<string name="settings_download_folder_description">डिफ़ॉल्ट म्यूज़िक फ़ोल्डर इस्तेमाल करने के लिए इसे खाली छोड़ें।</string> <string name="settings_download_folder_description">डिफ़ॉल्ट म्यूज़िक फ़ोल्डर इस्तेमाल करने के लिए इसे खाली छोड़ें।</string>
<string name="settings_download_folder_placeholder">C:\Music\Spotube</string> <string name="settings_download_folder_placeholder">C:\Music\Spotube</string>
<string name="settings_local_media_folders_title">लोकल मीडिया फ़ोल्डर</string> <string name="settings_local_media_folders_title">लोकल मीडिया फ़ोल्डर</string>

View File

@ -169,7 +169,6 @@
<string name="settings_download_folder_title">Ganti folder unduhan</string> <string name="settings_download_folder_title">Ganti folder unduhan</string>
<string name="settings_download_folder_current">Folder saat ini: %1$s</string> <string name="settings_download_folder_current">Folder saat ini: %1$s</string>
<string name="settings_download_folder_default">Folder saat ini: folder musik bawaan</string>
<string name="settings_download_folder_description">Biarkan kosong untuk memakai folder musik bawaan.</string> <string name="settings_download_folder_description">Biarkan kosong untuk memakai folder musik bawaan.</string>
<string name="settings_download_folder_placeholder">C:\Music\Spotube</string> <string name="settings_download_folder_placeholder">C:\Music\Spotube</string>
<string name="settings_local_media_folders_title">Folder media lokal</string> <string name="settings_local_media_folders_title">Folder media lokal</string>

View File

@ -173,7 +173,6 @@
<string name="settings_download_folder_title">Cartella di download personalizzata</string> <string name="settings_download_folder_title">Cartella di download personalizzata</string>
<string name="settings_download_folder_current">Cartella attuale: %1$s</string> <string name="settings_download_folder_current">Cartella attuale: %1$s</string>
<string name="settings_download_folder_default">Cartella attuale: cartella Musica predefinita</string>
<string name="settings_download_folder_description">Lascia vuoto per usare la cartella Musica predefinita.</string> <string name="settings_download_folder_description">Lascia vuoto per usare la cartella Musica predefinita.</string>
<string name="settings_download_folder_placeholder">C:\Music\Spotube</string> <string name="settings_download_folder_placeholder">C:\Music\Spotube</string>
<string name="settings_local_media_folders_title">Cartelle multimediali locali</string> <string name="settings_local_media_folders_title">Cartelle multimediali locali</string>

View File

@ -169,7 +169,6 @@
<string name="settings_download_folder_title">ダウンロードフォルダの変更</string> <string name="settings_download_folder_title">ダウンロードフォルダの変更</string>
<string name="settings_download_folder_current">現在のフォルダ: %1$s</string> <string name="settings_download_folder_current">現在のフォルダ: %1$s</string>
<string name="settings_download_folder_default">現在のフォルダ: デフォルトの音楽フォルダ</string>
<string name="settings_download_folder_description">空欄にするとデフォルトの音楽フォルダを使用します。</string> <string name="settings_download_folder_description">空欄にするとデフォルトの音楽フォルダを使用します。</string>
<string name="settings_download_folder_placeholder">C:\Music\Spotube</string> <string name="settings_download_folder_placeholder">C:\Music\Spotube</string>
<string name="settings_local_media_folders_title">ローカルメディアフォルダ</string> <string name="settings_local_media_folders_title">ローカルメディアフォルダ</string>

View File

@ -169,7 +169,6 @@
<string name="settings_download_folder_title">다운로드 폴더 지정</string> <string name="settings_download_folder_title">다운로드 폴더 지정</string>
<string name="settings_download_folder_current">현재 폴더: %1$s</string> <string name="settings_download_folder_current">현재 폴더: %1$s</string>
<string name="settings_download_folder_default">현재 폴더: 기본 음악 폴더</string>
<string name="settings_download_folder_description">기본 음악 폴더를 사용하려면 비워 두세요.</string> <string name="settings_download_folder_description">기본 음악 폴더를 사용하려면 비워 두세요.</string>
<string name="settings_download_folder_placeholder">C:\Music\Spotube</string> <string name="settings_download_folder_placeholder">C:\Music\Spotube</string>
<string name="settings_local_media_folders_title">로컬 미디어 폴더</string> <string name="settings_local_media_folders_title">로컬 미디어 폴더</string>

View File

@ -171,7 +171,6 @@
<string name="settings_download_folder_title">Andere downloadmap</string> <string name="settings_download_folder_title">Andere downloadmap</string>
<string name="settings_download_folder_current">Huidige map: %1$s</string> <string name="settings_download_folder_current">Huidige map: %1$s</string>
<string name="settings_download_folder_default">Huidige map: standaard muziekmap</string>
<string name="settings_download_folder_description">Laat dit leeg om de standaard muziekmap te gebruiken.</string> <string name="settings_download_folder_description">Laat dit leeg om de standaard muziekmap te gebruiken.</string>
<string name="settings_download_folder_placeholder">C:\Music\Spotube</string> <string name="settings_download_folder_placeholder">C:\Music\Spotube</string>
<string name="settings_local_media_folders_title">Lokale mediamappen</string> <string name="settings_local_media_folders_title">Lokale mediamappen</string>

View File

@ -175,7 +175,6 @@
<string name="settings_download_folder_title">Własny folder pobierania</string> <string name="settings_download_folder_title">Własny folder pobierania</string>
<string name="settings_download_folder_current">Obecny folder: %1$s</string> <string name="settings_download_folder_current">Obecny folder: %1$s</string>
<string name="settings_download_folder_default">Obecny folder: domyślny folder muzyki</string>
<string name="settings_download_folder_description">Pozostaw puste, aby używać domyślnego folderu muzyki.</string> <string name="settings_download_folder_description">Pozostaw puste, aby używać domyślnego folderu muzyki.</string>
<string name="settings_download_folder_placeholder">C:\Music\Spotube</string> <string name="settings_download_folder_placeholder">C:\Music\Spotube</string>
<string name="settings_local_media_folders_title">Foldery lokalnych multimediów</string> <string name="settings_local_media_folders_title">Foldery lokalnych multimediów</string>

View File

@ -173,7 +173,6 @@
<string name="settings_download_folder_title">Pasta de download personalizada</string> <string name="settings_download_folder_title">Pasta de download personalizada</string>
<string name="settings_download_folder_current">Pasta atual: %1$s</string> <string name="settings_download_folder_current">Pasta atual: %1$s</string>
<string name="settings_download_folder_default">Pasta atual: pasta de músicas padrão</string>
<string name="settings_download_folder_description">Deixe em branco para usar a pasta de músicas padrão.</string> <string name="settings_download_folder_description">Deixe em branco para usar a pasta de músicas padrão.</string>
<string name="settings_download_folder_placeholder">C:\Music\Spotube</string> <string name="settings_download_folder_placeholder">C:\Music\Spotube</string>
<string name="settings_local_media_folders_title">Pastas de mídia local</string> <string name="settings_local_media_folders_title">Pastas de mídia local</string>

View File

@ -175,7 +175,6 @@
<string name="settings_download_folder_title">Своя папка для загрузок</string> <string name="settings_download_folder_title">Своя папка для загрузок</string>
<string name="settings_download_folder_current">Текущая папка: %1$s</string> <string name="settings_download_folder_current">Текущая папка: %1$s</string>
<string name="settings_download_folder_default">Текущая папка: стандартная папка «Музыка»</string>
<string name="settings_download_folder_description">Оставьте пустым, чтобы использовать стандартную папку «Музыка».</string> <string name="settings_download_folder_description">Оставьте пустым, чтобы использовать стандартную папку «Музыка».</string>
<string name="settings_download_folder_placeholder">C:\Music\Spotube</string> <string name="settings_download_folder_placeholder">C:\Music\Spotube</string>
<string name="settings_local_media_folders_title">Папки с локальной музыкой</string> <string name="settings_local_media_folders_title">Папки с локальной музыкой</string>

View File

@ -169,7 +169,6 @@
<string name="settings_download_folder_title">กำหนดโฟลเดอร์ดาวน์โหลดเอง</string> <string name="settings_download_folder_title">กำหนดโฟลเดอร์ดาวน์โหลดเอง</string>
<string name="settings_download_folder_current">โฟลเดอร์ปัจจุบัน: %1$s</string> <string name="settings_download_folder_current">โฟลเดอร์ปัจจุบัน: %1$s</string>
<string name="settings_download_folder_default">โฟลเดอร์ปัจจุบัน: โฟลเดอร์เพลงเริ่มต้น</string>
<string name="settings_download_folder_description">เว้นว่างไว้เพื่อใช้โฟลเดอร์เพลงเริ่มต้น</string> <string name="settings_download_folder_description">เว้นว่างไว้เพื่อใช้โฟลเดอร์เพลงเริ่มต้น</string>
<string name="settings_download_folder_placeholder">C:\Music\Spotube</string> <string name="settings_download_folder_placeholder">C:\Music\Spotube</string>
<string name="settings_local_media_folders_title">โฟลเดอร์สื่อในเครื่อง</string> <string name="settings_local_media_folders_title">โฟลเดอร์สื่อในเครื่อง</string>

View File

@ -171,7 +171,6 @@
<string name="settings_download_folder_title">Özel indirme klasörü</string> <string name="settings_download_folder_title">Özel indirme klasörü</string>
<string name="settings_download_folder_current">Geçerli klasör: %1$s</string> <string name="settings_download_folder_current">Geçerli klasör: %1$s</string>
<string name="settings_download_folder_default">Geçerli klasör: varsayılan müzik klasörü</string>
<string name="settings_download_folder_description">Varsayılan müzik klasörünü kullanmak için boş bırak.</string> <string name="settings_download_folder_description">Varsayılan müzik klasörünü kullanmak için boş bırak.</string>
<string name="settings_download_folder_placeholder">C:\Music\Spotube</string> <string name="settings_download_folder_placeholder">C:\Music\Spotube</string>
<string name="settings_local_media_folders_title">Yerel medya klasörleri</string> <string name="settings_local_media_folders_title">Yerel medya klasörleri</string>

View File

@ -175,7 +175,6 @@
<string name="settings_download_folder_title">Власна папка для завантажень</string> <string name="settings_download_folder_title">Власна папка для завантажень</string>
<string name="settings_download_folder_current">Поточна папка: %1$s</string> <string name="settings_download_folder_current">Поточна папка: %1$s</string>
<string name="settings_download_folder_default">Поточна папка: стандартна папка «Музика»</string>
<string name="settings_download_folder_description">Залиште порожнім, щоб використовувати стандартну папку «Музика».</string> <string name="settings_download_folder_description">Залиште порожнім, щоб використовувати стандартну папку «Музика».</string>
<string name="settings_download_folder_placeholder">C:\Music\Spotube</string> <string name="settings_download_folder_placeholder">C:\Music\Spotube</string>
<string name="settings_local_media_folders_title">Папки з локальною музикою</string> <string name="settings_local_media_folders_title">Папки з локальною музикою</string>

View File

@ -169,7 +169,6 @@
<string name="settings_download_folder_title">Thư mục tải xuống tùy chỉnh</string> <string name="settings_download_folder_title">Thư mục tải xuống tùy chỉnh</string>
<string name="settings_download_folder_current">Thư mục hiện tại: %1$s</string> <string name="settings_download_folder_current">Thư mục hiện tại: %1$s</string>
<string name="settings_download_folder_default">Thư mục hiện tại: thư mục nhạc mặc định</string>
<string name="settings_download_folder_description">Để trống để dùng thư mục nhạc mặc định.</string> <string name="settings_download_folder_description">Để trống để dùng thư mục nhạc mặc định.</string>
<string name="settings_download_folder_placeholder">C:\Music\Spotube</string> <string name="settings_download_folder_placeholder">C:\Music\Spotube</string>
<string name="settings_local_media_folders_title">Thư mục nhạc trên máy</string> <string name="settings_local_media_folders_title">Thư mục nhạc trên máy</string>

View File

@ -169,7 +169,6 @@
<string name="settings_download_folder_title">自訂下載資料夾</string> <string name="settings_download_folder_title">自訂下載資料夾</string>
<string name="settings_download_folder_current">目前資料夾:%1$s</string> <string name="settings_download_folder_current">目前資料夾:%1$s</string>
<string name="settings_download_folder_default">目前資料夾:預設音樂資料夾</string>
<string name="settings_download_folder_description">留空即使用預設音樂資料夾。</string> <string name="settings_download_folder_description">留空即使用預設音樂資料夾。</string>
<string name="settings_download_folder_placeholder">C:\Music\Spotube</string> <string name="settings_download_folder_placeholder">C:\Music\Spotube</string>
<string name="settings_local_media_folders_title">本機媒體資料夾</string> <string name="settings_local_media_folders_title">本機媒體資料夾</string>

View File

@ -169,7 +169,6 @@
<string name="settings_download_folder_title">自定义下载文件夹</string> <string name="settings_download_folder_title">自定义下载文件夹</string>
<string name="settings_download_folder_current">当前文件夹:%1$s</string> <string name="settings_download_folder_current">当前文件夹:%1$s</string>
<string name="settings_download_folder_default">当前文件夹:默认音乐文件夹</string>
<string name="settings_download_folder_description">留空则使用默认音乐文件夹。</string> <string name="settings_download_folder_description">留空则使用默认音乐文件夹。</string>
<string name="settings_download_folder_placeholder">C:\Music\Spotube</string> <string name="settings_download_folder_placeholder">C:\Music\Spotube</string>
<string name="settings_local_media_folders_title">本地媒体文件夹</string> <string name="settings_local_media_folders_title">本地媒体文件夹</string>

View File

@ -76,6 +76,7 @@
<!-- Downloads --> <!-- Downloads -->
<string name="downloads_summary">%1$d active • %2$d completed</string> <string name="downloads_summary">%1$d active • %2$d completed</string>
<string name="downloads_saved_to">Saved to %1$s</string>
<string name="downloads_clear_completed">Clear completed</string> <string name="downloads_clear_completed">Clear completed</string>
<string name="downloads_empty">No downloads yet</string> <string name="downloads_empty">No downloads yet</string>
<string name="downloads_status_downloading">Downloading %1$d%</string> <string name="downloads_status_downloading">Downloading %1$d%</string>
@ -173,7 +174,7 @@
<string name="settings_download_folder_title">Download folder override</string> <string name="settings_download_folder_title">Download folder override</string>
<string name="settings_download_folder_current">Current folder: %1$s</string> <string name="settings_download_folder_current">Current folder: %1$s</string>
<string name="settings_download_folder_default">Current folder: default music folder</string> <string name="settings_download_folder_default_path">Current folder: %1$s (default)</string>
<string name="settings_download_folder_description">Leave this empty to use the default music folder.</string> <string name="settings_download_folder_description">Leave this empty to use the default music folder.</string>
<string name="settings_download_folder_placeholder">C:\Music\Spotube</string> <string name="settings_download_folder_placeholder">C:\Music\Spotube</string>
<string name="settings_local_media_folders_title">Local media folders</string> <string name="settings_local_media_folders_title">Local media folders</string>
@ -519,6 +520,7 @@
<string name="devices_resolving">Resolving...</string> <string name="devices_resolving">Resolving...</string>
<string name="devices_section_connected">Connected</string> <string name="devices_section_connected">Connected</string>
<string name="devices_section_available">Available devices</string> <string name="devices_section_available">Available devices</string>
<string name="devices_section_this_device">This device</string>
<string name="devices_status_connected">Connected</string> <string name="devices_status_connected">Connected</string>
<string name="devices_open_remote">Open remote</string> <string name="devices_open_remote">Open remote</string>
<string name="devices_search_again">Search again</string> <string name="devices_search_again">Search again</string>
@ -925,6 +927,14 @@
<string name="nav_group_on_device">On Device</string> <string name="nav_group_on_device">On Device</string>
<string name="nav_group_connect">Connect</string> <string name="nav_group_connect">Connect</string>
<string name="nav_devices">Devices</string> <string name="nav_devices">Devices</string>
<plurals name="connect_jam_hosting">
<item quantity="one">Hosting · %1$d listener</item>
<item quantity="other">Hosting · %1$d listeners</item>
</plurals>
<plurals name="connect_jam_joined">
<item quantity="one">In a Jam · %1$d listener</item>
<item quantity="other">In a Jam · %1$d listeners</item>
</plurals>
<!-- System tray (desktop) --> <!-- System tray (desktop) -->
<string name="tray_hide_window">Hide Window</string> <string name="tray_hide_window">Hide Window</string>

View File

@ -49,6 +49,7 @@ import org.spotube.spotube.modules.blacklist.BlacklistRepository
import org.spotube.spotube.modules.blacklist.BlacklistViewModel import org.spotube.spotube.modules.blacklist.BlacklistViewModel
import org.spotube.spotube.modules.devices.DevicesViewModel import org.spotube.spotube.modules.devices.DevicesViewModel
import org.spotube.spotube.modules.devices.RemoteControlViewModel 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.jam.JamViewModel
import org.spotube.spotube.modules.downloads.DownloadManager import org.spotube.spotube.modules.downloads.DownloadManager
import org.spotube.spotube.modules.downloads.DownloadsViewModel import org.spotube.spotube.modules.downloads.DownloadsViewModel
@ -219,6 +220,7 @@ val sharedModules = module {
viewModelOf(::BlacklistViewModel) viewModelOf(::BlacklistViewModel)
viewModel { DevicesViewModel(get()) } viewModel { DevicesViewModel(get()) }
viewModelOf(::RemoteControlViewModel) viewModelOf(::RemoteControlViewModel)
viewModelOf(::ConnectStatusViewModel)
viewModel { viewModel {
JamViewModel( JamViewModel(
jamRoomService = get(), jamRoomService = get(),

View File

@ -102,10 +102,19 @@ class StreamingUrlRepository(
logger.w { "No audio plugin selected while resolving stream for track $trackId" } logger.w { "No audio plugin selected while resolving stream for track $trackId" }
return null 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) { if (!forceRefresh) {
getCachedStreamUrl(trackId, preferredFormat, preferredQuality, audioPlugin.pluginId)?.let { cached -> 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 return cached
} }
} }
@ -130,6 +139,7 @@ class StreamingUrlRepository(
stream.streams, stream.streams,
preferredFormat, preferredFormat,
preferredQuality) preferredQuality)
logSelection(trackId, "matched source ${source.id}", stream.streams, preferredFormat, preferredQuality, resolvedStream)
if (resolvedStream != null) { if (resolvedStream != null) {
val info = resolvedStream.toStreamInfo() val info = resolvedStream.toStreamInfo()
cacheStreamInfo(trackId, info, preferredFormat, preferredQuality, stream, resolvedStream, audioPlugin.pluginId) cacheStreamInfo(trackId, info, preferredFormat, preferredQuality, stream, resolvedStream, audioPlugin.pluginId)
@ -189,6 +199,7 @@ class StreamingUrlRepository(
stream.streams, stream.streams,
preferredFormat, preferredFormat,
preferredQuality) preferredQuality)
logSelection(trackId, "track lookup -> ${stream.id}", stream.streams, preferredFormat, preferredQuality, resolvedStream)
if (resolvedStream != null) { if (resolvedStream != null) {
val info = resolvedStream.toStreamInfo() val info = resolvedStream.toStreamInfo()
cacheStreamInfo(trackId, info, preferredFormat, preferredQuality, stream, resolvedStream, audioPlugin.pluginId) cacheStreamInfo(trackId, info, preferredFormat, preferredQuality, stream, resolvedStream, audioPlugin.pluginId)
@ -197,6 +208,38 @@ class StreamingUrlRepository(
return null 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<AudioStream>,
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( private fun AudioStream.toStreamInfo() = StreamInfo(
url = normalizeManifestUrl(url, protocol), url = normalizeManifestUrl(url, protocol),
protocol = protocol, protocol = protocol,
@ -353,3 +396,20 @@ private fun AudioStream.channelDistance(preferredQuality: AudioQuality): Int = w
else -> 0 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=<unparseable ${url.take(60)}>")

View File

@ -58,8 +58,10 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.spotube.spotube.core.discovery.rememberLocalNetworkPermissionRequester import org.spotube.spotube.core.discovery.rememberLocalNetworkPermissionRequester
import org.spotube.spotube.core.ui.component.ApplicationMainBar 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.components.SettingCardItem
import org.spotube.spotube.modules.settings.sections.SettingsItemIcon 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.settingsSectionCard
import org.spotube.spotube.modules.settings.sections.settingsSectionHeader import org.spotube.spotube.modules.settings.sections.settingsSectionHeader
import org.spotube.spotube.modules.shell.LocalAppShellBottomInset 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_searching_more
import spotube.composeapp.generated.resources.devices_section_available import spotube.composeapp.generated.resources.devices_section_available
import spotube.composeapp.generated.resources.devices_section_connected 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_status_connected
import spotube.composeapp.generated.resources.devices_title import spotube.composeapp.generated.resources.devices_title
import spotube.composeapp.generated.resources.devices_unknown_device import spotube.composeapp.generated.resources.devices_unknown_device
@ -92,9 +95,11 @@ private val ContentMaxWidth = 720.dp
@Composable @Composable
fun DevicesScreen( fun DevicesScreen(
viewModel: DevicesViewModel = koinViewModel() viewModel: DevicesViewModel = koinViewModel(),
settingsViewModel: SettingsViewModel = koinViewModel()
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
val settings by settingsViewModel.settingsState.collectAsStateWithLifecycle()
val requestLocalNetworkPermission = rememberLocalNetworkPermissionRequester() val requestLocalNetworkPermission = rememberLocalNetworkPermissionRequester()
val shellBottomInset = LocalAppShellBottomInset.current 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, isDiscovering: Boolean,
onSearchAgain: () -> Unit onSearchAgain: () -> Unit
) { ) {
// Fixed padding rather than filling the viewport, so the "This device" section below stays visible.
Box( Box(
modifier = Modifier modifier = Modifier
.fillParentMaxHeight(0.8f)
.fillParentMaxWidth() .fillParentMaxWidth()
.padding(24.dp), .padding(horizontal = 24.dp, vertical = 48.dp),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Column( Column(

View File

@ -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.paths.Paths
import org.spotube.spotube.core.server.StreamInfo import org.spotube.spotube.core.server.StreamInfo
import org.spotube.spotube.core.server.StreamingUrlRepository 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 org.spotube.spotube.modules.settings.SettingsRepository
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
import io.ktor.client.plugins.HttpTimeout 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.client.statement.bodyAsChannel
import io.ktor.http.HttpHeaders import io.ktor.http.HttpHeaders
import io.ktor.http.contentLength import io.ktor.http.contentLength
import io.ktor.http.isSuccess
import io.ktor.utils.io.readRemaining import io.ktor.utils.io.readRemaining
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -112,6 +115,11 @@ class DownloadManager(
private val cachedDownloadFolder = MutableStateFlow<String?>(null) private val cachedDownloadFolder = MutableStateFlow<String?>(null)
/** Where downloads are saved right now: the user's override, else the platform default. */
val downloadFolder: StateFlow<String> = cachedDownloadFolder
.map { it?.takeIf(String::isNotBlank) ?: paths.getUserDownloadsDirPath() }
.stateIn(scope, SharingStarted.Eagerly, paths.getUserDownloadsDirPath())
private val activeJobs = mutableMapOf<String, Job>() private val activeJobs = mutableMapOf<String, Job>()
init { init {
@ -135,6 +143,13 @@ class DownloadManager(
track = track) track = track)
downloadsMap.update { it + (id to item) } 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() } scope.launch { processQueue() }
} }
@ -210,6 +225,7 @@ class DownloadManager(
} }
try { try {
logger.i { "[Download ${item.id}] resolving stream for '${track.title}'" }
val streamInfo = streamingUrlRepository.resolveStreamInfo(track) val streamInfo = streamingUrlRepository.resolveStreamInfo(track)
if (streamInfo == null) { if (streamInfo == null) {
logger.w { "Could not resolve stream URL for ${track.title}" } logger.w { "Could not resolve stream URL for ${track.title}" }
@ -226,6 +242,7 @@ class DownloadManager(
} }
val url = streamInfo.url val url = streamInfo.url
logResolvedStream(item, streamInfo)
val filename = buildFilename(item, streamInfo) val filename = buildFilename(item, streamInfo)
@ -235,15 +252,28 @@ class DownloadManager(
val tempDir = FileSystem.SYSTEM_TEMPORARY_DIRECTORY / "spotube_downloads".toPath() val tempDir = FileSystem.SYSTEM_TEMPORARY_DIRECTORY / "spotube_downloads".toPath()
fileSystem.createDirectories(tempDir) fileSystem.createDirectories(tempDir)
val (totalSize, supportsRanges) = probeUrl(url) val (totalSize, supportsRanges) = probeUrl(item.id, url)
if (totalSize > 0 && supportsRanges) { 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) executeMultiSegmentDownload(url, filename, item, tempDir, downloadDir, totalSize)
} else { } else {
logger.i { "[Download ${item.id}] single-connection download (size=$totalSize ranges=$supportsRanges) -> ${downloadDir / filename}" }
executeSingleConnectionDownload(url, filename, item, tempDir, downloadDir) 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) 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 -> downloadsMap.update { map ->
map[item.id]?.let { map[item.id]?.let {
@ -251,6 +281,7 @@ class DownloadManager(
} ?: map } ?: map
} }
} catch (e: CancellationException) { } catch (e: CancellationException) {
logger.i { "[Download ${item.id}] cancelled" }
downloadsMap.update { map -> downloadsMap.update { map ->
map[item.id]?.let { map[item.id]?.let {
map + (item.id to it.copy(status = DownloadStatus.Cancelled)) map + (item.id to it.copy(status = DownloadStatus.Cancelled))
@ -258,7 +289,7 @@ class DownloadManager(
} }
throw e throw e
} catch (e: Exception) { } 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) val failureMessage = e.message ?: getString(Res.string.common_unknown_error)
downloadsMap.update { map -> downloadsMap.update { map ->
map[item.id]?.let { map[item.id]?.let {
@ -322,6 +353,15 @@ class DownloadManager(
append(HttpHeaders.Range, "bytes=${segment.start}-${segment.end}") 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() val channel = response.bodyAsChannel()
fileSystem.sink(outputPath).buffer().use { sink -> fileSystem.sink(outputPath).buffer().use { sink ->
@ -344,6 +384,13 @@ class DownloadManager(
val response = httpClient.get(url) val response = httpClient.get(url)
val totalSize = response.contentLength() ?: 0L 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() val channel = response.bodyAsChannel()
fileSystem.sink(tempFile).buffer().use { sink -> fileSystem.sink(tempFile).buffer().use { sink ->
@ -375,22 +422,55 @@ class DownloadManager(
} }
} }
private suspend fun probeUrl(url: String): Pair<Long, Boolean> { private suspend fun probeUrl(id: String, url: String): Pair<Long, Boolean> {
return try { return try {
val response = httpClient.head(url) val response = httpClient.head(url)
val contentLength = response.contentLength() ?: 0L val contentLength = response.contentLength() ?: 0L
val acceptRanges = response.headers[HttpHeaders.AcceptRanges] val acceptRanges = response.headers[HttpHeaders.AcceptRanges]
val supportsRanges = acceptRanges?.equals("bytes", ignoreCase = true) == true 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) Pair(contentLength, supportsRanges)
} catch (e: Exception) { } 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) 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) { private suspend fun applyMetadata(item: DownloadItem, filename: String, downloadDir: Path) {
val filePath = downloadDir / filename.toPath() 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 val track = item.track ?: return
@ -421,18 +501,11 @@ class DownloadManager(
discNumber = track.discNumber, discNumber = track.discNumber,
coverBytes = coverBytes) coverBytes = coverBytes)
} catch (e: Exception) { } 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 { private fun resolveDownloadDir(): Path = downloadFolder.value.toPath()
val folder = cachedDownloadFolder.value
return if (!folder.isNullOrBlank()) {
folder.toPath()
} else {
paths.getUserDownloadsDirPath().toPath()
}
}
private fun cleanupTempFiles(id: String) { private fun cleanupTempFiles(id: String) {
try { try {

View File

@ -100,11 +100,18 @@ internal fun DownloadsToolbarTitle() {
@Composable @Composable
internal fun DownloadsToolbarSummary(state: DownloadsUiState) { internal fun DownloadsToolbarSummary(state: DownloadsUiState) {
if (state.items.isNotEmpty()) Text( Column(Modifier.padding(start = 28.dp)) {
stringResource(Res.string.downloads_summary, state.activeCount, state.completedCount), if (state.items.isNotEmpty()) Text(
modifier = Modifier.padding(start = 28.dp), stringResource(Res.string.downloads_summary, state.activeCount, state.completedCount),
style = AppTheme.typography.bodySmall, color = AppTheme.colors.onSurfaceVariant 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 @Composable

View File

@ -23,7 +23,7 @@ import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.combine
enum class DownloadRowAction { Cancel, Retry, Remove } enum class DownloadRowAction { Cancel, Retry, Remove }
@ -43,10 +43,12 @@ data class DownloadsUiState(
val items: List<DownloadRowUiState> = emptyList(), val items: List<DownloadRowUiState> = emptyList(),
val activeCount: Int = 0, val activeCount: Int = 0,
val completedCount: 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<DownloadItem>): DownloadsUiState { internal fun downloadsUiState(items: List<DownloadItem>, folder: String = ""): DownloadsUiState {
val completedCount = items.count { it.status == DownloadStatus.Completed } val completedCount = items.count { it.status == DownloadStatus.Completed }
return DownloadsUiState( return DownloadsUiState(
items = items.map { item -> items = items.map { item ->
@ -66,7 +68,8 @@ internal fun downloadsUiState(items: List<DownloadItem>): DownloadsUiState {
}, },
activeCount = items.count { it.status == DownloadStatus.Downloading || it.status == DownloadStatus.Queued }, activeCount = items.count { it.status == DownloadStatus.Downloading || it.status == DownloadStatus.Queued },
completedCount = completedCount, completedCount = completedCount,
canClearCompleted = completedCount > 0 canClearCompleted = completedCount > 0,
folder = folder
) )
} }
@ -79,7 +82,7 @@ class DownloadsViewModel(
emptyList() emptyList()
) )
val uiState = downloadManager.downloadsFlow.map(::downloadsUiState) val uiState = combine(downloadManager.downloadsFlow, downloadManager.downloadFolder, ::downloadsUiState)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), DownloadsUiState()) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), DownloadsUiState())
fun downloadTrack(track: MetadataTrack) { fun downloadTrack(track: MetadataTrack) {

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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)

View File

@ -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.ui.misc.SkeletonTree
import org.spotube.spotube.core.navigation.NavigationCommands import org.spotube.spotube.core.navigation.NavigationCommands
import org.spotube.spotube.core.navigation.Routes 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.getPlatform
import org.spotube.spotube.modules.shell.LocalAppShellBottomInset import org.spotube.spotube.modules.shell.LocalAppShellBottomInset
import org.spotube.spotube.modules.support.LocalSupportPrompt import org.spotube.spotube.modules.support.LocalSupportPrompt
import org.spotube.spotube.resources.iconsax.Iconsax import org.spotube.spotube.resources.iconsax.Iconsax
import org.spotube.spotube.resources.iconsax.IconsaxHeart2 import org.spotube.spotube.resources.iconsax.IconsaxHeart2
import org.spotube.spotube.resources.iconsax.IconsaxMirroringScreen import org.spotube.spotube.resources.iconsax.IconsaxMirroringScreen
import org.spotube.spotube.resources.iconsax.User
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import org.koin.compose.koinInject import org.koin.compose.koinInject
import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.Res
@ -93,6 +94,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import org.jetbrains.compose.resources.Font import org.jetbrains.compose.resources.Font
import org.spotube.millennium.adaptive.WindowBreakpoints import org.spotube.millennium.adaptive.WindowBreakpoints
import org.spotube.spotube.resources.iconsax.IconsaxMirrorScreenRegular
import spotube.composeapp.generated.resources.cookie_regular import spotube.composeapp.generated.resources.cookie_regular
@Composable @Composable
@ -161,7 +163,7 @@ fun HomeScreen(viewModel: HomeScreenViewModel) {
style = MillenniumIconButtonStyle.Quiet style = MillenniumIconButtonStyle.Quiet
) { ) {
MillenniumIcon( MillenniumIcon(
imageVector = Iconsax.IconsaxMirroringScreen, imageVector = Iconsax.IconsaxMirrorScreenRegular,
contentDescription = stringResource(Res.string.devices_title) contentDescription = stringResource(Res.string.devices_title)
) )
} }
@ -170,7 +172,7 @@ fun HomeScreen(viewModel: HomeScreenViewModel) {
style = MillenniumIconButtonStyle.Quiet style = MillenniumIconButtonStyle.Quiet
) { ) {
MillenniumIcon( MillenniumIcon(
imageVector = Iconsax.User, imageVector = Iconsax.IconsaxSoundTwotone,
contentDescription = stringResource(Res.string.jam_screen_title) 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( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()

View File

@ -23,16 +23,21 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.spotube.spotube.core.paths.Paths
interface SettingsProvider { interface SettingsProvider {
val settingsState: StateFlow<UserSettings?> val settingsState: StateFlow<UserSettings?>
} }
class SettingsViewModel( class SettingsViewModel(
private val repository: SettingsRepository private val repository: SettingsRepository,
private val paths: Paths
) : ViewModel(), SettingsProvider { ) : ViewModel(), SettingsProvider {
override val settingsState = repository.userSettings override val settingsState = repository.userSettings
/** Where downloads go when no override folder is set. */
val defaultDownloadFolder: String = paths.getUserDownloadsDirPath()
fun updateSettings(transform: UserSettings.() -> UserSettings) { fun updateSettings(transform: UserSettings.() -> UserSettings) {
viewModelScope.launch { viewModelScope.launch {
// Get current state, apply transform, and save // Get current state, apply transform, and save

View File

@ -46,7 +46,7 @@ import io.github.vinceglb.filekit.path
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.settings_download_folder_current 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_folder_title
import spotube.composeapp.generated.resources.settings_download_format_subtitle_current import spotube.composeapp.generated.resources.settings_download_format_subtitle_current
import spotube.composeapp.generated.resources.settings_download_format_title import spotube.composeapp.generated.resources.settings_download_format_title
@ -77,6 +77,7 @@ internal fun LazyListScope.downloadsSection(
{ {
DownloadFolderSettingCard( DownloadFolderSettingCard(
folder = settings.overloadedDownloadFolder, folder = settings.overloadedDownloadFolder,
defaultFolder = settingsViewModel.defaultDownloadFolder,
onFolderSelected = { folder -> onFolderSelected = { folder ->
settingsViewModel.updateSettings { settingsViewModel.updateSettings {
copy(overloadedDownloadFolder = folder) copy(overloadedDownloadFolder = folder)
@ -142,6 +143,7 @@ internal fun LazyListScope.downloadsSection(
@Composable @Composable
private fun DownloadFolderSettingCard( private fun DownloadFolderSettingCard(
folder: String?, folder: String?,
defaultFolder: String,
onFolderSelected: (String?) -> Unit onFolderSelected: (String?) -> Unit
) { ) {
val pickerLauncher = rememberDirectoryPickerLauncher { directory -> val pickerLauncher = rememberDirectoryPickerLauncher { directory ->
@ -157,7 +159,7 @@ private fun DownloadFolderSettingCard(
title = stringResource(Res.string.settings_download_folder_title), title = stringResource(Res.string.settings_download_folder_title),
subtitle = folder?.let { subtitle = folder?.let {
stringResource(Res.string.settings_download_folder_current, it) 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 = { icon = {
SettingsItemIcon( SettingsItemIcon(
Iconsax.IconsaxDirectboxReceive, Iconsax.IconsaxDirectboxReceive,

View File

@ -41,9 +41,7 @@ import org.spotube.spotube.modules.settings.components.SwitchSettingCard
import org.spotube.spotube.modules.settings.components.TextInputSettingCard import org.spotube.spotube.modules.settings.components.TextInputSettingCard
import org.spotube.spotube.resources.iconsax.CustomServer import org.spotube.spotube.resources.iconsax.CustomServer
import org.spotube.spotube.resources.iconsax.Iconsax 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.IconsaxForbidden
import org.spotube.spotube.resources.iconsax.IconsaxMirroringScreen
import org.spotube.spotube.resources.iconsax.IconsaxMusicPlay import org.spotube.spotube.resources.iconsax.IconsaxMusicPlay
import org.spotube.spotube.resources.iconsax.IconsaxRepeatArrow import org.spotube.spotube.resources.iconsax.IconsaxRepeatArrow
import org.spotube.spotube.resources.iconsax.IconsaxVerticalSetting import org.spotube.spotube.resources.iconsax.IconsaxVerticalSetting
@ -122,51 +120,7 @@ internal fun LazyListScope.playbackSection(
} }
) )
} }
add { addAll(remoteControlSettingCards(settings, settingsViewModel, requestLocalNetworkPermission))
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)
}
}
)
}
add { add {
val error_whole_number = stringResource(Res.string.settings_error_whole_number) val error_whole_number = stringResource(Res.string.settings_error_whole_number)
val error_port_range = stringResource(Res.string.settings_error_port_range) val error_port_range = stringResource(Res.string.settings_error_port_range)

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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)
}
}
)
}
)

View File

@ -20,6 +20,20 @@ package org.spotube.spotube.modules.shell
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut 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.Arrangement
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@ -80,8 +94,12 @@ fun AppSidebar(
navigator: Navigator, navigator: Navigator,
navigationState: NavigationState, navigationState: NavigationState,
modifier: Modifier = Modifier, 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) } var expanded by rememberSaveable { mutableStateOf(true) }
val groupExpansion = remember { mutableStateMapOf<String, Boolean>() } val groupExpansion = remember { mutableStateMapOf<String, Boolean>() }
val currentLibraryTab by libraryState.currentTab.collectAsState() val currentLibraryTab by libraryState.currentTab.collectAsState()
@ -144,6 +162,11 @@ fun AppSidebar(
SidebarNavItem( SidebarNavItem(
item = item, item = item,
selected = selected, selected = selected,
status = when (item.route) {
Routes.Devices -> remoteStatus
Routes.Jam -> jamStatus
else -> null
},
onClick = { onClick = {
if (item.route == Routes.Library) { if (item.route == Routes.Library) {
(item.data as? LibraryTab)?.let { (item.data as? LibraryTab)?.let {
@ -194,9 +217,15 @@ fun AppSidebar(
private fun SidebarNavItem( private fun SidebarNavItem(
item: NavigationItem.Tab<*>, item: NavigationItem.Tab<*>,
selected: Boolean, 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) val title = stringResource(item.title)
if (status != null) {
LiveSidebarNavItem(item, title, status, selected, onClick)
return
}
MillenniumSidebarItem( MillenniumSidebarItem(
label = title, label = title,
onClick = onClick, onClick = onClick,
@ -205,3 +234,44 @@ private fun SidebarNavItem(
icon = item.icon 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
)
}
}
}
}

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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)
)
}

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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<ConnectStatusUiState> = 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())
}

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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