mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-11-04 10:13:59 +00:00
Compare commits
8 Commits
64fb23e62b
...
08892c2aa0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08892c2aa0 | ||
|
|
8bbefdfb7d | ||
|
|
d4961bb5ed | ||
|
|
b38a4f6963 | ||
|
|
facc8ed0b9 | ||
|
|
0b21eff4c7 | ||
|
|
9d5854023b | ||
|
|
38e9bbc1d8 |
@ -18,6 +18,7 @@
|
||||
import gobley.gradle.GobleyHost
|
||||
import gobley.gradle.Variant
|
||||
import gobley.gradle.cargo.dsl.jvm
|
||||
import gobley.gradle.rust.targets.RustAndroidTarget
|
||||
import dev.nucleusframework.desktop.application.dsl.NativeImageOptimization
|
||||
import dev.nucleusframework.desktop.application.dsl.TargetFormat
|
||||
import org.jetbrains.compose.reload.gradle.ComposeHotRun
|
||||
@ -388,6 +389,14 @@ android {
|
||||
isMinifyEnabled = false
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
}
|
||||
getByName("debug") {
|
||||
// Debug and release share the application id, so sign both with the release key
|
||||
// when it's configured; otherwise one can't be installed over the other.
|
||||
// Without local.properties signing (contributors) keep the default debug keystore.
|
||||
if (signingConfigs.getByName("release").storeFile != null) {
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
}
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
@ -614,6 +623,15 @@ cargo {
|
||||
}
|
||||
}
|
||||
|
||||
// Gobley adds build/intermediates/rust/<triple>/<variant> as a jniLibs source dir, but AGP leaves source dirs
|
||||
// that don't exist at configuration time out of the merge inputs. On a clean build (CI) libcompose_app.so would
|
||||
// then silently miss the APK and crash at runtime with UnsatisfiedLinkError, so create the dirs up front.
|
||||
RustAndroidTarget.entries.forEach { target ->
|
||||
Variant.entries.forEach { variant ->
|
||||
layout.buildDirectory.dir("intermediates/rust/${target.rustTriple}/$variant").get().asFile.mkdirs()
|
||||
}
|
||||
}
|
||||
|
||||
// Google only ships x86_64 Linux NDK toolchains, so on a Linux arm64 host the Android Rust targets can never
|
||||
// link (clang: "Exec format error"). Desktop packaging doesn't need them; Android is built on x86_64 runners.
|
||||
run {
|
||||
|
||||
@ -19,17 +19,22 @@ package org.spotube.spotube.core.newpipe
|
||||
|
||||
import org.spotube.plugin_interfaces.plugin_apis.audio.AudioStream
|
||||
import org.spotube.plugin_interfaces.plugin_apis.audio.StreamProtocol
|
||||
import co.touchlab.kermit.Logger
|
||||
import org.spotube.spotube.core.server.describeForLog
|
||||
import io.ktor.http.Url
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.schabi.newpipe.extractor.ServiceList.YouTube
|
||||
import org.schabi.newpipe.extractor.services.youtube.linkHandler.YoutubeSearchQueryHandlerFactory
|
||||
import org.schabi.newpipe.extractor.stream.DeliveryMethod
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem
|
||||
|
||||
/** NewPipe reports durations in seconds (-1 when unknown). */
|
||||
private fun secondsToMs(seconds: Long): Long = if (seconds > 0) seconds * 1000 else 0
|
||||
|
||||
actual class NewPipeService {
|
||||
private val logger = Logger.withTag("NewPipeService")
|
||||
|
||||
actual suspend fun searchVideos(query: String, kind: SearchKind): List<VideoSearchResult> {
|
||||
return withContext(Dispatchers.IO) {
|
||||
val extractor = YouTube.getSearchExtractor(
|
||||
@ -66,13 +71,33 @@ actual class NewPipeService {
|
||||
val extractor = YouTube.getStreamExtractor("https://www.youtube.com/watch?v=$id")
|
||||
extractor.fetchPage()
|
||||
|
||||
// Raw extractor output, before any mapping/filtering, so stream-selection issues can be
|
||||
// traced back to what YouTube actually offered for this video.
|
||||
logger.i {
|
||||
"[Streams $id] '${extractor.name}' raw audioStreams=${extractor.audioStreams.size}:\n" +
|
||||
extractor.audioStreams.joinToString("\n") { stream ->
|
||||
" itag=${stream.itag} format=${stream.format?.name} mime=${stream.format?.mimeType} " +
|
||||
"suffix=${stream.format?.suffix} codec=${stream.codec} bitrate=${stream.bitrate} " +
|
||||
"avgBitrate=${stream.averageBitrate} delivery=${stream.deliveryMethod} isUrl=${stream.isUrl} " +
|
||||
"trackType=${stream.audioTrackType}"
|
||||
}
|
||||
}
|
||||
logger.i {
|
||||
"[Streams $id] dashMpdUrl=${extractor.dashMpdUrl.isNotBlank()} hlsUrl=${extractor.hlsUrl.isNotBlank()} " +
|
||||
"raw videoStreams=${extractor.videoStreams.size} videoOnlyStreams=${extractor.videoOnlyStreams.size}"
|
||||
}
|
||||
|
||||
val audioStreams = extractor.audioStreams
|
||||
.mapNotNull { stream ->
|
||||
val format = stream.format ?: return@mapNotNull null
|
||||
// Non-URL content is an inline manifest, not something a player or downloader can fetch.
|
||||
if (!stream.isUrl) return@mapNotNull null
|
||||
AudioStream.Lossy(
|
||||
url = stream.content,
|
||||
codec = stream.codec,
|
||||
container = stream.format!!.suffix,
|
||||
bitrate = stream.bitrate)
|
||||
codec = normalizeCodec(stream.codec),
|
||||
container = normalizeContainer(format.suffix),
|
||||
bitrate = stream.bitrate,
|
||||
protocol = stream.deliveryMethod.toProtocol())
|
||||
} + buildList {
|
||||
if (extractor.dashMpdUrl.isNotBlank()) add(
|
||||
AudioStream.Lossy(
|
||||
@ -103,6 +128,10 @@ actual class NewPipeService {
|
||||
bitrate = stream.bitrate,
|
||||
container = stream.format!!.suffix)
|
||||
}
|
||||
logger.i {
|
||||
"[Streams $id] mapped audio candidates (${audioStreams.size}):\n" +
|
||||
audioStreams.joinToString("\n") { " ${it.describeForLog()}" }
|
||||
}
|
||||
VideoInfo(
|
||||
id = id,
|
||||
title = extractor.name,
|
||||
@ -112,10 +141,37 @@ actual class NewPipeService {
|
||||
thumbnailUrl = extractor.thumbnails.first().url,
|
||||
// fallback to video streams if no audio streams are available
|
||||
audioStreams = audioStreams.ifEmpty {
|
||||
logger.w {
|
||||
"[Streams $id] no audio streams; FALLING BACK TO ${videoStreams.size} VIDEO streams " +
|
||||
"(muxed video+audio, downloads will be video files):\n" +
|
||||
extractor.videoStreams.joinToString("\n") {
|
||||
" itag=${it.itag} format=${it.format?.name} res=${it.resolution} " +
|
||||
"codec=${it.codec} videoOnly=${it.isVideoOnly} isUrl=${it.isUrl}"
|
||||
}
|
||||
}
|
||||
videoStreams
|
||||
},
|
||||
videoStreams = videoStreams
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* NewPipe names AAC "mp4a.40.x" in an "m4a" container, but the plugin advertises (and users pick)
|
||||
* "aac"/"mp4". Without this a real AAC stream never matched the preference, and the hard-coded
|
||||
* aac/mp4 DASH/HLS manifests always won instead.
|
||||
*/
|
||||
private fun normalizeCodec(codec: String?): String {
|
||||
val value = codec.orEmpty()
|
||||
return if (value.startsWith("mp4a", ignoreCase = true)) "aac" else value
|
||||
}
|
||||
|
||||
private fun normalizeContainer(suffix: String): String =
|
||||
if (suffix.equals("m4a", ignoreCase = true)) "mp4" else suffix
|
||||
|
||||
private fun DeliveryMethod.toProtocol(): StreamProtocol = when (this) {
|
||||
DeliveryMethod.DASH -> StreamProtocol.DASH
|
||||
DeliveryMethod.HLS -> StreamProtocol.HLS
|
||||
else -> StreamProtocol.PROGRESSIVE
|
||||
}
|
||||
|
||||
@ -18,7 +18,6 @@
|
||||
package org.spotube.spotube
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
@ -37,13 +36,8 @@ class MainActivity : ComponentActivity() {
|
||||
FileKit.init(this)
|
||||
NewPipeDownloader.init(Paths())
|
||||
|
||||
// Start PlaybackService from Activity context (allowed on Android 12+)
|
||||
val serviceIntent = Intent(this, PlaybackService::class.java)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
startForegroundService(serviceIntent)
|
||||
} else {
|
||||
startService(serviceIntent)
|
||||
}
|
||||
// Plain start from the foreground; Media3 promotes PlaybackService itself once playback starts
|
||||
startService(Intent(this, PlaybackService::class.java))
|
||||
|
||||
intent?.dataString?.let(ExternalUriHandler::onNewUri)
|
||||
setContent {
|
||||
|
||||
@ -19,7 +19,6 @@ package org.spotube.spotube.core.audioplayer
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.media3.common.AudioAttributes
|
||||
import androidx.media3.common.C
|
||||
@ -52,17 +51,14 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
|
||||
private val appContext: Context = (context as Context).applicationContext
|
||||
|
||||
private fun ensureServiceStarted() {
|
||||
// On Android 12+ starting a foreground service from the background throws
|
||||
// (ForegroundServiceStartNotAllowedException) — e.g. when a jam session or
|
||||
// remote control applies playback while the app is backgrounded. Never let
|
||||
// that crash the app; playback itself runs in-process without the service.
|
||||
// A plain start, never startForegroundService: that obliges the service to call
|
||||
// startForeground() within seconds of *every* call, and once Media3 has demoted it
|
||||
// on pause nothing does, so the app is killed (ForegroundServiceDidNotStartInTime).
|
||||
// Media3's MediaSessionService promotes itself to the foreground when playback starts.
|
||||
// Starting from the background (jam sync, remote control) throws; playback still runs
|
||||
// in-process without the service, so never let that crash the app.
|
||||
try {
|
||||
val intent = Intent(appContext, PlaybackService::class.java)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
appContext.startForegroundService(intent)
|
||||
} else {
|
||||
appContext.startService(intent)
|
||||
}
|
||||
appContext.startService(Intent(appContext, PlaybackService::class.java))
|
||||
} catch (e: Exception) {
|
||||
Log.w("AudioPlayer", "Failed to start playback service", e)
|
||||
}
|
||||
|
||||
@ -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.core.discovery
|
||||
|
||||
// The OS's own service-discovery stack advertises on the right network.
|
||||
internal actual fun preferredAdvertiseAddress(): String? = null
|
||||
@ -18,15 +18,19 @@
|
||||
package org.spotube.spotube.core.share
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.core.app.ShareCompat
|
||||
import org.spotube.spotube.core.share.ShareService
|
||||
|
||||
class AndroidShareService(private val context: Context) : ShareService {
|
||||
override fun share(url: String, title: String) {
|
||||
ShareCompat.IntentBuilder(context)
|
||||
// context is the application context (Koin), which can only start activities in a new task
|
||||
val chooser = ShareCompat.IntentBuilder(context)
|
||||
.setType("text/plain")
|
||||
.setSubject(title)
|
||||
.setText(url)
|
||||
.startChooser()
|
||||
.createChooserIntent()
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
context.startActivity(chooser)
|
||||
}
|
||||
}
|
||||
|
||||
@ -51,7 +51,6 @@ import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
@ -59,7 +58,6 @@ import org.jetbrains.compose.resources.getString
|
||||
import spotube.composeapp.generated.resources.Res
|
||||
import spotube.composeapp.generated.resources.notification_playback_channel_description
|
||||
import spotube.composeapp.generated.resources.notification_playback_channel_name
|
||||
import spotube.composeapp.generated.resources.notification_playback_service_text
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
class PlaybackService : MediaLibraryService(), KoinComponent {
|
||||
@ -96,9 +94,10 @@ class PlaybackService : MediaLibraryService(), KoinComponent {
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
// Short-lived placeholder until Media3 posts the playback notification. No localized text:
|
||||
// loading it here would block the main thread (see createNotificationChannel).
|
||||
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle("Spotube")
|
||||
.setContentText(runBlocking { getString(Res.string.notification_playback_service_text) })
|
||||
.setSmallIcon(R.drawable.ic_stat_spotube)
|
||||
.setContentIntent(sessionActivity)
|
||||
.setOngoing(true)
|
||||
@ -125,21 +124,32 @@ class PlaybackService : MediaLibraryService(), KoinComponent {
|
||||
Log.i(TAG, "Library session created")
|
||||
}
|
||||
|
||||
/**
|
||||
* The channel must exist before startForeground, so it's created right away under the app name
|
||||
* and renamed once the localized strings load.
|
||||
*
|
||||
* Never `runBlocking { getString(...) }` here: onCreate runs on the main thread, and Compose
|
||||
* resources share one in-flight load per strings file. If App()'s stringResource started that
|
||||
* load on the main dispatcher first, blocking main to await it deadlocks the app on its
|
||||
* splash screen.
|
||||
*/
|
||||
@SuppressLint("ObsoleteSdkInt")
|
||||
private fun createNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
runBlocking { getString(Res.string.notification_playback_channel_name) },
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
).apply {
|
||||
description = runBlocking {
|
||||
getString(Res.string.notification_playback_channel_description)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
val manager = getSystemService(NotificationManager::class.java)
|
||||
fun channel(name: String, description: String? = null) =
|
||||
NotificationChannel(CHANNEL_ID, name, NotificationManager.IMPORTANCE_LOW).apply {
|
||||
this.description = description
|
||||
setShowBadge(false)
|
||||
}
|
||||
val manager = getSystemService(NotificationManager::class.java)
|
||||
manager.createNotificationChannel(channel)
|
||||
|
||||
manager.createNotificationChannel(channel("Spotube"))
|
||||
serviceScope.launch {
|
||||
// Re-creating a channel with the same ID updates its name and description.
|
||||
manager.createNotificationChannel(
|
||||
channel(
|
||||
name = getString(Res.string.notification_playback_channel_name),
|
||||
description = getString(Res.string.notification_playback_channel_description)))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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_current">المجلد الحالي: %1$s</string>
|
||||
<string name="settings_download_folder_default">المجلد الحالي: مجلد الموسيقى الافتراضي</string>
|
||||
<string name="settings_download_folder_description">اتركه فارغًا لاستخدام مجلد الموسيقى الافتراضي.</string>
|
||||
<string name="settings_download_folder_placeholder">C:\Music\Spotube</string>
|
||||
<string name="settings_local_media_folders_title">مجلدات الوسائط المحلية</string>
|
||||
@ -225,8 +224,6 @@
|
||||
<string name="settings_error_cache_size_range">يجب أن يكون الحجم 0 أو رقمًا موجبًا.</string>
|
||||
<string name="settings_enable_endless_playback_title">تفعيل التشغيل اللانهائي</string>
|
||||
<string name="settings_enable_endless_playback_subtitle">واصل التشغيل بمقاطع مقترحة عند انتهاء قائمة الانتظار.</string>
|
||||
<string name="settings_enable_connect_title">تفعيل Connect</string>
|
||||
<string name="settings_enable_connect_subtitle">إتاحة عناصر التحكم في التشغيل عن بُعد عبر ميزة Spotube Connect.</string>
|
||||
<string name="settings_playback_port_title">منفذ خادم وكيل التشغيل</string>
|
||||
<string name="settings_allow_remote_control_title">السماح بالتحكم عن بُعد</string>
|
||||
<string name="settings_allow_remote_control_subtitle">اسمح للأجهزة الأخرى على الشبكة نفسها بالتحكم في التشغيل عند اتصالها.</string>
|
||||
@ -511,7 +508,6 @@
|
||||
<!-- Devices: discovery screen -->
|
||||
<string name="devices_title">الأجهزة</string>
|
||||
<string name="devices_refresh">تحديث</string>
|
||||
<string name="devices_connected_to">متصل بـ %1$s:%2$d</string>
|
||||
<string name="devices_disconnect">قطع الاتصال</string>
|
||||
<string name="devices_connecting">جارٍ الاتصال...</string>
|
||||
<string name="devices_searching">جارٍ البحث عن أجهزة على الشبكة...</string>
|
||||
@ -519,20 +515,13 @@
|
||||
<string name="devices_empty_hint">تأكد من تفعيل \"السماح بالتحكم عن بُعد\" في إعدادات الجهاز الآخر.</string>
|
||||
<string name="devices_unknown_device">جهاز غير معروف</string>
|
||||
<string name="devices_resolving">جارٍ التحديد...</string>
|
||||
<string name="devices_device_id">المعرّف: %1$s...</string>
|
||||
|
||||
<!-- Devices: remote control screen -->
|
||||
<string name="devices_remote_title">التحكم عن بُعد</string>
|
||||
<string name="devices_remote_disconnected">غير متصل</string>
|
||||
<string name="devices_remote_connection_error">خطأ في الاتصال: %1$s</string>
|
||||
<string name="devices_remote_album_cover">غلاف الألبوم</string>
|
||||
<string name="devices_remote_unknown_track">مقطع غير معروف</string>
|
||||
<string name="devices_remote_unknown_artist">فنان غير معروف</string>
|
||||
<string name="devices_remote_seek">التقديم والترجيع</string>
|
||||
<string name="devices_remote_shuffle">ترتيب عشوائي</string>
|
||||
<string name="devices_remote_previous">السابق</string>
|
||||
<string name="devices_remote_next">التالي</string>
|
||||
<string name="devices_remote_loop_mode">وضع التكرار</string>
|
||||
<string name="devices_remote_volume">مستوى الصوت</string>
|
||||
<string name="devices_remote_remove_from_queue">إزالة من قائمة الانتظار</string>
|
||||
<string name="devices_remote_more_options">خيارات إضافية</string>
|
||||
@ -949,7 +938,6 @@
|
||||
<string name="app_window_close">إغلاق</string>
|
||||
|
||||
<!-- Android playback notification -->
|
||||
<string name="notification_playback_service_text">خدمة التشغيل</string>
|
||||
<string name="notification_playback_channel_name">التشغيل</string>
|
||||
<string name="notification_playback_channel_description">عناصر التحكم في تشغيل الوسائط</string>
|
||||
|
||||
|
||||
@ -171,7 +171,6 @@
|
||||
|
||||
<string name="settings_download_folder_title">ডাউনলোড ফোল্ডার পরিবর্তন</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_placeholder">C:\Music\Spotube</string>
|
||||
<string name="settings_local_media_folders_title">লোকাল মিডিয়া ফোল্ডার</string>
|
||||
@ -213,8 +212,6 @@
|
||||
<string name="settings_error_cache_size_range">সাইজ 0 বা একটি ধনাত্মক সংখ্যা হতে হবে।</string>
|
||||
<string name="settings_enable_endless_playback_title">অবিরাম প্লেব্যাক চালু করুন</string>
|
||||
<string name="settings_enable_endless_playback_subtitle">কিউ শেষ হলে সাজেস্ট করা ট্র্যাক দিয়ে প্লেব্যাক চালু রাখুন।</string>
|
||||
<string name="settings_enable_connect_title">Connect চালু করুন</string>
|
||||
<string name="settings_enable_connect_subtitle">Spotube Connect ফিচারের মাধ্যমে রিমোট প্লেব্যাক কন্ট্রোল উপলব্ধ করুন।</string>
|
||||
<string name="settings_playback_port_title">প্লেব্যাক প্রক্সি সার্ভার পোর্ট</string>
|
||||
<string name="settings_allow_remote_control_title">রিমোট কন্ট্রোলের অনুমতি দিন</string>
|
||||
<string name="settings_allow_remote_control_subtitle">একই নেটওয়ার্কের অন্য ডিভাইস কানেক্ট হলে প্লেব্যাক নিয়ন্ত্রণ করতে দিন।</string>
|
||||
@ -495,7 +492,6 @@
|
||||
<!-- Devices: discovery screen -->
|
||||
<string name="devices_title">ডিভাইস</string>
|
||||
<string name="devices_refresh">রিফ্রেশ</string>
|
||||
<string name="devices_connected_to">%1$s:%2$d-এ কানেক্টেড</string>
|
||||
<string name="devices_disconnect">ডিসকানেক্ট</string>
|
||||
<string name="devices_connecting">কানেক্ট হচ্ছে...</string>
|
||||
<string name="devices_searching">নেটওয়ার্কে ডিভাইস খোঁজা হচ্ছে...</string>
|
||||
@ -503,20 +499,13 @@
|
||||
<string name="devices_empty_hint">অন্য ডিভাইসের সেটিংসে \"রিমোট কন্ট্রোলের অনুমতি দিন\" চালু আছে কিনা নিশ্চিত করুন।</string>
|
||||
<string name="devices_unknown_device">অজানা ডিভাইস</string>
|
||||
<string name="devices_resolving">খোঁজা হচ্ছে...</string>
|
||||
<string name="devices_device_id">ID: %1$s...</string>
|
||||
|
||||
<!-- Devices: remote control screen -->
|
||||
<string name="devices_remote_title">রিমোট কন্ট্রোল</string>
|
||||
<string name="devices_remote_disconnected">ডিসকানেক্টেড</string>
|
||||
<string name="devices_remote_connection_error">কানেকশন ত্রুটি: %1$s</string>
|
||||
<string name="devices_remote_album_cover">অ্যালবাম কভার</string>
|
||||
<string name="devices_remote_unknown_track">অজানা ট্র্যাক</string>
|
||||
<string name="devices_remote_unknown_artist">অজানা শিল্পী</string>
|
||||
<string name="devices_remote_seek">সিক</string>
|
||||
<string name="devices_remote_shuffle">শাফল</string>
|
||||
<string name="devices_remote_previous">আগের</string>
|
||||
<string name="devices_remote_next">পরের</string>
|
||||
<string name="devices_remote_loop_mode">লুপ মোড</string>
|
||||
<string name="devices_remote_volume">ভলিউম</string>
|
||||
<string name="devices_remote_remove_from_queue">কিউ থেকে সরান</string>
|
||||
<string name="devices_remote_more_options">আরও অপশন</string>
|
||||
@ -925,7 +914,6 @@
|
||||
<string name="app_window_close">বন্ধ করুন</string>
|
||||
|
||||
<!-- Android playback notification -->
|
||||
<string name="notification_playback_service_text">প্লেব্যাক সার্ভিস</string>
|
||||
<string name="notification_playback_channel_name">প্লেব্যাক</string>
|
||||
<string name="notification_playback_channel_description">মিডিয়া প্লেব্যাক কন্ট্রোল</string>
|
||||
|
||||
|
||||
@ -171,7 +171,6 @@
|
||||
|
||||
<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_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_placeholder">C:\Music\Spotube</string>
|
||||
<string name="settings_local_media_folders_title">Lokale Medienordner</string>
|
||||
@ -213,8 +212,6 @@
|
||||
<string name="settings_error_cache_size_range">Die Größe muss 0 oder eine positive Zahl sein.</string>
|
||||
<string name="settings_enable_endless_playback_title">Endlose Wiedergabe aktivieren</string>
|
||||
<string name="settings_enable_endless_playback_subtitle">Wenn deine Warteschlange endet, geht es mit empfohlenen Titeln weiter.</string>
|
||||
<string name="settings_enable_connect_title">Connect aktivieren</string>
|
||||
<string name="settings_enable_connect_subtitle">Fernsteuerung der Wiedergabe über Spotube Connect bereitstellen.</string>
|
||||
<string name="settings_playback_port_title">Port des Wiedergabe-Proxyservers</string>
|
||||
<string name="settings_allow_remote_control_title">Fernsteuerung erlauben</string>
|
||||
<string name="settings_allow_remote_control_subtitle">Andere Geräte im selben Netzwerk dürfen die Wiedergabe steuern, wenn sie sich verbinden.</string>
|
||||
@ -495,7 +492,6 @@
|
||||
<!-- Devices: discovery screen -->
|
||||
<string name="devices_title">Geräte</string>
|
||||
<string name="devices_refresh">Aktualisieren</string>
|
||||
<string name="devices_connected_to">Verbunden mit %1$s:%2$d</string>
|
||||
<string name="devices_disconnect">Trennen</string>
|
||||
<string name="devices_connecting">Verbinden...</string>
|
||||
<string name="devices_searching">Suche nach Geräten im Netzwerk...</string>
|
||||
@ -503,20 +499,13 @@
|
||||
<string name="devices_empty_hint">Stelle sicher, dass auf dem anderen Gerät „Fernsteuerung erlauben“ in den Einstellungen aktiviert ist.</string>
|
||||
<string name="devices_unknown_device">Unbekanntes Gerät</string>
|
||||
<string name="devices_resolving">Wird aufgelöst...</string>
|
||||
<string name="devices_device_id">ID: %1$s...</string>
|
||||
|
||||
<!-- Devices: remote control screen -->
|
||||
<string name="devices_remote_title">Fernsteuerung</string>
|
||||
<string name="devices_remote_disconnected">Getrennt</string>
|
||||
<string name="devices_remote_connection_error">Verbindungsfehler: %1$s</string>
|
||||
<string name="devices_remote_album_cover">Albumcover</string>
|
||||
<string name="devices_remote_unknown_track">Unbekannter Titel</string>
|
||||
<string name="devices_remote_unknown_artist">Unbekannter Künstler</string>
|
||||
<string name="devices_remote_seek">Spulen</string>
|
||||
<string name="devices_remote_shuffle">Zufallswiedergabe</string>
|
||||
<string name="devices_remote_previous">Zurück</string>
|
||||
<string name="devices_remote_next">Weiter</string>
|
||||
<string name="devices_remote_loop_mode">Wiederholungsmodus</string>
|
||||
<string name="devices_remote_volume">Lautstärke</string>
|
||||
<string name="devices_remote_remove_from_queue">Aus Warteschlange entfernen</string>
|
||||
<string name="devices_remote_more_options">Weitere Optionen</string>
|
||||
@ -925,7 +914,6 @@
|
||||
<string name="app_window_close">Schließen</string>
|
||||
|
||||
<!-- Android playback notification -->
|
||||
<string name="notification_playback_service_text">Wiedergabedienst</string>
|
||||
<string name="notification_playback_channel_name">Wiedergabe</string>
|
||||
<string name="notification_playback_channel_description">Steuerung der Medienwiedergabe</string>
|
||||
|
||||
|
||||
@ -173,7 +173,6 @@
|
||||
|
||||
<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_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_placeholder">C:\Music\Spotube</string>
|
||||
<string name="settings_local_media_folders_title">Carpetas de contenido local</string>
|
||||
@ -216,8 +215,6 @@
|
||||
<string name="settings_error_cache_size_range">El tamaño debe ser 0 o un número positivo.</string>
|
||||
<string name="settings_enable_endless_playback_title">Activar reproducción infinita</string>
|
||||
<string name="settings_enable_endless_playback_subtitle">Sigue reproduciendo canciones recomendadas cuando termine la cola.</string>
|
||||
<string name="settings_enable_connect_title">Activar Connect</string>
|
||||
<string name="settings_enable_connect_subtitle">Ofrece controles de reproducción remota mediante la función Spotube Connect.</string>
|
||||
<string name="settings_playback_port_title">Puerto del servidor proxy de reproducción</string>
|
||||
<string name="settings_allow_remote_control_title">Permitir control remoto</string>
|
||||
<string name="settings_allow_remote_control_subtitle">Permite que otros dispositivos de la misma red controlen la reproducción al conectarse.</string>
|
||||
@ -499,7 +496,6 @@
|
||||
<!-- Devices: discovery screen -->
|
||||
<string name="devices_title">Dispositivos</string>
|
||||
<string name="devices_refresh">Actualizar</string>
|
||||
<string name="devices_connected_to">Conectado a %1$s:%2$d</string>
|
||||
<string name="devices_disconnect">Desconectar</string>
|
||||
<string name="devices_connecting">Conectando...</string>
|
||||
<string name="devices_searching">Buscando dispositivos en la red...</string>
|
||||
@ -507,20 +503,13 @@
|
||||
<string name="devices_empty_hint">Asegúrate de que el otro dispositivo tenga activado \"Permitir control remoto\" en los ajustes.</string>
|
||||
<string name="devices_unknown_device">Dispositivo desconocido</string>
|
||||
<string name="devices_resolving">Resolviendo...</string>
|
||||
<string name="devices_device_id">ID: %1$s...</string>
|
||||
|
||||
<!-- Devices: remote control screen -->
|
||||
<string name="devices_remote_title">Control remoto</string>
|
||||
<string name="devices_remote_disconnected">Desconectado</string>
|
||||
<string name="devices_remote_connection_error">Error de conexión: %1$s</string>
|
||||
<string name="devices_remote_album_cover">Portada del álbum</string>
|
||||
<string name="devices_remote_unknown_track">Canción desconocida</string>
|
||||
<string name="devices_remote_unknown_artist">Artista desconocido</string>
|
||||
<string name="devices_remote_seek">Avanzar</string>
|
||||
<string name="devices_remote_shuffle">Aleatorio</string>
|
||||
<string name="devices_remote_previous">Anterior</string>
|
||||
<string name="devices_remote_next">Siguiente</string>
|
||||
<string name="devices_remote_loop_mode">Modo de repetición</string>
|
||||
<string name="devices_remote_volume">Volumen</string>
|
||||
<string name="devices_remote_remove_from_queue">Quitar de la cola</string>
|
||||
<string name="devices_remote_more_options">Más opciones</string>
|
||||
@ -931,7 +920,6 @@
|
||||
<string name="app_window_close">Cerrar</string>
|
||||
|
||||
<!-- Android playback notification -->
|
||||
<string name="notification_playback_service_text">Servicio de reproducción</string>
|
||||
<string name="notification_playback_channel_name">Reproducción</string>
|
||||
<string name="notification_playback_channel_description">Controles de reproducción multimedia</string>
|
||||
|
||||
|
||||
@ -171,7 +171,6 @@
|
||||
|
||||
<string name="settings_download_folder_title">پوشهٔ دانلود سفارشی</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_placeholder">C:\Music\Spotube</string>
|
||||
<string name="settings_local_media_folders_title">پوشههای رسانهٔ محلی</string>
|
||||
@ -213,8 +212,6 @@
|
||||
<string name="settings_error_cache_size_range">حجم باید 0 یا یک عدد مثبت باشد.</string>
|
||||
<string name="settings_enable_endless_playback_title">فعالسازی پخش بیپایان</string>
|
||||
<string name="settings_enable_endless_playback_subtitle">وقتی صف پخش تمام شد، پخش با آهنگهای پیشنهادی ادامه پیدا کند.</string>
|
||||
<string name="settings_enable_connect_title">فعالسازی Connect</string>
|
||||
<string name="settings_enable_connect_subtitle">کنترلهای پخش از راه دور را از طریق قابلیت Spotube Connect در دسترس قرار دهید.</string>
|
||||
<string name="settings_playback_port_title">درگاه سرور پراکسی پخش</string>
|
||||
<string name="settings_allow_remote_control_title">اجازهٔ کنترل از راه دور</string>
|
||||
<string name="settings_allow_remote_control_subtitle">به دستگاههای دیگر در همان شبکه اجازه دهید هنگام اتصال، پخش را کنترل کنند.</string>
|
||||
@ -495,7 +492,6 @@
|
||||
<!-- Devices: discovery screen -->
|
||||
<string name="devices_title">دستگاهها</string>
|
||||
<string name="devices_refresh">بهروزرسانی</string>
|
||||
<string name="devices_connected_to">متصل به %1$s:%2$d</string>
|
||||
<string name="devices_disconnect">قطع اتصال</string>
|
||||
<string name="devices_connecting">در حال اتصال...</string>
|
||||
<string name="devices_searching">در حال جستجوی دستگاهها در شبکه...</string>
|
||||
@ -503,20 +499,13 @@
|
||||
<string name="devices_empty_hint">مطمئن شوید گزینهٔ \"اجازهٔ کنترل از راه دور\" در تنظیمات دستگاه دیگر فعال است.</string>
|
||||
<string name="devices_unknown_device">دستگاه ناشناس</string>
|
||||
<string name="devices_resolving">در حال شناسایی...</string>
|
||||
<string name="devices_device_id">شناسه: %1$s...</string>
|
||||
|
||||
<!-- Devices: remote control screen -->
|
||||
<string name="devices_remote_title">کنترل از راه دور</string>
|
||||
<string name="devices_remote_disconnected">قطع شد</string>
|
||||
<string name="devices_remote_connection_error">خطای اتصال: %1$s</string>
|
||||
<string name="devices_remote_album_cover">کاور آلبوم</string>
|
||||
<string name="devices_remote_unknown_track">آهنگ ناشناس</string>
|
||||
<string name="devices_remote_unknown_artist">هنرمند ناشناس</string>
|
||||
<string name="devices_remote_seek">جابهجایی</string>
|
||||
<string name="devices_remote_shuffle">پخش تصادفی</string>
|
||||
<string name="devices_remote_previous">قبلی</string>
|
||||
<string name="devices_remote_next">بعدی</string>
|
||||
<string name="devices_remote_loop_mode">حالت تکرار</string>
|
||||
<string name="devices_remote_volume">صدا</string>
|
||||
<string name="devices_remote_remove_from_queue">حذف از صف</string>
|
||||
<string name="devices_remote_more_options">گزینههای بیشتر</string>
|
||||
@ -925,7 +914,6 @@
|
||||
<string name="app_window_close">بستن</string>
|
||||
|
||||
<!-- Android playback notification -->
|
||||
<string name="notification_playback_service_text">سرویس پخش</string>
|
||||
<string name="notification_playback_channel_name">پخش</string>
|
||||
<string name="notification_playback_channel_description">کنترلهای پخش رسانه</string>
|
||||
|
||||
|
||||
@ -173,7 +173,6 @@
|
||||
|
||||
<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_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_placeholder">C:\Music\Spotube</string>
|
||||
<string name="settings_local_media_folders_title">Dossiers de médias locaux</string>
|
||||
@ -216,8 +215,6 @@
|
||||
<string name="settings_error_cache_size_range">La taille doit être 0 ou un nombre positif.</string>
|
||||
<string name="settings_enable_endless_playback_title">Activer la lecture continue</string>
|
||||
<string name="settings_enable_endless_playback_subtitle">Poursuivre la lecture avec des titres recommandés à la fin de la file d\'attente.</string>
|
||||
<string name="settings_enable_connect_title">Activer Connect</string>
|
||||
<string name="settings_enable_connect_subtitle">Proposer des commandes de lecture à distance via la fonctionnalité Spotube Connect.</string>
|
||||
<string name="settings_playback_port_title">Port du serveur proxy de lecture</string>
|
||||
<string name="settings_allow_remote_control_title">Autoriser le contrôle à distance</string>
|
||||
<string name="settings_allow_remote_control_subtitle">Permettre aux autres appareils du même réseau de contrôler la lecture lorsqu\'ils se connectent.</string>
|
||||
@ -499,7 +496,6 @@
|
||||
<!-- Devices: discovery screen -->
|
||||
<string name="devices_title">Appareils</string>
|
||||
<string name="devices_refresh">Actualiser</string>
|
||||
<string name="devices_connected_to">Connecté à %1$s:%2$d</string>
|
||||
<string name="devices_disconnect">Se déconnecter</string>
|
||||
<string name="devices_connecting">Connexion...</string>
|
||||
<string name="devices_searching">Recherche d\'appareils sur le réseau...</string>
|
||||
@ -507,20 +503,13 @@
|
||||
<string name="devices_empty_hint">Assurez-vous que l\'option \"Autoriser le contrôle à distance\" est activée dans les paramètres de l\'autre appareil.</string>
|
||||
<string name="devices_unknown_device">Appareil inconnu</string>
|
||||
<string name="devices_resolving">Résolution...</string>
|
||||
<string name="devices_device_id">ID : %1$s...</string>
|
||||
|
||||
<!-- Devices: remote control screen -->
|
||||
<string name="devices_remote_title">Contrôle à distance</string>
|
||||
<string name="devices_remote_disconnected">Déconnecté</string>
|
||||
<string name="devices_remote_connection_error">Erreur de connexion : %1$s</string>
|
||||
<string name="devices_remote_album_cover">Pochette d\'album</string>
|
||||
<string name="devices_remote_unknown_track">Titre inconnu</string>
|
||||
<string name="devices_remote_unknown_artist">Artiste inconnu</string>
|
||||
<string name="devices_remote_seek">Avancer/reculer</string>
|
||||
<string name="devices_remote_shuffle">Lecture aléatoire</string>
|
||||
<string name="devices_remote_previous">Précédent</string>
|
||||
<string name="devices_remote_next">Suivant</string>
|
||||
<string name="devices_remote_loop_mode">Mode de répétition</string>
|
||||
<string name="devices_remote_volume">Volume</string>
|
||||
<string name="devices_remote_remove_from_queue">Retirer de la file d\'attente</string>
|
||||
<string name="devices_remote_more_options">Plus d\'options</string>
|
||||
@ -931,7 +920,6 @@
|
||||
<string name="app_window_close">Fermer</string>
|
||||
|
||||
<!-- Android playback notification -->
|
||||
<string name="notification_playback_service_text">Service de lecture</string>
|
||||
<string name="notification_playback_channel_name">Lecture</string>
|
||||
<string name="notification_playback_channel_description">Commandes de lecture multimédia</string>
|
||||
|
||||
|
||||
@ -170,7 +170,6 @@
|
||||
|
||||
<string name="settings_download_folder_title">डाउनलोड फ़ोल्डर बदलें</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_placeholder">C:\Music\Spotube</string>
|
||||
<string name="settings_local_media_folders_title">लोकल मीडिया फ़ोल्डर</string>
|
||||
@ -212,8 +211,6 @@
|
||||
<string name="settings_error_cache_size_range">साइज़ 0 या कोई पॉज़िटिव संख्या होनी चाहिए।</string>
|
||||
<string name="settings_enable_endless_playback_title">लगातार प्लेबैक चालू करें</string>
|
||||
<string name="settings_enable_endless_playback_subtitle">कतार खत्म होने पर सुझाए गए ट्रैक के साथ प्लेबैक जारी रखें।</string>
|
||||
<string name="settings_enable_connect_title">Connect चालू करें</string>
|
||||
<string name="settings_enable_connect_subtitle">Spotube Connect फ़ीचर के ज़रिए रिमोट प्लेबैक कंट्रोल उपलब्ध कराएं।</string>
|
||||
<string name="settings_playback_port_title">प्लेबैक प्रॉक्सी सर्वर पोर्ट</string>
|
||||
<string name="settings_allow_remote_control_title">रिमोट कंट्रोल की अनुमति दें</string>
|
||||
<string name="settings_allow_remote_control_subtitle">एक ही नेटवर्क के दूसरे डिवाइस कनेक्ट होने पर प्लेबैक कंट्रोल कर सकें।</string>
|
||||
@ -494,7 +491,6 @@
|
||||
<!-- Devices: discovery screen -->
|
||||
<string name="devices_title">डिवाइस</string>
|
||||
<string name="devices_refresh">रीफ़्रेश करें</string>
|
||||
<string name="devices_connected_to">%1$s:%2$d से कनेक्ट है</string>
|
||||
<string name="devices_disconnect">डिस्कनेक्ट करें</string>
|
||||
<string name="devices_connecting">कनेक्ट हो रहा है...</string>
|
||||
<string name="devices_searching">नेटवर्क पर डिवाइस खोजे जा रहे हैं...</string>
|
||||
@ -502,20 +498,13 @@
|
||||
<string name="devices_empty_hint">पक्का करें कि दूसरे डिवाइस की सेटिंग में \"रिमोट कंट्रोल की अनुमति दें\" चालू है।</string>
|
||||
<string name="devices_unknown_device">अज्ञात डिवाइस</string>
|
||||
<string name="devices_resolving">पता लगाया जा रहा है...</string>
|
||||
<string name="devices_device_id">ID: %1$s...</string>
|
||||
|
||||
<!-- Devices: remote control screen -->
|
||||
<string name="devices_remote_title">रिमोट कंट्रोल</string>
|
||||
<string name="devices_remote_disconnected">डिस्कनेक्ट हो गया</string>
|
||||
<string name="devices_remote_connection_error">कनेक्शन में गड़बड़ी: %1$s</string>
|
||||
<string name="devices_remote_album_cover">एल्बम कवर</string>
|
||||
<string name="devices_remote_unknown_track">अज्ञात ट्रैक</string>
|
||||
<string name="devices_remote_unknown_artist">अज्ञात कलाकार</string>
|
||||
<string name="devices_remote_seek">आगे-पीछे करें</string>
|
||||
<string name="devices_remote_shuffle">शफ़ल</string>
|
||||
<string name="devices_remote_previous">पिछला</string>
|
||||
<string name="devices_remote_next">अगला</string>
|
||||
<string name="devices_remote_loop_mode">रिपीट मोड</string>
|
||||
<string name="devices_remote_volume">वॉल्यूम</string>
|
||||
<string name="devices_remote_remove_from_queue">कतार से हटाएं</string>
|
||||
<string name="devices_remote_more_options">और विकल्प</string>
|
||||
@ -924,7 +913,6 @@
|
||||
<string name="app_window_close">बंद करें</string>
|
||||
|
||||
<!-- Android playback notification -->
|
||||
<string name="notification_playback_service_text">प्लेबैक सेवा</string>
|
||||
<string name="notification_playback_channel_name">प्लेबैक</string>
|
||||
<string name="notification_playback_channel_description">मीडिया प्लेबैक कंट्रोल</string>
|
||||
|
||||
|
||||
@ -169,7 +169,6 @@
|
||||
|
||||
<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_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_placeholder">C:\Music\Spotube</string>
|
||||
<string name="settings_local_media_folders_title">Folder media lokal</string>
|
||||
@ -210,8 +209,6 @@
|
||||
<string name="settings_error_cache_size_range">Ukuran harus 0 atau bilangan positif.</string>
|
||||
<string name="settings_enable_endless_playback_title">Aktifkan pemutaran tanpa henti</string>
|
||||
<string name="settings_enable_endless_playback_subtitle">Lanjutkan pemutaran dengan lagu rekomendasi saat antrean habis.</string>
|
||||
<string name="settings_enable_connect_title">Aktifkan Connect</string>
|
||||
<string name="settings_enable_connect_subtitle">Tampilkan kontrol pemutaran jarak jauh melalui fitur Spotube Connect.</string>
|
||||
<string name="settings_playback_port_title">Port server proxy pemutaran</string>
|
||||
<string name="settings_allow_remote_control_title">Izinkan kontrol jarak jauh</string>
|
||||
<string name="settings_allow_remote_control_subtitle">Izinkan perangkat lain di jaringan yang sama mengontrol pemutaran saat terhubung.</string>
|
||||
@ -491,7 +488,6 @@
|
||||
<!-- Devices: discovery screen -->
|
||||
<string name="devices_title">Perangkat</string>
|
||||
<string name="devices_refresh">Muat ulang</string>
|
||||
<string name="devices_connected_to">Terhubung ke %1$s:%2$d</string>
|
||||
<string name="devices_disconnect">Putuskan</string>
|
||||
<string name="devices_connecting">Menghubungkan...</string>
|
||||
<string name="devices_searching">Mencari perangkat di jaringan...</string>
|
||||
@ -499,20 +495,13 @@
|
||||
<string name="devices_empty_hint">Pastikan perangkat lain telah mengaktifkan \"Izinkan kontrol jarak jauh\" di pengaturan.</string>
|
||||
<string name="devices_unknown_device">Perangkat Tidak Dikenal</string>
|
||||
<string name="devices_resolving">Menyelesaikan...</string>
|
||||
<string name="devices_device_id">ID: %1$s...</string>
|
||||
|
||||
<!-- Devices: remote control screen -->
|
||||
<string name="devices_remote_title">Kontrol Jarak Jauh</string>
|
||||
<string name="devices_remote_disconnected">Terputus</string>
|
||||
<string name="devices_remote_connection_error">Kesalahan koneksi: %1$s</string>
|
||||
<string name="devices_remote_album_cover">Sampul album</string>
|
||||
<string name="devices_remote_unknown_track">Lagu Tidak Dikenal</string>
|
||||
<string name="devices_remote_unknown_artist">Artis Tidak Dikenal</string>
|
||||
<string name="devices_remote_seek">Cari posisi</string>
|
||||
<string name="devices_remote_shuffle">Acak</string>
|
||||
<string name="devices_remote_previous">Sebelumnya</string>
|
||||
<string name="devices_remote_next">Berikutnya</string>
|
||||
<string name="devices_remote_loop_mode">Mode ulang</string>
|
||||
<string name="devices_remote_volume">Volume</string>
|
||||
<string name="devices_remote_remove_from_queue">Hapus dari antrean</string>
|
||||
<string name="devices_remote_more_options">Opsi lainnya</string>
|
||||
@ -919,7 +908,6 @@
|
||||
<string name="app_window_close">Tutup</string>
|
||||
|
||||
<!-- Android playback notification -->
|
||||
<string name="notification_playback_service_text">Layanan pemutaran</string>
|
||||
<string name="notification_playback_channel_name">Pemutaran</string>
|
||||
<string name="notification_playback_channel_description">Kontrol pemutaran media</string>
|
||||
|
||||
|
||||
@ -173,7 +173,6 @@
|
||||
|
||||
<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_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_placeholder">C:\Music\Spotube</string>
|
||||
<string name="settings_local_media_folders_title">Cartelle multimediali locali</string>
|
||||
@ -216,8 +215,6 @@
|
||||
<string name="settings_error_cache_size_range">La dimensione deve essere 0 o un numero positivo.</string>
|
||||
<string name="settings_enable_endless_playback_title">Attiva riproduzione continua</string>
|
||||
<string name="settings_enable_endless_playback_subtitle">Continua la riproduzione con brani consigliati quando la coda finisce.</string>
|
||||
<string name="settings_enable_connect_title">Attiva Connect</string>
|
||||
<string name="settings_enable_connect_subtitle">Rendi disponibili i controlli di riproduzione remota tramite la funzione Spotube Connect.</string>
|
||||
<string name="settings_playback_port_title">Porta del server proxy di riproduzione</string>
|
||||
<string name="settings_allow_remote_control_title">Consenti controllo remoto</string>
|
||||
<string name="settings_allow_remote_control_subtitle">Consenti agli altri dispositivi sulla stessa rete di controllare la riproduzione quando si connettono.</string>
|
||||
@ -499,7 +496,6 @@
|
||||
<!-- Devices: discovery screen -->
|
||||
<string name="devices_title">Dispositivi</string>
|
||||
<string name="devices_refresh">Aggiorna</string>
|
||||
<string name="devices_connected_to">Connesso a %1$s:%2$d</string>
|
||||
<string name="devices_disconnect">Disconnetti</string>
|
||||
<string name="devices_connecting">Connessione...</string>
|
||||
<string name="devices_searching">Ricerca di dispositivi sulla rete...</string>
|
||||
@ -507,20 +503,13 @@
|
||||
<string name="devices_empty_hint">Assicurati che sull\'altro dispositivo sia attivo \"Consenti controllo remoto\" nelle impostazioni.</string>
|
||||
<string name="devices_unknown_device">Dispositivo sconosciuto</string>
|
||||
<string name="devices_resolving">Risoluzione...</string>
|
||||
<string name="devices_device_id">ID: %1$s...</string>
|
||||
|
||||
<!-- Devices: remote control screen -->
|
||||
<string name="devices_remote_title">Controllo remoto</string>
|
||||
<string name="devices_remote_disconnected">Disconnesso</string>
|
||||
<string name="devices_remote_connection_error">Errore di connessione: %1$s</string>
|
||||
<string name="devices_remote_album_cover">Copertina dell\'album</string>
|
||||
<string name="devices_remote_unknown_track">Brano sconosciuto</string>
|
||||
<string name="devices_remote_unknown_artist">Artista sconosciuto</string>
|
||||
<string name="devices_remote_seek">Posizione</string>
|
||||
<string name="devices_remote_shuffle">Riproduzione casuale</string>
|
||||
<string name="devices_remote_previous">Precedente</string>
|
||||
<string name="devices_remote_next">Successivo</string>
|
||||
<string name="devices_remote_loop_mode">Modalità ripetizione</string>
|
||||
<string name="devices_remote_volume">Volume</string>
|
||||
<string name="devices_remote_remove_from_queue">Rimuovi dalla coda</string>
|
||||
<string name="devices_remote_more_options">Altre opzioni</string>
|
||||
@ -931,7 +920,6 @@
|
||||
<string name="app_window_close">Chiudi</string>
|
||||
|
||||
<!-- Android playback notification -->
|
||||
<string name="notification_playback_service_text">Servizio di riproduzione</string>
|
||||
<string name="notification_playback_channel_name">Riproduzione</string>
|
||||
<string name="notification_playback_channel_description">Controlli di riproduzione multimediale</string>
|
||||
|
||||
|
||||
@ -169,7 +169,6 @@
|
||||
|
||||
<string name="settings_download_folder_title">ダウンロードフォルダの変更</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_placeholder">C:\Music\Spotube</string>
|
||||
<string name="settings_local_media_folders_title">ローカルメディアフォルダ</string>
|
||||
@ -210,8 +209,6 @@
|
||||
<string name="settings_error_cache_size_range">サイズは0または正の数で指定してください。</string>
|
||||
<string name="settings_enable_endless_playback_title">エンドレス再生を有効化</string>
|
||||
<string name="settings_enable_endless_playback_subtitle">キューが終わったら、おすすめの曲で再生を続けます。</string>
|
||||
<string name="settings_enable_connect_title">Connectを有効化</string>
|
||||
<string name="settings_enable_connect_subtitle">Spotube Connect機能でリモート再生コントロールを公開します。</string>
|
||||
<string name="settings_playback_port_title">再生プロキシサーバーのポート</string>
|
||||
<string name="settings_allow_remote_control_title">リモート操作を許可</string>
|
||||
<string name="settings_allow_remote_control_subtitle">同じネットワーク上の他のデバイスが接続したときに再生を操作できるようにします。</string>
|
||||
@ -491,7 +488,6 @@
|
||||
<!-- Devices: discovery screen -->
|
||||
<string name="devices_title">デバイス</string>
|
||||
<string name="devices_refresh">更新</string>
|
||||
<string name="devices_connected_to">%1$s:%2$d に接続済み</string>
|
||||
<string name="devices_disconnect">切断</string>
|
||||
<string name="devices_connecting">接続中...</string>
|
||||
<string name="devices_searching">ネットワーク上のデバイスを検索中...</string>
|
||||
@ -499,20 +495,13 @@
|
||||
<string name="devices_empty_hint">相手のデバイスの設定で「リモート操作を許可」が有効になっていることを確認してください。</string>
|
||||
<string name="devices_unknown_device">不明なデバイス</string>
|
||||
<string name="devices_resolving">解決中...</string>
|
||||
<string name="devices_device_id">ID: %1$s...</string>
|
||||
|
||||
<!-- Devices: remote control screen -->
|
||||
<string name="devices_remote_title">リモート操作</string>
|
||||
<string name="devices_remote_disconnected">切断されました</string>
|
||||
<string name="devices_remote_connection_error">接続エラー: %1$s</string>
|
||||
<string name="devices_remote_album_cover">アルバムカバー</string>
|
||||
<string name="devices_remote_unknown_track">不明な曲</string>
|
||||
<string name="devices_remote_unknown_artist">不明なアーティスト</string>
|
||||
<string name="devices_remote_seek">シーク</string>
|
||||
<string name="devices_remote_shuffle">シャッフル</string>
|
||||
<string name="devices_remote_previous">前へ</string>
|
||||
<string name="devices_remote_next">次へ</string>
|
||||
<string name="devices_remote_loop_mode">リピートモード</string>
|
||||
<string name="devices_remote_volume">音量</string>
|
||||
<string name="devices_remote_remove_from_queue">キューから削除</string>
|
||||
<string name="devices_remote_more_options">その他のオプション</string>
|
||||
@ -919,7 +908,6 @@
|
||||
<string name="app_window_close">閉じる</string>
|
||||
|
||||
<!-- Android playback notification -->
|
||||
<string name="notification_playback_service_text">再生サービス</string>
|
||||
<string name="notification_playback_channel_name">再生</string>
|
||||
<string name="notification_playback_channel_description">メディア再生コントロール</string>
|
||||
|
||||
|
||||
@ -169,7 +169,6 @@
|
||||
|
||||
<string name="settings_download_folder_title">다운로드 폴더 지정</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_placeholder">C:\Music\Spotube</string>
|
||||
<string name="settings_local_media_folders_title">로컬 미디어 폴더</string>
|
||||
@ -210,8 +209,6 @@
|
||||
<string name="settings_error_cache_size_range">용량은 0 또는 양수여야 해요.</string>
|
||||
<string name="settings_enable_endless_playback_title">끝없는 재생 사용</string>
|
||||
<string name="settings_enable_endless_playback_subtitle">대기열이 끝나면 추천 트랙으로 계속 재생해요.</string>
|
||||
<string name="settings_enable_connect_title">Connect 사용</string>
|
||||
<string name="settings_enable_connect_subtitle">Spotube Connect 기능을 통해 원격 재생 컨트롤을 제공해요.</string>
|
||||
<string name="settings_playback_port_title">재생 프록시 서버 포트</string>
|
||||
<string name="settings_allow_remote_control_title">원격 제어 허용</string>
|
||||
<string name="settings_allow_remote_control_subtitle">같은 네트워크의 다른 기기가 연결되면 재생을 제어할 수 있도록 허용해요.</string>
|
||||
@ -491,7 +488,6 @@
|
||||
<!-- Devices: discovery screen -->
|
||||
<string name="devices_title">기기</string>
|
||||
<string name="devices_refresh">새로고침</string>
|
||||
<string name="devices_connected_to">%1$s:%2$d에 연결됨</string>
|
||||
<string name="devices_disconnect">연결 해제</string>
|
||||
<string name="devices_connecting">연결 중...</string>
|
||||
<string name="devices_searching">네트워크에서 기기를 찾는 중...</string>
|
||||
@ -499,20 +495,13 @@
|
||||
<string name="devices_empty_hint">상대 기기의 설정에서 “원격 제어 허용”이 켜져 있는지 확인하세요.</string>
|
||||
<string name="devices_unknown_device">알 수 없는 기기</string>
|
||||
<string name="devices_resolving">확인 중...</string>
|
||||
<string name="devices_device_id">ID: %1$s...</string>
|
||||
|
||||
<!-- Devices: remote control screen -->
|
||||
<string name="devices_remote_title">원격 제어</string>
|
||||
<string name="devices_remote_disconnected">연결 끊김</string>
|
||||
<string name="devices_remote_connection_error">연결 오류: %1$s</string>
|
||||
<string name="devices_remote_album_cover">앨범 커버</string>
|
||||
<string name="devices_remote_unknown_track">알 수 없는 트랙</string>
|
||||
<string name="devices_remote_unknown_artist">알 수 없는 아티스트</string>
|
||||
<string name="devices_remote_seek">탐색</string>
|
||||
<string name="devices_remote_shuffle">셔플</string>
|
||||
<string name="devices_remote_previous">이전</string>
|
||||
<string name="devices_remote_next">다음</string>
|
||||
<string name="devices_remote_loop_mode">반복 모드</string>
|
||||
<string name="devices_remote_volume">볼륨</string>
|
||||
<string name="devices_remote_remove_from_queue">대기열에서 삭제</string>
|
||||
<string name="devices_remote_more_options">옵션 더보기</string>
|
||||
@ -919,7 +908,6 @@
|
||||
<string name="app_window_close">닫기</string>
|
||||
|
||||
<!-- Android playback notification -->
|
||||
<string name="notification_playback_service_text">재생 서비스</string>
|
||||
<string name="notification_playback_channel_name">재생</string>
|
||||
<string name="notification_playback_channel_description">미디어 재생 컨트롤</string>
|
||||
|
||||
|
||||
@ -171,7 +171,6 @@
|
||||
|
||||
<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_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_placeholder">C:\Music\Spotube</string>
|
||||
<string name="settings_local_media_folders_title">Lokale mediamappen</string>
|
||||
@ -213,8 +212,6 @@
|
||||
<string name="settings_error_cache_size_range">Grootte moet 0 of een positief getal zijn.</string>
|
||||
<string name="settings_enable_endless_playback_title">Eindeloos afspelen inschakelen</string>
|
||||
<string name="settings_enable_endless_playback_subtitle">Blijf afspelen met aanbevolen nummers als je wachtrij leeg is.</string>
|
||||
<string name="settings_enable_connect_title">Connect inschakelen</string>
|
||||
<string name="settings_enable_connect_subtitle">Maak bediening op afstand beschikbaar via Spotube Connect.</string>
|
||||
<string name="settings_playback_port_title">Poort van afspeelproxyserver</string>
|
||||
<string name="settings_allow_remote_control_title">Bediening op afstand toestaan</string>
|
||||
<string name="settings_allow_remote_control_subtitle">Laat andere apparaten op hetzelfde netwerk het afspelen bedienen als ze verbinding maken.</string>
|
||||
@ -495,7 +492,6 @@
|
||||
<!-- Devices: discovery screen -->
|
||||
<string name="devices_title">Apparaten</string>
|
||||
<string name="devices_refresh">Vernieuwen</string>
|
||||
<string name="devices_connected_to">Verbonden met %1$s:%2$d</string>
|
||||
<string name="devices_disconnect">Verbinding verbreken</string>
|
||||
<string name="devices_connecting">Verbinden...</string>
|
||||
<string name="devices_searching">Zoeken naar apparaten op het netwerk...</string>
|
||||
@ -503,20 +499,13 @@
|
||||
<string name="devices_empty_hint">Zorg dat \"Bediening op afstand toestaan\" is ingeschakeld in de instellingen van het andere apparaat.</string>
|
||||
<string name="devices_unknown_device">Onbekend apparaat</string>
|
||||
<string name="devices_resolving">Bezig met ophalen...</string>
|
||||
<string name="devices_device_id">ID: %1$s...</string>
|
||||
|
||||
<!-- Devices: remote control screen -->
|
||||
<string name="devices_remote_title">Bediening op afstand</string>
|
||||
<string name="devices_remote_disconnected">Niet verbonden</string>
|
||||
<string name="devices_remote_connection_error">Verbindingsfout: %1$s</string>
|
||||
<string name="devices_remote_album_cover">Albumhoes</string>
|
||||
<string name="devices_remote_unknown_track">Onbekend nummer</string>
|
||||
<string name="devices_remote_unknown_artist">Onbekende artiest</string>
|
||||
<string name="devices_remote_seek">Zoeken in nummer</string>
|
||||
<string name="devices_remote_shuffle">Shuffle</string>
|
||||
<string name="devices_remote_previous">Vorige</string>
|
||||
<string name="devices_remote_next">Volgende</string>
|
||||
<string name="devices_remote_loop_mode">Herhaalmodus</string>
|
||||
<string name="devices_remote_volume">Volume</string>
|
||||
<string name="devices_remote_remove_from_queue">Verwijderen uit wachtrij</string>
|
||||
<string name="devices_remote_more_options">Meer opties</string>
|
||||
@ -925,7 +914,6 @@
|
||||
<string name="app_window_close">Sluiten</string>
|
||||
|
||||
<!-- Android playback notification -->
|
||||
<string name="notification_playback_service_text">Afspeelservice</string>
|
||||
<string name="notification_playback_channel_name">Afspelen</string>
|
||||
<string name="notification_playback_channel_description">Bediening voor media-afspelen</string>
|
||||
|
||||
|
||||
@ -175,7 +175,6 @@
|
||||
|
||||
<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_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_placeholder">C:\Music\Spotube</string>
|
||||
<string name="settings_local_media_folders_title">Foldery lokalnych multimediów</string>
|
||||
@ -219,8 +218,6 @@
|
||||
<string name="settings_error_cache_size_range">Rozmiar musi wynosić 0 lub być liczbą dodatnią.</string>
|
||||
<string name="settings_enable_endless_playback_title">Włącz nieskończone odtwarzanie</string>
|
||||
<string name="settings_enable_endless_playback_subtitle">Gdy kolejka się skończy, odtwarzaj dalej polecane utwory.</string>
|
||||
<string name="settings_enable_connect_title">Włącz Connect</string>
|
||||
<string name="settings_enable_connect_subtitle">Udostępniaj sterowanie odtwarzaniem przez funkcję Spotube Connect.</string>
|
||||
<string name="settings_playback_port_title">Port serwera proxy odtwarzania</string>
|
||||
<string name="settings_allow_remote_control_title">Zezwól na zdalne sterowanie</string>
|
||||
<string name="settings_allow_remote_control_subtitle">Pozwól innym urządzeniom w tej samej sieci sterować odtwarzaniem po połączeniu.</string>
|
||||
@ -503,7 +500,6 @@
|
||||
<!-- Devices: discovery screen -->
|
||||
<string name="devices_title">Urządzenia</string>
|
||||
<string name="devices_refresh">Odśwież</string>
|
||||
<string name="devices_connected_to">Połączono z %1$s:%2$d</string>
|
||||
<string name="devices_disconnect">Rozłącz</string>
|
||||
<string name="devices_connecting">Łączenie...</string>
|
||||
<string name="devices_searching">Wyszukiwanie urządzeń w sieci...</string>
|
||||
@ -511,20 +507,13 @@
|
||||
<string name="devices_empty_hint">Upewnij się, że na drugim urządzeniu w ustawieniach włączono opcję „Zezwól na zdalne sterowanie”.</string>
|
||||
<string name="devices_unknown_device">Nieznane urządzenie</string>
|
||||
<string name="devices_resolving">Ustalanie adresu...</string>
|
||||
<string name="devices_device_id">ID: %1$s...</string>
|
||||
|
||||
<!-- Devices: remote control screen -->
|
||||
<string name="devices_remote_title">Zdalne sterowanie</string>
|
||||
<string name="devices_remote_disconnected">Rozłączono</string>
|
||||
<string name="devices_remote_connection_error">Błąd połączenia: %1$s</string>
|
||||
<string name="devices_remote_album_cover">Okładka albumu</string>
|
||||
<string name="devices_remote_unknown_track">Nieznany utwór</string>
|
||||
<string name="devices_remote_unknown_artist">Nieznany wykonawca</string>
|
||||
<string name="devices_remote_seek">Przewiń</string>
|
||||
<string name="devices_remote_shuffle">Odtwarzanie losowe</string>
|
||||
<string name="devices_remote_previous">Poprzedni</string>
|
||||
<string name="devices_remote_next">Następny</string>
|
||||
<string name="devices_remote_loop_mode">Tryb powtarzania</string>
|
||||
<string name="devices_remote_volume">Głośność</string>
|
||||
<string name="devices_remote_remove_from_queue">Usuń z kolejki</string>
|
||||
<string name="devices_remote_more_options">Więcej opcji</string>
|
||||
@ -937,7 +926,6 @@
|
||||
<string name="app_window_close">Zamknij</string>
|
||||
|
||||
<!-- Android playback notification -->
|
||||
<string name="notification_playback_service_text">Usługa odtwarzania</string>
|
||||
<string name="notification_playback_channel_name">Odtwarzanie</string>
|
||||
<string name="notification_playback_channel_description">Sterowanie odtwarzaniem multimediów</string>
|
||||
|
||||
|
||||
@ -173,7 +173,6 @@
|
||||
|
||||
<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_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_placeholder">C:\Music\Spotube</string>
|
||||
<string name="settings_local_media_folders_title">Pastas de mídia local</string>
|
||||
@ -216,8 +215,6 @@
|
||||
<string name="settings_error_cache_size_range">O tamanho deve ser 0 ou um número positivo.</string>
|
||||
<string name="settings_enable_endless_playback_title">Ativar reprodução contínua</string>
|
||||
<string name="settings_enable_endless_playback_subtitle">Continua tocando faixas recomendadas quando sua fila acabar.</string>
|
||||
<string name="settings_enable_connect_title">Ativar Connect</string>
|
||||
<string name="settings_enable_connect_subtitle">Disponibiliza controles de reprodução remota pelo recurso Spotube Connect.</string>
|
||||
<string name="settings_playback_port_title">Porta do servidor proxy de reprodução</string>
|
||||
<string name="settings_allow_remote_control_title">Permitir controle remoto</string>
|
||||
<string name="settings_allow_remote_control_subtitle">Permite que outros dispositivos na mesma rede controlem a reprodução quando se conectarem.</string>
|
||||
@ -499,7 +496,6 @@
|
||||
<!-- Devices: discovery screen -->
|
||||
<string name="devices_title">Dispositivos</string>
|
||||
<string name="devices_refresh">Atualizar</string>
|
||||
<string name="devices_connected_to">Conectado a %1$s:%2$d</string>
|
||||
<string name="devices_disconnect">Desconectar</string>
|
||||
<string name="devices_connecting">Conectando...</string>
|
||||
<string name="devices_searching">Procurando dispositivos na rede...</string>
|
||||
@ -507,20 +503,13 @@
|
||||
<string name="devices_empty_hint">Verifique se o outro dispositivo está com \"Permitir controle remoto\" ativado nas configurações.</string>
|
||||
<string name="devices_unknown_device">Dispositivo desconhecido</string>
|
||||
<string name="devices_resolving">Resolvendo...</string>
|
||||
<string name="devices_device_id">ID: %1$s...</string>
|
||||
|
||||
<!-- Devices: remote control screen -->
|
||||
<string name="devices_remote_title">Controle remoto</string>
|
||||
<string name="devices_remote_disconnected">Desconectado</string>
|
||||
<string name="devices_remote_connection_error">Erro de conexão: %1$s</string>
|
||||
<string name="devices_remote_album_cover">Capa do álbum</string>
|
||||
<string name="devices_remote_unknown_track">Faixa desconhecida</string>
|
||||
<string name="devices_remote_unknown_artist">Artista desconhecido</string>
|
||||
<string name="devices_remote_seek">Avançar/retroceder</string>
|
||||
<string name="devices_remote_shuffle">Ordem aleatória</string>
|
||||
<string name="devices_remote_previous">Anterior</string>
|
||||
<string name="devices_remote_next">Próxima</string>
|
||||
<string name="devices_remote_loop_mode">Modo de repetição</string>
|
||||
<string name="devices_remote_volume">Volume</string>
|
||||
<string name="devices_remote_remove_from_queue">Remover da fila</string>
|
||||
<string name="devices_remote_more_options">Mais opções</string>
|
||||
@ -931,7 +920,6 @@
|
||||
<string name="app_window_close">Fechar</string>
|
||||
|
||||
<!-- Android playback notification -->
|
||||
<string name="notification_playback_service_text">Serviço de reprodução</string>
|
||||
<string name="notification_playback_channel_name">Reprodução</string>
|
||||
<string name="notification_playback_channel_description">Controles de reprodução de mídia</string>
|
||||
|
||||
|
||||
@ -175,7 +175,6 @@
|
||||
|
||||
<string name="settings_download_folder_title">Своя папка для загрузок</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_placeholder">C:\Music\Spotube</string>
|
||||
<string name="settings_local_media_folders_title">Папки с локальной музыкой</string>
|
||||
@ -219,8 +218,6 @@
|
||||
<string name="settings_error_cache_size_range">Размер должен быть 0 или положительным числом.</string>
|
||||
<string name="settings_enable_endless_playback_title">Бесконечное воспроизведение</string>
|
||||
<string name="settings_enable_endless_playback_subtitle">Продолжать воспроизведение рекомендованными треками, когда очередь закончится.</string>
|
||||
<string name="settings_enable_connect_title">Включить Connect</string>
|
||||
<string name="settings_enable_connect_subtitle">Предоставлять удалённое управление воспроизведением через Spotube Connect.</string>
|
||||
<string name="settings_playback_port_title">Порт прокси-сервера воспроизведения</string>
|
||||
<string name="settings_allow_remote_control_title">Разрешить удалённое управление</string>
|
||||
<string name="settings_allow_remote_control_subtitle">Разрешить другим устройствам в той же сети управлять воспроизведением после подключения.</string>
|
||||
@ -503,7 +500,6 @@
|
||||
<!-- Devices: discovery screen -->
|
||||
<string name="devices_title">Устройства</string>
|
||||
<string name="devices_refresh">Обновить</string>
|
||||
<string name="devices_connected_to">Подключено к %1$s:%2$d</string>
|
||||
<string name="devices_disconnect">Отключиться</string>
|
||||
<string name="devices_connecting">Подключение...</string>
|
||||
<string name="devices_searching">Поиск устройств в сети...</string>
|
||||
@ -511,20 +507,13 @@
|
||||
<string name="devices_empty_hint">Убедитесь, что на другом устройстве в настройках включено «Разрешить удалённое управление».</string>
|
||||
<string name="devices_unknown_device">Неизвестное устройство</string>
|
||||
<string name="devices_resolving">Определение адреса...</string>
|
||||
<string name="devices_device_id">ID: %1$s...</string>
|
||||
|
||||
<!-- Devices: remote control screen -->
|
||||
<string name="devices_remote_title">Удалённое управление</string>
|
||||
<string name="devices_remote_disconnected">Отключено</string>
|
||||
<string name="devices_remote_connection_error">Ошибка подключения: %1$s</string>
|
||||
<string name="devices_remote_album_cover">Обложка альбома</string>
|
||||
<string name="devices_remote_unknown_track">Неизвестный трек</string>
|
||||
<string name="devices_remote_unknown_artist">Неизвестный исполнитель</string>
|
||||
<string name="devices_remote_seek">Перемотка</string>
|
||||
<string name="devices_remote_shuffle">Перемешать</string>
|
||||
<string name="devices_remote_previous">Предыдущий</string>
|
||||
<string name="devices_remote_next">Следующий</string>
|
||||
<string name="devices_remote_loop_mode">Режим повтора</string>
|
||||
<string name="devices_remote_volume">Громкость</string>
|
||||
<string name="devices_remote_remove_from_queue">Удалить из очереди</string>
|
||||
<string name="devices_remote_more_options">Другие действия</string>
|
||||
@ -937,7 +926,6 @@
|
||||
<string name="app_window_close">Закрыть</string>
|
||||
|
||||
<!-- Android playback notification -->
|
||||
<string name="notification_playback_service_text">Служба воспроизведения</string>
|
||||
<string name="notification_playback_channel_name">Воспроизведение</string>
|
||||
<string name="notification_playback_channel_description">Управление воспроизведением</string>
|
||||
|
||||
|
||||
@ -169,7 +169,6 @@
|
||||
|
||||
<string name="settings_download_folder_title">กำหนดโฟลเดอร์ดาวน์โหลดเอง</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_placeholder">C:\Music\Spotube</string>
|
||||
<string name="settings_local_media_folders_title">โฟลเดอร์สื่อในเครื่อง</string>
|
||||
@ -210,8 +209,6 @@
|
||||
<string name="settings_error_cache_size_range">ขนาดต้องเป็น 0 หรือจำนวนบวก</string>
|
||||
<string name="settings_enable_endless_playback_title">เปิดใช้การเล่นต่อเนื่องไม่สิ้นสุด</string>
|
||||
<string name="settings_enable_endless_playback_subtitle">เล่นต่อด้วยเพลงแนะนำเมื่อคิวของคุณหมด</string>
|
||||
<string name="settings_enable_connect_title">เปิดใช้ Connect</string>
|
||||
<string name="settings_enable_connect_subtitle">เปิดให้ควบคุมการเล่นจากระยะไกลผ่านฟีเจอร์ Spotube Connect</string>
|
||||
<string name="settings_playback_port_title">พอร์ตเซิร์ฟเวอร์พร็อกซีการเล่น</string>
|
||||
<string name="settings_allow_remote_control_title">อนุญาตการควบคุมระยะไกล</string>
|
||||
<string name="settings_allow_remote_control_subtitle">ให้อุปกรณ์อื่นในเครือข่ายเดียวกันควบคุมการเล่นได้เมื่อเชื่อมต่อ</string>
|
||||
@ -491,7 +488,6 @@
|
||||
<!-- Devices: discovery screen -->
|
||||
<string name="devices_title">อุปกรณ์</string>
|
||||
<string name="devices_refresh">รีเฟรช</string>
|
||||
<string name="devices_connected_to">เชื่อมต่อกับ %1$s:%2$d แล้ว</string>
|
||||
<string name="devices_disconnect">ยกเลิกการเชื่อมต่อ</string>
|
||||
<string name="devices_connecting">กำลังเชื่อมต่อ...</string>
|
||||
<string name="devices_searching">กำลังค้นหาอุปกรณ์ในเครือข่าย...</string>
|
||||
@ -499,20 +495,13 @@
|
||||
<string name="devices_empty_hint">ตรวจสอบว่าอุปกรณ์อีกเครื่องเปิด “อนุญาตการควบคุมระยะไกล” ไว้ในการตั้งค่า</string>
|
||||
<string name="devices_unknown_device">อุปกรณ์ที่ไม่รู้จัก</string>
|
||||
<string name="devices_resolving">กำลังระบุ...</string>
|
||||
<string name="devices_device_id">ID: %1$s...</string>
|
||||
|
||||
<!-- Devices: remote control screen -->
|
||||
<string name="devices_remote_title">รีโมตคอนโทรล</string>
|
||||
<string name="devices_remote_disconnected">ยกเลิกการเชื่อมต่อแล้ว</string>
|
||||
<string name="devices_remote_connection_error">ข้อผิดพลาดในการเชื่อมต่อ: %1$s</string>
|
||||
<string name="devices_remote_album_cover">ปกอัลบั้ม</string>
|
||||
<string name="devices_remote_unknown_track">ไม่ทราบชื่อเพลง</string>
|
||||
<string name="devices_remote_unknown_artist">ไม่ทราบศิลปิน</string>
|
||||
<string name="devices_remote_seek">เลื่อนตำแหน่ง</string>
|
||||
<string name="devices_remote_shuffle">สุ่มเพลง</string>
|
||||
<string name="devices_remote_previous">ก่อนหน้า</string>
|
||||
<string name="devices_remote_next">ถัดไป</string>
|
||||
<string name="devices_remote_loop_mode">โหมดเล่นซ้ำ</string>
|
||||
<string name="devices_remote_volume">ระดับเสียง</string>
|
||||
<string name="devices_remote_remove_from_queue">นำออกจากคิว</string>
|
||||
<string name="devices_remote_more_options">ตัวเลือกเพิ่มเติม</string>
|
||||
@ -919,7 +908,6 @@
|
||||
<string name="app_window_close">ปิด</string>
|
||||
|
||||
<!-- Android playback notification -->
|
||||
<string name="notification_playback_service_text">บริการเล่นเพลง</string>
|
||||
<string name="notification_playback_channel_name">การเล่น</string>
|
||||
<string name="notification_playback_channel_description">ตัวควบคุมการเล่นสื่อ</string>
|
||||
|
||||
|
||||
@ -171,7 +171,6 @@
|
||||
|
||||
<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_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_placeholder">C:\Music\Spotube</string>
|
||||
<string name="settings_local_media_folders_title">Yerel medya klasörleri</string>
|
||||
@ -213,8 +212,6 @@
|
||||
<string name="settings_error_cache_size_range">Boyut 0 veya pozitif bir sayı olmalı.</string>
|
||||
<string name="settings_enable_endless_playback_title">Sonsuz çalmayı etkinleştir</string>
|
||||
<string name="settings_enable_endless_playback_subtitle">Sıran bittiğinde önerilen parçalarla çalmaya devam et.</string>
|
||||
<string name="settings_enable_connect_title">Connect\'i etkinleştir</string>
|
||||
<string name="settings_enable_connect_subtitle">Uzaktan çalma kontrollerini Spotube Connect özelliğiyle sun.</string>
|
||||
<string name="settings_playback_port_title">Çalma proxy sunucusu portu</string>
|
||||
<string name="settings_allow_remote_control_title">Uzaktan kontrole izin ver</string>
|
||||
<string name="settings_allow_remote_control_subtitle">Aynı ağdaki diğer cihazlar bağlandığında çalmayı kontrol edebilsin.</string>
|
||||
@ -495,7 +492,6 @@
|
||||
<!-- Devices: discovery screen -->
|
||||
<string name="devices_title">Cihazlar</string>
|
||||
<string name="devices_refresh">Yenile</string>
|
||||
<string name="devices_connected_to">%1$s:%2$d adresine bağlı</string>
|
||||
<string name="devices_disconnect">Bağlantıyı kes</string>
|
||||
<string name="devices_connecting">Bağlanıyor...</string>
|
||||
<string name="devices_searching">Ağda cihaz aranıyor...</string>
|
||||
@ -503,20 +499,13 @@
|
||||
<string name="devices_empty_hint">Diğer cihazın ayarlarında \"Uzaktan kontrole izin ver\" seçeneğinin açık olduğundan emin ol.</string>
|
||||
<string name="devices_unknown_device">Bilinmeyen Cihaz</string>
|
||||
<string name="devices_resolving">Çözümleniyor...</string>
|
||||
<string name="devices_device_id">Kimlik: %1$s...</string>
|
||||
|
||||
<!-- Devices: remote control screen -->
|
||||
<string name="devices_remote_title">Uzaktan Kontrol</string>
|
||||
<string name="devices_remote_disconnected">Bağlantı kesildi</string>
|
||||
<string name="devices_remote_connection_error">Bağlantı hatası: %1$s</string>
|
||||
<string name="devices_remote_album_cover">Albüm kapağı</string>
|
||||
<string name="devices_remote_unknown_track">Bilinmeyen Parça</string>
|
||||
<string name="devices_remote_unknown_artist">Bilinmeyen Sanatçı</string>
|
||||
<string name="devices_remote_seek">Konum</string>
|
||||
<string name="devices_remote_shuffle">Karıştır</string>
|
||||
<string name="devices_remote_previous">Önceki</string>
|
||||
<string name="devices_remote_next">Sonraki</string>
|
||||
<string name="devices_remote_loop_mode">Tekrar modu</string>
|
||||
<string name="devices_remote_volume">Ses düzeyi</string>
|
||||
<string name="devices_remote_remove_from_queue">Sıradan kaldır</string>
|
||||
<string name="devices_remote_more_options">Diğer seçenekler</string>
|
||||
@ -925,7 +914,6 @@
|
||||
<string name="app_window_close">Kapat</string>
|
||||
|
||||
<!-- Android playback notification -->
|
||||
<string name="notification_playback_service_text">Çalma hizmeti</string>
|
||||
<string name="notification_playback_channel_name">Çalma</string>
|
||||
<string name="notification_playback_channel_description">Medya çalma kontrolleri</string>
|
||||
|
||||
|
||||
@ -175,7 +175,6 @@
|
||||
|
||||
<string name="settings_download_folder_title">Власна папка для завантажень</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_placeholder">C:\Music\Spotube</string>
|
||||
<string name="settings_local_media_folders_title">Папки з локальною музикою</string>
|
||||
@ -219,8 +218,6 @@
|
||||
<string name="settings_error_cache_size_range">Розмір має бути 0 або додатним числом.</string>
|
||||
<string name="settings_enable_endless_playback_title">Безкінечне відтворення</string>
|
||||
<string name="settings_enable_endless_playback_subtitle">Продовжувати відтворення рекомендованими треками, коли черга закінчиться.</string>
|
||||
<string name="settings_enable_connect_title">Увімкнути Connect</string>
|
||||
<string name="settings_enable_connect_subtitle">Надавати віддалене керування відтворенням через Spotube Connect.</string>
|
||||
<string name="settings_playback_port_title">Порт проксі-сервера відтворення</string>
|
||||
<string name="settings_allow_remote_control_title">Дозволити віддалене керування</string>
|
||||
<string name="settings_allow_remote_control_subtitle">Дозволити іншим пристроям у тій самій мережі керувати відтворенням після підключення.</string>
|
||||
@ -503,7 +500,6 @@
|
||||
<!-- Devices: discovery screen -->
|
||||
<string name="devices_title">Пристрої</string>
|
||||
<string name="devices_refresh">Оновити</string>
|
||||
<string name="devices_connected_to">Підключено до %1$s:%2$d</string>
|
||||
<string name="devices_disconnect">Від’єднатися</string>
|
||||
<string name="devices_connecting">Підключення...</string>
|
||||
<string name="devices_searching">Пошук пристроїв у мережі...</string>
|
||||
@ -511,20 +507,13 @@
|
||||
<string name="devices_empty_hint">Переконайтеся, що на іншому пристрої в налаштуваннях увімкнено «Дозволити віддалене керування».</string>
|
||||
<string name="devices_unknown_device">Невідомий пристрій</string>
|
||||
<string name="devices_resolving">Визначення адреси...</string>
|
||||
<string name="devices_device_id">ID: %1$s...</string>
|
||||
|
||||
<!-- Devices: remote control screen -->
|
||||
<string name="devices_remote_title">Віддалене керування</string>
|
||||
<string name="devices_remote_disconnected">Від’єднано</string>
|
||||
<string name="devices_remote_connection_error">Помилка підключення: %1$s</string>
|
||||
<string name="devices_remote_album_cover">Обкладинка альбому</string>
|
||||
<string name="devices_remote_unknown_track">Невідомий трек</string>
|
||||
<string name="devices_remote_unknown_artist">Невідомий виконавець</string>
|
||||
<string name="devices_remote_seek">Перемотування</string>
|
||||
<string name="devices_remote_shuffle">Перемішати</string>
|
||||
<string name="devices_remote_previous">Попередній</string>
|
||||
<string name="devices_remote_next">Наступний</string>
|
||||
<string name="devices_remote_loop_mode">Режим повтору</string>
|
||||
<string name="devices_remote_volume">Гучність</string>
|
||||
<string name="devices_remote_remove_from_queue">Видалити з черги</string>
|
||||
<string name="devices_remote_more_options">Інші дії</string>
|
||||
@ -937,7 +926,6 @@
|
||||
<string name="app_window_close">Закрити</string>
|
||||
|
||||
<!-- Android playback notification -->
|
||||
<string name="notification_playback_service_text">Служба відтворення</string>
|
||||
<string name="notification_playback_channel_name">Відтворення</string>
|
||||
<string name="notification_playback_channel_description">Керування відтворенням</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_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_placeholder">C:\Music\Spotube</string>
|
||||
<string name="settings_local_media_folders_title">Thư mục nhạc trên máy</string>
|
||||
@ -210,8 +209,6 @@
|
||||
<string name="settings_error_cache_size_range">Dung lượng phải là 0 hoặc một số dương.</string>
|
||||
<string name="settings_enable_endless_playback_title">Bật phát liên tục</string>
|
||||
<string name="settings_enable_endless_playback_subtitle">Tiếp tục phát với các bài hát được đề xuất khi danh sách chờ kết thúc.</string>
|
||||
<string name="settings_enable_connect_title">Bật Connect</string>
|
||||
<string name="settings_enable_connect_subtitle">Cung cấp điều khiển phát từ xa qua tính năng Spotube Connect.</string>
|
||||
<string name="settings_playback_port_title">Cổng máy chủ proxy phát lại</string>
|
||||
<string name="settings_allow_remote_control_title">Cho phép điều khiển từ xa</string>
|
||||
<string name="settings_allow_remote_control_subtitle">Cho phép các thiết bị khác trong cùng mạng điều khiển phát lại khi kết nối.</string>
|
||||
@ -491,7 +488,6 @@
|
||||
<!-- Devices: discovery screen -->
|
||||
<string name="devices_title">Thiết bị</string>
|
||||
<string name="devices_refresh">Làm mới</string>
|
||||
<string name="devices_connected_to">Đã kết nối với %1$s:%2$d</string>
|
||||
<string name="devices_disconnect">Ngắt kết nối</string>
|
||||
<string name="devices_connecting">Đang kết nối...</string>
|
||||
<string name="devices_searching">Đang tìm thiết bị trong mạng...</string>
|
||||
@ -499,20 +495,13 @@
|
||||
<string name="devices_empty_hint">Hãy đảm bảo thiết bị kia đã bật \"Cho phép điều khiển từ xa\" trong cài đặt.</string>
|
||||
<string name="devices_unknown_device">Thiết bị không xác định</string>
|
||||
<string name="devices_resolving">Đang phân giải...</string>
|
||||
<string name="devices_device_id">ID: %1$s...</string>
|
||||
|
||||
<!-- Devices: remote control screen -->
|
||||
<string name="devices_remote_title">Điều khiển từ xa</string>
|
||||
<string name="devices_remote_disconnected">Đã ngắt kết nối</string>
|
||||
<string name="devices_remote_connection_error">Lỗi kết nối: %1$s</string>
|
||||
<string name="devices_remote_album_cover">Ảnh bìa album</string>
|
||||
<string name="devices_remote_unknown_track">Bài hát không xác định</string>
|
||||
<string name="devices_remote_unknown_artist">Nghệ sĩ không xác định</string>
|
||||
<string name="devices_remote_seek">Tua</string>
|
||||
<string name="devices_remote_shuffle">Phát ngẫu nhiên</string>
|
||||
<string name="devices_remote_previous">Trước</string>
|
||||
<string name="devices_remote_next">Tiếp</string>
|
||||
<string name="devices_remote_loop_mode">Chế độ lặp</string>
|
||||
<string name="devices_remote_volume">Âm lượng</string>
|
||||
<string name="devices_remote_remove_from_queue">Xóa khỏi danh sách chờ</string>
|
||||
<string name="devices_remote_more_options">Tùy chọn khác</string>
|
||||
@ -919,7 +908,6 @@
|
||||
<string name="app_window_close">Đóng</string>
|
||||
|
||||
<!-- Android playback notification -->
|
||||
<string name="notification_playback_service_text">Dịch vụ phát nhạc</string>
|
||||
<string name="notification_playback_channel_name">Phát nhạc</string>
|
||||
<string name="notification_playback_channel_description">Điều khiển phát nội dung</string>
|
||||
|
||||
|
||||
@ -169,7 +169,6 @@
|
||||
|
||||
<string name="settings_download_folder_title">自訂下載資料夾</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_placeholder">C:\Music\Spotube</string>
|
||||
<string name="settings_local_media_folders_title">本機媒體資料夾</string>
|
||||
@ -210,8 +209,6 @@
|
||||
<string name="settings_error_cache_size_range">大小必須為 0 或正數。</string>
|
||||
<string name="settings_enable_endless_playback_title">啟用無限播放</string>
|
||||
<string name="settings_enable_endless_playback_subtitle">待播清單播完後,以推薦曲目繼續播放。</string>
|
||||
<string name="settings_enable_connect_title">啟用 Connect</string>
|
||||
<string name="settings_enable_connect_subtitle">透過 Spotube Connect 功能提供遠端播放控制。</string>
|
||||
<string name="settings_playback_port_title">播放代理伺服器連接埠</string>
|
||||
<string name="settings_allow_remote_control_title">允許遠端控制</string>
|
||||
<string name="settings_allow_remote_control_subtitle">讓同一網路中的其他裝置在連線後控制播放。</string>
|
||||
@ -491,7 +488,6 @@
|
||||
<!-- Devices: discovery screen -->
|
||||
<string name="devices_title">裝置</string>
|
||||
<string name="devices_refresh">重新整理</string>
|
||||
<string name="devices_connected_to">已連線至 %1$s:%2$d</string>
|
||||
<string name="devices_disconnect">中斷連線</string>
|
||||
<string name="devices_connecting">連線中...</string>
|
||||
<string name="devices_searching">正在搜尋網路上的裝置...</string>
|
||||
@ -499,20 +495,13 @@
|
||||
<string name="devices_empty_hint">請確認另一部裝置已在設定中啟用「允許遠端控制」。</string>
|
||||
<string name="devices_unknown_device">未知裝置</string>
|
||||
<string name="devices_resolving">解析中...</string>
|
||||
<string name="devices_device_id">ID:%1$s...</string>
|
||||
|
||||
<!-- Devices: remote control screen -->
|
||||
<string name="devices_remote_title">遠端控制</string>
|
||||
<string name="devices_remote_disconnected">已中斷連線</string>
|
||||
<string name="devices_remote_connection_error">連線錯誤:%1$s</string>
|
||||
<string name="devices_remote_album_cover">專輯封面</string>
|
||||
<string name="devices_remote_unknown_track">未知曲目</string>
|
||||
<string name="devices_remote_unknown_artist">未知藝人</string>
|
||||
<string name="devices_remote_seek">播放進度</string>
|
||||
<string name="devices_remote_shuffle">隨機播放</string>
|
||||
<string name="devices_remote_previous">上一首</string>
|
||||
<string name="devices_remote_next">下一首</string>
|
||||
<string name="devices_remote_loop_mode">重複播放模式</string>
|
||||
<string name="devices_remote_volume">音量</string>
|
||||
<string name="devices_remote_remove_from_queue">從待播清單移除</string>
|
||||
<string name="devices_remote_more_options">更多選項</string>
|
||||
@ -919,7 +908,6 @@
|
||||
<string name="app_window_close">關閉</string>
|
||||
|
||||
<!-- Android playback notification -->
|
||||
<string name="notification_playback_service_text">播放服務</string>
|
||||
<string name="notification_playback_channel_name">播放</string>
|
||||
<string name="notification_playback_channel_description">媒體播放控制項</string>
|
||||
|
||||
|
||||
@ -169,7 +169,6 @@
|
||||
|
||||
<string name="settings_download_folder_title">自定义下载文件夹</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_placeholder">C:\Music\Spotube</string>
|
||||
<string name="settings_local_media_folders_title">本地媒体文件夹</string>
|
||||
@ -210,8 +209,6 @@
|
||||
<string name="settings_error_cache_size_range">大小必须为 0 或正数。</string>
|
||||
<string name="settings_enable_endless_playback_title">启用无尽播放</string>
|
||||
<string name="settings_enable_endless_playback_subtitle">队列播放完毕后,继续播放推荐的歌曲。</string>
|
||||
<string name="settings_enable_connect_title">启用 Connect</string>
|
||||
<string name="settings_enable_connect_subtitle">通过 Spotube Connect 功能开放远程播放控制。</string>
|
||||
<string name="settings_playback_port_title">播放代理服务器端口</string>
|
||||
<string name="settings_allow_remote_control_title">允许远程控制</string>
|
||||
<string name="settings_allow_remote_control_subtitle">允许同一网络中的其他设备在连接后控制播放。</string>
|
||||
@ -491,7 +488,6 @@
|
||||
<!-- Devices: discovery screen -->
|
||||
<string name="devices_title">设备</string>
|
||||
<string name="devices_refresh">刷新</string>
|
||||
<string name="devices_connected_to">已连接到 %1$s:%2$d</string>
|
||||
<string name="devices_disconnect">断开连接</string>
|
||||
<string name="devices_connecting">正在连接...</string>
|
||||
<string name="devices_searching">正在搜索网络中的设备...</string>
|
||||
@ -499,20 +495,13 @@
|
||||
<string name="devices_empty_hint">请确保另一台设备已在设置中启用“允许远程控制”。</string>
|
||||
<string name="devices_unknown_device">未知设备</string>
|
||||
<string name="devices_resolving">正在解析...</string>
|
||||
<string name="devices_device_id">ID:%1$s...</string>
|
||||
|
||||
<!-- Devices: remote control screen -->
|
||||
<string name="devices_remote_title">远程控制</string>
|
||||
<string name="devices_remote_disconnected">已断开连接</string>
|
||||
<string name="devices_remote_connection_error">连接错误:%1$s</string>
|
||||
<string name="devices_remote_album_cover">专辑封面</string>
|
||||
<string name="devices_remote_unknown_track">未知歌曲</string>
|
||||
<string name="devices_remote_unknown_artist">未知艺人</string>
|
||||
<string name="devices_remote_seek">进度</string>
|
||||
<string name="devices_remote_shuffle">随机播放</string>
|
||||
<string name="devices_remote_previous">上一首</string>
|
||||
<string name="devices_remote_next">下一首</string>
|
||||
<string name="devices_remote_loop_mode">循环模式</string>
|
||||
<string name="devices_remote_volume">音量</string>
|
||||
<string name="devices_remote_remove_from_queue">从队列中移除</string>
|
||||
<string name="devices_remote_more_options">更多选项</string>
|
||||
@ -919,7 +908,6 @@
|
||||
<string name="app_window_close">关闭</string>
|
||||
|
||||
<!-- Android playback notification -->
|
||||
<string name="notification_playback_service_text">播放服务</string>
|
||||
<string name="notification_playback_channel_name">播放</string>
|
||||
<string name="notification_playback_channel_description">媒体播放控制</string>
|
||||
|
||||
|
||||
@ -76,6 +76,7 @@
|
||||
|
||||
<!-- Downloads -->
|
||||
<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_empty">No downloads yet</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_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_placeholder">C:\Music\Spotube</string>
|
||||
<string name="settings_local_media_folders_title">Local media folders</string>
|
||||
@ -215,8 +216,6 @@
|
||||
<string name="settings_error_cache_size_range">Size must be 0 or a positive number.</string>
|
||||
<string name="settings_enable_endless_playback_title">Enable endless playback</string>
|
||||
<string name="settings_enable_endless_playback_subtitle">Keep playback going with recommended tracks when your queue ends.</string>
|
||||
<string name="settings_enable_connect_title">Enable Connect</string>
|
||||
<string name="settings_enable_connect_subtitle">Expose remote playback controls through the Spotube Connect feature.</string>
|
||||
<string name="settings_playback_port_title">Playback proxy server port</string>
|
||||
<string name="settings_allow_remote_control_title">Allow remote control</string>
|
||||
<string name="settings_allow_remote_control_subtitle">Let other devices on the same network control playback when they connect.</string>
|
||||
@ -504,6 +503,7 @@
|
||||
<!-- Devices: connection errors -->
|
||||
<string name="devices_error_connection_failed">Connection failed</string>
|
||||
<string name="devices_error_connection_lost">Connection lost</string>
|
||||
<string name="devices_error_connection_denied">The other device declined the request, or has remote control turned off.</string>
|
||||
<string name="devices_error_send_failed">Send failed</string>
|
||||
<string name="devices_error_discovery_failed">Discovery failed: %1$s</string>
|
||||
<string name="devices_error_connect_failed">Failed to connect: %1$s</string>
|
||||
@ -511,7 +511,6 @@
|
||||
<!-- Devices: discovery screen -->
|
||||
<string name="devices_title">Devices</string>
|
||||
<string name="devices_refresh">Refresh</string>
|
||||
<string name="devices_connected_to">Connected to %1$s:%2$d</string>
|
||||
<string name="devices_disconnect">Disconnect</string>
|
||||
<string name="devices_connecting">Connecting...</string>
|
||||
<string name="devices_searching">Searching for devices on the network...</string>
|
||||
@ -519,23 +518,29 @@
|
||||
<string name="devices_empty_hint">Make sure the other device has \"Allow remote control\" enabled in settings.</string>
|
||||
<string name="devices_unknown_device">Unknown Device</string>
|
||||
<string name="devices_resolving">Resolving...</string>
|
||||
<string name="devices_device_id">ID: %1$s...</string>
|
||||
<string name="devices_section_connected">Connected</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_open_remote">Open remote</string>
|
||||
<string name="devices_search_again">Search again</string>
|
||||
<string name="devices_searching_more">Still looking for more devices...</string>
|
||||
|
||||
<!-- Devices: remote control screen -->
|
||||
<string name="devices_remote_title">Remote Control</string>
|
||||
<string name="devices_remote_disconnected">Disconnected</string>
|
||||
<string name="devices_remote_connection_error">Connection error: %1$s</string>
|
||||
<string name="devices_remote_album_cover">Album cover</string>
|
||||
<string name="devices_remote_unknown_track">Unknown Track</string>
|
||||
<string name="devices_remote_unknown_artist">Unknown Artist</string>
|
||||
<string name="devices_remote_seek">Seek</string>
|
||||
<string name="devices_remote_shuffle">Shuffle</string>
|
||||
<string name="devices_remote_previous">Previous</string>
|
||||
<string name="devices_remote_next">Next</string>
|
||||
<string name="devices_remote_loop_mode">Loop mode</string>
|
||||
<string name="devices_remote_volume">Volume</string>
|
||||
<string name="devices_remote_remove_from_queue">Remove from queue</string>
|
||||
<string name="devices_remote_more_options">More options</string>
|
||||
<string name="devices_remote_playing_on">Playing on %1$s</string>
|
||||
<string name="devices_remote_connecting_hint">If asked, allow this device on the other Spotube first.</string>
|
||||
<string name="devices_remote_disconnected_hint">You\'re no longer controlling the other device. Pick it again to reconnect.</string>
|
||||
<string name="devices_remote_back_to_devices">Back to devices</string>
|
||||
<string name="devices_remote_nothing_playing">Nothing playing</string>
|
||||
<string name="devices_remote_nothing_playing_hint">Start something on the other device, or pick a track from its queue.</string>
|
||||
|
||||
<!-- Group Jam: play destination picker -->
|
||||
<string name="jam_destination_title">Play Where?</string>
|
||||
@ -705,6 +710,9 @@
|
||||
<string name="downloads_error_no_metadata">No track metadata</string>
|
||||
<string name="downloads_error_stream_unresolved_short">Could not resolve stream URL</string>
|
||||
<string name="downloads_error_stream_unresolved">Could not resolve stream URL. Try playing the track first.</string>
|
||||
<string name="downloads_error_http">The server refused the download (HTTP %1$d)</string>
|
||||
<string name="downloads_error_range_ignored">The server sent an incomplete download. Try again.</string>
|
||||
<string name="downloads_error_video_only">Only a video is available for this track, so it wasn\'t downloaded</string>
|
||||
|
||||
<!-- Saved tracks -->
|
||||
<string name="saved_tracks_title">Saved Tracks</string>
|
||||
@ -922,6 +930,14 @@
|
||||
<string name="nav_group_on_device">On Device</string>
|
||||
<string name="nav_group_connect">Connect</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) -->
|
||||
<string name="tray_hide_window">Hide Window</string>
|
||||
@ -949,7 +965,6 @@
|
||||
<string name="app_window_close">Close</string>
|
||||
|
||||
<!-- Android playback notification -->
|
||||
<string name="notification_playback_service_text">Playback service</string>
|
||||
<string name="notification_playback_channel_name">Playback</string>
|
||||
<string name="notification_playback_channel_description">Media playback controls</string>
|
||||
|
||||
|
||||
@ -49,6 +49,7 @@ import org.spotube.spotube.modules.blacklist.BlacklistRepository
|
||||
import org.spotube.spotube.modules.blacklist.BlacklistViewModel
|
||||
import org.spotube.spotube.modules.devices.DevicesViewModel
|
||||
import org.spotube.spotube.modules.devices.RemoteControlViewModel
|
||||
import org.spotube.spotube.modules.shell.ConnectStatusViewModel
|
||||
import org.spotube.spotube.modules.jam.JamViewModel
|
||||
import org.spotube.spotube.modules.downloads.DownloadManager
|
||||
import org.spotube.spotube.modules.downloads.DownloadsViewModel
|
||||
@ -219,6 +220,7 @@ val sharedModules = module {
|
||||
viewModelOf(::BlacklistViewModel)
|
||||
viewModel { DevicesViewModel(get()) }
|
||||
viewModelOf(::RemoteControlViewModel)
|
||||
viewModelOf(::ConnectStatusViewModel)
|
||||
viewModel {
|
||||
JamViewModel(
|
||||
jamRoomService = get(),
|
||||
|
||||
@ -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.core.discovery
|
||||
|
||||
/**
|
||||
* The LAN address to publish in our mDNS advertisement, or null to let the platform decide.
|
||||
*
|
||||
* Only the desktop needs this: there the dns-sd library otherwise grabs the first site-local
|
||||
* address of *any* interface, which on a dev box is often a Docker/VM bridge (e.g. minikube's
|
||||
* 192.168.49.1) that other devices can't reach.
|
||||
*/
|
||||
internal expect fun preferredAdvertiseAddress(): String?
|
||||
@ -21,8 +21,12 @@ import com.appstractive.dnssd.DiscoveryEvent
|
||||
import com.appstractive.dnssd.NetService
|
||||
import com.appstractive.dnssd.createNetService
|
||||
import com.appstractive.dnssd.discoverServices
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.IO
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.text.decodeToString
|
||||
|
||||
data class DiscoveredDevice(
|
||||
@ -35,7 +39,8 @@ data class DiscoveredDevice(
|
||||
}
|
||||
|
||||
sealed interface DiscoveryState {
|
||||
data class Discovered(val device: DiscoveredDevice, val resolve: () -> Unit) : DiscoveryState
|
||||
/** [resolve] can block for seconds on desktop (JmDNS), so it's a suspend call that runs off the main thread. */
|
||||
data class Discovered(val device: DiscoveredDevice, val resolve: suspend () -> Unit) : DiscoveryState
|
||||
data class Resolved(val device: DiscoveredDevice) : DiscoveryState
|
||||
data class Removed(val device: DiscoveredDevice) : DiscoveryState
|
||||
}
|
||||
@ -46,6 +51,11 @@ class DeviceDiscoveryService {
|
||||
const val TXT_DEVICE_ID = "deviceId"
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs on [Dispatchers.IO]: on desktop, cancelling the underlying flow calls the blocking
|
||||
* `JmDNS.close()` (it waits up to 5s for its shutdown), which froze the UI whenever the
|
||||
* collector was cancelled on the main thread.
|
||||
*/
|
||||
fun discover(): Flow<DiscoveryState> = discoverServices(SERVICE_TYPE).map { event ->
|
||||
when (event) {
|
||||
is DiscoveryEvent.Discovered -> {
|
||||
@ -55,7 +65,9 @@ class DeviceDiscoveryService {
|
||||
host = event.service.host,
|
||||
port = event.service.port,
|
||||
deviceId = event.service.txt[TXT_DEVICE_ID]?.decodeToString().orEmpty())
|
||||
DiscoveryState.Discovered(device = device, resolve = event.resolve)
|
||||
DiscoveryState.Discovered(
|
||||
device = device,
|
||||
resolve = { withContext(Dispatchers.IO) { event.resolve() } })
|
||||
}
|
||||
|
||||
is DiscoveryEvent.Resolved -> {
|
||||
@ -78,7 +90,7 @@ class DeviceDiscoveryService {
|
||||
DiscoveryState.Removed(device = device)
|
||||
}
|
||||
}
|
||||
}
|
||||
}.flowOn(Dispatchers.IO)
|
||||
|
||||
suspend fun advertise(
|
||||
name: String,
|
||||
@ -97,5 +109,6 @@ class DeviceDiscoveryService {
|
||||
type = SERVICE_TYPE,
|
||||
name = name,
|
||||
port = port,
|
||||
addresses = preferredAdvertiseAddress()?.let { listOf(it) },
|
||||
txt = mapOf(TXT_DEVICE_ID to deviceId))
|
||||
}
|
||||
@ -45,7 +45,7 @@ sealed class JamMessage {
|
||||
* merely auto-advanced because its song ended (guests stay put).
|
||||
*/
|
||||
val follow: Boolean = false,
|
||||
/** Host's live play state — late-joining guests start with it. */
|
||||
/** Room-wide play/pause; guests follow every change of it. */
|
||||
val isPlaying: Boolean = false) : JamMessage()
|
||||
|
||||
@Serializable
|
||||
@ -76,11 +76,15 @@ sealed class JamMessage {
|
||||
}
|
||||
|
||||
/**
|
||||
* Playback commands. Only queue navigation is global — play/pause, seek,
|
||||
* Playback commands. Queue navigation and play/pause are global — seek,
|
||||
* volume, shuffle and loop are local to each participant.
|
||||
*/
|
||||
@Serializable
|
||||
sealed class PlaybackCmd {
|
||||
@Serializable
|
||||
@SerialName("setPlaying")
|
||||
data class SetPlaying(val playing: Boolean) : PlaybackCmd()
|
||||
|
||||
@Serializable
|
||||
@SerialName("skipNext")
|
||||
data object SkipNext : PlaybackCmd()
|
||||
|
||||
@ -33,11 +33,14 @@ import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.mapNotNull
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.random.Random
|
||||
import org.jetbrains.compose.resources.getString
|
||||
@ -52,9 +55,9 @@ import kotlin.time.Clock
|
||||
* Sync rules:
|
||||
* - The queue list and current index are global. [skipNext]/[skipPrevious]/[jumpTo]
|
||||
* from anyone are applied by the host, then broadcast via the retained state topic.
|
||||
* - Play/pause, seek, volume and loop are local to each device — never broadcast.
|
||||
* When the queue moves on, a paused participant stays paused; a playing one
|
||||
* keeps playing the new current item.
|
||||
* - Play/pause is global: anyone's play/pause is sent to the host, which applies it
|
||||
* and broadcasts it; guests follow every change of the host's play state.
|
||||
* - Seek, volume and loop are local to each device — never broadcast.
|
||||
* - Shuffle is host-only; guests mirror the host's shuffle setting.
|
||||
* - Guests can only add to the queue (suggest); the host applies suggestions.
|
||||
* - If the host leaves, the participant with the lowest client id takes over.
|
||||
@ -63,7 +66,7 @@ private data class PlaybackBroadcast(
|
||||
val queue: List<QueueEntry>,
|
||||
val current: QueueEntry?,
|
||||
val shuffle: Boolean,
|
||||
val playerState: PlayerState)
|
||||
val isPlaying: Boolean)
|
||||
|
||||
class JamRoomService(
|
||||
private val jamClient: JamRoomClient,
|
||||
@ -73,6 +76,20 @@ class JamRoomService(
|
||||
private val log = Logger.withTag("JamRoomService")
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
|
||||
/**
|
||||
* Play/pause intent. Transient states (buffering between tracks, loading, completion)
|
||||
* keep the last value, so they never pause or resume the room.
|
||||
*/
|
||||
private val playIntent: StateFlow<Boolean> = audioPlayer.playerStateFlow
|
||||
.mapNotNull { state ->
|
||||
when (state) {
|
||||
PlayerState.PLAYING -> true
|
||||
PlayerState.PAUSED -> false
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
.stateIn(scope, SharingStarted.Eagerly, audioPlayer.playerStateFlow.value == PlayerState.PLAYING)
|
||||
|
||||
private val _role = MutableStateFlow<JamRole?>(null)
|
||||
val role: StateFlow<JamRole?> = _role.asStateFlow()
|
||||
|
||||
@ -116,8 +133,8 @@ class JamRoomService(
|
||||
*/
|
||||
private var autoAdvanceDeadlineMs = 0L
|
||||
|
||||
/** Guest side: playback has started at least once (local control is the guest's own). */
|
||||
private var hasStartedPlayback = false
|
||||
/** Guest side: the room's play state from the last host broadcast (null until the first one). */
|
||||
private var lastHostIsPlaying: Boolean? = null
|
||||
|
||||
/** Host side: client ids banned for this session. */
|
||||
private val bannedClientIds = mutableSetOf<String>()
|
||||
@ -145,12 +162,14 @@ class JamRoomService(
|
||||
audioPlayer.completionFlow
|
||||
.onEach { autoAdvanceDeadlineMs = now() + AUTO_ADVANCE_WINDOW_MS }
|
||||
.launchIn(scope)
|
||||
// Once a guest has played on its own (or was started by the host), its
|
||||
// play/pause is its own — the host's play state only starts fresh joiners.
|
||||
audioPlayer.playerStateFlow
|
||||
.onEach { state ->
|
||||
if (_role.value == JamRole.Guest && state == PlayerState.PLAYING) {
|
||||
hasStartedPlayback = true
|
||||
// A guest's own play/pause (UI, notification, headset) goes to the host, which
|
||||
// applies it for the whole room. Intent that matches the room's state came from
|
||||
// following the host, so it isn't echoed back.
|
||||
playIntent
|
||||
.onEach { playing ->
|
||||
val roomPlaying = lastHostIsPlaying
|
||||
if (_role.value == JamRole.Guest && !leaving && roomPlaying != null && playing != roomPlaying) {
|
||||
publishCommand(PlaybackCmd.SetPlaying(playing))
|
||||
}
|
||||
}
|
||||
.launchIn(scope)
|
||||
@ -182,7 +201,7 @@ class JamRoomService(
|
||||
bannedClientIds.clear()
|
||||
leaving = false
|
||||
autoAdvanceDeadlineMs = 0L
|
||||
hasStartedPlayback = false
|
||||
lastHostIsPlaying = null
|
||||
startHostBroadcast()
|
||||
persistLastCode(code)
|
||||
code
|
||||
@ -214,7 +233,7 @@ class JamRoomService(
|
||||
lastAppliedItems = emptyList()
|
||||
lastAppliedIndex = -1
|
||||
leaving = false
|
||||
hasStartedPlayback = false
|
||||
lastHostIsPlaying = null
|
||||
persistLastCode(normalized)
|
||||
}
|
||||
}
|
||||
@ -232,7 +251,7 @@ class JamRoomService(
|
||||
lastAppliedItems = emptyList()
|
||||
lastAppliedIndex = -1
|
||||
bannedClientIds.clear()
|
||||
hasStartedPlayback = false
|
||||
lastHostIsPlaying = null
|
||||
}
|
||||
|
||||
// ---------- Controls (called from the UI) ----------
|
||||
@ -294,8 +313,8 @@ class JamRoomService(
|
||||
audioPlayerQueue.queueFlow,
|
||||
audioPlayerQueue.currentQueueEntryFlow,
|
||||
audioPlayer.shuffleModeFlow,
|
||||
audioPlayer.playerStateFlow) { queue, current, shuffle, playerState ->
|
||||
PlaybackBroadcast(queue, current, shuffle, playerState)
|
||||
playIntent) { queue, current, shuffle, isPlaying ->
|
||||
PlaybackBroadcast(queue, current, shuffle, isPlaying)
|
||||
}
|
||||
.onEach { broadcast ->
|
||||
if (_role.value != JamRole.Host) return@onEach
|
||||
@ -311,7 +330,7 @@ class JamRoomService(
|
||||
currentIndex = index.coerceAtLeast(0),
|
||||
shuffleEnabled = broadcast.shuffle,
|
||||
follow = now() > autoAdvanceDeadlineMs,
|
||||
isPlaying = broadcast.playerState == PlayerState.PLAYING)
|
||||
isPlaying = broadcast.isPlaying)
|
||||
)
|
||||
}
|
||||
.launchIn(this)
|
||||
@ -332,15 +351,17 @@ class JamRoomService(
|
||||
_shuffleEnabled.value = state.shuffleEnabled
|
||||
runCatching { audioPlayer.shuffle(state.shuffleEnabled) }
|
||||
|
||||
// Late joiner: the host is already playing, so start immediately. Once
|
||||
// this guest has played on its own, the host's play state is ignored.
|
||||
if (state.isPlaying && !hasStartedPlayback) {
|
||||
runCatching { audioPlayer.play() }
|
||||
.onFailure { log.w(it) { "Failed to start playback on host play state" } }
|
||||
// Play/pause is room-wide: follow every change of the host's play state. The room
|
||||
// state is recorded first, so the resulting local intent isn't sent back as a command.
|
||||
if (state.isPlaying != lastHostIsPlaying) {
|
||||
lastHostIsPlaying = state.isPlaying
|
||||
if (state.isPlaying != playIntent.value) {
|
||||
runCatching { if (state.isPlaying) audioPlayer.play() else audioPlayer.pause() }
|
||||
.onFailure { log.w(it) { "Failed to follow the host's play state" } }
|
||||
}
|
||||
}
|
||||
|
||||
val items = state.items.filter { it.trackId.isNotBlank() || it.url.isNotBlank() }
|
||||
val wasPlaying = audioPlayer.playerStateFlow.value == PlayerState.PLAYING
|
||||
|
||||
if (items != lastAppliedItems) {
|
||||
val previous = lastAppliedItems
|
||||
@ -369,7 +390,7 @@ class JamRoomService(
|
||||
runCatching {
|
||||
audioPlayerQueue.load(
|
||||
entries = items.map { it.toQueueEntry() },
|
||||
autoPlay = wasPlaying,
|
||||
autoPlay = state.isPlaying,
|
||||
startPosition = startIndex)
|
||||
}.onFailure { log.w(it) { "Failed to apply jam queue" } }
|
||||
return
|
||||
@ -419,6 +440,10 @@ class JamRoomService(
|
||||
|
||||
private suspend fun applyCommand(command: PlaybackCmd) {
|
||||
when (command) {
|
||||
// Applied locally; the host broadcast carries the new play state to every guest
|
||||
is PlaybackCmd.SetPlaying -> runCatching {
|
||||
if (command.playing) audioPlayer.play() else audioPlayer.pause()
|
||||
}
|
||||
PlaybackCmd.SkipNext -> runCatching { audioPlayer.skipToNext() }
|
||||
PlaybackCmd.SkipPrevious -> runCatching { audioPlayer.skipToPrevious() }
|
||||
is PlaybackCmd.JumpTo -> runCatching { audioPlayer.jumpTo(command.index) }
|
||||
|
||||
@ -192,7 +192,7 @@ val navigationModule = module {
|
||||
BlacklistScreen()
|
||||
}
|
||||
navigation<Routes.Devices> {
|
||||
DevicesScreen(navigationCommands = get())
|
||||
DevicesScreen()
|
||||
}
|
||||
navigation<Routes.RemoteControl> {
|
||||
RemoteControlScreen(
|
||||
|
||||
@ -26,7 +26,7 @@ import io.ktor.client.plugins.websocket.WebSockets
|
||||
import io.ktor.client.plugins.websocket.webSocketSession
|
||||
import io.ktor.websocket.CloseReason
|
||||
import io.ktor.websocket.Frame
|
||||
import io.ktor.websocket.WebSocketSession
|
||||
import io.ktor.websocket.DefaultWebSocketSession
|
||||
import io.ktor.websocket.close
|
||||
import io.ktor.websocket.readText
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@ -40,6 +40,7 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
import spotube.composeapp.generated.resources.Res
|
||||
import spotube.composeapp.generated.resources.devices_error_connection_denied
|
||||
import spotube.composeapp.generated.resources.devices_error_connection_failed
|
||||
import spotube.composeapp.generated.resources.devices_error_connection_lost
|
||||
import spotube.composeapp.generated.resources.devices_error_send_failed
|
||||
@ -66,7 +67,7 @@ class RemoteControlClient {
|
||||
}
|
||||
}
|
||||
|
||||
private var session: WebSocketSession? = null
|
||||
private var session: DefaultWebSocketSession? = null
|
||||
|
||||
private val _connectionState = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
|
||||
val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow()
|
||||
@ -77,12 +78,23 @@ class RemoteControlClient {
|
||||
private val _latestQueue = MutableStateFlow<RemoteControlEvent.QueueUpdated?>(null)
|
||||
val latestQueue: StateFlow<RemoteControlEvent.QueueUpdated?> = _latestQueue.asStateFlow()
|
||||
|
||||
suspend fun connect(host: String, port: Int, deviceId: String, deviceName: String) {
|
||||
/**
|
||||
* [deviceName] identifies this controller to the host; [remoteDeviceName] is the
|
||||
* host's advertised name, kept only so the UI can show what it is controlling.
|
||||
*/
|
||||
suspend fun connect(
|
||||
host: String,
|
||||
port: Int,
|
||||
deviceId: String,
|
||||
deviceName: String,
|
||||
remoteDeviceName: String = "") {
|
||||
if (_connectionState.value is ConnectionState.Connected) {
|
||||
logger.w { "Already connected" }
|
||||
return
|
||||
}
|
||||
|
||||
// Drop a stale attempt (e.g. one still waiting on the host's permission prompt).
|
||||
closeSession()
|
||||
_connectionState.value = ConnectionState.Connecting
|
||||
try {
|
||||
session = httpClient.webSocketSession {
|
||||
@ -95,16 +107,16 @@ class RemoteControlClient {
|
||||
|
||||
// Start receiving messages in a separate coroutine
|
||||
scope.launch {
|
||||
receiveLoop(host, port)
|
||||
receiveLoop(host, port, remoteDeviceName)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.e(e) { "Failed to connect to $host:$port" }
|
||||
closeSession()
|
||||
_connectionState.value = ConnectionState.Error(e.message, Res.string.devices_error_connection_failed)
|
||||
disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun receiveLoop(host: String, port: Int) {
|
||||
private suspend fun receiveLoop(host: String, port: Int, remoteDeviceName: String) {
|
||||
val currentSession = session ?: return
|
||||
logger.d { "Starting receive loop for $host:$port" }
|
||||
try {
|
||||
@ -119,7 +131,7 @@ class RemoteControlClient {
|
||||
when (event) {
|
||||
is RemoteControlEvent.Connected -> {
|
||||
logger.i { "Connection authorized by server" }
|
||||
_connectionState.value = ConnectionState.Connected(host, port)
|
||||
_connectionState.value = ConnectionState.Connected(host, port, remoteDeviceName)
|
||||
}
|
||||
is RemoteControlEvent.WaitingForPermission -> {
|
||||
logger.i { "Waiting for permission: ${event.message}" }
|
||||
@ -144,18 +156,37 @@ class RemoteControlClient {
|
||||
logger.w(e) { "Failed to parse message: $text" }
|
||||
}
|
||||
}
|
||||
is Frame.Close -> {
|
||||
logger.i { "WebSocket closed by server" }
|
||||
_connectionState.value = ConnectionState.Disconnected
|
||||
break
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
logger.d { "Receive loop exited normally" }
|
||||
// Ktor consumes Close frames itself and just ends `incoming`, so the server
|
||||
// closing the socket (denied request, remote control turned off, host quit)
|
||||
// only shows up here. Without this the state stayed Connecting/Connected forever
|
||||
// and every later connect attempt was swallowed.
|
||||
val reason = currentSession.closeReason.await()
|
||||
logger.i { "WebSocket closed by server: $reason" }
|
||||
onServerClosed(currentSession, reason)
|
||||
} catch (e: Exception) {
|
||||
logger.e(e) { "Error in receive loop" }
|
||||
_connectionState.value = ConnectionState.Error(e.message, Res.string.devices_error_connection_lost)
|
||||
if (session === currentSession) {
|
||||
session = null
|
||||
_connectionState.value = ConnectionState.Error(e.message, Res.string.devices_error_connection_lost)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onServerClosed(closedSession: DefaultWebSocketSession, reason: CloseReason?) {
|
||||
// disconnect() (or a newer connect()) already replaced this session; nothing to report.
|
||||
if (session !== closedSession) return
|
||||
session = null
|
||||
val wasAuthorized = _connectionState.value is ConnectionState.Connected
|
||||
_connectionState.value = when {
|
||||
// The host refuses with VIOLATED_POLICY before authorizing: the user hit Deny
|
||||
// (or dismissed the prompt), or remote control is turned off over there.
|
||||
!wasAuthorized && reason?.knownReason == CloseReason.Codes.VIOLATED_POLICY ->
|
||||
ConnectionState.Error(null, Res.string.devices_error_connection_denied)
|
||||
wasAuthorized -> ConnectionState.Disconnected
|
||||
else -> ConnectionState.Error(reason?.message?.ifBlank { null }, Res.string.devices_error_connection_failed)
|
||||
}
|
||||
}
|
||||
|
||||
@ -180,12 +211,18 @@ class RemoteControlClient {
|
||||
}
|
||||
|
||||
suspend fun disconnect() {
|
||||
session?.close(CloseReason(CloseReason.Codes.NORMAL, "Client disconnecting"))
|
||||
session = null
|
||||
closeSession()
|
||||
_connectionState.value = ConnectionState.Disconnected
|
||||
logger.i { "Disconnected" }
|
||||
}
|
||||
|
||||
/** Detaches before closing so the receive loop doesn't report this as a server close. */
|
||||
private suspend fun closeSession() {
|
||||
val current = session ?: return
|
||||
session = null
|
||||
runCatching { current.close(CloseReason(CloseReason.Codes.NORMAL, "Client disconnecting")) }
|
||||
}
|
||||
|
||||
private fun randomShortId(): String {
|
||||
val chars = "0123456789abcdef"
|
||||
return buildString(8) {
|
||||
@ -199,7 +236,7 @@ class RemoteControlClient {
|
||||
sealed interface ConnectionState {
|
||||
data object Disconnected : ConnectionState
|
||||
data object Connecting : ConnectionState
|
||||
data class Connected(val host: String, val port: Int) : ConnectionState
|
||||
data class Connected(val host: String, val port: Int, val deviceName: String = "") : ConnectionState
|
||||
/**
|
||||
* [message] is the raw (untranslated) cause when available; [fallback] is the
|
||||
* localized description to show when it isn't.
|
||||
|
||||
@ -54,6 +54,16 @@ private data class CachedAlternatives(
|
||||
val pluginId: String,
|
||||
val sources: List<AudioSource>)
|
||||
|
||||
/**
|
||||
* Stream preferences for callers other than playback. The downloader passes the user's *download*
|
||||
* format/quality and needs a file it can fetch with plain HTTP, so DASH/HLS manifests are excluded.
|
||||
* Such requests bypass the playback stream-URL cache in both directions.
|
||||
*/
|
||||
data class StreamRequest(
|
||||
val format: AudioFormat,
|
||||
val quality: AudioQuality,
|
||||
val progressiveOnly: Boolean)
|
||||
|
||||
data class StreamInfo(
|
||||
val url: String,
|
||||
val protocol: StreamProtocol,
|
||||
@ -90,22 +100,49 @@ class StreamingUrlRepository(
|
||||
|
||||
suspend fun resolveStreamInfo(
|
||||
track: MetadataTrack,
|
||||
forceRefresh: Boolean = false
|
||||
forceRefresh: Boolean = false,
|
||||
request: StreamRequest? = null
|
||||
): StreamInfo? {
|
||||
val trackId = track.id
|
||||
val settings = settingsRepository.userSettings.value
|
||||
val preferredFormat = settings.streamingMusicFormat
|
||||
val preferredQuality = settings.streamingMusicQuality
|
||||
val preferredFormat = request?.format ?: settings.streamingMusicFormat
|
||||
val preferredQuality = request?.quality ?: settings.streamingMusicQuality
|
||||
// The URL cache holds what *playback* resolved; a download with other prefs must not
|
||||
// read it, nor replace it with a stream playback didn't ask for.
|
||||
val useUrlCache = request == null
|
||||
|
||||
val audioPlugin = pluginManager.selectedAudioPlugin.value
|
||||
if (audioPlugin == null) {
|
||||
logger.w { "No audio plugin selected while resolving stream for track $trackId" }
|
||||
return null
|
||||
}
|
||||
logger.i {
|
||||
"[Resolve $trackId] '${track.title}' plugin=${audioPlugin.pluginId} forceRefresh=$forceRefresh " +
|
||||
"prefs: format=${preferredFormat.codec}/${preferredFormat.container} quality=$preferredQuality " +
|
||||
if (request == null) "(streaming settings)" else "(caller request, progressiveOnly=${request.progressiveOnly})"
|
||||
}
|
||||
|
||||
if (!forceRefresh) {
|
||||
fun select(stream: AudioSource.Streamed, origin: String): AudioStream? {
|
||||
val candidates = if (request?.progressiveOnly == true) {
|
||||
stream.streams.filter { it.protocol == StreamProtocol.PROGRESSIVE }.also { kept ->
|
||||
if (kept.size != stream.streams.size) logger.i {
|
||||
"[Resolve $trackId] dropped ${stream.streams.size - kept.size} manifest (DASH/HLS) candidates; " +
|
||||
"caller needs a directly downloadable file"
|
||||
}
|
||||
}
|
||||
} else {
|
||||
stream.streams
|
||||
}
|
||||
return selectPreferredAudioStream(candidates, preferredFormat, preferredQuality)
|
||||
.also { logSelection(trackId, origin, candidates, preferredFormat, preferredQuality, it) }
|
||||
}
|
||||
|
||||
if (useUrlCache && !forceRefresh) {
|
||||
getCachedStreamUrl(trackId, preferredFormat, preferredQuality, audioPlugin.pluginId)?.let { cached ->
|
||||
logger.v { "Using cached stream URL for track $trackId" }
|
||||
logger.i {
|
||||
"[Resolve $trackId] cache hit: codec=${cached.codec} container=${cached.container} " +
|
||||
"protocol=${cached.protocol} ${urlSummary(cached.url)}"
|
||||
}
|
||||
return cached
|
||||
}
|
||||
}
|
||||
@ -126,13 +163,12 @@ class StreamingUrlRepository(
|
||||
|
||||
if (stream != null) {
|
||||
logger.d { "Resolved stream for track $trackId using cached source" }
|
||||
val resolvedStream = selectPreferredAudioStream(
|
||||
stream.streams,
|
||||
preferredFormat,
|
||||
preferredQuality)
|
||||
val resolvedStream = select(stream, "matched source ${source.id}")
|
||||
if (resolvedStream != null) {
|
||||
val info = resolvedStream.toStreamInfo()
|
||||
cacheStreamInfo(trackId, info, preferredFormat, preferredQuality, stream, resolvedStream, audioPlugin.pluginId)
|
||||
if (useUrlCache) {
|
||||
cacheStreamInfo(trackId, info, preferredFormat, preferredQuality, stream, resolvedStream, audioPlugin.pluginId)
|
||||
}
|
||||
return info
|
||||
}
|
||||
}
|
||||
@ -185,18 +221,49 @@ class StreamingUrlRepository(
|
||||
}
|
||||
|
||||
logger.d { "Resolved stream for track $trackId via track lookup" }
|
||||
val resolvedStream = selectPreferredAudioStream(
|
||||
stream.streams,
|
||||
preferredFormat,
|
||||
preferredQuality)
|
||||
val resolvedStream = select(stream, "track lookup -> ${stream.id}")
|
||||
if (resolvedStream != null) {
|
||||
val info = resolvedStream.toStreamInfo()
|
||||
cacheStreamInfo(trackId, info, preferredFormat, preferredQuality, stream, resolvedStream, audioPlugin.pluginId)
|
||||
if (useUrlCache) {
|
||||
cacheStreamInfo(trackId, info, preferredFormat, preferredQuality, stream, resolvedStream, audioPlugin.pluginId)
|
||||
}
|
||||
return info
|
||||
}
|
||||
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(
|
||||
url = normalizeManifestUrl(url, protocol),
|
||||
protocol = protocol,
|
||||
@ -353,3 +420,20 @@ private fun AudioStream.channelDistance(preferredQuality: AudioQuality): Int = w
|
||||
|
||||
else -> 0
|
||||
}
|
||||
|
||||
internal fun AudioStream.describeForLog(): String = when (this) {
|
||||
is AudioStream.Lossy -> "lossy codec=$codec container=$container bitrate=$bitrate protocol=$protocol"
|
||||
is AudioStream.Lossless ->
|
||||
"lossless codec=$codec container=$container sampleRate=$sampleRate channels=$channels protocol=$protocol"
|
||||
}
|
||||
|
||||
/**
|
||||
* Host plus the query params that identify a YouTube stream (itag, mime, clen = byte size, dur),
|
||||
* instead of the full signed URL.
|
||||
*/
|
||||
internal fun urlSummary(url: String): String = runCatching {
|
||||
val parsed = io.ktor.http.Url(url)
|
||||
val keys = listOf("itag", "mime", "clen", "dur")
|
||||
val params = keys.mapNotNull { key -> parsed.parameters[key]?.let { "$key=$it" } }
|
||||
"url=${parsed.host}${parsed.encodedPath.take(40)}" + if (params.isEmpty()) "" else " ${params.joinToString(" ")}"
|
||||
}.getOrDefault("url=<unparseable ${url.take(60)}>")
|
||||
|
||||
@ -18,266 +18,401 @@
|
||||
package org.spotube.spotube.modules.devices
|
||||
|
||||
|
||||
import org.spotube.millennium.composables.MillenniumSpokeSpinner
|
||||
import org.spotube.millennium.composables.MillenniumIcon
|
||||
import org.spotube.millennium.composables.MillenniumButton
|
||||
import org.spotube.millennium.composables.MillenniumButtonStyle
|
||||
import org.spotube.millennium.composables.MillenniumCard
|
||||
import org.spotube.millennium.composables.MillenniumIcon
|
||||
import org.spotube.millennium.composables.MillenniumIconButton
|
||||
import org.spotube.millennium.composables.MillenniumIconButtonStyle
|
||||
import org.spotube.millennium.composables.MillenniumSpokeSpinner
|
||||
import org.spotube.millennium.theme.MillenniumTheme
|
||||
import androidx.compose.foundation.background
|
||||
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.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.LazyItemScope
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
|
||||
import org.spotube.spotube.core.ui.theming.AppTheme
|
||||
|
||||
import com.composeunstyled.Text
|
||||
import compose.icons.FeatherIcons
|
||||
import compose.icons.feathericons.ChevronRight
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
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.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import org.spotube.spotube.core.discovery.DiscoveredDevice
|
||||
import org.spotube.spotube.core.discovery.rememberLocalNetworkPermissionRequester
|
||||
import org.spotube.spotube.core.navigation.NavigationCommands
|
||||
import org.spotube.spotube.core.remote.ConnectionState
|
||||
import org.spotube.spotube.core.ui.component.ApplicationMainBar
|
||||
import org.spotube.spotube.modules.settings.SettingsViewModel
|
||||
import org.spotube.spotube.modules.settings.components.SettingCardItem
|
||||
import org.spotube.spotube.modules.settings.sections.SettingsItemIcon
|
||||
import org.spotube.spotube.modules.settings.sections.remoteControlSettingCards
|
||||
import org.spotube.spotube.modules.settings.sections.settingsSectionCard
|
||||
import org.spotube.spotube.modules.settings.sections.settingsSectionHeader
|
||||
import org.spotube.spotube.modules.shell.LocalAppShellBottomInset
|
||||
import org.spotube.spotube.resources.iconsax.Iconsax
|
||||
import org.spotube.spotube.resources.iconsax.IconsaxCloseSquare
|
||||
import org.spotube.spotube.resources.iconsax.IconsaxInformation
|
||||
import org.spotube.spotube.resources.iconsax.IconsaxMirroringScreen
|
||||
import org.spotube.spotube.resources.iconsax.IconsaxRefreshRight
|
||||
import org.spotube.spotube.resources.iconsax.IconsaxWifiSquare
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import spotube.composeapp.generated.resources.Res
|
||||
import spotube.composeapp.generated.resources.devices_connected_to
|
||||
import spotube.composeapp.generated.resources.devices_connecting
|
||||
import spotube.composeapp.generated.resources.devices_device_id
|
||||
import spotube.composeapp.generated.resources.devices_disconnect
|
||||
import spotube.composeapp.generated.resources.devices_empty
|
||||
import spotube.composeapp.generated.resources.devices_empty_hint
|
||||
import spotube.composeapp.generated.resources.devices_open_remote
|
||||
import spotube.composeapp.generated.resources.devices_refresh
|
||||
import spotube.composeapp.generated.resources.devices_resolving
|
||||
import spotube.composeapp.generated.resources.devices_search_again
|
||||
import spotube.composeapp.generated.resources.devices_searching
|
||||
import spotube.composeapp.generated.resources.devices_searching_more
|
||||
import spotube.composeapp.generated.resources.devices_section_available
|
||||
import spotube.composeapp.generated.resources.devices_section_connected
|
||||
import spotube.composeapp.generated.resources.devices_section_this_device
|
||||
import spotube.composeapp.generated.resources.devices_status_connected
|
||||
import spotube.composeapp.generated.resources.devices_title
|
||||
import spotube.composeapp.generated.resources.devices_unknown_device
|
||||
import spotube.composeapp.generated.resources.jam_error_dismiss
|
||||
|
||||
private val ContentMaxWidth = 720.dp
|
||||
|
||||
@Composable
|
||||
fun DevicesScreen(
|
||||
navigationCommands: NavigationCommands) {
|
||||
val viewModel = koinViewModel<DevicesViewModel>()
|
||||
val devices by viewModel.devices.collectAsStateWithLifecycle()
|
||||
val isDiscovering by viewModel.isDiscovering.collectAsStateWithLifecycle()
|
||||
val connectingToDevice by viewModel.connectingToDevice.collectAsStateWithLifecycle()
|
||||
val connectionState by viewModel.connectionState.collectAsStateWithLifecycle()
|
||||
val error by viewModel.error.collectAsStateWithLifecycle()
|
||||
viewModel: DevicesViewModel = koinViewModel(),
|
||||
settingsViewModel: SettingsViewModel = koinViewModel()
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val settings by settingsViewModel.settingsState.collectAsStateWithLifecycle()
|
||||
val requestLocalNetworkPermission = rememberLocalNetworkPermissionRequester()
|
||||
val shellBottomInset = LocalAppShellBottomInset.current
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
// Android 16+ needs NEARBY_WIFI_DEVICES granted at runtime before mDNS works.
|
||||
requestLocalNetworkPermission()
|
||||
viewModel.startDiscovery()
|
||||
onDispose {
|
||||
// The remote connection itself outlives this screen; only discovery stops.
|
||||
viewModel.stopDiscovery()
|
||||
// Don't disconnect here - the connection should persist when navigating to RemoteControlScreen
|
||||
// The RemoteControlViewModel will manage the connection lifecycle
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MillenniumTheme.colors.background)) {
|
||||
.background(MillenniumTheme.colors.background)
|
||||
) {
|
||||
ApplicationMainBar(
|
||||
backButton = true,
|
||||
title = { Text(stringResource(Res.string.devices_title)) },
|
||||
actions = {
|
||||
if (isDiscovering && connectingToDevice == null) {
|
||||
if (state.isDiscovering) {
|
||||
MillenniumSpokeSpinner(
|
||||
size = 24.dp,
|
||||
modifier = Modifier.padding(end = 8.dp))
|
||||
size = 20.dp,
|
||||
modifier = Modifier.padding(horizontal = 10.dp)
|
||||
)
|
||||
} else {
|
||||
MillenniumIcon(
|
||||
imageVector = Iconsax.IconsaxRefreshRight,
|
||||
contentDescription = stringResource(Res.string.devices_refresh),
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
.clickable(enabled = connectingToDevice == null) {
|
||||
viewModel.startDiscovery()
|
||||
})
|
||||
}
|
||||
})
|
||||
val shellBottomInset = LocalAppShellBottomInset.current
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.weight(1f)) {
|
||||
// Error banner
|
||||
error?.let { devicesError ->
|
||||
val errorMessage = devicesError.rawMessage
|
||||
?: stringResource(devicesError.message, *devicesError.args.toTypedArray())
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)) {
|
||||
Column {
|
||||
Text(
|
||||
text = errorMessage,
|
||||
color = AppTheme.colors.error,
|
||||
style = AppTheme.typography.bodyMedium)
|
||||
MillenniumButton(
|
||||
onClick = { viewModel.clearError() },
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
style = MillenniumButtonStyle.Secondary) {
|
||||
Text(stringResource(Res.string.jam_error_dismiss))
|
||||
}
|
||||
MillenniumIconButton(
|
||||
onClick = viewModel::startDiscovery,
|
||||
enabled = !state.isConnecting,
|
||||
style = MillenniumIconButtonStyle.Quiet
|
||||
) {
|
||||
MillenniumIcon(
|
||||
imageVector = Iconsax.IconsaxRefreshRight,
|
||||
contentDescription = stringResource(Res.string.devices_refresh)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Connection status
|
||||
when (val state = connectionState) {
|
||||
is ConnectionState.Connected -> {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)) {
|
||||
Column {
|
||||
Text(
|
||||
text = stringResource(Res.string.devices_connected_to, state.host, state.port),
|
||||
style = AppTheme.typography.bodyMedium,
|
||||
color = AppTheme.colors.primary)
|
||||
MillenniumButton(
|
||||
onClick = { viewModel.disconnect() },
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
style = MillenniumButtonStyle.Secondary) {
|
||||
Text(stringResource(Res.string.devices_disconnect))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth(),
|
||||
contentAlignment = Alignment.TopCenter
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.widthIn(max = ContentMaxWidth)
|
||||
.fillMaxSize(),
|
||||
contentPadding = PaddingValues(
|
||||
start = 8.dp,
|
||||
end = 8.dp,
|
||||
top = 8.dp,
|
||||
bottom = shellBottomInset + 16.dp
|
||||
)
|
||||
) {
|
||||
state.error?.let { error ->
|
||||
item {
|
||||
ErrorBanner(
|
||||
message = error.rawMessage
|
||||
?: stringResource(error.message, *error.args.toTypedArray()),
|
||||
onDismiss = viewModel::clearError
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
state.connectedDevice?.let { device ->
|
||||
settingsSectionHeader(Res.string.devices_section_connected)
|
||||
item {
|
||||
ConnectedDeviceCard(
|
||||
device = device,
|
||||
onOpenRemote = viewModel::openRemote,
|
||||
onDisconnect = viewModel::disconnect
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (state.devices.isNotEmpty()) {
|
||||
settingsSectionHeader(Res.string.devices_section_available)
|
||||
settingsSectionCard(
|
||||
items = state.devices.map { device ->
|
||||
{
|
||||
DeviceTile(
|
||||
device = device,
|
||||
enabled = !state.isConnecting || device.isConnecting,
|
||||
onClick = { viewModel.onDeviceClick(device.key) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
is ConnectionState.Connecting -> {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
contentAlignment = Alignment.CenterStart) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically) {
|
||||
MillenniumSpokeSpinner(size = 24.dp)
|
||||
)
|
||||
if (state.isDiscovering) {
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(Res.string.devices_connecting),
|
||||
style = AppTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
|
||||
// Device list
|
||||
if (devices.isEmpty() && connectingToDevice == null) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(bottom = shellBottomInset),
|
||||
contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
text = if (isDiscovering) {
|
||||
stringResource(Res.string.devices_searching)
|
||||
} else {
|
||||
stringResource(Res.string.devices_empty)
|
||||
},
|
||||
style = AppTheme.typography.bodyMedium,
|
||||
color = AppTheme.colors.onSurfaceVariant)
|
||||
if (!isDiscovering) {
|
||||
Text(
|
||||
text = stringResource(Res.string.devices_empty_hint),
|
||||
text = stringResource(Res.string.devices_searching_more),
|
||||
style = AppTheme.typography.bodySmall,
|
||||
color = AppTheme.colors.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 8.dp, start = 32.dp, end = 32.dp))
|
||||
color = MillenniumTheme.colors.muted,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 12.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(
|
||||
horizontal = 16.dp,
|
||||
vertical = 8.dp)) {
|
||||
items(devices.values.toList(), key = { it.key }) { device ->
|
||||
DeviceRow(
|
||||
device = device,
|
||||
isConnecting = connectingToDevice?.key == device.key,
|
||||
onClick = { viewModel.connectToDevice(device) })
|
||||
}
|
||||
} else if (state.showEmptyState) {
|
||||
item {
|
||||
Box(modifier = Modifier.padding(bottom = shellBottomInset))
|
||||
EmptyState(
|
||||
isDiscovering = state.isDiscovering,
|
||||
onSearchAgain = viewModel::startDiscovery
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DeviceRow(
|
||||
device: DiscoveredDevice,
|
||||
isConnecting: Boolean,
|
||||
onClick: () -> Unit) {
|
||||
Row(
|
||||
private fun DeviceTile(
|
||||
device: DeviceItemUi,
|
||||
enabled: Boolean,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val name = device.name ?: stringResource(Res.string.devices_unknown_device)
|
||||
SettingCardItem(
|
||||
enabled = enabled,
|
||||
title = name,
|
||||
subtitle = device.address ?: stringResource(Res.string.devices_resolving),
|
||||
icon = { SettingsItemIcon(Iconsax.IconsaxMirroringScreen, name) },
|
||||
trailingContent = {
|
||||
when {
|
||||
device.isConnecting -> MillenniumSpokeSpinner(size = 20.dp)
|
||||
device.isConnected -> Text(
|
||||
text = stringResource(Res.string.devices_status_connected),
|
||||
style = AppTheme.typography.labelMedium,
|
||||
color = AppTheme.colors.primary
|
||||
)
|
||||
else -> MillenniumIcon(
|
||||
imageVector = FeatherIcons.ChevronRight,
|
||||
contentDescription = null,
|
||||
tint = AppTheme.colors.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
},
|
||||
onClick = onClick
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LazyItemScope.ConnectedDeviceCard(
|
||||
device: ConnectedDeviceUi,
|
||||
onOpenRemote: () -> Unit,
|
||||
onDisconnect: () -> Unit
|
||||
) {
|
||||
val name = device.name ?: stringResource(Res.string.devices_unknown_device)
|
||||
MillenniumCard(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick, enabled = !isConnecting)
|
||||
.padding(vertical = 12.dp, horizontal = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
if (isConnecting) {
|
||||
MillenniumSpokeSpinner(size = 24.dp)
|
||||
} else {
|
||||
MillenniumIcon(
|
||||
imageVector = Iconsax.IconsaxMirroringScreen,
|
||||
contentDescription = null,
|
||||
tint = AppTheme.colors.primary)
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = device.name.ifBlank { stringResource(Res.string.devices_unknown_device) },
|
||||
style = AppTheme.typography.bodyLarge,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis)
|
||||
Text(
|
||||
text = if (device.host.isNotBlank() && device.port > 0) {
|
||||
"${device.host}:${device.port}"
|
||||
} else {
|
||||
stringResource(Res.string.devices_resolving)
|
||||
},
|
||||
style = AppTheme.typography.bodySmall,
|
||||
color = AppTheme.colors.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis)
|
||||
if (device.deviceId.isNotBlank()) {
|
||||
.fillParentMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
maxWidth = ContentMaxWidth,
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
contentSpacing = 16.dp,
|
||||
shadowElevation = 6.dp
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
SettingsItemIcon(Iconsax.IconsaxMirroringScreen, name)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = stringResource(Res.string.devices_device_id, device.deviceId.take(8)),
|
||||
style = AppTheme.typography.labelSmall,
|
||||
color = AppTheme.colors.onSurfaceVariant.copy(alpha = 0.6f),
|
||||
text = name,
|
||||
style = AppTheme.typography.titleMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis)
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
text = device.address,
|
||||
style = AppTheme.typography.bodySmall,
|
||||
color = AppTheme.colors.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End)
|
||||
) {
|
||||
MillenniumButton(
|
||||
onClick = onDisconnect,
|
||||
style = MillenniumButtonStyle.Secondary
|
||||
) {
|
||||
Text(stringResource(Res.string.devices_disconnect))
|
||||
}
|
||||
MillenniumButton(
|
||||
onClick = onOpenRemote,
|
||||
style = MillenniumButtonStyle.Primary
|
||||
) {
|
||||
Text(stringResource(Res.string.devices_open_remote))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ErrorBanner(
|
||||
message: String,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
.clip(AppTheme.shapes.medium)
|
||||
.background(AppTheme.colors.errorContainer)
|
||||
.padding(start = 12.dp, top = 4.dp, bottom = 4.dp, end = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
MillenniumIcon(
|
||||
imageVector = Iconsax.IconsaxInformation,
|
||||
contentDescription = null,
|
||||
tint = AppTheme.colors.onErrorContainer,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
Text(
|
||||
text = message,
|
||||
style = AppTheme.typography.bodyMedium,
|
||||
color = AppTheme.colors.onErrorContainer,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
MillenniumIconButton(
|
||||
onClick = onDismiss,
|
||||
style = MillenniumIconButtonStyle.Quiet
|
||||
) {
|
||||
MillenniumIcon(
|
||||
imageVector = Iconsax.IconsaxCloseSquare,
|
||||
contentDescription = stringResource(Res.string.jam_error_dismiss),
|
||||
tint = AppTheme.colors.onErrorContainer
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LazyItemScope.EmptyState(
|
||||
isDiscovering: Boolean,
|
||||
onSearchAgain: () -> Unit
|
||||
) {
|
||||
// Fixed padding rather than filling the viewport, so the "This device" section below stays visible.
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillParentMaxWidth()
|
||||
.padding(horizontal = 24.dp, vertical = 48.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.widthIn(max = 360.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(72.dp)
|
||||
.clip(CircleShape)
|
||||
.background(AppTheme.colors.primary.copy(alpha = 0.12f)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (isDiscovering) {
|
||||
MillenniumSpokeSpinner(size = 32.dp)
|
||||
} else {
|
||||
MillenniumIcon(
|
||||
imageVector = Iconsax.IconsaxWifiSquare,
|
||||
contentDescription = null,
|
||||
tint = AppTheme.colors.primary,
|
||||
modifier = Modifier.size(32.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = stringResource(
|
||||
if (isDiscovering) Res.string.devices_searching else Res.string.devices_empty
|
||||
),
|
||||
style = AppTheme.typography.titleLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(top = 8.dp)
|
||||
)
|
||||
Text(
|
||||
text = stringResource(Res.string.devices_empty_hint),
|
||||
style = AppTheme.typography.bodyMedium,
|
||||
color = AppTheme.colors.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
if (!isDiscovering) {
|
||||
MillenniumButton(
|
||||
onClick = onSearchAgain,
|
||||
style = MillenniumButtonStyle.Secondary,
|
||||
modifier = Modifier.padding(top = 8.dp)
|
||||
) {
|
||||
Text(stringResource(Res.string.devices_search_again))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -29,10 +29,13 @@ import org.spotube.spotube.core.remote.ConnectionState
|
||||
import org.spotube.spotube.core.remote.RemoteControlClient
|
||||
import org.spotube.spotube.core.remote.RemoteControlService
|
||||
import org.spotube.spotube.modules.settings.SettingsProvider
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.core.component.KoinComponent
|
||||
@ -52,6 +55,28 @@ data class DevicesError(
|
||||
val args: List<String> = emptyList(),
|
||||
val rawMessage: String? = null)
|
||||
|
||||
data class DeviceItemUi(
|
||||
val key: String,
|
||||
/** Null when the device advertised a blank name. */
|
||||
val name: String?,
|
||||
/** `host:port`, or null while the address is still being resolved. */
|
||||
val address: String?,
|
||||
val isConnecting: Boolean,
|
||||
val isConnected: Boolean)
|
||||
|
||||
data class ConnectedDeviceUi(
|
||||
val name: String?,
|
||||
val address: String)
|
||||
|
||||
data class DevicesUiState(
|
||||
val devices: List<DeviceItemUi> = emptyList(),
|
||||
val isDiscovering: Boolean = false,
|
||||
val isConnecting: Boolean = false,
|
||||
val connectedDevice: ConnectedDeviceUi? = null,
|
||||
val error: DevicesError? = null) {
|
||||
val showEmptyState: Boolean get() = devices.isEmpty() && !isConnecting
|
||||
}
|
||||
|
||||
class DevicesViewModel(
|
||||
private val navigationCommands: NavigationCommands) : ViewModel(), KoinComponent {
|
||||
private val logger = Logger.withTag("DevicesViewModel")
|
||||
@ -61,36 +86,63 @@ class DevicesViewModel(
|
||||
private val settingsProvider: SettingsProvider by inject()
|
||||
|
||||
private val _devices = MutableStateFlow<Map<String, DiscoveredDevice>>(emptyMap())
|
||||
val devices: StateFlow<Map<String, DiscoveredDevice>> = _devices.asStateFlow()
|
||||
|
||||
private val _isDiscovering = MutableStateFlow(false)
|
||||
val isDiscovering: StateFlow<Boolean> = _isDiscovering.asStateFlow()
|
||||
|
||||
private val _connectingToDevice = MutableStateFlow<DiscoveredDevice?>(null)
|
||||
val connectingToDevice: StateFlow<DiscoveredDevice?> = _connectingToDevice.asStateFlow()
|
||||
|
||||
private val _connectionState = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
|
||||
val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow()
|
||||
|
||||
private val _error = MutableStateFlow<DevicesError?>(null)
|
||||
val error: StateFlow<DevicesError?> = _error.asStateFlow()
|
||||
|
||||
val uiState: StateFlow<DevicesUiState> = combine(
|
||||
_devices,
|
||||
_isDiscovering,
|
||||
_connectingToDevice,
|
||||
remoteControlClient.connectionState,
|
||||
_error
|
||||
) { devices, isDiscovering, connectingTo, connection, error ->
|
||||
val connected = connection as? ConnectionState.Connected
|
||||
DevicesUiState(
|
||||
devices = devices.values.map { device ->
|
||||
DeviceItemUi(
|
||||
key = device.key,
|
||||
name = device.name.ifBlank { null },
|
||||
address = device.address(),
|
||||
isConnecting = connectingTo?.key == device.key,
|
||||
isConnected = connected != null && device.matches(connected))
|
||||
},
|
||||
isDiscovering = isDiscovering,
|
||||
isConnecting = connectingTo != null,
|
||||
connectedDevice = connected?.let {
|
||||
ConnectedDeviceUi(
|
||||
name = it.deviceName.ifBlank { null },
|
||||
address = "${it.host}:${it.port}")
|
||||
},
|
||||
error = error)
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), DevicesUiState())
|
||||
|
||||
private var discoveryJob: Job? = null
|
||||
|
||||
init {
|
||||
// Observe connection state from the client
|
||||
viewModelScope.launch {
|
||||
var previous: ConnectionState = remoteControlClient.connectionState.value
|
||||
remoteControlClient.connectionState.collect { state ->
|
||||
_connectionState.value = state
|
||||
if (state is ConnectionState.Error) {
|
||||
_error.value = DevicesError(message = state.fallback, rawMessage = state.message)
|
||||
_connectingToDevice.value = null
|
||||
} else if (state is ConnectionState.Disconnected) {
|
||||
_connectingToDevice.value = null
|
||||
} else if (state is ConnectionState.Connected) {
|
||||
// Navigate to remote control screen after successful connection
|
||||
_connectingToDevice.value = null
|
||||
navigationCommands.navigateTo(Routes.RemoteControl)
|
||||
val wasConnecting = previous is ConnectionState.Connecting
|
||||
previous = state
|
||||
when (state) {
|
||||
is ConnectionState.Error -> {
|
||||
_error.value = DevicesError(message = state.fallback, rawMessage = state.message)
|
||||
_connectingToDevice.value = null
|
||||
}
|
||||
// Only a drop *during* an attempt means it failed. Switching devices
|
||||
// disconnects the old one first, and that must not cancel the new attempt.
|
||||
is ConnectionState.Disconnected -> if (wasConnecting) _connectingToDevice.value = null
|
||||
is ConnectionState.Connected -> {
|
||||
// Only navigate for a connection this screen started. A connection
|
||||
// that already existed when the screen opened must not bounce the
|
||||
// user straight to the remote screen.
|
||||
if (_connectingToDevice.value != null) {
|
||||
_connectingToDevice.value = null
|
||||
navigationCommands.navigateTo(Routes.RemoteControl)
|
||||
}
|
||||
}
|
||||
is ConnectionState.Connecting -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -119,7 +171,8 @@ class DevicesViewModel(
|
||||
logger.d { "Discovery event: $event" }
|
||||
when (event) {
|
||||
is DiscoveryState.Discovered -> {
|
||||
event.resolve()
|
||||
// Don't hold up the rest of the events while this device resolves.
|
||||
launch { event.resolve() }
|
||||
if (!isSelf(event.device)) {
|
||||
_devices.update { it + (event.device.key to event.device.copy()) }
|
||||
}
|
||||
@ -137,6 +190,9 @@ class DevicesViewModel(
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
// stopDiscovery() (e.g. leaving the screen) is not a failure.
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
logger.e(e) { "Discovery failed" }
|
||||
_error.value = DevicesError(Res.string.devices_error_discovery_failed, listOf(e.message.toString()))
|
||||
@ -175,7 +231,25 @@ class DevicesViewModel(
|
||||
_isDiscovering.value = false
|
||||
}
|
||||
|
||||
fun connectToDevice(device: DiscoveredDevice) {
|
||||
fun onDeviceClick(key: String) {
|
||||
val device = _devices.value[key] ?: return
|
||||
val connected = remoteControlClient.connectionState.value as? ConnectionState.Connected
|
||||
when {
|
||||
// Already controlling it (e.g. came back from the remote screen): just reopen it.
|
||||
connected != null && device.matches(connected) -> openRemote()
|
||||
connected != null -> viewModelScope.launch {
|
||||
remoteControlClient.disconnect()
|
||||
connectToDevice(device)
|
||||
}
|
||||
else -> connectToDevice(device)
|
||||
}
|
||||
}
|
||||
|
||||
fun openRemote() {
|
||||
navigationCommands.navigateTo(Routes.RemoteControl)
|
||||
}
|
||||
|
||||
private fun connectToDevice(device: DiscoveredDevice) {
|
||||
if (_connectingToDevice.value != null) {
|
||||
logger.w { "Already connecting to a device" }
|
||||
return
|
||||
@ -200,11 +274,11 @@ class DevicesViewModel(
|
||||
host = device.host,
|
||||
port = device.port,
|
||||
deviceId = deviceId,
|
||||
deviceName = deviceName)
|
||||
|
||||
// Clear connecting state after connection attempt
|
||||
// The connectionState flow will show the actual connection status
|
||||
_connectingToDevice.value = null
|
||||
deviceName = deviceName,
|
||||
remoteDeviceName = device.name)
|
||||
// connect() returns once the socket is open, but the host may still be
|
||||
// asking its user for permission. The connection-state collector clears
|
||||
// the connecting flag once the outcome is known.
|
||||
} catch (e: Exception) {
|
||||
logger.e(e) { "Failed to connect to device" }
|
||||
_error.value = DevicesError(Res.string.devices_error_connect_failed, listOf(e.message.toString()))
|
||||
@ -223,3 +297,9 @@ class DevicesViewModel(
|
||||
_error.value = null
|
||||
}
|
||||
}
|
||||
|
||||
private fun DiscoveredDevice.address(): String? =
|
||||
if (host.isNotBlank() && port > 0) "$host:$port" else null
|
||||
|
||||
private fun DiscoveredDevice.matches(connected: ConnectionState.Connected): Boolean =
|
||||
host == connected.host && port == connected.port
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -19,164 +19,244 @@ package org.spotube.spotube.modules.devices
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import co.touchlab.kermit.Logger
|
||||
import org.spotube.spotube.core.remote.ConnectionState
|
||||
import org.spotube.spotube.core.remote.RemoteControlClient
|
||||
import org.spotube.spotube.core.remote.RemoteControlCommand
|
||||
import org.spotube.spotube.core.remote.RemoteControlEvent
|
||||
import org.spotube.spotube.core.remote.RemoteQueueEntry
|
||||
import org.spotube.spotube.modules.shell.formatPlayerTime
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
data class RemotePlayerState(
|
||||
enum class RemoteLoopMode(val wireValue: String) {
|
||||
NONE("none"),
|
||||
ONE("one"),
|
||||
ALL("all");
|
||||
|
||||
fun next(): RemoteLoopMode = when (this) {
|
||||
NONE -> ONE
|
||||
ONE -> ALL
|
||||
ALL -> NONE
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun fromWire(value: String): RemoteLoopMode = entries.firstOrNull { it.wireValue == value } ?: NONE
|
||||
}
|
||||
}
|
||||
|
||||
enum class RemoteVolumeLevel { MUTED, LOW, HIGH }
|
||||
|
||||
data class RemoteQueueItemUi(
|
||||
val key: String,
|
||||
val index: Int,
|
||||
val mediaUrl: String,
|
||||
val title: String,
|
||||
val artists: String,
|
||||
val coverUrl: String?,
|
||||
val durationLabel: String,
|
||||
val isCurrent: Boolean)
|
||||
|
||||
data class RemoteControlUiState(
|
||||
val connection: ConnectionState = ConnectionState.Disconnected,
|
||||
val deviceName: String = "",
|
||||
val hasTrack: Boolean = false,
|
||||
val title: String? = null,
|
||||
val artists: String? = null,
|
||||
val album: String? = null,
|
||||
val coverUrl: String? = null,
|
||||
val isPlaying: Boolean = false,
|
||||
val positionMs: Long = 0,
|
||||
val durationMs: Long = 0,
|
||||
val volume: Float = 1.0f,
|
||||
val shuffleEnabled: Boolean = false,
|
||||
val loopMode: String = "none",
|
||||
val currentTrackId: String? = null,
|
||||
val currentTrackTitle: String? = null,
|
||||
val currentTrackArtists: String? = null,
|
||||
val currentTrackAlbum: String? = null,
|
||||
val currentTrackCoverUrl: String? = null)
|
||||
val isShuffling: Boolean = false,
|
||||
val loopMode: RemoteLoopMode = RemoteLoopMode.NONE,
|
||||
val canSeek: Boolean = false,
|
||||
val seekProgress: Float = 0f,
|
||||
val positionLabel: String = formatPlayerTime(0.milliseconds),
|
||||
val durationLabel: String = formatPlayerTime(0.milliseconds),
|
||||
val volume: Float = 1f,
|
||||
val volumeLevel: RemoteVolumeLevel = RemoteVolumeLevel.HIGH,
|
||||
val queue: List<RemoteQueueItemUi> = emptyList(),
|
||||
val currentQueueIndex: Int = -1,
|
||||
val isQueueVisible: Boolean = false)
|
||||
|
||||
data class RemoteQueueState(
|
||||
val entries: List<RemoteQueueEntry> = emptyList(),
|
||||
val currentIndex: Int = -1)
|
||||
private data class LocalOverrides(
|
||||
val seekProgress: Float?,
|
||||
val volume: Float?,
|
||||
val isQueueVisible: Boolean)
|
||||
|
||||
class RemoteControlViewModel : ViewModel(), KoinComponent {
|
||||
private val logger = Logger.withTag("RemoteControlViewModel")
|
||||
private val remoteControlClient: RemoteControlClient by inject()
|
||||
|
||||
private val _playerState = MutableStateFlow(RemotePlayerState())
|
||||
val playerState: StateFlow<RemotePlayerState> = _playerState.asStateFlow()
|
||||
// While the user drags a slider these hold the local value, so the host's
|
||||
// periodic state pushes don't yank the thumb back mid-gesture.
|
||||
private val seekOverride = MutableStateFlow<Float?>(null)
|
||||
private val volumeOverride = MutableStateFlow<Float?>(null)
|
||||
private val isQueueVisible = MutableStateFlow(false)
|
||||
|
||||
private val _connectionState = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
|
||||
val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow()
|
||||
private val overrides = combine(seekOverride, volumeOverride, isQueueVisible) { seek, volume, queueVisible ->
|
||||
LocalOverrides(seek, volume, queueVisible)
|
||||
}
|
||||
|
||||
private val _queueState = MutableStateFlow(RemoteQueueState())
|
||||
val queueState: StateFlow<RemoteQueueState> = _queueState.asStateFlow()
|
||||
|
||||
private val _isQueueVisible = MutableStateFlow(false)
|
||||
val isQueueVisible: StateFlow<Boolean> = _isQueueVisible.asStateFlow()
|
||||
val uiState: StateFlow<RemoteControlUiState> = combine(
|
||||
remoteControlClient.connectionState,
|
||||
remoteControlClient.latestPlayerState,
|
||||
remoteControlClient.latestQueue,
|
||||
overrides
|
||||
) { connection, player, queue, local ->
|
||||
buildUiState(connection, player, queue, local)
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), RemoteControlUiState())
|
||||
|
||||
init {
|
||||
// Stream volume changes while dragging, throttled so a fast drag doesn't
|
||||
// flood the socket with one command per frame.
|
||||
viewModelScope.launch {
|
||||
remoteControlClient.connectionState.collect { state ->
|
||||
_connectionState.value = state
|
||||
}
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
remoteControlClient.latestPlayerState.collect { event ->
|
||||
if (event != null) {
|
||||
handlePlayerState(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
remoteControlClient.latestQueue.collect { event ->
|
||||
if (event != null) {
|
||||
handleQueueUpdated(event)
|
||||
}
|
||||
volumeOverride.filterNotNull().collect { volume ->
|
||||
remoteControlClient.sendCommand(RemoteControlCommand.SetVolume(volume))
|
||||
delay(VOLUME_THROTTLE_MS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handlePlayerState(event: RemoteControlEvent.PlayerState) {
|
||||
_playerState.update {
|
||||
it.copy(
|
||||
isPlaying = event.isPlaying,
|
||||
positionMs = event.positionMs,
|
||||
durationMs = event.durationMs,
|
||||
volume = event.volume,
|
||||
shuffleEnabled = event.shuffleEnabled,
|
||||
loopMode = event.loopMode,
|
||||
currentTrackId = event.currentTrackId,
|
||||
currentTrackTitle = event.currentTrackTitle,
|
||||
currentTrackArtists = event.currentTrackArtists,
|
||||
currentTrackAlbum = event.currentTrackAlbum,
|
||||
currentTrackCoverUrl = event.currentTrackCoverUrl)
|
||||
private fun buildUiState(
|
||||
connection: ConnectionState,
|
||||
player: RemoteControlEvent.PlayerState?,
|
||||
queue: RemoteControlEvent.QueueUpdated?,
|
||||
local: LocalOverrides
|
||||
): RemoteControlUiState {
|
||||
val durationMs = player?.durationMs ?: 0L
|
||||
val canSeek = durationMs > 0L
|
||||
val liveProgress = if (canSeek) {
|
||||
((player?.positionMs ?: 0L).toFloat() / durationMs).coerceIn(0f, 1f)
|
||||
} else {
|
||||
0f
|
||||
}
|
||||
val seekProgress = local.seekProgress ?: liveProgress
|
||||
val currentIndex = queue?.currentIndex ?: -1
|
||||
val volume = local.volume ?: player?.volume ?: 1f
|
||||
|
||||
return RemoteControlUiState(
|
||||
connection = connection,
|
||||
deviceName = (connection as? ConnectionState.Connected)?.deviceName.orEmpty(),
|
||||
hasTrack = player?.currentTrackId != null || player?.currentTrackTitle != null,
|
||||
title = player?.currentTrackTitle,
|
||||
artists = player?.currentTrackArtists,
|
||||
album = player?.currentTrackAlbum,
|
||||
coverUrl = player?.currentTrackCoverUrl,
|
||||
isPlaying = player?.isPlaying ?: false,
|
||||
isShuffling = player?.shuffleEnabled ?: false,
|
||||
loopMode = RemoteLoopMode.fromWire(player?.loopMode ?: RemoteLoopMode.NONE.wireValue),
|
||||
canSeek = canSeek,
|
||||
seekProgress = seekProgress,
|
||||
positionLabel = formatPlayerTime((durationMs * seekProgress).toLong().milliseconds),
|
||||
durationLabel = formatPlayerTime(durationMs.milliseconds),
|
||||
volume = volume,
|
||||
volumeLevel = when {
|
||||
volume <= 0f -> RemoteVolumeLevel.MUTED
|
||||
volume < 0.5f -> RemoteVolumeLevel.LOW
|
||||
else -> RemoteVolumeLevel.HIGH
|
||||
},
|
||||
queue = queue?.entries.orEmpty().mapIndexed { index, entry ->
|
||||
RemoteQueueItemUi(
|
||||
key = "${entry.mediaUrl}@$index",
|
||||
index = index,
|
||||
mediaUrl = entry.mediaUrl,
|
||||
title = entry.title,
|
||||
artists = entry.artists,
|
||||
coverUrl = entry.coverUrl,
|
||||
durationLabel = formatPlayerTime(entry.durationMs.milliseconds),
|
||||
isCurrent = index == currentIndex)
|
||||
},
|
||||
currentQueueIndex = currentIndex,
|
||||
isQueueVisible = local.isQueueVisible)
|
||||
}
|
||||
|
||||
private fun handleQueueUpdated(event: RemoteControlEvent.QueueUpdated) {
|
||||
_queueState.value = RemoteQueueState(
|
||||
entries = event.entries,
|
||||
currentIndex = event.currentIndex)
|
||||
}
|
||||
|
||||
fun togglePlayPause() {
|
||||
private fun send(command: RemoteControlCommand) {
|
||||
viewModelScope.launch {
|
||||
remoteControlClient.sendCommand(RemoteControlCommand.TogglePlayPause)
|
||||
remoteControlClient.sendCommand(command)
|
||||
}
|
||||
}
|
||||
|
||||
fun skipNext() {
|
||||
/**
|
||||
* Keeps [override] at [value] until the host reports back (or a timeout), so
|
||||
* the slider doesn't flash the stale value in between. Skips the reset if the
|
||||
* user already started a new drag.
|
||||
*/
|
||||
private suspend fun releaseOverrideAfterEcho(override: MutableStateFlow<Float?>, value: Float) {
|
||||
withTimeoutOrNull(OVERRIDE_RELEASE_TIMEOUT_MS) {
|
||||
remoteControlClient.latestPlayerState.drop(1).first()
|
||||
}
|
||||
override.compareAndSet(value, null)
|
||||
}
|
||||
|
||||
fun togglePlayPause() = send(RemoteControlCommand.TogglePlayPause)
|
||||
|
||||
fun skipNext() = send(RemoteControlCommand.SkipNext)
|
||||
|
||||
fun skipPrevious() = send(RemoteControlCommand.SkipPrevious)
|
||||
|
||||
fun onSeekChange(progress: Float) {
|
||||
seekOverride.value = progress.coerceIn(0f, 1f)
|
||||
}
|
||||
|
||||
fun onSeekFinished() {
|
||||
val progress = seekOverride.value ?: return
|
||||
val durationMs = remoteControlClient.latestPlayerState.value?.durationMs ?: 0L
|
||||
if (durationMs <= 0L) {
|
||||
seekOverride.value = null
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
remoteControlClient.sendCommand(RemoteControlCommand.SkipNext)
|
||||
remoteControlClient.sendCommand(RemoteControlCommand.Seek((durationMs * progress).toLong()))
|
||||
releaseOverrideAfterEcho(seekOverride, progress)
|
||||
}
|
||||
}
|
||||
|
||||
fun skipPrevious() {
|
||||
viewModelScope.launch {
|
||||
remoteControlClient.sendCommand(RemoteControlCommand.SkipPrevious)
|
||||
}
|
||||
fun onVolumeChange(volume: Float) {
|
||||
volumeOverride.value = volume.coerceIn(0f, 1f)
|
||||
}
|
||||
|
||||
fun seek(positionMs: Long) {
|
||||
viewModelScope.launch {
|
||||
remoteControlClient.sendCommand(RemoteControlCommand.Seek(positionMs))
|
||||
}
|
||||
}
|
||||
|
||||
fun setVolume(volume: Float) {
|
||||
fun onVolumeChangeFinished() {
|
||||
val volume = volumeOverride.value ?: return
|
||||
viewModelScope.launch {
|
||||
remoteControlClient.sendCommand(RemoteControlCommand.SetVolume(volume))
|
||||
releaseOverrideAfterEcho(volumeOverride, volume)
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleShuffle() {
|
||||
viewModelScope.launch {
|
||||
val newState = !_playerState.value.shuffleEnabled
|
||||
remoteControlClient.sendCommand(RemoteControlCommand.SetShuffle(newState))
|
||||
}
|
||||
val shuffling = remoteControlClient.latestPlayerState.value?.shuffleEnabled ?: false
|
||||
send(RemoteControlCommand.SetShuffle(!shuffling))
|
||||
}
|
||||
|
||||
fun cycleLoopMode() {
|
||||
viewModelScope.launch {
|
||||
val currentMode = _playerState.value.loopMode
|
||||
val newMode = when (currentMode) {
|
||||
"none" -> "one"
|
||||
"one" -> "all"
|
||||
else -> "none"
|
||||
}
|
||||
remoteControlClient.sendCommand(RemoteControlCommand.SetLoopMode(newMode))
|
||||
}
|
||||
val current = RemoteLoopMode.fromWire(
|
||||
remoteControlClient.latestPlayerState.value?.loopMode ?: RemoteLoopMode.NONE.wireValue)
|
||||
send(RemoteControlCommand.SetLoopMode(current.next().wireValue))
|
||||
}
|
||||
|
||||
fun playQueueItem(index: Int) {
|
||||
if (index < 0) return
|
||||
viewModelScope.launch {
|
||||
remoteControlClient.sendCommand(RemoteControlCommand.PlayIndex(index))
|
||||
}
|
||||
send(RemoteControlCommand.PlayIndex(index))
|
||||
}
|
||||
|
||||
fun removeQueueItem(mediaUrl: String) {
|
||||
viewModelScope.launch {
|
||||
remoteControlClient.sendCommand(RemoteControlCommand.RemoveFromQueue(mediaUrl))
|
||||
}
|
||||
}
|
||||
fun removeQueueItem(mediaUrl: String) = send(RemoteControlCommand.RemoveFromQueue(mediaUrl))
|
||||
|
||||
fun toggleQueueVisibility() {
|
||||
_isQueueVisible.update { !it }
|
||||
isQueueVisible.update { !it }
|
||||
}
|
||||
|
||||
fun dismissQueue() {
|
||||
isQueueVisible.value = false
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
@ -184,4 +264,9 @@ class RemoteControlViewModel : ViewModel(), KoinComponent {
|
||||
remoteControlClient.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val VOLUME_THROTTLE_MS = 100L
|
||||
const val OVERRIDE_RELEASE_TIMEOUT_MS = 1_500L
|
||||
}
|
||||
}
|
||||
|
||||
@ -21,7 +21,10 @@ import co.touchlab.kermit.Logger
|
||||
import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
||||
import org.spotube.spotube.core.paths.Paths
|
||||
import org.spotube.spotube.core.server.StreamInfo
|
||||
import org.spotube.spotube.core.server.StreamRequest
|
||||
import org.spotube.spotube.core.server.StreamingUrlRepository
|
||||
import org.spotube.spotube.core.server.urlSummary
|
||||
import org.spotube.plugin_interfaces.plugin_apis.audio.StreamProtocol
|
||||
import org.spotube.spotube.modules.settings.SettingsRepository
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.plugins.HttpTimeout
|
||||
@ -30,7 +33,9 @@ import io.ktor.client.request.head
|
||||
import io.ktor.client.request.headers
|
||||
import io.ktor.client.statement.bodyAsChannel
|
||||
import io.ktor.http.HttpHeaders
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.http.contentLength
|
||||
import io.ktor.http.isSuccess
|
||||
import io.ktor.utils.io.readRemaining
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@ -112,6 +117,11 @@ class DownloadManager(
|
||||
|
||||
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>()
|
||||
|
||||
init {
|
||||
@ -135,6 +145,13 @@ class DownloadManager(
|
||||
track = track)
|
||||
|
||||
downloadsMap.update { it + (id to item) }
|
||||
val settings = settingsRepository.userSettings.value
|
||||
logger.i {
|
||||
"[Download $id] enqueued '${track.title}' by $artistNames (track=${track.id}) " +
|
||||
"downloadPrefs=${settings.downloadMusicFormat.codec}/${settings.downloadMusicFormat.container} " +
|
||||
"${settings.downloadMusicQuality} streamingPrefs=${settings.streamingMusicFormat.codec}/" +
|
||||
"${settings.streamingMusicFormat.container} ${settings.streamingMusicQuality} folder=${downloadFolder.value}"
|
||||
}
|
||||
scope.launch { processQueue() }
|
||||
}
|
||||
|
||||
@ -210,7 +227,15 @@ class DownloadManager(
|
||||
}
|
||||
|
||||
try {
|
||||
val streamInfo = streamingUrlRepository.resolveStreamInfo(track)
|
||||
logger.i { "[Download ${item.id}] resolving stream for '${track.title}'" }
|
||||
val settings = settingsRepository.userSettings.value
|
||||
val streamInfo = streamingUrlRepository.resolveStreamInfo(
|
||||
track,
|
||||
request = StreamRequest(
|
||||
format = settings.downloadMusicFormat,
|
||||
quality = settings.downloadMusicQuality,
|
||||
// A manifest URL would be saved as-is instead of the audio it points to.
|
||||
progressiveOnly = true))
|
||||
if (streamInfo == null) {
|
||||
logger.w { "Could not resolve stream URL for ${track.title}" }
|
||||
val unresolvedStatus = getString(Res.string.downloads_error_stream_unresolved_short)
|
||||
@ -226,6 +251,7 @@ class DownloadManager(
|
||||
}
|
||||
|
||||
val url = streamInfo.url
|
||||
logResolvedStream(item, streamInfo)
|
||||
|
||||
val filename = buildFilename(item, streamInfo)
|
||||
|
||||
@ -235,15 +261,28 @@ class DownloadManager(
|
||||
val tempDir = FileSystem.SYSTEM_TEMPORARY_DIRECTORY / "spotube_downloads".toPath()
|
||||
fileSystem.createDirectories(tempDir)
|
||||
|
||||
val (totalSize, supportsRanges) = probeUrl(url)
|
||||
val (totalSize, supportsRanges) = probeUrl(item.id, url)
|
||||
|
||||
if (totalSize > 0 && supportsRanges) {
|
||||
logger.i { "[Download ${item.id}] multi-segment download ($SEGMENT_COUNT segments, $totalSize bytes) -> ${downloadDir / filename}" }
|
||||
executeMultiSegmentDownload(url, filename, item, tempDir, downloadDir, totalSize)
|
||||
} else {
|
||||
logger.i { "[Download ${item.id}] single-connection download (size=$totalSize ranges=$supportsRanges) -> ${downloadDir / filename}" }
|
||||
executeSingleConnectionDownload(url, filename, item, tempDir, downloadDir)
|
||||
}
|
||||
|
||||
val finalPath = downloadDir / filename.toPath()
|
||||
val writtenBytes = fileSystem.metadataOrNull(finalPath)?.size
|
||||
logger.i { "[Download ${item.id}] wrote $finalPath ($writtenBytes bytes, expected $totalSize)" }
|
||||
if (totalSize > 0 && writtenBytes != null && writtenBytes != totalSize) {
|
||||
logger.w { "[Download ${item.id}] SIZE MISMATCH: wrote $writtenBytes bytes, server reported $totalSize" }
|
||||
}
|
||||
|
||||
applyMetadata(item, filename, downloadDir)
|
||||
// Without this Android music players and the Files app's Audio view don't see the
|
||||
// file until the next full media scan.
|
||||
addToMediaLibrary((downloadDir / filename.toPath()).toString())
|
||||
logger.i { "[Download ${item.id}] completed '${item.title}'" }
|
||||
|
||||
downloadsMap.update { map ->
|
||||
map[item.id]?.let {
|
||||
@ -251,6 +290,7 @@ class DownloadManager(
|
||||
} ?: map
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
logger.i { "[Download ${item.id}] cancelled" }
|
||||
downloadsMap.update { map ->
|
||||
map[item.id]?.let {
|
||||
map + (item.id to it.copy(status = DownloadStatus.Cancelled))
|
||||
@ -258,7 +298,7 @@ class DownloadManager(
|
||||
}
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
logger.e(e) { "Download failed: ${item.title}" }
|
||||
logger.e(e) { "[Download ${item.id}] FAILED '${item.title}': ${e.message}" }
|
||||
val failureMessage = e.message ?: getString(Res.string.common_unknown_error)
|
||||
downloadsMap.update { map ->
|
||||
map[item.id]?.let {
|
||||
@ -322,6 +362,21 @@ class DownloadManager(
|
||||
append(HttpHeaders.Range, "bytes=${segment.start}-${segment.end}")
|
||||
}
|
||||
}
|
||||
logger.d {
|
||||
"[Download ${item.id}] segment ${segment.index} bytes=${segment.start}-${segment.end} -> " +
|
||||
"HTTP ${response.status.value} type=${response.headers[HttpHeaders.ContentType]} " +
|
||||
"range=${response.headers[HttpHeaders.ContentRange]}"
|
||||
}
|
||||
// expectSuccess = false, so without these checks an error page, or the whole file once per
|
||||
// segment (a 200 to a range request), would be written into the "audio" file.
|
||||
if (!response.status.isSuccess()) {
|
||||
logger.w { "[Download ${item.id}] segment ${segment.index} got HTTP ${response.status}; failing download" }
|
||||
throw DownloadException(getString(Res.string.downloads_error_http, response.status.value))
|
||||
}
|
||||
if (response.status != HttpStatusCode.PartialContent) {
|
||||
logger.w { "[Download ${item.id}] segment ${segment.index} got ${response.status} instead of 206; range ignored" }
|
||||
throw DownloadException(getString(Res.string.downloads_error_range_ignored))
|
||||
}
|
||||
val channel = response.bodyAsChannel()
|
||||
|
||||
fileSystem.sink(outputPath).buffer().use { sink ->
|
||||
@ -344,6 +399,15 @@ class DownloadManager(
|
||||
|
||||
val response = httpClient.get(url)
|
||||
val totalSize = response.contentLength() ?: 0L
|
||||
logger.i {
|
||||
"[Download ${item.id}] GET -> HTTP ${response.status.value} " +
|
||||
"type=${response.headers[HttpHeaders.ContentType]} length=$totalSize"
|
||||
}
|
||||
if (!response.status.isSuccess()) {
|
||||
logger.w { "[Download ${item.id}] GET got HTTP ${response.status}; failing download" }
|
||||
throw DownloadException(getString(Res.string.downloads_error_http, response.status.value))
|
||||
}
|
||||
rejectVideo(item.id, response.headers[HttpHeaders.ContentType])
|
||||
val channel = response.bodyAsChannel()
|
||||
|
||||
fileSystem.sink(tempFile).buffer().use { sink ->
|
||||
@ -375,22 +439,65 @@ class DownloadManager(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun probeUrl(url: String): Pair<Long, Boolean> {
|
||||
private suspend fun probeUrl(id: String, url: String): Pair<Long, Boolean> {
|
||||
return try {
|
||||
val response = httpClient.head(url)
|
||||
val contentLength = response.contentLength() ?: 0L
|
||||
val acceptRanges = response.headers[HttpHeaders.AcceptRanges]
|
||||
val supportsRanges = acceptRanges?.equals("bytes", ignoreCase = true) == true
|
||||
val contentType = response.headers[HttpHeaders.ContentType]
|
||||
logger.i {
|
||||
"[Download $id] HEAD -> HTTP ${response.status.value} type=$contentType " +
|
||||
"length=$contentLength acceptRanges=$acceptRanges"
|
||||
}
|
||||
rejectVideo(id, contentType)
|
||||
Pair(contentLength, supportsRanges)
|
||||
} catch (e: DownloadException) {
|
||||
throw e
|
||||
} 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)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Some sources only offer muxed video (e.g. NewPipe falls back to video streams when a video
|
||||
* has no audio-only ones). Saving that as a "song" is worse than failing with a clear reason.
|
||||
*/
|
||||
private suspend fun rejectVideo(id: String, contentType: String?) {
|
||||
if (contentType?.startsWith("video/", ignoreCase = true) == true) {
|
||||
logger.w { "[Download $id] server says this is VIDEO ($contentType), not audio; failing download" }
|
||||
throw DownloadException(getString(Res.string.downloads_error_video_only))
|
||||
}
|
||||
}
|
||||
|
||||
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}] FORMAT FALLBACK: download setting is ${wanted.codec}/${wanted.container} " +
|
||||
"${settings.downloadMusicQuality}, but the closest downloadable stream offered is ${info.codec}/${info.container}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun applyMetadata(item: DownloadItem, filename: String, downloadDir: Path) {
|
||||
val filePath = downloadDir / filename.toPath()
|
||||
if (!fileSystem.exists(filePath)) return
|
||||
if (!fileSystem.exists(filePath)) {
|
||||
logger.w { "[Download ${item.id}] skipping metadata: $filePath does not exist" }
|
||||
return
|
||||
}
|
||||
logger.d { "[Download ${item.id}] writing metadata to $filePath" }
|
||||
|
||||
val track = item.track ?: return
|
||||
|
||||
@ -421,18 +528,11 @@ class DownloadManager(
|
||||
discNumber = track.discNumber,
|
||||
coverBytes = coverBytes)
|
||||
} catch (e: Exception) {
|
||||
logger.w(e) { "Failed to write audio metadata for ${item.title}" }
|
||||
logger.w(e) { "[Download ${item.id}] failed to write audio metadata for ${item.title}" }
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveDownloadDir(): Path {
|
||||
val folder = cachedDownloadFolder.value
|
||||
return if (!folder.isNullOrBlank()) {
|
||||
folder.toPath()
|
||||
} else {
|
||||
paths.getUserDownloadsDirPath().toPath()
|
||||
}
|
||||
}
|
||||
private fun resolveDownloadDir(): Path = downloadFolder.value.toPath()
|
||||
|
||||
private fun cleanupTempFiles(id: String) {
|
||||
try {
|
||||
@ -452,13 +552,21 @@ class DownloadManager(
|
||||
private fun buildFilename(downloadItem: DownloadItem, streamInfo: StreamInfo): String {
|
||||
val safeTitle = downloadItem.title.sanitizeFilename()
|
||||
val safeArtist = downloadItem.artists.sanitizeFilename()
|
||||
val extension = streamInfo.container
|
||||
val extension = fileExtension(streamInfo)
|
||||
return "$safeArtist - $safeTitle.$extension"
|
||||
}
|
||||
|
||||
/** AAC in an MP4 container is audio-only, so give it the audio extension players expect. */
|
||||
private fun fileExtension(info: StreamInfo): String =
|
||||
if (info.codec.equals("aac", ignoreCase = true) && info.container.equals("mp4", ignoreCase = true)) "m4a"
|
||||
else info.container
|
||||
|
||||
private fun String.sanitizeFilename(): String {
|
||||
return this.replace(Regex("[^a-zA-Z0-9.\\-_' ]"), "_").take(200)
|
||||
}
|
||||
|
||||
private data class Segment(val index: Int, val start: Long, val end: Long)
|
||||
}
|
||||
|
||||
/** A download failure whose [message] is already localized and safe to show the user. */
|
||||
private class DownloadException(message: String) : Exception(message)
|
||||
|
||||
@ -100,11 +100,18 @@ internal fun DownloadsToolbarTitle() {
|
||||
|
||||
@Composable
|
||||
internal fun DownloadsToolbarSummary(state: DownloadsUiState) {
|
||||
if (state.items.isNotEmpty()) Text(
|
||||
stringResource(Res.string.downloads_summary, state.activeCount, state.completedCount),
|
||||
modifier = Modifier.padding(start = 28.dp),
|
||||
style = AppTheme.typography.bodySmall, color = AppTheme.colors.onSurfaceVariant
|
||||
)
|
||||
Column(Modifier.padding(start = 28.dp)) {
|
||||
if (state.items.isNotEmpty()) Text(
|
||||
stringResource(Res.string.downloads_summary, state.activeCount, state.completedCount),
|
||||
style = AppTheme.typography.bodySmall, color = AppTheme.colors.onSurfaceVariant
|
||||
)
|
||||
// Always visible (empty list included), so users know where their files end up.
|
||||
if (state.folder.isNotBlank()) Text(
|
||||
stringResource(Res.string.downloads_saved_to, state.folder),
|
||||
style = AppTheme.typography.bodySmall, color = MillenniumTheme.colors.muted,
|
||||
maxLines = 2, overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@ -23,7 +23,7 @@ import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.combine
|
||||
|
||||
enum class DownloadRowAction { Cancel, Retry, Remove }
|
||||
|
||||
@ -43,10 +43,12 @@ data class DownloadsUiState(
|
||||
val items: List<DownloadRowUiState> = emptyList(),
|
||||
val activeCount: Int = 0,
|
||||
val completedCount: Int = 0,
|
||||
val canClearCompleted: Boolean = false
|
||||
val canClearCompleted: Boolean = false,
|
||||
/** Folder new downloads are saved to, shown so users can find their files. */
|
||||
val folder: String = ""
|
||||
)
|
||||
|
||||
internal fun downloadsUiState(items: List<DownloadItem>): DownloadsUiState {
|
||||
internal fun downloadsUiState(items: List<DownloadItem>, folder: String = ""): DownloadsUiState {
|
||||
val completedCount = items.count { it.status == DownloadStatus.Completed }
|
||||
return DownloadsUiState(
|
||||
items = items.map { item ->
|
||||
@ -66,7 +68,8 @@ internal fun downloadsUiState(items: List<DownloadItem>): DownloadsUiState {
|
||||
},
|
||||
activeCount = items.count { it.status == DownloadStatus.Downloading || it.status == DownloadStatus.Queued },
|
||||
completedCount = completedCount,
|
||||
canClearCompleted = completedCount > 0
|
||||
canClearCompleted = completedCount > 0,
|
||||
folder = folder
|
||||
)
|
||||
}
|
||||
|
||||
@ -79,7 +82,7 @@ class DownloadsViewModel(
|
||||
emptyList()
|
||||
)
|
||||
|
||||
val uiState = downloadManager.downloadsFlow.map(::downloadsUiState)
|
||||
val uiState = combine(downloadManager.downloadsFlow, downloadManager.downloadFolder, ::downloadsUiState)
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), DownloadsUiState())
|
||||
|
||||
fun downloadTrack(track: MetadataTrack) {
|
||||
|
||||
@ -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.navigation.NavigationCommands
|
||||
import org.spotube.spotube.core.navigation.Routes
|
||||
import org.spotube.spotube.modules.shell.ConnectStatusBanner
|
||||
import org.spotube.spotube.resources.iconsax.IconsaxSoundTwotone
|
||||
import org.spotube.spotube.getPlatform
|
||||
import org.spotube.spotube.modules.shell.LocalAppShellBottomInset
|
||||
import org.spotube.spotube.modules.support.LocalSupportPrompt
|
||||
import org.spotube.spotube.resources.iconsax.Iconsax
|
||||
import org.spotube.spotube.resources.iconsax.IconsaxHeart2
|
||||
import org.spotube.spotube.resources.iconsax.IconsaxMirroringScreen
|
||||
import org.spotube.spotube.resources.iconsax.User
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.koin.compose.koinInject
|
||||
import spotube.composeapp.generated.resources.Res
|
||||
@ -93,6 +94,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import org.jetbrains.compose.resources.Font
|
||||
import org.spotube.millennium.adaptive.WindowBreakpoints
|
||||
import org.spotube.spotube.resources.iconsax.IconsaxMirrorScreenRegular
|
||||
import spotube.composeapp.generated.resources.cookie_regular
|
||||
|
||||
@Composable
|
||||
@ -161,7 +163,7 @@ fun HomeScreen(viewModel: HomeScreenViewModel) {
|
||||
style = MillenniumIconButtonStyle.Quiet
|
||||
) {
|
||||
MillenniumIcon(
|
||||
imageVector = Iconsax.IconsaxMirroringScreen,
|
||||
imageVector = Iconsax.IconsaxMirrorScreenRegular,
|
||||
contentDescription = stringResource(Res.string.devices_title)
|
||||
)
|
||||
}
|
||||
@ -170,7 +172,7 @@ fun HomeScreen(viewModel: HomeScreenViewModel) {
|
||||
style = MillenniumIconButtonStyle.Quiet
|
||||
) {
|
||||
MillenniumIcon(
|
||||
imageVector = Iconsax.User,
|
||||
imageVector = Iconsax.IconsaxSoundTwotone,
|
||||
contentDescription = stringResource(Res.string.jam_screen_title)
|
||||
)
|
||||
}
|
||||
@ -186,6 +188,12 @@ fun HomeScreen(viewModel: HomeScreenViewModel) {
|
||||
}
|
||||
}
|
||||
})
|
||||
if (isSmallScreen) {
|
||||
ConnectStatusBanner(
|
||||
onOpenRemote = { navigationCommands.navigateTo(Routes.RemoteControl) },
|
||||
onOpenJam = { navigationCommands.navigateTo(Routes.Jam) }
|
||||
)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
|
||||
@ -56,7 +56,6 @@ data class UserSettings(
|
||||
val cacheFolder: String? = null,
|
||||
val cacheSizeLimitMB: Long = -1L,
|
||||
val enableEndlessPlayback: Boolean = true,
|
||||
val enableConnect: Boolean = false,
|
||||
val playbackProxyServerPort: Int = 14769,
|
||||
|
||||
// Remote Control (LAN)
|
||||
|
||||
@ -23,16 +23,21 @@ import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import org.spotube.spotube.core.paths.Paths
|
||||
|
||||
interface SettingsProvider {
|
||||
val settingsState: StateFlow<UserSettings?>
|
||||
}
|
||||
|
||||
class SettingsViewModel(
|
||||
private val repository: SettingsRepository
|
||||
private val repository: SettingsRepository,
|
||||
private val paths: Paths
|
||||
) : ViewModel(), SettingsProvider {
|
||||
override val settingsState = repository.userSettings
|
||||
|
||||
/** Where downloads go when no override folder is set. */
|
||||
val defaultDownloadFolder: String = paths.getUserDownloadsDirPath()
|
||||
|
||||
fun updateSettings(transform: UserSettings.() -> UserSettings) {
|
||||
viewModelScope.launch {
|
||||
// Get current state, apply transform, and save
|
||||
|
||||
@ -46,7 +46,7 @@ import io.github.vinceglb.filekit.path
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import spotube.composeapp.generated.resources.Res
|
||||
import spotube.composeapp.generated.resources.settings_download_folder_current
|
||||
import spotube.composeapp.generated.resources.settings_download_folder_default
|
||||
import spotube.composeapp.generated.resources.settings_download_folder_default_path
|
||||
import spotube.composeapp.generated.resources.settings_download_folder_title
|
||||
import spotube.composeapp.generated.resources.settings_download_format_subtitle_current
|
||||
import spotube.composeapp.generated.resources.settings_download_format_title
|
||||
@ -77,6 +77,7 @@ internal fun LazyListScope.downloadsSection(
|
||||
{
|
||||
DownloadFolderSettingCard(
|
||||
folder = settings.overloadedDownloadFolder,
|
||||
defaultFolder = settingsViewModel.defaultDownloadFolder,
|
||||
onFolderSelected = { folder ->
|
||||
settingsViewModel.updateSettings {
|
||||
copy(overloadedDownloadFolder = folder)
|
||||
@ -142,6 +143,7 @@ internal fun LazyListScope.downloadsSection(
|
||||
@Composable
|
||||
private fun DownloadFolderSettingCard(
|
||||
folder: String?,
|
||||
defaultFolder: String,
|
||||
onFolderSelected: (String?) -> Unit
|
||||
) {
|
||||
val pickerLauncher = rememberDirectoryPickerLauncher { directory ->
|
||||
@ -157,7 +159,7 @@ private fun DownloadFolderSettingCard(
|
||||
title = stringResource(Res.string.settings_download_folder_title),
|
||||
subtitle = folder?.let {
|
||||
stringResource(Res.string.settings_download_folder_current, it)
|
||||
} ?: stringResource(Res.string.settings_download_folder_default),
|
||||
} ?: stringResource(Res.string.settings_download_folder_default_path, defaultFolder),
|
||||
icon = {
|
||||
SettingsItemIcon(
|
||||
Iconsax.IconsaxDirectboxReceive,
|
||||
|
||||
@ -41,9 +41,7 @@ import org.spotube.spotube.modules.settings.components.SwitchSettingCard
|
||||
import org.spotube.spotube.modules.settings.components.TextInputSettingCard
|
||||
import org.spotube.spotube.resources.iconsax.CustomServer
|
||||
import org.spotube.spotube.resources.iconsax.Iconsax
|
||||
import org.spotube.spotube.resources.iconsax.IconsaxEdit
|
||||
import org.spotube.spotube.resources.iconsax.IconsaxForbidden
|
||||
import org.spotube.spotube.resources.iconsax.IconsaxMirroringScreen
|
||||
import org.spotube.spotube.resources.iconsax.IconsaxMusicPlay
|
||||
import org.spotube.spotube.resources.iconsax.IconsaxRepeatArrow
|
||||
import org.spotube.spotube.resources.iconsax.IconsaxVerticalSetting
|
||||
@ -122,66 +120,7 @@ internal fun LazyListScope.playbackSection(
|
||||
}
|
||||
)
|
||||
}
|
||||
add {
|
||||
SwitchSettingCard(
|
||||
title = stringResource(Res.string.settings_enable_connect_title),
|
||||
subtitle = stringResource(Res.string.settings_enable_connect_subtitle),
|
||||
icon = {
|
||||
SettingsItemIcon(Iconsax.IconsaxMirroringScreen, stringResource(Res.string.settings_enable_connect_title))
|
||||
},
|
||||
checked = settings.enableConnect,
|
||||
onCheckedChange = { enabled ->
|
||||
settingsViewModel.updateSettings {
|
||||
copy(enableConnect = enabled)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
add {
|
||||
SwitchSettingCard(
|
||||
title = stringResource(Res.string.settings_allow_remote_control_title),
|
||||
subtitle = stringResource(Res.string.settings_allow_remote_control_subtitle),
|
||||
icon = {
|
||||
SettingsItemIcon(
|
||||
Iconsax.IconsaxMirroringScreen,
|
||||
stringResource(Res.string.settings_allow_remote_control_title)
|
||||
)
|
||||
},
|
||||
checked = settings.allowRemoteControl,
|
||||
onCheckedChange = { enabled ->
|
||||
settingsViewModel.updateSettings {
|
||||
copy(allowRemoteControl = enabled)
|
||||
}
|
||||
// Request the local network permission when enabling remote control
|
||||
// so that DNS-SD registration can succeed on Android 16+
|
||||
if (enabled) {
|
||||
requestLocalNetworkPermission()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
add {
|
||||
TextInputSettingCard(
|
||||
title = stringResource(Res.string.settings_remote_device_name_title),
|
||||
subtitle = stringResource(
|
||||
Res.string.settings_remote_device_name_subtitle,
|
||||
settings.remoteControlDeviceName.ifBlank { stringResource(Res.string.settings_remote_device_name_default) }
|
||||
),
|
||||
icon = {
|
||||
SettingsItemIcon(Iconsax.IconsaxEdit, stringResource(Res.string.settings_remote_device_name_title))
|
||||
},
|
||||
value = settings.remoteControlDeviceName,
|
||||
dialogDescription = stringResource(Res.string.settings_remote_device_name_description),
|
||||
placeholder = stringResource(Res.string.settings_remote_device_name_placeholder),
|
||||
normalize = { it.trim() },
|
||||
validate = { _ -> null },
|
||||
onValueSaved = { value ->
|
||||
settingsViewModel.updateSettings {
|
||||
copy(remoteControlDeviceName = value)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
addAll(remoteControlSettingCards(settings, settingsViewModel, requestLocalNetworkPermission))
|
||||
add {
|
||||
val error_whole_number = stringResource(Res.string.settings_error_whole_number)
|
||||
val error_port_range = stringResource(Res.string.settings_error_port_range)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
@ -431,6 +431,7 @@ fun AppExpandedPlayer(
|
||||
val albumId = currentTrack?.album?.id
|
||||
if (albumId != null) {
|
||||
navigationCommands.navigateTo(Routes.Album(albumId))
|
||||
onCollapse()
|
||||
}
|
||||
onGoToAlbum()
|
||||
})
|
||||
@ -535,6 +536,8 @@ fun AppExpandedPlayer(
|
||||
artists = playerUiState.artistList,
|
||||
onArtistClick = { artistId ->
|
||||
navigationCommands.navigateTo(Routes.Artist(artistId))
|
||||
// The page opens beneath the player, so get out of the way
|
||||
onCollapse()
|
||||
},
|
||||
style = AppTheme.typography.bodyLarge,
|
||||
color = AppTheme.colors.onSurfaceVariant,
|
||||
|
||||
@ -20,6 +20,20 @@ package org.spotube.spotube.modules.shell
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.composeunstyled.LocalTextStyle
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import org.spotube.millennium.composables.LocalMillenniumSidebar
|
||||
import org.spotube.millennium.composables.MillenniumIcon
|
||||
import org.spotube.millennium.theme.MillenniumControlState
|
||||
import org.spotube.millennium.theme.MillenniumTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@ -80,8 +94,12 @@ fun AppSidebar(
|
||||
navigator: Navigator,
|
||||
navigationState: NavigationState,
|
||||
modifier: Modifier = Modifier,
|
||||
libraryState: LibraryState = koinInject()
|
||||
libraryState: LibraryState = koinInject(),
|
||||
connectStatusViewModel: ConnectStatusViewModel = koinViewModel()
|
||||
) {
|
||||
val connectStatus by connectStatusViewModel.uiState.collectAsStateWithLifecycle()
|
||||
val remoteStatus = connectStatus.remote?.let { remoteStatusLabel(it) }
|
||||
val jamStatus = connectStatus.jam?.let { jamStatusLabel(it) }
|
||||
var expanded by rememberSaveable { mutableStateOf(true) }
|
||||
val groupExpansion = remember { mutableStateMapOf<String, Boolean>() }
|
||||
val currentLibraryTab by libraryState.currentTab.collectAsState()
|
||||
@ -144,6 +162,11 @@ fun AppSidebar(
|
||||
SidebarNavItem(
|
||||
item = item,
|
||||
selected = selected,
|
||||
status = when (item.route) {
|
||||
Routes.Devices -> remoteStatus
|
||||
Routes.Jam -> jamStatus
|
||||
else -> null
|
||||
},
|
||||
onClick = {
|
||||
if (item.route == Routes.Library) {
|
||||
(item.data as? LibraryTab)?.let {
|
||||
@ -194,9 +217,15 @@ fun AppSidebar(
|
||||
private fun SidebarNavItem(
|
||||
item: NavigationItem.Tab<*>,
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit
|
||||
onClick: () -> Unit,
|
||||
/** Live status (e.g. "Playing on Krtx"); when set the row gets a dot and a second line. */
|
||||
status: String? = null
|
||||
) {
|
||||
val title = stringResource(item.title)
|
||||
if (status != null) {
|
||||
LiveSidebarNavItem(item, title, status, selected, onClick)
|
||||
return
|
||||
}
|
||||
MillenniumSidebarItem(
|
||||
label = title,
|
||||
onClick = onClick,
|
||||
@ -205,3 +234,44 @@ private fun SidebarNavItem(
|
||||
icon = item.icon
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LiveSidebarNavItem(
|
||||
item: NavigationItem.Tab<*>,
|
||||
title: String,
|
||||
status: String,
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
MillenniumSidebarItem(
|
||||
onClick = onClick,
|
||||
selected = selected,
|
||||
contentDescription = "$title, $status"
|
||||
) {
|
||||
// Match the text overload's icon metrics so live rows line up with their siblings.
|
||||
val metrics = MillenniumTheme.sidebarTheme.item.resolve(MillenniumControlState.Default)
|
||||
val contentColor = LocalTextStyle.current.color
|
||||
Box {
|
||||
MillenniumIcon(
|
||||
imageVector = item.icon,
|
||||
contentDescription = null,
|
||||
tint = contentColor,
|
||||
modifier = Modifier.size(metrics.iconSize)
|
||||
)
|
||||
LiveDot(Modifier.align(Alignment.TopEnd).offset(x = 3.dp, y = (-3).dp))
|
||||
}
|
||||
if (LocalMillenniumSidebar.current?.collapsed != true) {
|
||||
Spacer(Modifier.width(metrics.iconLabelSpacing))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(title, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(
|
||||
text = status,
|
||||
style = AppTheme.typography.labelSmall,
|
||||
color = contentColor.copy(alpha = 0.7f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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())
|
||||
}
|
||||
@ -17,7 +17,10 @@
|
||||
|
||||
package org.spotube.spotube.modules.shell.player_queue
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Modifier
|
||||
@ -34,9 +37,6 @@ import org.spotube.millennium.composables.MillenniumModalBottomSheet
|
||||
import org.spotube.millennium.composables.rememberMillenniumDrawerState
|
||||
|
||||
private val SlidingSheetBreakpoint = 840.dp
|
||||
private val MaxHeightSheetDetent = SheetDetent("max-height-85-percent") { containerHeight, sheetHeight ->
|
||||
minOf(sheetHeight, containerHeight * 0.85f)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun QueueSheet(
|
||||
@ -88,9 +88,11 @@ internal fun BottomSheetContent(
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
if (!isVisible) return
|
||||
// Show the whole sheet and cap the content instead: a detent that reveals only part of a
|
||||
// full-height sheet leaves the bottom of the queue list off-screen and unreachable.
|
||||
val sheetState = rememberModalBottomSheetState(
|
||||
initialDetent = MaxHeightSheetDetent,
|
||||
detents = listOf(SheetDetent.Hidden, MaxHeightSheetDetent)
|
||||
initialDetent = SheetDetent.FullyExpanded,
|
||||
detents = listOf(SheetDetent.Hidden, SheetDetent.FullyExpanded)
|
||||
)
|
||||
LaunchedEffect(sheetState.bottomSheetState.currentDetent) {
|
||||
if (sheetState.bottomSheetState.currentDetent == SheetDetent.Hidden) {
|
||||
@ -102,6 +104,8 @@ internal fun BottomSheetContent(
|
||||
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp),
|
||||
contentSpacing = 8.dp
|
||||
) {
|
||||
content()
|
||||
Box(Modifier.fillMaxWidth().fillMaxHeight(0.85f)) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -545,4 +545,68 @@ class StreamingUrlRepositoryBehaviorTest {
|
||||
// Fetching the entry doesn't call the plugin again.
|
||||
assertEquals(1, plugin.audioAPI.getStreamsByTrackCallCount)
|
||||
}
|
||||
|
||||
// ---------- caller requests (downloads) ----------
|
||||
|
||||
private val aacRequest = StreamRequest(
|
||||
format = org.spotube.plugin_interfaces.plugin_apis.audio.AudioFormat(
|
||||
codec = "aac", container = "mp4", qualities = listOf(AudioQuality.Lossy(128_000))),
|
||||
quality = AudioQuality.Lossy(128_000),
|
||||
progressiveOnly = true)
|
||||
|
||||
private fun aacStream(url: String, protocol: StreamProtocol = StreamProtocol.PROGRESSIVE) = AudioStream.Lossy(
|
||||
url = url, codec = "aac", container = "mp4", protocol = protocol, bitrate = 128_000)
|
||||
|
||||
@Test
|
||||
fun `a request's format overrides the streaming settings`() = runTest {
|
||||
val h = Harness()
|
||||
val plugin = h.selectPlugin("pluginA")
|
||||
plugin.audioAPI.onGetStreamsByTrack = {
|
||||
listOf(streamedSource("src1", listOf(stream("https://example.com/a.opus"), aacStream("https://example.com/a.m4a"))))
|
||||
}
|
||||
|
||||
val info = h.repository.resolveStreamInfo(track("t1"), request = aacRequest)
|
||||
|
||||
assertEquals("https://example.com/a.m4a", info?.url)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `progressiveOnly never picks a manifest even when it matches best`() = runTest {
|
||||
val h = Harness()
|
||||
val plugin = h.selectPlugin("pluginA")
|
||||
plugin.audioAPI.onGetStreamsByTrack = {
|
||||
listOf(streamedSource("src1", listOf(
|
||||
aacStream("https://example.com/manifest.mpd", StreamProtocol.DASH),
|
||||
stream("https://example.com/a.opus"))))
|
||||
}
|
||||
|
||||
val info = h.repository.resolveStreamInfo(track("t1"), request = aacRequest)
|
||||
|
||||
assertEquals("https://example.com/a.opus", info?.url)
|
||||
assertEquals(StreamProtocol.PROGRESSIVE, info?.protocol)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `progressiveOnly returns null when only manifests exist`() = runTest {
|
||||
val h = Harness()
|
||||
val plugin = h.selectPlugin("pluginA")
|
||||
plugin.audioAPI.onGetStreamsByTrack = {
|
||||
listOf(streamedSource("src1", listOf(aacStream("https://example.com/manifest.m3u8", StreamProtocol.HLS))))
|
||||
}
|
||||
|
||||
assertNull(h.repository.resolveStreamInfo(track("t1"), request = aacRequest))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a request does not write the playback url cache`() = runTest {
|
||||
val h = Harness()
|
||||
val plugin = h.selectPlugin("pluginA")
|
||||
plugin.audioAPI.onGetStreamsByTrack = {
|
||||
listOf(streamedSource("src1", listOf(aacStream("https://example.com/a.m4a"))))
|
||||
}
|
||||
|
||||
h.repository.resolveStreamInfo(track("t1"), request = aacRequest)
|
||||
|
||||
assertNull(h.repository.getCachedStreamUrlEntry("t1"))
|
||||
}
|
||||
}
|
||||
|
||||
@ -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.core.discovery
|
||||
|
||||
// The OS's own service-discovery stack advertises on the right network.
|
||||
internal actual fun preferredAdvertiseAddress(): String? = null
|
||||
@ -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,53 @@
|
||||
/*
|
||||
* 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.core.discovery
|
||||
|
||||
import java.net.DatagramSocket
|
||||
import java.net.Inet4Address
|
||||
import java.net.InetAddress
|
||||
import java.net.NetworkInterface
|
||||
|
||||
// Interface name prefixes for container/VM/VPN links other LAN devices can't reach.
|
||||
private val VirtualInterfacePrefixes = listOf(
|
||||
"docker", "br-", "veth", "virbr", "vmnet", "vboxnet", "cni", "flannel", "podman",
|
||||
"lxc", "lxd", "tun", "tap", "wg", "zt", "tailscale", "utun", "ham")
|
||||
|
||||
internal actual fun preferredAdvertiseAddress(): String? =
|
||||
(routedLanAddress() ?: firstPhysicalLanAddress())?.hostAddress
|
||||
|
||||
/**
|
||||
* The source address the OS would use to reach the outside world, i.e. the interface that
|
||||
* owns the default route. A connected UDP socket sends nothing; it only resolves the route.
|
||||
*/
|
||||
private fun routedLanAddress(): InetAddress? = runCatching {
|
||||
DatagramSocket().use { socket ->
|
||||
socket.connect(InetAddress.getByName("192.0.2.1"), 9) // TEST-NET-1, never routed anywhere
|
||||
socket.localAddress
|
||||
}
|
||||
}.getOrNull()?.takeIf { it is Inet4Address && it.isSiteLocalAddress }
|
||||
|
||||
/** Fallback for networks without a default route (e.g. an offline LAN). */
|
||||
private fun firstPhysicalLanAddress(): InetAddress? = runCatching {
|
||||
NetworkInterface.getNetworkInterfaces().toList()
|
||||
.filter { iface ->
|
||||
iface.isUp && !iface.isLoopback && !iface.isVirtual && !iface.isPointToPoint &&
|
||||
VirtualInterfacePrefixes.none { iface.name.startsWith(it) }
|
||||
}
|
||||
.flatMap { it.inetAddresses.toList() }
|
||||
.firstOrNull { it is Inet4Address && it.isSiteLocalAddress }
|
||||
}.getOrNull()
|
||||
@ -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
|
||||
@ -34,4 +34,7 @@ android.builtInKotlin=false
|
||||
android.newDsl=false
|
||||
android.sourceset.disallowProvider=false
|
||||
|
||||
org.gradle.vfs.watch=true
|
||||
org.gradle.vfs.watch=true
|
||||
|
||||
nightly=true
|
||||
buildNumber=999
|
||||
Loading…
Reference in New Issue
Block a user