mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-11-04 10:13:59 +00:00
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:
parent
b38a4f6963
commit
d4961bb5ed
@ -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,10 +136,18 @@ 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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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(),
|
||||||
|
|||||||
@ -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)}>")
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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)
|
||||||
@ -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()
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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())
|
||||||
|
}
|
||||||
@ -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
|
||||||
@ -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
|
||||||
Loading…
Reference in New Issue
Block a user