diff --git a/.gitignore b/.gitignore
index 7db742767..21a4fb190 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,6 +9,7 @@
.cxx
local.properties
.vscode
+*.log
app/debug/
app/release/
diff --git a/ComposeKit b/ComposeKit
index 72269269c..de124bfbd 160000
--- a/ComposeKit
+++ b/ComposeKit
@@ -1 +1 @@
-Subproject commit 72269269c7ea410d0c9961fb63b40c47fb3d3bc9
+Subproject commit de124bfbd8c6ed1f7ed0ae3365c1d4af9ccde0e8
diff --git a/androidApp/src/main/AndroidManifest.xml b/androidApp/src/main/AndroidManifest.xml
index fe5c2eb57..21cbba572 100644
--- a/androidApp/src/main/AndroidManifest.xml
+++ b/androidApp/src/main/AndroidManifest.xml
@@ -66,7 +66,7 @@
?, instance: Int) {
- sendMessageOut(PlayerDownloadManager.PlayerDownloadMessage(
- IntentAction.START_DOWNLOAD,
- mapOf(
- "status" to getStatusObject(),
- "result" to (result ?: Result.success(null))
- ),
- instance
- ))
- }
-
- fun broadcastStatus(started: Boolean = false) {
- sendMessageOut(PlayerDownloadManager.PlayerDownloadMessage(
- IntentAction.STATUS_CHANGED,
- mapOf("status" to getStatusObject(), "started" to started)
- ))
- }
-
- override fun toString(): String =
- "Download(id=${song.id}, quality=$quality, silent=$silent, instance=$instance, file=$song_file)"
- }
-
- private var download_inc: Int = 0
- private fun getOrCreateDownload(context: AppContext, song: Song, silent: Boolean, file_uri: String?): Download {
- synchronized(downloads) {
- for (download in downloads) {
- if (download.song.id == song.id) {
- return download
- }
- }
- return Download(context, song, Settings.getEnum(StreamingSettings.Key.DOWNLOAD_AUDIO_QUALITY), silent, download_inc++, file_uri)
- }
- }
-
- fun getAllDownloadsStatus(): List =
- downloads.map { it.getStatusObject() }
-
- fun getDownloadStatus(song: Song): DownloadStatus? =
- downloads.firstOrNull { it.song.id == song.id }?.getStatusObject()
-
- private fun getTotalDownloadProgress(): Float {
- if (downloads.isEmpty()) {
- return 1f
- }
-
- val finished = completed_downloads + failed_downloads
-
- var total_progress: Float = finished.toFloat()
- for (download in downloads) {
- total_progress += download.progress
- }
- return total_progress / (downloads.size + finished)
- }
-
- enum class IntentAction {
- STOP, START_DOWNLOAD, CANCEL_DOWNLOAD, CANCEL_ALL, PAUSE_RESUME, STATUS_CHANGED
- }
-
- data class FilenameData(
- val id_or_title: String,
- val extension: String,
- val downloading: Boolean
- )
-
- companion object {
- fun getDownloadPath(song: Song, extension: String, in_progress: Boolean, context: AppContext): String {
- if (in_progress) {
- return "${song.id}.$extension$FILE_DOWNLOADING_SUFFIX"
- }
- else {
- return "${song.getActiveTitle(context.database)}.$extension"
- }
- }
-
- fun isFileDownloadInProgress(file: PlatformFile): Boolean =
- file.name.endsWith(FILE_DOWNLOADING_SUFFIX)
-
- fun isFileDownloadInProgressForSong(file: PlatformFile, song: Song): Boolean =
- isFileDownloadInProgress(file) && file.name.startsWith("${song.id}.")
-
- fun getSongIdOfInProgressDownload(file: PlatformFile): String? =
- if (file.name.endsWith(FILE_DOWNLOADING_SUFFIX)) file.name.split('.', limit = 2).first() else null
- }
-
- private var notification_builder: NotificationCompat.Builder? = null
- private lateinit var notification_manager: NotificationManagerCompat
-
- private val song_download_dir: PlatformFile get() = PlayerDownloadManager.getSongDownloadDir(context)
-
- private val downloads: MutableList = mutableListOf()
- private val executor = Executors.newFixedThreadPool(3)
- private var stopping = false
-
- private var start_time: Long = 0
- private var completed_downloads: Int = 0
- private var failed_downloads: Int = 0
- private var cancelled: Boolean = false
- private var notification_update_time: Long = -1
-
- private var paused: Boolean = false
- set(value) {
- field = value
- pause_resume_action.title = if (value) getString("action_download_resume") else getString("action_download_pause")
- }
-
- private lateinit var notification_delete_intent: PendingIntent
- private lateinit var pause_resume_action: NotificationCompat.Action
-
- override fun onCreate() {
- super.onCreate()
-
- initResources(context.getUiLanguage(), context)
-
- synchronized(downloads) {
- downloads.clear()
- }
-
- notification_manager = NotificationManagerCompat.from(this)
- notification_delete_intent = PendingIntent.getService(
- this,
- IntentAction.STOP.ordinal,
- Intent(this, PlayerDownloadService::class.java).putExtra("action", IntentAction.STOP),
- PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_ONE_SHOT
- )
- pause_resume_action = NotificationCompat.Action.Builder(
- IconCompat.createWithResource(this, android.R.drawable.ic_menu_close_clear_cancel),
- getString("action_download_pause") ?: "Pause",
- PendingIntent.getService(
- this,
- IntentAction.PAUSE_RESUME.ordinal,
- Intent(this, PlayerDownloadService::class.java).putExtra("action", IntentAction.PAUSE_RESUME),
- PendingIntent.FLAG_IMMUTABLE
- )
- ).build()
- }
-
- override fun onDestroy() {
- executor.shutdownNow()
- try {
- downloads.clear()
- }
- catch (_: Throwable) {}
- super.onDestroy()
- }
-
- override fun onMessage(data: Any?) {
- require(data is PlayerDownloadManager.PlayerDownloadMessage)
- onActionIntentReceived(data)
- }
-
- private fun onActionIntentReceived(message: PlayerDownloadManager.PlayerDownloadMessage) {
- when (message.action) {
- IntentAction.STOP -> {
- SpMp.Log.info("Download service stopping...")
- synchronized(executor) {
- stopping = true
- }
- stopSelf()
- }
- IntentAction.START_DOWNLOAD -> startDownload(message)
- IntentAction.CANCEL_DOWNLOAD -> cancelDownload(message)
- IntentAction.CANCEL_ALL -> cancelAllDownloads(message)
- IntentAction.PAUSE_RESUME -> {
- paused = !paused
- onDownloadProgress()
- }
- IntentAction.STATUS_CHANGED -> throw IllegalStateException("STATUS_CHANGED is for output only")
- }
- }
-
- private fun startDownload(message: PlayerDownloadManager.PlayerDownloadMessage) {
- require(message.instance != null)
-
- val download: Download = getOrCreateDownload(
- context,
- SongRef(message.data["song_id"] as String),
- silent = message.data["silent"] as Boolean,
- file_uri = message.data["file_uri"] as String?
- )
-
- synchronized(download) {
- if (download.finished) {
- download.broadcastResult(Result.success(download.song_file), message.instance)
- return
- }
-
- if (download.downloading) {
- if (paused) {
- paused = false
- }
- download.broadcastResult(null, message.instance)
- return
- }
-
- synchronized(downloads) {
- if (downloads.isEmpty()) {
- if (!download.silent) {
- if (
- Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
- && ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PermissionChecker.PERMISSION_GRANTED
- ) {
- context.sendToast("(BUG) No notification permission")
- return
- }
-
- notification_builder = getNotificationBuilder()
- startForeground(NOTIFICATION_ID, notification_builder!!.build())
- }
- start_time = System.currentTimeMillis()
- completed_downloads = 0
- failed_downloads = 0
- cancelled = false
- }
-
- downloads.add(download)
- download.broadcastStatus(true)
- }
- }
-
- onDownloadProgress()
-
- executor.submit {
- runBlocking {
- var result: Result? = null
- var retry_count: Int = 0
-
- while (
- retry_count++ < DOWNLOAD_MAX_RETRY_COUNT && (
- result == null || download.status == DownloadStatus.Status.IDLE || download.status == DownloadStatus.Status.PAUSED
- )
- ) {
- if (paused && !download.cancelled) {
- onDownloadProgress()
- delay(500)
- continue
- }
-
- result =
- try {
- performDownload(download)
- }
- catch (e: Exception) {
- Result.failure(e)
- }
- }
-
- synchronized(downloads) {
- downloads.removeAll { it.song.id == download.song.id }
-
- if (downloads.isEmpty()) {
- cancelled = download.cancelled
- stopForeground(false)
- }
-
- if (result?.isSuccess == true) {
- completed_downloads += 1
- }
- else {
- failed_downloads += 1
- }
- }
-
- download.broadcastResult(result, message.instance)
-
- if (notification_update_time > 0) {
- val delay_duration = 1000 - (System.currentTimeMillis() - notification_update_time)
- if (delay_duration > 0) {
- delay(delay_duration)
- }
- }
- onDownloadProgress()
- }
- }
- }
-
- private fun cancelDownload(message: PlayerDownloadManager.PlayerDownloadMessage) {
- val id = message.data["id"] as String
- synchronized(downloads) {
- downloads.firstOrNull { it.song.id == id }?.cancel()
- }
- }
-
- private fun cancelAllDownloads(message: PlayerDownloadManager.PlayerDownloadMessage) {
- SpMp.Log.info("Download manager cancelling all downloads $downloads")
- synchronized(downloads) {
- downloads.forEach { it.cancel() }
- }
- }
-
- private suspend fun performDownload(download: Download): Result = withContext(Dispatchers.IO) {
- val format: YoutubeVideoFormat = getSongFormatByQuality(download.song.id, download.quality, context).fold(
- { it },
- { return@withContext Result.failure(it) }
- )
-
- val connection: HttpURLConnection = URL(format.url).openConnection() as HttpURLConnection
- connection.connectTimeout = 3000
- connection.setRequestProperty("Range", "bytes=${download.downloaded}-")
-
- try {
- connection.connect()
- }
- catch (e: Throwable) {
- return@withContext Result.failure(RuntimeException(connection.url.toString(), e))
- }
-
- if (connection.responseCode != 200 && connection.responseCode != 206) {
- return@withContext Result.failure(ConnectException(
- "${download.song.id}: Server returned code ${connection.responseCode} ${connection.responseMessage}"
- ))
- }
-
- val format_extension: String =
- when (connection.contentType) {
- "audio/webm" -> "webm"
- "audio/mp4" -> "mp4"
- else -> return@withContext Result.failure(NotImplementedError(connection.contentType))
- }
-
- var file: PlatformFile? = download.song_file
- check(song_download_dir.mkdirs()) { song_download_dir.toString() }
-
- if (file == null) {
- file = song_download_dir.resolve(download.generatePath(format_extension, true))
- }
-
- check(file.name.endsWith(FILE_DOWNLOADING_SUFFIX))
-
- val data: ByteArray = ByteArray(4096)
- val input_stream: InputStream = connection.inputStream
- val output_stream: OutputStream = file.outputStream(true)
-
- fun close(status: DownloadStatus.Status) {
- input_stream.close()
- output_stream.close()
- connection.disconnect()
- download.status = status
- }
-
- try {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
- download.total_size = connection.contentLengthLong + download.downloaded
- }
- else {
- download.total_size = connection.contentLength + download.downloaded
- }
- download.status = DownloadStatus.Status.DOWNLOADING
-
- while (true) {
- val size = input_stream.read(data)
- if (size < 0) {
- break
- }
-
- synchronized(executor) {
- if (stopping || download.cancelled) {
- close(DownloadStatus.Status.CANCELLED)
- return@withContext Result.success(null)
- }
- if (paused) {
- close(DownloadStatus.Status.PAUSED)
- return@withContext Result.success(null)
- }
- }
-
- download.downloaded += size
- output_stream.write(data, 0, size)
-
- onDownloadProgress()
- }
-
- val metadata_retriever: MediaMetadataRetriever = MediaMetadataRetriever()
- metadata_retriever.setDataSource(context.ctx, Uri.parse(file.uri))
-
- val duration_ms: Long? = metadata_retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLong()
- download.song.Duration.setNotNull(duration_ms, context.database)
-
- runBlocking {
- launch {
- LocalSongMetadataProcessor.addMetadataToLocalSong(download.song, file, format_extension, context)
- }
- launch {
- if (download.lyrics_file == null) {
- val lyrics_file: PlatformFile = MediaItemLibrary.getLocalLyricsFile(download.song, context)
-
- SongLyricsLoader.loadBySong(download.song, context)?.onSuccess { lyrics ->
- with (LyricsFileConverter) {
- lyrics.saveToFile(lyrics_file, context)
- }
- }
- }
- }
- }
-
- close(DownloadStatus.Status.FINISHED)
- }
- catch (e: Throwable) {
- e.printStackTrace()
- close(DownloadStatus.Status.CANCELLED)
- }
-
- if (download.file_uri != null) {
- val uri_file: PlatformFile = context.getUserDirectoryFile(download.file_uri)
- uri_file.outputStream().use { output ->
- Files.copy(File(file.absolute_path), output)
- }
- file.delete()
- download.song_file = uri_file
- }
- else {
- val renamed: PlatformFile = file.renameTo(
- download.generatePath(format_extension, false)
- )
- download.song_file = renamed
- }
-
- download.status = DownloadStatus.Status.FINISHED
-
- return@withContext Result.success(download.song_file)
- }
-
- private fun getNotificationText(): String {
- var text = ""
- var additional = ""
-
- synchronized(downloads) {
- var downloading = 0
- for (dl in downloads) {
- if (dl.status != DownloadStatus.Status.DOWNLOADING && dl.status != DownloadStatus.Status.PAUSED) {
- continue
- }
-
- if (text.isNotEmpty()) {
- text += ", ${dl.percent_progress}%"
- }
- else {
- text += "${dl.percent_progress}%"
- }
-
- downloading += 1
- }
-
- if (downloading < downloads.size) {
- additional += "${downloads.size - downloading} queued"
- }
- if (completed_downloads > 0) {
- if (additional.isNotEmpty()) {
- additional += ", $completed_downloads finished"
- }
- else {
- additional += "$completed_downloads finished"
- }
- }
- if (failed_downloads > 0) {
- if (additional.isNotEmpty()) {
- additional += ", $failed_downloads failed"
- }
- else {
- additional += "$failed_downloads failed"
- }
- }
- }
-
- return if (additional.isEmpty()) text else "$text ($additional)"
- }
-
- private fun onDownloadProgress() {
- synchronized(downloads) {
- if (downloads.isNotEmpty() && downloads.all { it.silent }) {
- return
- }
-
- notification_builder?.also { builder ->
- notification_update_time = System.currentTimeMillis()
- val total_progress = getTotalDownloadProgress()
-
- if (!downloads.any { !it.silent }) {
- builder.setProgress(0, 0, false)
- builder.setOngoing(false)
- builder.setDeleteIntent(notification_delete_intent)
-
- if (cancelled) {
- builder.setContentTitle("Download cancelled")
- builder.setSmallIcon(android.R.drawable.ic_menu_close_clear_cancel)
- }
- else if (completed_downloads == 0) {
- builder.setContentTitle("Download failed")
- builder.setSmallIcon(android.R.drawable.stat_notify_error)
- }
- else {
- NotificationManagerCompat.from(this).cancel(NOTIFICATION_ID)
- return
- }
-
- builder.setContentText("")
- }
- else {
- builder.setProgress(100, (total_progress * 100).toInt(), false)
-
- var title: String? = null
- if (downloads.size == 1) {
- val song_title = downloads.first().song.getActiveTitle(context.database)
- if (song_title != null) {
- title = getString("downloading_song_\$title").replace("\$title", song_title)
- }
- }
-
- if (title == null) {
- title = getString("downloading_\$x_songs").replace("\$x", downloads.size.toString())
- }
-
- builder.setContentTitle(if (paused) "$title (paused)" else title)
- builder.setContentText(getNotificationText())
-
- val elapsed_minutes = ((System.currentTimeMillis() - start_time) / 60000f).toInt()
- builder.setSubText(
- if (elapsed_minutes == 0) getString("download_just_started")
- else getString("download_started_\$x_minutes_ago").replace("\$x", elapsed_minutes.toString())
- )
- }
-
- if (ActivityCompat.checkSelfPermission(this, "android.permission.POST_NOTIFICATIONS") == PackageManager.PERMISSION_GRANTED) {
- try {
- NotificationManagerCompat.from(this).notify(
- NOTIFICATION_ID, builder.build().apply {
- if (downloads.isEmpty() || total_progress == 1f) {
- actions = arrayOf()
- }
- }
- )
- }
- catch (_: Throwable) {}
- }
- }
- }
- }
-
- override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
- val action = intent?.extras?.get("action")
- if (action is IntentAction) {
- SpMp.Log.info("Download service received action $action")
- onActionIntentReceived(
- PlayerDownloadManager.PlayerDownloadMessage(
- action,
- emptyMap()
- )
- )
- }
-
- return super.onStartCommand(intent, flags, startId)
- }
-
- private fun getNotificationBuilder(): NotificationCompat.Builder {
- val content_intent: PendingIntent = PendingIntent.getActivity(
- this, 0,
- Intent(this@PlayerDownloadService, AppContext.main_activity),
- PendingIntent.FLAG_IMMUTABLE
- )
-
- return NotificationCompat.Builder(this, getNotificationChannel())
- .setSmallIcon(android.R.drawable.stat_sys_download)
- .setContentIntent(content_intent)
- .setOnlyAlertOnce(true)
- .setOngoing(true)
- .setProgress(100, 0, false)
- .addAction(pause_resume_action)
- .addAction(
- NotificationCompat.Action.Builder(
- IconCompat.createWithResource(this, android.R.drawable.ic_menu_close_clear_cancel),
- getString("action_cancel"),
- PendingIntent.getService(
- this,
- IntentAction.CANCEL_ALL.ordinal,
- Intent(this, PlayerDownloadService::class.java).putExtra("action", IntentAction.CANCEL_ALL),
- PendingIntent.FLAG_IMMUTABLE
- )
- ).build()
- )
- .apply {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
- foregroundServiceBehavior = Notification.FOREGROUND_SERVICE_IMMEDIATE
- }
- }
- }
-
- private fun getNotificationChannel(): String {
- val channel =
- NotificationChannelCompat.Builder(
- NOTIFICATION_CHANNEL_ID,
- NotificationManager.IMPORTANCE_LOW
- )
- .setName(getString("download_service_name"))
- .setSound(null, null)
- .build()
-
- notification_manager.createNotificationChannel(channel)
- return NOTIFICATION_CHANNEL_ID
- }
-
- inner class ServiceBinder: PlatformBinder() {
- fun getService(): PlayerDownloadService = this@PlayerDownloadService
- }
- private val binder = ServiceBinder()
- override fun onBind() = binder
-}
diff --git a/shared/src/androidMain/kotlin/com/toasterofbread/spmp/platform/ProcessMediaDataSpec.kt b/shared/src/androidMain/kotlin/com/toasterofbread/spmp/platform/ProcessMediaDataSpec.kt
index 63accd589..5892156e2 100644
--- a/shared/src/androidMain/kotlin/com/toasterofbread/spmp/platform/ProcessMediaDataSpec.kt
+++ b/shared/src/androidMain/kotlin/com/toasterofbread/spmp/platform/ProcessMediaDataSpec.kt
@@ -8,6 +8,7 @@ import com.toasterofbread.spmp.model.mediaitem.db.getPlayCount
import com.toasterofbread.spmp.model.mediaitem.song.SongRef
import com.toasterofbread.spmp.model.mediaitem.song.getSongStreamFormat
import com.toasterofbread.spmp.model.settings.category.StreamingSettings
+import com.toasterofbread.spmp.platform.download.DownloadStatus
import com.toasterofbread.spmp.platform.download.PlayerDownloadManager
import com.toasterofbread.spmp.platform.download.getLocalSongFile
import com.toasterofbread.spmp.platform.playerservice.AUTO_DOWNLOAD_SOFT_TIMEOUT
@@ -33,31 +34,31 @@ internal suspend fun processMediaDataSpec(data_spec: DataSpec, context: AppConte
) {
var done: Boolean = false
runBlocking {
- val initial_status: PlayerDownloadManager.DownloadStatus? = download_manager.getDownload(song)
+ val initial_status: DownloadStatus? = download_manager.getDownload(song)
when (initial_status?.status) {
- PlayerDownloadManager.DownloadStatus.Status.IDLE, PlayerDownloadManager.DownloadStatus.Status.CANCELLED, PlayerDownloadManager.DownloadStatus.Status.PAUSED, null -> {
+ DownloadStatus.Status.IDLE, DownloadStatus.Status.CANCELLED, DownloadStatus.Status.PAUSED, null -> {
download_manager.startDownload(song, true) { status ->
local_file = status?.file
done = true
}
}
- PlayerDownloadManager.DownloadStatus.Status.ALREADY_FINISHED, PlayerDownloadManager.DownloadStatus.Status.FINISHED -> throw IllegalStateException()
+ DownloadStatus.Status.ALREADY_FINISHED, DownloadStatus.Status.FINISHED -> throw IllegalStateException()
else -> {}
}
val listener: PlayerDownloadManager.DownloadStatusListener = object : PlayerDownloadManager.DownloadStatusListener() {
- override fun onDownloadChanged(status: PlayerDownloadManager.DownloadStatus) {
+ override fun onDownloadChanged(status: DownloadStatus) {
if (status.song.id != song.id) {
return
}
when (status.status) {
- PlayerDownloadManager.DownloadStatus.Status.IDLE, PlayerDownloadManager.DownloadStatus.Status.DOWNLOADING -> return
- PlayerDownloadManager.DownloadStatus.Status.PAUSED -> throw IllegalStateException()
- PlayerDownloadManager.DownloadStatus.Status.CANCELLED -> {
+ DownloadStatus.Status.IDLE, DownloadStatus.Status.DOWNLOADING -> return
+ DownloadStatus.Status.PAUSED -> throw IllegalStateException()
+ DownloadStatus.Status.CANCELLED -> {
done = true
}
- PlayerDownloadManager.DownloadStatus.Status.FINISHED, PlayerDownloadManager.DownloadStatus.Status.ALREADY_FINISHED -> {
+ DownloadStatus.Status.FINISHED, DownloadStatus.Status.ALREADY_FINISHED -> {
launch {
local_file = song.getLocalSongFile(context)
done = true
diff --git a/shared/src/androidMain/kotlin/com/toasterofbread/spmp/platform/download/PlayerDownloadManager.android.kt b/shared/src/androidMain/kotlin/com/toasterofbread/spmp/platform/download/PlayerDownloadManager.android.kt
index f2dff906b..139e70a5f 100644
--- a/shared/src/androidMain/kotlin/com/toasterofbread/spmp/platform/download/PlayerDownloadManager.android.kt
+++ b/shared/src/androidMain/kotlin/com/toasterofbread/spmp/platform/download/PlayerDownloadManager.android.kt
@@ -2,11 +2,9 @@ package com.toasterofbread.spmp.platform.download
import android.os.Build
import com.toasterofbread.composekit.platform.PlatformFile
-import com.toasterofbread.spmp.PlayerDownloadService
import com.toasterofbread.spmp.model.lyrics.LyricsFileConverter
import com.toasterofbread.spmp.model.mediaitem.library.MediaItemLibrary
import com.toasterofbread.spmp.model.mediaitem.song.Song
-import com.toasterofbread.spmp.model.mediaitem.song.SongAudioQuality
import com.toasterofbread.spmp.model.mediaitem.song.SongData
import com.toasterofbread.spmp.model.mediaitem.song.SongRef
import com.toasterofbread.spmp.platform.AppContext
@@ -35,19 +33,6 @@ actual class PlayerDownloadManager actual constructor(val context: AppContext) {
onResultIntentReceived(data)
}
- actual data class DownloadStatus(
- actual val song: Song,
- actual val status: Status,
- actual val quality: SongAudioQuality?,
- actual val progress: Float,
- actual val id: String,
- val file: PlatformFile?
- ) {
- actual enum class Status { IDLE, PAUSED, DOWNLOADING, CANCELLED, ALREADY_FINISHED, FINISHED }
-
- actual fun isCompleted(): Boolean = progress >= 1f
- }
-
actual open class DownloadStatusListener {
actual open fun onDownloadAdded(status: DownloadStatus) {}
actual open fun onDownloadRemoved(id: String) {}
@@ -128,79 +113,28 @@ actual class PlayerDownloadManager actual constructor(val context: AppContext) {
actual suspend fun getDownload(song: Song): DownloadStatus? = withContext(Dispatchers.IO) {
service?.apply {
- val service_status: DownloadStatus? = getDownloadStatus(song)
+ val service_status: DownloadStatus? = downloader?.getDownloadStatus(song)
if (service_status != null) {
return@withContext service_status
}
}
- for (file in getSongDownloadDir(context).listFiles() ?: emptyList()) {
- val in_progress: Boolean
- if (PlayerDownloadService.isFileDownloadInProgress(file)) {
- if (!PlayerDownloadService.isFileDownloadInProgressForSong(file, song)) {
- continue
- }
-
- in_progress = true
- }
- else if (LocalSongMetadataProcessor.readLocalSongMetadata(file, match_id = song.id, load_data = false) != null) {
- in_progress = false
- }
- else {
- continue
- }
-
- return@withContext DownloadStatus(
- song = song,
- status = if (in_progress) DownloadStatus.Status.IDLE else DownloadStatus.Status.FINISHED,
- quality = null,
- progress = if (in_progress) -1f else 1f,
- id = file.name,
- file = file
- )
- }
-
- return@withContext null
+ return@withContext MediaItemLibrary.getLocalSongDownload(song, context)
}
actual suspend fun getDownloads(): List = withContext(Dispatchers.IO) {
- val current_downloads: List = service?.run {
- getAllDownloadsStatus()
- } ?: emptyList()
-
- val files: List = getSongDownloadDir(context).listFiles() ?: emptyList()
- return@withContext current_downloads + files.mapNotNull { file ->
- if (current_downloads.any { it.file?.matches(file) == true }) {
- return@mapNotNull null
- }
+ val current_downloads: List = service?.downloader?.getAllDownloadsStatus() ?: emptyList()
+ val local_downloads: List = MediaItemLibrary.getLocalSongDownloads(context)
- val song: Song
- val in_progress: Boolean
-
- val song_id: String? = PlayerDownloadService.getSongIdOfInProgressDownload(file)
- if (song_id != null) {
- song = SongRef(song_id)
- in_progress = true
- }
- else {
- song = LocalSongMetadataProcessor.readLocalSongMetadata(file, load_data = false) ?: return@mapNotNull null
- in_progress = false
+ return@withContext current_downloads + local_downloads.filter { local ->
+ current_downloads.none { current ->
+ current.file?.matches(local.file!!) == true
}
-
- DownloadStatus(
- song = song,
- status = if (in_progress) DownloadStatus.Status.IDLE else DownloadStatus.Status.FINISHED,
- quality = null,
- progress = if (in_progress) -1f else 1f,
- id = file.name,
- file = file
- )
}
}
@Synchronized
actual fun startDownload(song: Song, silent: Boolean, file_uri: String?, callback: DownloadRequestCallback?) {
- // If needed, get notification permission on A13 and above
if (!silent && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
context.application_context?.requestNotficationPermission() { granted ->
if (granted) {
@@ -278,8 +212,8 @@ actual class PlayerDownloadManager actual constructor(val context: AppContext) {
)
}
- actual suspend fun deleteSongLocalAudioFile(song: Song) = withContext(Dispatchers.IO) {
- val download: DownloadStatus = getDownload(song) ?: return@withContext
+ actual suspend fun deleteSongLocalAudioFile(song: Song) {
+ val download: DownloadStatus = getDownload(song) ?: return
download.file?.delete()
forEachDownloadStatusListener { it.onDownloadRemoved(download.id) }
}
@@ -298,28 +232,3 @@ actual class PlayerDownloadManager actual constructor(val context: AppContext) {
MediaItemLibrary.getLocalLyricsDir(context)
}
}
-
-actual suspend fun Song.getLocalSongFile(context: AppContext, allow_partial: Boolean): PlatformFile? {
- val files: List = PlayerDownloadManager.getSongDownloadDir(context).listFiles() ?: return null
- for (file in files) {
- if (PlayerDownloadService.isFileDownloadInProgressForSong(file, this)) {
- if (allow_partial) {
- return file
- }
- return null
- }
-
- val metadata: SongData? = LocalSongMetadataProcessor.readLocalSongMetadata(file, id, load_data = false)
- if (metadata != null) {
- return file
- }
- }
- return null
-}
-
-actual fun Song.getLocalLyricsFile(context: AppContext, allow_partial: Boolean): PlatformFile? {
- val filename: String = LyricsFileConverter.getSongLyricsFileName(this)
-
- val files: List = PlayerDownloadManager.getLyricsDownloadDir(context).listFiles() ?: return null
- return files.firstOrNull { it.name == filename }
-}
diff --git a/shared/src/androidMain/kotlin/com/toasterofbread/spmp/platform/download/PlayerDownloadService.kt b/shared/src/androidMain/kotlin/com/toasterofbread/spmp/platform/download/PlayerDownloadService.kt
new file mode 100644
index 000000000..7189381e0
--- /dev/null
+++ b/shared/src/androidMain/kotlin/com/toasterofbread/spmp/platform/download/PlayerDownloadService.kt
@@ -0,0 +1,384 @@
+package com.toasterofbread.spmp.platform.download
+
+import SpMp
+import android.Manifest
+import android.app.Notification
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.media.MediaMetadataRetriever
+import android.net.Uri
+import android.os.Build
+import androidx.core.app.ActivityCompat
+import androidx.core.app.NotificationChannelCompat
+import androidx.core.app.NotificationCompat
+import androidx.core.app.NotificationManagerCompat
+import androidx.core.content.ContextCompat
+import androidx.core.content.PermissionChecker
+import androidx.core.graphics.drawable.IconCompat
+import com.toasterofbread.composekit.platform.PlatformFile
+import com.toasterofbread.spmp.model.mediaitem.song.SongRef
+import com.toasterofbread.spmp.platform.AppContext
+import com.toasterofbread.spmp.platform.PlatformBinder
+import com.toasterofbread.spmp.platform.PlatformServiceImpl
+import com.toasterofbread.spmp.platform.getUiLanguage
+import com.toasterofbread.spmp.resources.getString
+import com.toasterofbread.spmp.resources.getStringOrNull
+import com.toasterofbread.spmp.resources.initResources
+import java.util.concurrent.Executors
+
+private const val NOTIFICATION_ID = 1
+private const val NOTIFICATION_CHANNEL_ID = "download_channel"
+
+class PlayerDownloadService: PlatformServiceImpl() {
+ internal inner class SongDownloaderImpl: SongDownloader(
+ context,
+ Executors.newFixedThreadPool(3)
+ ) {
+ override fun getAudioFileDurationMs(file: PlatformFile): Long? {
+ val metadata_retriever: MediaMetadataRetriever = MediaMetadataRetriever()
+ metadata_retriever.setDataSource(context.ctx, Uri.parse(file.uri))
+ return metadata_retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLong()
+ }
+
+ override fun onDownloadStatusChanged(download: Download, started: Boolean) {
+ sendMessageOut(
+ PlayerDownloadManager.PlayerDownloadMessage(
+ IntentAction.STATUS_CHANGED,
+ mapOf("status" to download.getStatusObject(), "started" to started)
+ )
+ )
+ }
+
+ override fun onPausedChanged() {
+ pause_resume_action.title = if (paused) getString("action_download_resume") else getString(
+ "action_download_pause"
+ )
+ }
+
+ override fun onFirstDownloadStarting(download: Download) {
+ if (!download.silent) {
+ if (
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
+ && ContextCompat.checkSelfPermission(context.ctx, Manifest.permission.POST_NOTIFICATIONS) != PermissionChecker.PERMISSION_GRANTED
+ ) {
+ context.sendToast("(BUG) No notification permission")
+ return
+ }
+
+ notification_builder = getNotificationBuilder()
+ startForeground(NOTIFICATION_ID, notification_builder!!.build())
+ }
+ }
+
+ override fun onLastDownloadFinished() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+ stopForeground(STOP_FOREGROUND_DETACH)
+ }
+ else {
+ @Suppress("DEPRECATION")
+ stopForeground(false)
+ }
+ }
+
+ override fun onDownloadProgress() {
+ updateNotification()
+ }
+
+ fun updateNotification() {
+ synchronized(downloads) {
+ if (downloads.isNotEmpty() && downloads.all { it.silent }) {
+ return
+ }
+
+ notification_builder?.also { builder ->
+ notification_update_time = System.currentTimeMillis()
+ val total_progress: Float = getTotalDownloadProgress()
+
+ if (!downloads.any { !it.silent }) {
+ builder.setProgress(0, 0, false)
+ builder.setOngoing(false)
+ builder.setDeleteIntent(notification_delete_intent)
+
+ if (cancelled) {
+ builder.setContentTitle("Download cancelled")
+ builder.setSmallIcon(android.R.drawable.ic_menu_close_clear_cancel)
+ }
+ else if (completed_downloads == 0) {
+ builder.setContentTitle("Download failed")
+ builder.setSmallIcon(android.R.drawable.stat_notify_error)
+ }
+ else {
+ NotificationManagerCompat.from(context.ctx).cancel(NOTIFICATION_ID)
+ return
+ }
+
+ builder.setContentText("")
+ }
+ else {
+ builder.setProgress(100, (total_progress * 100).toInt(), false)
+
+ var title: String? = null
+ if (downloads.size == 1) {
+ val song_title = downloads.first().song.getActiveTitle(context.database)
+ if (song_title != null) {
+ title = getString("downloading_song_\$title").replace("\$title", song_title)
+ }
+ }
+
+ if (title == null) {
+ title = getString("downloading_\$x_songs").replace("\$x", downloads.size.toString())
+ }
+
+ builder.setContentTitle(if (paused) "$title (paused)" else title)
+ builder.setContentText(getNotificationText())
+
+ val elapsed_minutes = ((System.currentTimeMillis() - start_time_ms) / 60000f).toInt()
+ builder.setSubText(
+ if (elapsed_minutes == 0) getString("download_just_started")
+ else getString("download_started_\$x_minutes_ago").replace("\$x", elapsed_minutes.toString())
+ )
+ }
+
+ if (ActivityCompat.checkSelfPermission(context.ctx, "android.permission.POST_NOTIFICATIONS") == PackageManager.PERMISSION_GRANTED) {
+ try {
+ NotificationManagerCompat.from(context.ctx).notify(
+ NOTIFICATION_ID, builder.build().apply {
+ if (downloads.isEmpty() || total_progress == 1f) {
+ actions = arrayOf()
+ }
+ }
+ )
+ }
+ catch (_: Throwable) {}
+ }
+ }
+ }
+ }
+ }
+
+ internal var downloader: SongDownloaderImpl? = null
+
+ enum class IntentAction {
+ STOP, START_DOWNLOAD, CANCEL_DOWNLOAD, CANCEL_ALL, PAUSE_RESUME, STATUS_CHANGED
+ }
+
+ private var notification_builder: NotificationCompat.Builder? = null
+ private lateinit var notification_manager: NotificationManagerCompat
+ private var notification_update_time: Long = -1
+ private lateinit var notification_delete_intent: PendingIntent
+ private lateinit var pause_resume_action: NotificationCompat.Action
+
+ override fun onCreate() {
+ super.onCreate()
+
+ if (downloader == null) {
+ downloader = SongDownloaderImpl()
+ }
+
+ initResources(context.getUiLanguage(), context)
+
+ downloader?.downloads?.also { downloads ->
+ synchronized(downloads) {
+ downloads.clear()
+ }
+ }
+
+ notification_manager = NotificationManagerCompat.from(this)
+ notification_delete_intent = PendingIntent.getService(
+ this,
+ IntentAction.STOP.ordinal,
+ Intent(this, PlayerDownloadService::class.java).putExtra("action", IntentAction.STOP),
+ PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_ONE_SHOT
+ )
+ pause_resume_action = NotificationCompat.Action.Builder(
+ IconCompat.createWithResource(this, android.R.drawable.ic_menu_close_clear_cancel),
+ getStringOrNull("action_download_pause") ?: "Pause",
+ PendingIntent.getService(
+ this,
+ IntentAction.PAUSE_RESUME.ordinal,
+ Intent(this, PlayerDownloadService::class.java).putExtra("action", IntentAction.PAUSE_RESUME),
+ PendingIntent.FLAG_IMMUTABLE
+ )
+ ).build()
+ }
+
+ override fun onDestroy() {
+ downloader?.release()
+ downloader = null
+ }
+
+ override fun onMessage(data: Any?) {
+ require(data is PlayerDownloadManager.PlayerDownloadMessage)
+ onActionIntentReceived(data)
+ }
+
+ private fun onActionIntentReceived(message: PlayerDownloadManager.PlayerDownloadMessage) {
+ when (message.action) {
+ IntentAction.STOP -> {
+ SpMp.Log.info("Download service stopping...")
+ downloader?.stop()
+ stopSelf()
+ }
+ IntentAction.START_DOWNLOAD -> startDownload(message)
+ IntentAction.CANCEL_DOWNLOAD -> cancelDownload(message)
+ IntentAction.CANCEL_ALL -> cancelAllDownloads(message)
+ IntentAction.PAUSE_RESUME -> {
+ downloader?.also { downloader ->
+ downloader.paused = !downloader.paused
+ downloader.updateNotification()
+ }
+ }
+ IntentAction.STATUS_CHANGED -> throw IllegalStateException("STATUS_CHANGED is for output only")
+ }
+ }
+
+ private fun startDownload(message: PlayerDownloadManager.PlayerDownloadMessage) {
+ require(message.instance != null)
+
+ downloader?.startDownload(
+ SongRef(message.data["song_id"] as String),
+ silent = message.data["silent"] as Boolean,
+ file_uri = message.data["file_uri"] as String?
+ ) { download, result ->
+ sendMessageOut(
+ PlayerDownloadManager.PlayerDownloadMessage(
+ IntentAction.START_DOWNLOAD,
+ mapOf(
+ "status" to download.getStatusObject(),
+ "result" to result
+ ),
+ message.instance
+ )
+ )
+ }
+ }
+
+ private fun cancelDownload(message: PlayerDownloadManager.PlayerDownloadMessage) {
+ val id: String = message.data["id"] as String
+ downloader?.cancelDownloads { download ->
+ download.song.id == id
+ }
+ }
+
+ private fun cancelAllDownloads(message: PlayerDownloadManager.PlayerDownloadMessage) {
+ downloader?.cancelDownloads { true }
+ }
+
+ private fun getNotificationText(): String {
+ val downloader: SongDownloaderImpl = downloader ?: return ""
+ val downloads: MutableList = downloader.downloads
+
+ var text: String = ""
+ var additional: String = ""
+
+ synchronized(downloads) {
+ var downloading = 0
+ for (download in downloads) {
+ if (download.status != DownloadStatus.Status.DOWNLOADING && download.status != DownloadStatus.Status.PAUSED) {
+ continue
+ }
+
+ if (text.isNotEmpty()) {
+ text += ", ${download.percent_progress}%"
+ }
+ else {
+ text += "${download.percent_progress}%"
+ }
+
+ downloading += 1
+ }
+
+ if (downloading < downloads.size) {
+ additional += "${downloads.size - downloading} queued"
+ }
+ if (downloader.completed_downloads > 0) {
+ if (additional.isNotEmpty()) {
+ additional += ", ${downloader.completed_downloads} finished"
+ }
+ else {
+ additional += "${downloader.completed_downloads} finished"
+ }
+ }
+ if (downloader.failed_downloads > 0) {
+ if (additional.isNotEmpty()) {
+ additional += ", ${downloader.failed_downloads} failed"
+ }
+ else {
+ additional += "${downloader.failed_downloads} failed"
+ }
+ }
+ }
+
+ return if (additional.isEmpty()) text else "$text ($additional)"
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ val action = intent?.extras?.get("action")
+ if (action is IntentAction) {
+ SpMp.Log.info("Download service received action $action")
+ onActionIntentReceived(
+ PlayerDownloadManager.PlayerDownloadMessage(
+ action,
+ emptyMap()
+ )
+ )
+ }
+
+ return super.onStartCommand(intent, flags, startId)
+ }
+
+ private fun getNotificationBuilder(): NotificationCompat.Builder {
+ val content_intent: PendingIntent = PendingIntent.getActivity(
+ this, 0,
+ Intent(this@PlayerDownloadService, AppContext.main_activity),
+ PendingIntent.FLAG_IMMUTABLE
+ )
+
+ return NotificationCompat.Builder(this, getNotificationChannel())
+ .setSmallIcon(android.R.drawable.stat_sys_download)
+ .setContentIntent(content_intent)
+ .setOnlyAlertOnce(true)
+ .setOngoing(true)
+ .setProgress(100, 0, false)
+ .addAction(pause_resume_action)
+ .addAction(
+ NotificationCompat.Action.Builder(
+ IconCompat.createWithResource(this, android.R.drawable.ic_menu_close_clear_cancel),
+ getString("action_cancel"),
+ PendingIntent.getService(
+ this,
+ IntentAction.CANCEL_ALL.ordinal,
+ Intent(this, PlayerDownloadService::class.java).putExtra("action", IntentAction.CANCEL_ALL),
+ PendingIntent.FLAG_IMMUTABLE
+ )
+ ).build()
+ )
+ .apply {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ foregroundServiceBehavior = Notification.FOREGROUND_SERVICE_IMMEDIATE
+ }
+ }
+ }
+
+ private fun getNotificationChannel(): String {
+ val channel =
+ NotificationChannelCompat.Builder(
+ NOTIFICATION_CHANNEL_ID,
+ NotificationManager.IMPORTANCE_LOW
+ )
+ .setName(getString("download_service_name"))
+ .setSound(null, null)
+ .build()
+
+ notification_manager.createNotificationChannel(channel)
+ return NOTIFICATION_CHANNEL_ID
+ }
+
+ inner class ServiceBinder: PlatformBinder() {
+ fun getService(): PlayerDownloadService = this@PlayerDownloadService
+ }
+ private val binder = ServiceBinder()
+ override fun onBind() = binder
+}
\ No newline at end of file
diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/model/lyrics/LyricsFileConverter.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/model/lyrics/LyricsFileConverter.kt
index 4e017d883..685697678 100644
--- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/model/lyrics/LyricsFileConverter.kt
+++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/model/lyrics/LyricsFileConverter.kt
@@ -55,7 +55,8 @@ object LyricsFileConverter {
file: PlatformFile,
context: AppContext
): Result = withContext(Dispatchers.IO) {
- val temp_file = file.getSibling("${file.name}.tmp")
+ val temp_file: PlatformFile = file.getSibling("${file.name}.tmp")
+ check(temp_file.createFile()) { temp_file.toString() }
return@withContext runCatching {
temp_file.outputStream().use { stream ->
diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/model/mediaitem/MediaItemPreviewInteraction.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/model/mediaitem/MediaItemPreviewInteraction.kt
index a715bf60c..170e87258 100644
--- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/model/mediaitem/MediaItemPreviewInteraction.kt
+++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/model/mediaitem/MediaItemPreviewInteraction.kt
@@ -11,12 +11,15 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalViewConfiguration
import com.toasterofbread.composekit.platform.Platform
import com.toasterofbread.composekit.platform.composable.platformClickable
import com.toasterofbread.composekit.platform.vibrateShort
import com.toasterofbread.composekit.utils.composable.OnChangedEffect
import com.toasterofbread.spmp.ui.component.longpressmenu.LongPressMenuData
+import com.toasterofbread.spmp.ui.component.longpressmenu.longPressItem
import com.toasterofbread.spmp.ui.layout.apppage.mainpage.PlayerState
import kotlinx.coroutines.delay
@@ -24,11 +27,13 @@ enum class MediaItemPreviewInteractionPressStage {
INSTANT, BRIEF, LONG_1, LONG_2;
fun execute(
- item: MediaItem,
- long_press_menu_data: LongPressMenuData,
+ item: MediaItem,
+ long_press_menu_data: LongPressMenuData,
+ click_offset: Offset,
onClick: (item: MediaItem, multiselect_key: Int?) -> Unit,
onLongClick: (item: MediaItem, long_press_menu_data: LongPressMenuData) -> Unit
) {
+ long_press_menu_data.click_offset = click_offset
when (this) {
INSTANT -> {
if (long_press_menu_data.multiselect_context?.is_active == true) {
@@ -54,28 +59,50 @@ enum class MediaItemPreviewInteractionPressStage {
}
}
-private fun getIndication(): Indication? = null
-
@Composable
fun Modifier.mediaItemPreviewInteraction(
item: MediaItem,
long_press_menu_data: LongPressMenuData,
onClick: ((item: MediaItem, multiselect_key: Int?) -> Unit)? = null,
onLongClick: ((item: MediaItem, long_press_menu_data: LongPressMenuData) -> Unit)? = null
+): Modifier {
+ val base: Modifier = when (Platform.current) {
+ Platform.ANDROID -> androidMediaItemPreviewInteraction(item, long_press_menu_data, onClick, onLongClick)
+ Platform.DESKTOP -> desktopMediaItemPreviewInteraction(item, long_press_menu_data, onClick, onLongClick)
+ }
+ return base.longPressItem(long_press_menu_data)
+}
+
+@Composable
+private fun Modifier.desktopMediaItemPreviewInteraction(
+ item: MediaItem,
+ long_press_menu_data: LongPressMenuData,
+ onClick: ((item: MediaItem, multiselect_key: Int?) -> Unit)? = null,
+ onLongClick: ((item: MediaItem, long_press_menu_data: LongPressMenuData) -> Unit)? = null
): Modifier {
val player: PlayerState = LocalPlayerState.current
-
val onItemClick = onClick ?: player::onMediaItemClicked
val onItemLongClick = onLongClick ?: player::onMediaItemLongClicked
- if (Platform.DESKTOP.isCurrent()) {
- return platformClickable(
- onClick = { MediaItemPreviewInteractionPressStage.INSTANT.execute(item, long_press_menu_data, onItemClick, onItemLongClick) },
- onAltClick = { MediaItemPreviewInteractionPressStage.LONG_1.execute(item, long_press_menu_data, onItemClick, onItemLongClick) },
- onAlt2Click = { MediaItemPreviewInteractionPressStage.LONG_2.execute(item, long_press_menu_data, onItemClick, onItemLongClick) },
- indication = getIndication()
- )
- }
+ return platformClickable(
+ onClick = { MediaItemPreviewInteractionPressStage.INSTANT.execute(item, long_press_menu_data, it, onItemClick, onItemLongClick) },
+ onAltClick = { MediaItemPreviewInteractionPressStage.LONG_1.execute(item, long_press_menu_data, it, onItemClick, onItemLongClick) },
+ onAlt2Click = { MediaItemPreviewInteractionPressStage.LONG_2.execute(item, long_press_menu_data, it, onItemClick, onItemLongClick) },
+ indication = null
+ )
+}
+
+@Composable
+private fun Modifier.androidMediaItemPreviewInteraction(
+ item: MediaItem,
+ long_press_menu_data: LongPressMenuData,
+ onClick: ((item: MediaItem, multiselect_key: Int?) -> Unit)? = null,
+ onLongClick: ((item: MediaItem, long_press_menu_data: LongPressMenuData) -> Unit)? = null
+): Modifier {
+ val player: PlayerState = LocalPlayerState.current
+
+ val onItemClick = onClick ?: player::onMediaItemClicked
+ val onItemLongClick = onLongClick ?: player::onMediaItemLongClicked
var current_press_stage: MediaItemPreviewInteractionPressStage by remember { mutableStateOf(MediaItemPreviewInteractionPressStage.INSTANT) }
val long_press_timeout: Long = LocalViewConfiguration.current.longPressTimeoutMillis
@@ -102,7 +129,7 @@ fun Modifier.mediaItemPreviewInteraction(
player.context.vibrateShort()
if (stage == MediaItemPreviewInteractionPressStage.values().last { it.isAvailable(long_press_menu_data) }) {
- current_press_stage.execute(item, long_press_menu_data, onItemClick, onItemLongClick)
+ current_press_stage.execute(item, long_press_menu_data, Offset.Zero, onItemClick, onItemLongClick)
long_press_menu_data.current_interaction_stage = null
break
}
@@ -111,14 +138,14 @@ fun Modifier.mediaItemPreviewInteraction(
}
else {
if (current_press_stage != MediaItemPreviewInteractionPressStage.values().last { it.isAvailable(long_press_menu_data) }) {
- current_press_stage.execute(item, long_press_menu_data, onItemClick, onItemLongClick)
+ current_press_stage.execute(item, long_press_menu_data, Offset.Zero, onItemClick, onItemLongClick)
}
current_press_stage = MediaItemPreviewInteractionPressStage.INSTANT
long_press_menu_data.current_interaction_stage = null
}
}
- return clickable(interaction_source, getIndication(), onClick = {
- current_press_stage.execute(item, long_press_menu_data, onItemClick, onItemLongClick)
+ return clickable(interaction_source, null, onClick = {
+ current_press_stage.execute(item, long_press_menu_data, Offset.Zero, onItemClick, onItemLongClick)
})
}
diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/model/mediaitem/ToInfoString.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/model/mediaitem/ToInfoString.kt
index 3f48dcf07..87ca60920 100644
--- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/model/mediaitem/ToInfoString.kt
+++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/model/mediaitem/ToInfoString.kt
@@ -5,13 +5,13 @@ fun MediaItem.toInfoString(): String {
return toString()
}
- val string = StringBuilder(toString())
+ val string: StringBuilder = StringBuilder(toString())
val values: Map = getDataValues()
if (values.isNotEmpty()) {
- string.append("\n{")
+ string.append(" {")
for (entry in values) {
- string.append("\n${entry.key}=${entry.value}")
+ string.append("\n ${entry.key}=${entry.value}")
}
string.append("\n}")
}
diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/model/mediaitem/layout/MediaItemLayout.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/model/mediaitem/layout/MediaItemLayout.kt
index 362a9405f..47992d72c 100644
--- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/model/mediaitem/layout/MediaItemLayout.kt
+++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/model/mediaitem/layout/MediaItemLayout.kt
@@ -1,6 +1,7 @@
package com.toasterofbread.spmp.model.mediaitem.layout
import LocalPlayerState
+import androidx.compose.foundation.gestures.ScrollableState
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@@ -22,7 +23,7 @@ import com.toasterofbread.spmp.youtubeapi.RadioBuilderModifier
@Composable
fun getDefaultMediaItemPreviewSize(): DpSize =
- if (LocalPlayerState.current.isLargeFormFactor()) DpSize(180.dp, 250.dp)
+ if (LocalPlayerState.current.isLargeFormFactor()) DpSize(180.dp, 200.dp)
else DpSize(100.dp, 120.dp)
@Composable
@@ -149,5 +150,6 @@ data class MediaItemLayout(
internal fun shouldShowTitleBar(
title: LocalisedString?,
subtitle: LocalisedString?,
- view_more: ViewMore? = null
-): Boolean = title != null || subtitle != null || view_more != null
+ view_more: ViewMore? = null,
+ scrollable_state: ScrollableState? = null
+): Boolean = title != null || subtitle != null || view_more != null || scrollable_state != null
diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/model/mediaitem/library/MediaItemLibrary.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/model/mediaitem/library/MediaItemLibrary.kt
index 0f3f705c0..29859dd31 100644
--- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/model/mediaitem/library/MediaItemLibrary.kt
+++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/model/mediaitem/library/MediaItemLibrary.kt
@@ -8,8 +8,14 @@ import com.toasterofbread.spmp.model.mediaitem.playlist.Playlist
import com.toasterofbread.spmp.model.mediaitem.playlist.PlaylistData
import com.toasterofbread.spmp.model.mediaitem.playlist.PlaylistFileConverter
import com.toasterofbread.spmp.model.mediaitem.song.Song
+import com.toasterofbread.spmp.model.mediaitem.song.SongRef
import com.toasterofbread.spmp.model.settings.category.SystemSettings
import com.toasterofbread.spmp.platform.AppContext
+import com.toasterofbread.spmp.platform.download.DownloadStatus
+import com.toasterofbread.spmp.platform.download.LocalSongMetadataProcessor
+import com.toasterofbread.spmp.platform.download.SongDownloader
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
object MediaItemLibrary {
fun getLibraryDir(
@@ -72,4 +78,61 @@ object MediaItemLibrary {
}
}
}
+
+ suspend fun getLocalSongDownload(song: Song, context: AppContext): DownloadStatus? = withContext(Dispatchers.IO) {
+ for (file in getLocalSongsDir(context).listFiles() ?: emptyList()) {
+ val in_progress: Boolean
+ if (SongDownloader.isFileDownloadInProgress(file)) {
+ if (!SongDownloader.isFileDownloadInProgressForSong(file, song)) {
+ continue
+ }
+
+ in_progress = true
+ }
+ else if (LocalSongMetadataProcessor.readLocalSongMetadata(file, match_id = song.id, load_data = false) != null) {
+ in_progress = false
+ }
+ else {
+ continue
+ }
+
+ return@withContext DownloadStatus(
+ song = song,
+ status = if (in_progress) DownloadStatus.Status.IDLE else DownloadStatus.Status.FINISHED,
+ quality = null,
+ progress = if (in_progress) -1f else 1f,
+ id = file.name,
+ file = file
+ )
+ }
+
+ return@withContext null
+ }
+
+ suspend fun getLocalSongDownloads(context: AppContext): List = withContext(Dispatchers.IO) {
+ val files: List = getLocalSongsDir(context).listFiles() ?: emptyList()
+ return@withContext files.mapNotNull { file ->
+ val song: Song
+ val in_progress: Boolean
+
+ val song_id: String? = SongDownloader.getSongIdOfInProgressDownload(file)
+ if (song_id != null) {
+ song = SongRef(song_id)
+ in_progress = true
+ }
+ else {
+ song = LocalSongMetadataProcessor.readLocalSongMetadata(file, load_data = false) ?: return@mapNotNull null
+ in_progress = false
+ }
+
+ DownloadStatus(
+ song = song,
+ status = if (in_progress) DownloadStatus.Status.IDLE else DownloadStatus.Status.FINISHED,
+ quality = null,
+ progress = if (in_progress) -1f else 1f,
+ id = file.name,
+ file = file
+ )
+ }
+ }
}
diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/model/settings/SettingsKey.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/model/settings/SettingsKey.kt
index 9600cb589..a04ebae83 100644
--- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/model/settings/SettingsKey.kt
+++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/model/settings/SettingsKey.kt
@@ -11,6 +11,7 @@ interface SettingsKey {
fun getDefaultValue(): T
fun getName(): String = category.getNameOfKey(this)
+ fun isHidden(): Boolean = false
fun get(preferences: PlatformPreferences = Settings.prefs): T {
return Settings.get(this, preferences)
diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/model/settings/category/BehaviourSettings.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/model/settings/category/BehaviourSettings.kt
index 8a58c2520..12ff0a164 100644
--- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/model/settings/category/BehaviourSettings.kt
+++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/model/settings/category/BehaviourSettings.kt
@@ -2,6 +2,7 @@ package com.toasterofbread.spmp.model.settings.category
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.TouchApp
+import com.toasterofbread.composekit.platform.Platform
import com.toasterofbread.spmp.model.settings.SettingsKey
import com.toasterofbread.spmp.platform.AppContext
import com.toasterofbread.spmp.resources.getString
@@ -27,7 +28,8 @@ data object BehaviourSettings: SettingsCategory("behaviour") {
SEARCH_SHOW_SUGGESTIONS,
STOP_PLAYER_ON_APP_CLOSE,
LPM_CLOSE_ON_ACTION,
- LPM_INCREMENT_PLAY_AFTER;
+ LPM_INCREMENT_PLAY_AFTER,
+ DESKTOP_LPM_KEEP_ON_BACKGROUND_SCROLL;
override val category: SettingsCategory get() = BehaviourSettings
@@ -44,6 +46,13 @@ data object BehaviourSettings: SettingsCategory("behaviour") {
STOP_PLAYER_ON_APP_CLOSE -> false
LPM_CLOSE_ON_ACTION -> true
LPM_INCREMENT_PLAY_AFTER -> true
+ DESKTOP_LPM_KEEP_ON_BACKGROUND_SCROLL -> false
} as T
+
+ override fun isHidden(): Boolean =
+ when (this) {
+ DESKTOP_LPM_KEEP_ON_BACKGROUND_SCROLL -> !Platform.DESKTOP.isCurrent()
+ else -> false
+ }
}
}
diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/model/settings/category/DesktopSettings.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/model/settings/category/DesktopSettings.kt
index abe687ec7..1e0196e78 100644
--- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/model/settings/category/DesktopSettings.kt
+++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/model/settings/category/DesktopSettings.kt
@@ -2,6 +2,7 @@ package com.toasterofbread.spmp.model.settings.category
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.DesktopWindows
+import com.toasterofbread.composekit.platform.Platform
import com.toasterofbread.spmp.model.settings.SettingsKey
import com.toasterofbread.spmp.resources.getString
import com.toasterofbread.spmp.ui.layout.apppage.settingspage.category.getDesktopCategoryItems
@@ -10,15 +11,16 @@ data object DesktopSettings: SettingsCategory("desktop") {
override val keys: List = Key.values().toList()
override fun getPage(): Page? =
- Page(
- getString("s_cat_desktop"),
- getString("s_cat_desc_desktop"),
- { getDesktopCategoryItems() }
- ) { Icons.Outlined.DesktopWindows }
+ if (Platform.DESKTOP.isCurrent())
+ Page(
+ getString("s_cat_desktop"),
+ getString("s_cat_desc_desktop"),
+ { getDesktopCategoryItems() }
+ ) { Icons.Outlined.DesktopWindows }
+ else null
enum class Key: SettingsKey {
STARTUP_COMMAND,
-
SERVER_IP_ADDRESS,
SERVER_PORT,
SERVER_LOCAL_COMMAND,
diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/model/settings/category/SettingsCategory.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/model/settings/category/SettingsCategory.kt
index 677121de3..e4e4b37e2 100644
--- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/model/settings/category/SettingsCategory.kt
+++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/model/settings/category/SettingsCategory.kt
@@ -67,7 +67,7 @@ sealed class SettingsCategory(id: String) {
override fun getItems(context: AppContext): List? {
if (items == null) {
- items = getPageItems(context)
+ items = getPageItems(context).filter { it.getKeys().none { category.getKeyOfName(it)!!.isHidden() } }
}
return items!!
}
diff --git a/shared/src/androidMain/kotlin/com/toasterofbread/spmp/platform/download/LocalSongMetadataProcessor.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/platform/download/LocalSongMetadataProcessor.kt
similarity index 68%
rename from shared/src/androidMain/kotlin/com/toasterofbread/spmp/platform/download/LocalSongMetadataProcessor.kt
rename to shared/src/commonMain/kotlin/com/toasterofbread/spmp/platform/download/LocalSongMetadataProcessor.kt
index 7c1b996b5..a08c761fd 100644
--- a/shared/src/androidMain/kotlin/com/toasterofbread/spmp/platform/download/LocalSongMetadataProcessor.kt
+++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/platform/download/LocalSongMetadataProcessor.kt
@@ -1,16 +1,16 @@
package com.toasterofbread.spmp.platform.download
-import com.toasterofbread.spmp.model.mediaitem.song.Song
-import com.toasterofbread.spmp.platform.AppContext
import com.toasterofbread.composekit.platform.PlatformFile
import com.toasterofbread.spmp.model.mediaitem.MediaItem
import com.toasterofbread.spmp.model.mediaitem.MediaItemData
-import com.toasterofbread.spmp.model.mediaitem.artist.Artist.Companion.getForItemId
+import com.toasterofbread.spmp.model.mediaitem.artist.Artist
import com.toasterofbread.spmp.model.mediaitem.artist.ArtistData
import com.toasterofbread.spmp.model.mediaitem.artist.ArtistRef
import com.toasterofbread.spmp.model.mediaitem.playlist.RemotePlaylist
import com.toasterofbread.spmp.model.mediaitem.playlist.RemotePlaylistData
+import com.toasterofbread.spmp.model.mediaitem.song.Song
import com.toasterofbread.spmp.model.mediaitem.song.SongData
+import com.toasterofbread.spmp.platform.AppContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
@@ -18,14 +18,20 @@ import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.jaudiotagger.audio.AudioFile
import org.jaudiotagger.audio.AudioFileIO
+import org.jaudiotagger.audio.mp4.Mp4TagReader
import org.jaudiotagger.tag.FieldKey
import org.jaudiotagger.tag.Tag
import org.jaudiotagger.tag.mp4.Mp4Tag
import java.io.File
+import java.util.logging.Level
private val CUSTOM_METADATA_KEY: FieldKey = FieldKey.CUSTOM1
object LocalSongMetadataProcessor {
+ init {
+ Mp4TagReader.logger.level = Level.OFF
+ }
+
@Serializable
private data class CustomMetadata(
val song_id: String?, val artist_id: String?, val album_id: String?
@@ -38,7 +44,7 @@ object LocalSongMetadataProcessor {
}
else if (item_title != null) {
// TODO
- item = createItem(getForItemId(this))
+ item = createItem(Artist.getForItemId(this))
}
else {
return null
@@ -53,8 +59,7 @@ object LocalSongMetadataProcessor {
fun set(key: FieldKey, value: String?) {
if (value == null) {
deleteField(key)
- }
- else {
+ } else {
setField(key, value)
}
}
@@ -82,27 +87,34 @@ object LocalSongMetadataProcessor {
audio_file.commit()
}
- suspend fun readLocalSongMetadata(file: PlatformFile, match_id: String? = null, load_data: Boolean = true): SongData? = withContext(Dispatchers.IO) {
- val tag: Tag
- try {
- tag = AudioFileIO.read(File(file.absolute_path)).tag
- }
- catch (e: Throwable) {
- return@withContext null
- }
+ suspend fun readLocalSongMetadata(file: PlatformFile, match_id: String? = null, load_data: Boolean = true): SongData? =
+ withContext(Dispatchers.IO) {
+ val tag: Tag =
+ try {
+ AudioFileIO.read(File(file.absolute_path)).tag
+ }
+ catch (e: Throwable) {
+ e.printStackTrace()
+ return@withContext null
+ }
- val custom_metadata: CustomMetadata = Json.decodeFromString(tag.getFirst(CUSTOM_METADATA_KEY))
- if (custom_metadata.song_id == null || (match_id != null && custom_metadata.song_id != match_id)) {
- return@withContext null
- }
+ val custom_metadata: CustomMetadata? =
+ try {
+ Json.decodeFromString(tag.getFirst(CUSTOM_METADATA_KEY))
+ }
+ catch (_: Throwable) { null }
- return@withContext SongData(custom_metadata.song_id).apply {
- if (!load_data) {
- return@apply
+ if (custom_metadata?.song_id == null || (match_id != null && custom_metadata.song_id != match_id)) {
+ return@withContext null
+ }
+
+ return@withContext SongData(custom_metadata.song_id).apply {
+ if (!load_data) {
+ return@apply
+ }
+ title = tag.getFirst(FieldKey.TITLE)
+ artist = getItemWithOrForTitle(custom_metadata.artist_id, tag.getFirst(FieldKey.ARTIST)) { ArtistData(it) }
+ album = getItemWithOrForTitle(custom_metadata.album_id, tag.getFirst(FieldKey.ALBUM)) { RemotePlaylistData(it) }
}
- title = tag.getFirst(FieldKey.TITLE)
- artist = getItemWithOrForTitle(custom_metadata.artist_id, tag.getFirst(FieldKey.ARTIST)) { ArtistData(it) }
- album = getItemWithOrForTitle(custom_metadata.album_id, tag.getFirst(FieldKey.ALBUM)) { RemotePlaylistData(it) }
}
- }
-}
+}
\ No newline at end of file
diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/platform/download/PlayerDownloadManager.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/platform/download/PlayerDownloadManager.kt
index 0051fda3f..79953cf7a 100644
--- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/platform/download/PlayerDownloadManager.kt
+++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/platform/download/PlayerDownloadManager.kt
@@ -8,10 +8,14 @@ import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import com.toasterofbread.composekit.platform.Platform
import com.toasterofbread.composekit.platform.PlatformFile
+import com.toasterofbread.spmp.model.lyrics.LyricsFileConverter
import com.toasterofbread.spmp.model.mediaitem.enums.MediaItemType
+import com.toasterofbread.spmp.model.mediaitem.library.MediaItemLibrary
import com.toasterofbread.spmp.model.mediaitem.song.Song
import com.toasterofbread.spmp.model.mediaitem.song.SongAudioQuality
+import com.toasterofbread.spmp.model.mediaitem.song.SongData
import com.toasterofbread.spmp.platform.AppContext
import com.toasterofbread.spmp.resources.getString
import com.toasterofbread.spmp.ui.layout.apppage.mainpage.DownloadRequestCallback
@@ -64,6 +68,10 @@ enum class DownloadMethod {
val directory: PlatformFile = context.getUserDirectoryFile(uri)
+ Platform.ANDROID.only {
+ directory.mkdirs()
+ }
+
for (song in songs) {
var file: PlatformFile
val name: String = song.getActiveTitle(context.database) ?: MediaItemType.SONG.getReadable(false)
@@ -79,7 +87,10 @@ enum class DownloadMethod {
}
while (file.exists)
- file.createFile()
+ Platform.ANDROID.only {
+ // File must be created at this stage on Android, it will fail if done later
+ file.createFile()
+ }
context.download_manager.startDownload(song, file_uri = file.uri, callback = callback)
}
@@ -94,18 +105,19 @@ enum class DownloadMethod {
}
}
-expect class PlayerDownloadManager(context: AppContext) {
- class DownloadStatus {
- val song: Song
- val status: Status
- val quality: SongAudioQuality?
- val progress: Float
- val id: String
- enum class Status { IDLE, PAUSED, DOWNLOADING, CANCELLED, ALREADY_FINISHED, FINISHED }
-
- fun isCompleted(): Boolean
- }
+class DownloadStatus(
+ val song: Song,
+ val status: Status,
+ val quality: SongAudioQuality?,
+ val progress: Float,
+ val id: String,
+ val file: PlatformFile?
+) {
+ enum class Status { IDLE, PAUSED, DOWNLOADING, CANCELLED, ALREADY_FINISHED, FINISHED }
+ fun isCompleted(): Boolean = progress >= 1f
+}
+expect class PlayerDownloadManager(context: AppContext) {
open class DownloadStatusListener() {
open fun onDownloadAdded(status: DownloadStatus)
open fun onDownloadRemoved(id: String)
@@ -132,9 +144,9 @@ expect class PlayerDownloadManager(context: AppContext) {
}
@Composable
-fun Song.rememberDownloadStatus(): State {
+fun Song.rememberDownloadStatus(): State {
val download_manager: PlayerDownloadManager = LocalPlayerState.current.context.download_manager
- val download_state: MutableState = remember { mutableStateOf(null) }
+ val download_state: MutableState = remember { mutableStateOf(null) }
LaunchedEffect(id) {
download_state.value = null
@@ -143,7 +155,7 @@ fun Song.rememberDownloadStatus(): State
DisposableEffect(id) {
val listener = object : PlayerDownloadManager.DownloadStatusListener() {
- override fun onDownloadAdded(status: PlayerDownloadManager.DownloadStatus) {
+ override fun onDownloadAdded(status: DownloadStatus) {
if (status.song.id == id) {
download_state.value = status
}
@@ -153,7 +165,7 @@ fun Song.rememberDownloadStatus(): State
download_state.value = null
}
}
- override fun onDownloadChanged(status: PlayerDownloadManager.DownloadStatus) {
+ override fun onDownloadChanged(status: DownloadStatus) {
if (status.song.id == id) {
download_state.value = status
}
@@ -170,9 +182,9 @@ fun Song.rememberDownloadStatus(): State
}
@Composable
-fun rememberSongDownloads(): State> {
+fun rememberSongDownloads(): State> {
val download_manager = LocalPlayerState.current.context.download_manager
- val download_state: MutableState> = remember { mutableStateOf(emptyList()) }
+ val download_state: MutableState> = remember { mutableStateOf(emptyList()) }
LaunchedEffect(Unit) {
download_state.value = download_manager.getDownloads()
@@ -180,7 +192,7 @@ fun rememberSongDownloads(): State> {
DisposableEffect(Unit) {
val listener = object : PlayerDownloadManager.DownloadStatusListener() {
- override fun onDownloadAdded(status: PlayerDownloadManager.DownloadStatus) {
+ override fun onDownloadAdded(status: DownloadStatus) {
synchronized(download_state) {
download_state.value += status
}
@@ -192,7 +204,7 @@ fun rememberSongDownloads(): State> {
}
}
}
- override fun onDownloadChanged(status: PlayerDownloadManager.DownloadStatus) {
+ override fun onDownloadChanged(status: DownloadStatus) {
synchronized(download_state) {
val temp = download_state.value.toMutableList()
for (i in 0 until download_state.value.size) {
@@ -214,5 +226,28 @@ fun rememberSongDownloads(): State> {
return download_state
}
-expect suspend fun Song.getLocalSongFile(context: AppContext, allow_partial: Boolean = false): PlatformFile?
-expect fun Song.getLocalLyricsFile(context: AppContext, allow_partial: Boolean = false): PlatformFile?
+suspend fun Song.getLocalSongFile(context: AppContext, allow_partial: Boolean = false): PlatformFile? {
+ val files: List = MediaItemLibrary.getLocalSongsDir(context).listFiles() ?: return null
+ for (file in files) {
+ if (SongDownloader.isFileDownloadInProgressForSong(file, this)) {
+ if (allow_partial) {
+ return file
+ }
+ return null
+ }
+
+ val metadata: SongData? = LocalSongMetadataProcessor.readLocalSongMetadata(file, id, load_data = false)
+ if (metadata != null) {
+ return file
+ }
+ }
+ return null
+}
+
+fun Song.getLocalLyricsFile(context: AppContext, allow_partial: Boolean = false): PlatformFile? {
+ val file: PlatformFile = MediaItemLibrary.getLocalLyricsFile(this, context)
+ if (!file.is_file) {
+ return null
+ }
+ return file
+}
diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/platform/download/SongDownloader.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/platform/download/SongDownloader.kt
new file mode 100644
index 000000000..c91b6801d
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/platform/download/SongDownloader.kt
@@ -0,0 +1,412 @@
+package com.toasterofbread.spmp.platform.download
+
+import com.toasterofbread.composekit.platform.PlatformFile
+import com.toasterofbread.composekit.platform.getPlatformForbiddenFilenameCharacters
+import com.toasterofbread.spmp.model.lyrics.LyricsFileConverter
+import com.toasterofbread.spmp.model.mediaitem.library.MediaItemLibrary
+import com.toasterofbread.spmp.model.mediaitem.loader.SongLyricsLoader
+import com.toasterofbread.spmp.model.mediaitem.song.Song
+import com.toasterofbread.spmp.model.mediaitem.song.SongAudioQuality
+import com.toasterofbread.spmp.model.mediaitem.song.getSongFormatByQuality
+import com.toasterofbread.spmp.model.settings.Settings
+import com.toasterofbread.spmp.model.settings.category.StreamingSettings
+import com.toasterofbread.spmp.platform.AppContext
+import com.toasterofbread.spmp.youtubeapi.YoutubeVideoFormat
+import com.toasterofbread.spmp.youtubeapi.impl.youtubemusic.getOrThrowHere
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.withContext
+import java.io.InputStream
+import java.io.OutputStream
+import java.net.ConnectException
+import java.net.HttpURLConnection
+import java.net.URI
+import java.net.URL
+import java.nio.file.Files
+import java.nio.file.Path
+import java.util.concurrent.ExecutorService
+
+private const val FILE_DOWNLOADING_SUFFIX = ".part"
+
+abstract class SongDownloader(
+ private val context: AppContext,
+ private val download_executor: ExecutorService,
+ private val max_retry_count: Int = 3
+) {
+ protected abstract fun getAudioFileDurationMs(file: PlatformFile): Long?
+ protected abstract fun onDownloadStatusChanged(download: Download, started: Boolean = false)
+ protected open fun onDownloadProgress() {}
+ protected open fun onPausedChanged() {}
+ protected open fun onFirstDownloadStarting(download: Download) {}
+ protected open fun onLastDownloadFinished() {}
+
+ inner class Download(
+ val song: Song,
+ val quality: SongAudioQuality,
+ var silent: Boolean,
+ val instance: Int,
+ val file_uri: String?
+ ) {
+ var song_file: PlatformFile? = runBlocking {
+ // This is fine :)
+ song.getLocalSongFile(context, allow_partial = true)
+ }
+ var lyrics_file: PlatformFile? = song.getLocalLyricsFile(context, allow_partial = true)
+
+ var status: DownloadStatus.Status =
+ if (song_file?.let { isFileDownloadInProgressForSong(it, song) } == false) DownloadStatus.Status.ALREADY_FINISHED
+ else DownloadStatus.Status.IDLE
+ set(value) {
+ if (field != value) {
+ field = value
+ onDownloadStatusChanged(this)
+ }
+ }
+
+ val finished: Boolean get() = status == DownloadStatus.Status.ALREADY_FINISHED || status == DownloadStatus.Status.FINISHED
+ val downloading: Boolean get() = status == DownloadStatus.Status.DOWNLOADING || status == DownloadStatus.Status.PAUSED
+
+ var cancelled: Boolean = false
+ private set
+
+ var downloaded: Long = 0
+ var total_size: Long = -1
+
+ val progress: Float get() = if (total_size < 0f) 0f else downloaded.toFloat() / total_size
+ val percent_progress: Int get() = (progress * 100).toInt()
+
+ fun getStatusObject(): DownloadStatus =
+ DownloadStatus(
+ song,
+ status,
+ quality,
+ progress,
+ instance.toString(),
+ song_file
+ )
+
+ fun cancel() {
+ cancelled = true
+ }
+
+ fun generatePath(extension: String, in_progress: Boolean): String {
+ return getDownloadPath(song, extension, in_progress, context)
+ }
+
+ override fun toString(): String =
+ "Download(id=${song.id}, quality=$quality, silent=$silent, instance=$instance, file=$song_file)"
+ }
+
+ private var download_inc: Int = 0
+ private fun getOrCreateDownload(song: Song, silent: Boolean, file_uri: String?): Download {
+ synchronized(downloads) {
+ for (download in downloads) {
+ if (download.song.id == song.id) {
+ return download
+ }
+ }
+ return Download(song, Settings.getEnum(StreamingSettings.Key.DOWNLOAD_AUDIO_QUALITY), silent, download_inc++, file_uri)
+ }
+ }
+
+ fun getAllDownloadsStatus(): List =
+ downloads.map { it.getStatusObject() }
+
+ fun getDownloadStatus(song: Song): DownloadStatus? =
+ downloads.firstOrNull { it.song.id == song.id }?.getStatusObject()
+
+ fun getTotalDownloadProgress(): Float {
+ if (downloads.isEmpty()) {
+ return 1f
+ }
+
+ val finished = completed_downloads + failed_downloads
+
+ var total_progress: Float = finished.toFloat()
+ for (download in downloads) {
+ total_progress += download.progress
+ }
+ return total_progress / (downloads.size + finished)
+ }
+
+ companion object {
+ fun getDownloadPath(song: Song, extension: String, in_progress: Boolean, context: AppContext): String {
+ val forbidden_chars: String = getPlatformForbiddenFilenameCharacters()
+
+ val filename: String = (
+ if (in_progress) song.id
+ else song.getActiveTitle(context.database) ?: song.id
+ ).filter { !forbidden_chars.contains(it) }
+
+ if (in_progress) {
+ return "$filename.$extension$FILE_DOWNLOADING_SUFFIX"
+ }
+ else {
+ return "$filename.$extension"
+ }
+ }
+
+ fun isFileDownloadInProgress(file: PlatformFile): Boolean =
+ file.name.endsWith(FILE_DOWNLOADING_SUFFIX)
+
+ fun isFileDownloadInProgressForSong(file: PlatformFile, song: Song): Boolean =
+ isFileDownloadInProgress(file) && file.name.startsWith("${song.id}.")
+
+ fun getSongIdOfInProgressDownload(file: PlatformFile): String? =
+ if (file.name.endsWith(FILE_DOWNLOADING_SUFFIX)) file.name.split('.', limit = 2).first() else null
+ }
+
+ private val song_download_dir: PlatformFile get() = MediaItemLibrary.getLocalSongsDir(context)
+
+ val downloads: MutableList = mutableListOf()
+ private var stopping: Boolean = false
+ fun stop() {
+ synchronized(download_executor) {
+ stopping = true
+ }
+ }
+
+ var start_time_ms: Long = 0
+ private set
+ var completed_downloads: Int = 0
+ private set
+ var failed_downloads: Int = 0
+ private set
+ var cancelled: Boolean = false
+ private set
+
+ var paused: Boolean = false
+ set(value) {
+ field = value
+ onPausedChanged()
+ }
+
+ fun release() {
+ download_executor.shutdownNow()
+ try {
+ downloads.clear()
+ }
+ catch (_: Throwable) {}
+ }
+
+ fun startDownload(song: Song, silent: Boolean, file_uri: String?, callback: (Download, Result) -> Unit) {
+ val download: Download = getOrCreateDownload(
+ song,
+ silent = silent,
+ file_uri = file_uri
+ )
+
+ synchronized(download) {
+ if (download.finished) {
+ callback(download, Result.success(download.song_file))
+ return
+ }
+
+ if (download.downloading) {
+ if (paused) {
+ paused = false
+ }
+ callback(download, Result.success(null))
+ return
+ }
+
+ synchronized(downloads) {
+ if (downloads.isEmpty()) {
+ onFirstDownloadStarting(download)
+ start_time_ms = System.currentTimeMillis()
+ completed_downloads = 0
+ failed_downloads = 0
+ cancelled = false
+ }
+
+ downloads.add(download)
+ onDownloadStatusChanged(download, true)
+ }
+ }
+
+ onDownloadProgress()
+
+ download_executor.submit {
+ runBlocking {
+ var result: Result? = null
+ var retry_count: Int = 0
+
+ while (
+ retry_count++ < max_retry_count && (
+ result == null || download.status == DownloadStatus.Status.IDLE || download.status == DownloadStatus.Status.PAUSED
+ )
+ ) {
+ if (paused && !download.cancelled) {
+ onDownloadProgress()
+ delay(500)
+ continue
+ }
+
+ result =
+ try {
+ performDownload(download)
+ }
+ catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+
+ synchronized(downloads) {
+ downloads.removeAll { it.song.id == download.song.id }
+
+ if (downloads.isEmpty()) {
+ cancelled = download.cancelled
+ onLastDownloadFinished()
+ }
+
+ if (result?.isSuccess == true) {
+ completed_downloads += 1
+ }
+ else {
+ failed_downloads += 1
+ }
+ }
+
+ callback(download, result ?: Result.failure(RuntimeException("Download not performed")))
+
+ onDownloadProgress()
+ }
+ }
+ }
+
+ fun cancelDownloads(filter: (Download) -> Boolean) {
+ synchronized(downloads) {
+ for (download in downloads) {
+ if (filter(download)) {
+ download.cancel()
+ }
+ }
+ }
+ }
+
+ private suspend fun performDownload(download: Download): Result = withContext(Dispatchers.IO) {
+ val format: YoutubeVideoFormat = getSongFormatByQuality(download.song.id, download.quality, context).fold(
+ { it },
+ { return@withContext Result.failure(it) }
+ )
+
+ val connection: HttpURLConnection = URL(format.url).openConnection() as HttpURLConnection
+ connection.connectTimeout = 3000
+ connection.setRequestProperty("Range", "bytes=${download.downloaded}-")
+
+ try {
+ connection.connect()
+ }
+ catch (e: Throwable) {
+ return@withContext Result.failure(RuntimeException(connection.url.toString(), e))
+ }
+
+ if (connection.responseCode != 200 && connection.responseCode != 206) {
+ return@withContext Result.failure(
+ ConnectException(
+ "${download.song.id}: Server returned code ${connection.responseCode} ${connection.responseMessage}"
+ )
+ )
+ }
+
+ val format_extension: String =
+ when (connection.contentType) {
+ "audio/webm" -> "webm"
+ "audio/mp4" -> "mp4"
+ else -> return@withContext Result.failure(NotImplementedError(connection.contentType))
+ }
+
+ var file: PlatformFile? = download.song_file
+ check(song_download_dir.mkdirs()) { song_download_dir.toString() }
+
+ if (file == null) {
+ file = song_download_dir.resolve(download.generatePath(format_extension, true))
+ }
+
+ check(file.name.endsWith(FILE_DOWNLOADING_SUFFIX))
+
+ val data: ByteArray = ByteArray(4096)
+ val input_stream: InputStream = connection.inputStream
+ val output_stream: OutputStream = file.outputStream(true)
+
+ fun close(status: DownloadStatus.Status) {
+ input_stream.close()
+ output_stream.close()
+ connection.disconnect()
+ download.status = status
+ }
+
+ try {
+ download.total_size = connection.contentLengthLong + download.downloaded
+ download.status = DownloadStatus.Status.DOWNLOADING
+
+ while (true) {
+ val size = input_stream.read(data)
+ if (size < 0) {
+ break
+ }
+
+ synchronized(download_executor) {
+ if (stopping || download.cancelled) {
+ close(DownloadStatus.Status.CANCELLED)
+ return@withContext Result.success(null)
+ }
+ if (paused) {
+ close(DownloadStatus.Status.PAUSED)
+ return@withContext Result.success(null)
+ }
+ }
+
+ download.downloaded += size
+ output_stream.write(data, 0, size)
+
+ onDownloadProgress()
+ }
+
+ runBlocking {
+ launch {
+ download.song.Duration.setNotNull(getAudioFileDurationMs(file), context.database)
+ LocalSongMetadataProcessor.addMetadataToLocalSong(download.song, file, format_extension, context)
+ }
+ launch {
+ if (download.lyrics_file == null) {
+ val lyrics_file: PlatformFile = MediaItemLibrary.getLocalLyricsFile(download.song, context)
+ SongLyricsLoader.loadBySong(download.song, context)?.onSuccess { lyrics ->
+ with (LyricsFileConverter) {
+ val exception: Throwable? = lyrics.saveToFile(lyrics_file, context).exceptionOrNull()
+ exception?.printStackTrace()
+ }
+ }
+ }
+ }
+ }
+
+ close(DownloadStatus.Status.FINISHED)
+ }
+ catch (e: Throwable) {
+ e.printStackTrace()
+ close(DownloadStatus.Status.CANCELLED)
+ }
+
+ if (download.file_uri != null) {
+ val uri_file: PlatformFile = context.getUserDirectoryFile(download.file_uri)
+ uri_file.outputStream().use { output ->
+ Files.copy(Path.of(URI.create(file.absolute_path)), output)
+ }
+ file.delete()
+ download.song_file = uri_file
+ }
+ else {
+ val renamed: PlatformFile = file.renameTo(
+ download.generatePath(format_extension, false)
+ )
+ download.song_file = renamed
+ }
+
+ download.status = DownloadStatus.Status.FINISHED
+
+ return@withContext Result.success(download.song_file)
+ }
+}
diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/component/MusicTopBar.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/component/MusicTopBar.kt
index 24a2a057f..6dd9518d2 100644
--- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/component/MusicTopBar.kt
+++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/component/MusicTopBar.kt
@@ -277,7 +277,7 @@ class MusicTopBar(val player: PlayerState) {
modifier
.height(IntrinsicSize.Min)
.platformClickable(
- onClick = onClick,
+ onClick = { onClick?.invoke() },
onAltClick = {
if (current_state is SongLyrics) {
player.openNowPlayingPlayerOverlayMenu(PlayerOverlayMenu.getLyricsMenu())
diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/component/longpressmenu/LongPressMenu.android.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/component/longpressmenu/LongPressMenu.android.kt
new file mode 100644
index 000000000..c4b3cdafc
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/component/longpressmenu/LongPressMenu.android.kt
@@ -0,0 +1,149 @@
+package com.toasterofbread.spmp.ui.component.longpressmenu
+
+import LocalPlayerState
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.core.FiniteAnimationSpec
+import androidx.compose.animation.core.spring
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.slideInVertically
+import androidx.compose.animation.slideOutVertically
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.requiredHeight
+import androidx.compose.foundation.layout.systemBars
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.IntOffset
+import androidx.compose.ui.unit.dp
+import com.toasterofbread.composekit.platform.composable.BackHandler
+import com.toasterofbread.composekit.utils.common.contrastAgainst
+import com.toasterofbread.composekit.utils.common.launchSingle
+import com.toasterofbread.composekit.utils.composable.getBottom
+import com.toasterofbread.composekit.utils.composable.getEnd
+import com.toasterofbread.composekit.utils.composable.getStart
+import com.toasterofbread.spmp.model.mediaitem.db.rememberThemeColour
+import com.toasterofbread.spmp.model.settings.category.BehaviourSettings
+import com.toasterofbread.spmp.ui.layout.apppage.mainpage.PlayerState
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.delay
+
+private const val MENU_OPEN_ANIM_MS: Int = 150
+private const val MENU_CONTENT_PADDING_DP: Float = 25f
+
+@Composable
+internal fun AndroidLongPressMenu(
+ showing: Boolean,
+ onDismissRequest: () -> Unit,
+ data: LongPressMenuData
+) {
+ val player: PlayerState = LocalPlayerState.current
+
+ val coroutine_scope: CoroutineScope = rememberCoroutineScope()
+ var show_dialog: Boolean by remember { mutableStateOf(showing) }
+ var show_content: Boolean by remember{ mutableStateOf(false) }
+
+ fun close() {
+ coroutine_scope.launchSingle {
+ show_content = false
+ delay(MENU_OPEN_ANIM_MS.toLong())
+ show_dialog = false
+ onDismissRequest()
+ }
+ }
+
+ BackHandler(show_content) {
+ close()
+ }
+
+ LaunchedEffect(showing) {
+ if (!showing) {
+ close()
+ }
+ else {
+ show_dialog = true
+ show_content = true
+ }
+ }
+
+ if (show_dialog) {
+ AnimatedVisibility(
+ show_content,
+ Modifier
+ .fillMaxWidth()
+ .requiredHeight(
+ player.screen_size.height
+ ),
+ enter = fadeIn(tween(MENU_OPEN_ANIM_MS)),
+ exit = fadeOut(tween(MENU_OPEN_ANIM_MS))
+ ) {
+ LongPressMenuBackground {
+ close()
+ }
+ }
+
+ val slide_spring: FiniteAnimationSpec = spring()
+ AnimatedVisibility(
+ show_content,
+ Modifier.fillMaxSize(),
+ enter = slideInVertically(slide_spring) { it / 2 },
+ exit = slideOutVertically(slide_spring) { it / 2 }
+ ) {
+ var accent_colour: Color? = data.item.rememberThemeColour()?.contrastAgainst(player.theme.background)
+
+ DisposableEffect(Unit) {
+ val theme_colour = data.item.ThemeColour.get(player.database)
+ if (theme_colour != null) {
+ accent_colour = theme_colour
+ }
+
+ player.onNavigationBarTargetColourChanged(player.theme.background, true)
+ onDispose {
+ player.onNavigationBarTargetColourChanged(null, true)
+ }
+ }
+
+ Box(Modifier.fillMaxSize(), contentAlignment = Alignment.BottomCenter) {
+ LongPressMenuContent(
+ data,
+ RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp),
+ player.theme.background,
+ PaddingValues(
+ start = MENU_CONTENT_PADDING_DP.dp + WindowInsets.systemBars.getStart(),
+ end = MENU_CONTENT_PADDING_DP.dp + WindowInsets.systemBars.getEnd(),
+ top = MENU_CONTENT_PADDING_DP.dp,
+ bottom = MENU_CONTENT_PADDING_DP.dp + WindowInsets.systemBars.getBottom()
+ ),
+ { accent_colour },
+ Modifier
+ // Prevent click-through to backrgound
+ .clickable(
+ remember { MutableInteractionSource() },
+ null
+ ) {},
+ {
+ if (BehaviourSettings.Key.LPM_CLOSE_ON_ACTION.get()) {
+ close()
+ }
+ }
+ )
+ }
+ }
+ }
+}
diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/component/longpressmenu/LongPressMenu.desktop.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/component/longpressmenu/LongPressMenu.desktop.kt
new file mode 100644
index 000000000..dfd9fd991
--- /dev/null
+++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/component/longpressmenu/LongPressMenu.desktop.kt
@@ -0,0 +1,305 @@
+package com.toasterofbread.spmp.ui.component.longpressmenu
+
+import LocalPlayerState
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.core.Animatable
+import androidx.compose.animation.core.AnimationVector1D
+import androidx.compose.animation.core.FiniteAnimationSpec
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.offset
+import androidx.compose.foundation.layout.systemBars
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.IconButton
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButtonDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.focus.onFocusChanged
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.onSizeChanged
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.IntOffset
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.zIndex
+import com.toasterofbread.composekit.platform.composable.BackHandler
+import com.toasterofbread.composekit.utils.common.blendWith
+import com.toasterofbread.composekit.utils.common.contrastAgainst
+import com.toasterofbread.composekit.utils.common.getContrasted
+import com.toasterofbread.composekit.utils.common.launchSingle
+import com.toasterofbread.composekit.utils.common.snapOrAnimateTo
+import com.toasterofbread.composekit.utils.composable.OnChangedEffect
+import com.toasterofbread.composekit.utils.composable.ShapedIconButton
+import com.toasterofbread.composekit.utils.composable.getBottom
+import com.toasterofbread.composekit.utils.composable.getEnd
+import com.toasterofbread.composekit.utils.composable.getStart
+import com.toasterofbread.spmp.model.mediaitem.db.rememberThemeColour
+import com.toasterofbread.spmp.model.settings.category.BehaviourSettings
+import com.toasterofbread.spmp.ui.layout.apppage.mainpage.MINIMISED_NOW_PLAYING_HEIGHT_DP
+import com.toasterofbread.spmp.ui.layout.apppage.mainpage.PlayerState
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import kotlin.math.roundToInt
+
+private const val MENU_OPEN_ANIM_MS: Int = 150
+private const val MENU_CONTENT_PADDING_DP: Float = 25f
+private const val MENU_WIDTH_DP: Float = 400f
+
+@Composable
+internal fun DesktopLongPressMenu(
+ showing: Boolean,
+ onDismissRequest: () -> Unit,
+ data: LongPressMenuData
+) {
+ BoxWithConstraints(Modifier.fillMaxSize()) {
+ val player: PlayerState = LocalPlayerState.current
+ val density: Density = LocalDensity.current
+
+ val coroutine_scope: CoroutineScope = rememberCoroutineScope()
+ var show_dialog: Boolean by remember { mutableStateOf(showing) }
+ var show_content: Boolean by remember{ mutableStateOf(false) }
+ var show_background: Boolean by remember{ mutableStateOf(false) }
+
+ var menu_width: Dp by remember { mutableStateOf(0.dp) }
+ var menu_height: Dp by remember { mutableStateOf(0.dp) }
+
+ fun Density.getTargetPosition(): Offset {
+ val left: Float = data.layout_offset.x + data.click_offset.x
+ val right: Float = left + menu_width.toPx()
+ val top: Float = data.layout_offset.y + data.click_offset.y
+ val bottom: Float = top + menu_height.toPx()
+
+ val max_width: Float = this@BoxWithConstraints.maxWidth.toPx()
+ val max_height: Float = this@BoxWithConstraints.maxHeight.toPx()
+
+ return Offset(
+ if (left < 0) 0f
+ else if (right > max_width) left - (right - max_width)
+ else left,
+
+ if (top < 0) 0f
+ else if (bottom > max_height) top - (bottom - max_height)
+ else top,
+ )
+ }
+
+ val offset_x: Animatable = remember { Animatable(density.getTargetPosition().x) }
+ val offset_y: Animatable = remember { Animatable(density.getTargetPosition().y) }
+
+ fun close() {
+ coroutine_scope.launchSingle {
+ show_content = false
+ show_background = false
+ delay(MENU_OPEN_ANIM_MS.toLong())
+ show_dialog = false
+ onDismissRequest()
+ }
+ }
+
+ val minimised_now_playing_height: Dp = MINIMISED_NOW_PLAYING_HEIGHT_DP.dp
+ fun updatePosition(snap: Boolean = false) {
+ coroutine_scope.launch {
+ with (density) {
+ if (show_background) {
+ val target_position: Offset = density.getTargetPosition()
+ launch {
+ offset_x.snapOrAnimateTo(target_position.x, snap)
+ }
+ launch {
+ offset_y.snapOrAnimateTo(target_position.y, snap)
+ }
+ }
+ else {
+ val padding: Dp = 20.dp
+ launch {
+ val x: Dp = maxWidth - menu_width - padding
+ offset_x.snapOrAnimateTo(x.toPx(), snap)
+ }
+ launch {
+ val y: Dp = maxHeight - menu_height - padding - (if (player.session_started) minimised_now_playing_height else 0.dp)
+ offset_y.snapOrAnimateTo(y.toPx(), snap)
+ }
+ }
+ }
+ }
+ }
+
+ BackHandler(show_content && show_background) {
+ close()
+ }
+
+ LaunchedEffect(showing) {
+ if (!showing) {
+ close()
+ }
+ else {
+ show_dialog = true
+ show_content = true
+ show_background = true
+ }
+ }
+
+ OnChangedEffect(data) {
+ show_background = true
+ updatePosition()
+ }
+
+ OnChangedEffect(menu_width, menu_height, player.session_started) {
+ updatePosition()
+ }
+
+ if (show_dialog) {
+ val fade_tween: FiniteAnimationSpec = tween(100)
+ AnimatedVisibility(
+ show_background,
+ Modifier.fillMaxSize().zIndex(-1f),
+ enter = fadeIn(fade_tween),
+ exit = fadeOut(fade_tween)
+ ) {
+ LongPressMenuBackground(
+ Modifier.fillMaxSize(),
+ enable_input = show_background,
+ onScroll = {
+ if (BehaviourSettings.Key.DESKTOP_LPM_KEEP_ON_BACKGROUND_SCROLL.get()) {
+ show_background = false
+ updatePosition()
+ }
+ else {
+ close()
+ }
+ }
+ ) { close() }
+ }
+
+ AnimatedVisibility(
+ show_content,
+ Modifier.fillMaxSize(),
+ enter = fadeIn(fade_tween),
+ exit = fadeOut(fade_tween)
+ ) {
+ var accent_colour: Color? = data.item.rememberThemeColour()?.contrastAgainst(player.theme.background)
+
+ DisposableEffect(Unit) {
+ val theme_colour = data.item.ThemeColour.get(player.database)
+ if (theme_colour != null) {
+ accent_colour = theme_colour
+ }
+
+ player.onNavigationBarTargetColourChanged(player.theme.background, true)
+ onDispose {
+ player.onNavigationBarTargetColourChanged(null, true)
+ }
+ }
+
+ val focus_requester: FocusRequester = remember { FocusRequester() }
+ var focused: Boolean by remember { mutableStateOf(false) }
+
+ LaunchedEffect(Unit) {
+ focus_requester.requestFocus()
+ }
+
+ Box(
+ Modifier.fillMaxSize(),
+ contentAlignment = Alignment.TopStart
+ ) {
+ var prev_data: LongPressMenuData? by remember { mutableStateOf(null) }
+ LaunchedEffect(this@BoxWithConstraints.maxWidth, this@BoxWithConstraints.maxHeight, menu_width, menu_height) {
+ if (prev_data == data) {
+ updatePosition(true)
+ }
+ prev_data = data
+ }
+
+ val shape: RoundedCornerShape = RoundedCornerShape(5.dp)
+ Column(
+ Modifier
+ .offset {
+ IntOffset(
+ offset_x.value.roundToInt(),
+ offset_y.value.roundToInt()
+ )
+ }
+ .width(MENU_WIDTH_DP.dp)
+ .focusRequester(focus_requester)
+ .onFocusChanged {
+ if (focused && !it.hasFocus) {
+ close()
+ }
+ focused = it.hasFocus
+ }
+ .onSizeChanged {
+ menu_width = with (density) { it.width.toDp() }
+ menu_height = with (density) { it.height.toDp() }
+ }
+ // Prevent click-through to backrgound
+ .clickable(
+ remember { MutableInteractionSource() },
+ null
+ ) {},
+ horizontalAlignment = Alignment.End,
+ verticalArrangement = Arrangement.spacedBy(10.dp)
+ ) {
+ val background_colour = player.theme.accent.blendWith(player.theme.background, 0.1f)
+
+ ShapedIconButton(
+ { close() },
+ IconButtonDefaults.iconButtonColors(
+ containerColor = background_colour,
+ contentColor = background_colour.getContrasted()
+ )
+ ) {
+ Icon(Icons.Default.Close, null)
+ }
+
+ LongPressMenuContent(
+ data,
+ shape,
+ background_colour,
+ PaddingValues(
+ start = MENU_CONTENT_PADDING_DP.dp + WindowInsets.systemBars.getStart(),
+ end = MENU_CONTENT_PADDING_DP.dp + WindowInsets.systemBars.getEnd(),
+ top = MENU_CONTENT_PADDING_DP.dp,
+ bottom = MENU_CONTENT_PADDING_DP.dp + WindowInsets.systemBars.getBottom()
+ ),
+ { accent_colour },
+ Modifier.border(2.dp, player.theme.on_background.copy(alpha = 0.1f), shape),
+ {
+ if (show_background && BehaviourSettings.Key.LPM_CLOSE_ON_ACTION.get()) {
+ close()
+ }
+ }
+ )
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/component/longpressmenu/LongPressMenu.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/component/longpressmenu/LongPressMenu.kt
index 9f952880c..330dccaab 100644
--- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/component/longpressmenu/LongPressMenu.kt
+++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/component/longpressmenu/LongPressMenu.kt
@@ -1,51 +1,15 @@
package com.toasterofbread.spmp.ui.component.longpressmenu
-import LocalPlayerState
-import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
-import androidx.compose.animation.core.spring
-import androidx.compose.animation.core.tween
-import androidx.compose.animation.fadeIn
-import androidx.compose.animation.fadeOut
-import androidx.compose.animation.slideInVertically
-import androidx.compose.animation.slideOutVertically
-import androidx.compose.foundation.clickable
-import androidx.compose.foundation.interaction.MutableInteractionSource
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.PaddingValues
-import androidx.compose.foundation.layout.WindowInsets
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.requiredHeight
-import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.DisposableEffect
-import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.rememberCoroutineScope
-import androidx.compose.runtime.setValue
-import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.unit.dp
-import com.toasterofbread.composekit.platform.composable.BackHandler
-import com.toasterofbread.composekit.utils.common.contrastAgainst
-import com.toasterofbread.composekit.utils.common.launchSingle
-import com.toasterofbread.composekit.utils.composable.getBottom
-import com.toasterofbread.composekit.utils.composable.getEnd
-import com.toasterofbread.composekit.utils.composable.getStart
-import com.toasterofbread.spmp.model.mediaitem.db.rememberThemeColour
-import com.toasterofbread.spmp.model.settings.category.BehaviourSettings
+import com.toasterofbread.composekit.platform.Platform
import com.toasterofbread.spmp.ui.layout.nowplaying.overlay.DEFAULT_THUMBNAIL_ROUNDING
-import kotlinx.coroutines.delay
-private const val MENU_OPEN_ANIM_MS: Int = 150
-private const val MENU_CONTENT_PADDING_DP: Float = 25f
private const val LONG_PRESS_ICON_INDICATION_SCALE: Float = 0.4f
@Composable
@@ -62,93 +26,8 @@ fun LongPressMenu(
onDismissRequest: () -> Unit,
data: LongPressMenuData
) {
- val player = LocalPlayerState.current
-
- val coroutine_scope = rememberCoroutineScope()
- var close_requested by remember { mutableStateOf(false) }
- var show_dialog by remember { mutableStateOf(showing) }
- var show_content by remember{ mutableStateOf(false) }
-
- fun closePopup() {
- coroutine_scope.launchSingle {
- show_content = false
- delay(MENU_OPEN_ANIM_MS.toLong())
- show_dialog = false
- onDismissRequest()
- }
- }
-
- BackHandler(show_content) {
- closePopup()
- }
-
- LaunchedEffect(showing, close_requested) {
- if (!showing || close_requested) {
- closePopup()
- close_requested = false
- }
- else {
- show_dialog = true
- show_content = true
- }
- }
-
- if (show_dialog) {
- AnimatedVisibility(
- show_content,
- Modifier
- .fillMaxWidth()
- .requiredHeight(
- player.screen_size.height
- ),
- enter = fadeIn(tween(MENU_OPEN_ANIM_MS)),
- exit = fadeOut(tween(MENU_OPEN_ANIM_MS))
- ) {
- LongPressMenuBackground(
- Modifier,
- { close_requested = true }
- )
- }
-
- AnimatedVisibility(
- show_content,
- Modifier.fillMaxSize(),
- enter = slideInVertically(spring()) { it / 2 },
- exit = slideOutVertically(spring()) { it / 2 }
- ) {
- var accent_colour: Color? = data.item.rememberThemeColour()?.contrastAgainst(player.theme.background)
-
- DisposableEffect(Unit) {
- val theme_colour = data.item.ThemeColour.get(player.database)
- if (theme_colour != null) {
- accent_colour = theme_colour
- }
-
- player.onNavigationBarTargetColourChanged(player.theme.background, true)
- onDispose {
- player.onNavigationBarTargetColourChanged(null, true)
- }
- }
-
- Box(Modifier.fillMaxSize(), contentAlignment = Alignment.BottomCenter) {
- LongPressMenuContent(
- data,
- PaddingValues(
- start = MENU_CONTENT_PADDING_DP.dp + WindowInsets.systemBars.getStart(),
- end = MENU_CONTENT_PADDING_DP.dp + WindowInsets.systemBars.getEnd(),
- top = MENU_CONTENT_PADDING_DP.dp,
- bottom = MENU_CONTENT_PADDING_DP.dp + WindowInsets.systemBars.getBottom()
- ),
- { accent_colour },
- Modifier
- // Prevent click-through to backrgound
- .clickable(
- remember { MutableInteractionSource() },
- null
- ) {},
- { if (BehaviourSettings.Key.LPM_CLOSE_ON_ACTION.get()) close_requested = true }
- )
- }
- }
+ when (Platform.current) {
+ Platform.ANDROID -> AndroidLongPressMenu(showing, onDismissRequest, data)
+ Platform.DESKTOP -> DesktopLongPressMenu(showing, onDismissRequest, data)
}
}
diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/component/longpressmenu/LongPressMenuContent.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/component/longpressmenu/LongPressMenuContent.kt
index d2dae6cd8..9464d304e 100644
--- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/component/longpressmenu/LongPressMenuContent.kt
+++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/component/longpressmenu/LongPressMenuContent.kt
@@ -12,14 +12,12 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
-import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredHeight
import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
@@ -42,14 +40,20 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.input.pointer.PointerEvent
+import androidx.compose.ui.input.pointer.PointerEventType
+import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.toasterofbread.composekit.platform.composable.platformClickable
import com.toasterofbread.composekit.platform.vibrateShort
import com.toasterofbread.composekit.utils.common.copy
+import com.toasterofbread.composekit.utils.common.getContrasted
import com.toasterofbread.composekit.utils.common.thenIf
import com.toasterofbread.composekit.utils.composable.AlignableCrossfade
import com.toasterofbread.composekit.utils.composable.Marquee
@@ -64,17 +68,22 @@ import com.toasterofbread.spmp.resources.getString
import com.toasterofbread.spmp.ui.component.MediaItemTitleEditDialog
import com.toasterofbread.spmp.ui.component.Thumbnail
import com.toasterofbread.spmp.ui.component.mediaitempreview.MediaItemPreviewLong
+import com.toasterofbread.spmp.ui.layout.apppage.mainpage.PlayerState
import com.toasterofbread.spmp.ui.layout.nowplaying.overlay.DEFAULT_THUMBNAIL_ROUNDING
+import kotlinx.coroutines.currentCoroutineContext
+import kotlinx.coroutines.isActive
@Composable
internal fun LongPressMenuContent(
data: LongPressMenuData,
+ shape: Shape,
+ background_colour: Color,
content_padding: PaddingValues,
getAccentColour: () -> Color?,
- modifier: Modifier,
+ modifier: Modifier = Modifier,
onAction: () -> Unit
) {
- val player = LocalPlayerState.current
+ val player: PlayerState = LocalPlayerState.current
@Composable
fun Thumb(modifier: Modifier) {
@@ -91,183 +100,173 @@ internal fun LongPressMenuContent(
var item_pinned_to_home: Boolean by data.item.observePinnedToHome()
val item_title: String? by data.item.observeActiveTitle()
- Column(modifier) {
- val density = LocalDensity.current
- var height by remember { mutableStateOf(0.dp) }
+ val density: Density = LocalDensity.current
+ var height: Dp by remember { mutableStateOf(0.dp) }
- var show_info by remember { mutableStateOf(false) }
- var main_actions_showing by remember { mutableStateOf(true) }
- var info_showing by remember { mutableStateOf(false) }
+ var show_info: Boolean by remember { mutableStateOf(false) }
+ var main_actions_showing: Boolean by remember { mutableStateOf(true) }
+ var info_showing: Boolean by remember { mutableStateOf(false) }
- Column(
- Modifier
- .background(player.theme.background, RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp))
- .fillMaxWidth()
- .onSizeChanged {
- if (show_info || !main_actions_showing) {
- return@onSizeChanged
- }
+ Column(
+ modifier
+ .background(background_colour, shape)
+ .onSizeChanged {
+ if (show_info || !main_actions_showing) {
+ return@onSizeChanged
+ }
- val h = with(density) { it.height.toDp() }
- if (h > height) {
- height = h
- }
+ val h = with(density) { it.height.toDp() }
+ if (h > height) {
+ height = h
}
- .thenIf(show_info || info_showing, Modifier.height(height)),
- ) {
- Box(Modifier.height(content_padding.calculateTopPadding()).fillMaxWidth()) {
- NoRipple {
- val pin_button_size = 24.dp
- val pin_button_padding = 15.dp
- Crossfade(
- item_pinned_to_home,
- Modifier.align(Alignment.CenterEnd).offset(x = -pin_button_padding, y = pin_button_padding)
- ) { pinned ->
- IconButton(
- {
- item_pinned_to_home = !pinned
- },
- Modifier.size(pin_button_size).bounceOnClick()
- ) {
- Icon(
- if (pinned) Icons.Filled.PushPin
- else Icons.Outlined.PushPin,
- null
- )
- }
+ }
+ .thenIf(show_info || info_showing, Modifier.height(height)),
+ ) {
+ Box(Modifier.height(content_padding.calculateTopPadding()).align(Alignment.End)) {
+ NoRipple {
+ val pin_button_size = 24.dp
+ val pin_button_padding = 15.dp
+ Crossfade(
+ item_pinned_to_home,
+ Modifier.align(Alignment.CenterEnd).offset(x = -pin_button_padding, y = pin_button_padding)
+ ) { pinned ->
+ IconButton(
+ {
+ item_pinned_to_home = !pinned
+ },
+ Modifier.size(pin_button_size).bounceOnClick()
+ ) {
+ Icon(
+ if (pinned) Icons.Filled.PushPin
+ else Icons.Outlined.PushPin,
+ null
+ )
}
}
}
+ }
- Column(
- Modifier.padding(content_padding.copy(top = 0.dp)).fillMaxWidth(),
- verticalArrangement = Arrangement.spacedBy(MENU_ITEM_SPACING.dp)
- ) {
- CompositionLocalProvider(LocalContentColor provides player.theme.on_background) {
- Row(
+ Column(
+ Modifier.padding(content_padding.copy(top = 0.dp)),
+ verticalArrangement = Arrangement.spacedBy(MENU_ITEM_SPACING.dp)
+ ) {
+ CompositionLocalProvider(LocalContentColor provides background_colour.getContrasted()) {
+ Row(Modifier.height(80.dp)) {
+ Thumb(Modifier.aspectRatio(1f))
+
+ // Item info
+ Column(
Modifier
- .height(80.dp)
.fillMaxWidth()
+ .weight(1f)
+ .padding(horizontal = 15.dp),
+ verticalArrangement = Arrangement.SpaceEvenly
) {
- Thumb(Modifier.aspectRatio(1f))
-
- // Item info
- Column(
- Modifier
- .fillMaxSize()
- .weight(1f)
- .padding(horizontal = 15.dp),
- verticalArrangement = Arrangement.SpaceEvenly
+ // Title
+ Marquee(
+ Modifier.platformClickable(
+ onAltClick = {
+ show_title_edit_dialog = !show_title_edit_dialog
+ player.context.vibrateShort()
+ }
+ )
) {
- // Title
- Marquee(
- Modifier.platformClickable(
- onAltClick = {
- show_title_edit_dialog = !show_title_edit_dialog
- player.context.vibrateShort()
- }
- )
- ) {
- Text(
- item_title ?: "",
- Modifier.fillMaxWidth(),
- softWrap = false,
- overflow = TextOverflow.Ellipsis
- )
- }
+ Text(
+ item_title ?: "",
+ Modifier.fillMaxWidth(),
+ softWrap = false,
+ overflow = TextOverflow.Ellipsis
+ )
+ }
- // Artist
- if (data.item is MediaItem.WithArtist) {
- val item_artist: Artist? by data.item.Artist.observe(player.database)
- item_artist?.also { artist ->
- Marquee {
- val player = LocalPlayerState.current
- CompositionLocalProvider(
- LocalPlayerState provides remember {
- player.copy(
- onClickedOverride = { item, _ ->
- onAction()
- player.onMediaItemClicked(item)
- }
- )
- }
- ) {
- MediaItemPreviewLong(artist, Modifier.fillMaxWidth())
+ // Artist
+ if (data.item is MediaItem.WithArtist) {
+ val item_artist: Artist? by data.item.Artist.observe(player.database)
+ item_artist?.also { artist ->
+ Marquee {
+ CompositionLocalProvider(
+ LocalPlayerState provides remember {
+ player.copy(
+ onClickedOverride = { item, _ ->
+ onAction()
+ player.onMediaItemClicked(item)
+ }
+ )
}
+ ) {
+ MediaItemPreviewLong(artist)
}
}
}
}
}
+ }
- // Info header
- Row(Modifier.requiredHeight(1.dp), horizontalArrangement = Arrangement.spacedBy(20.dp)) {
- var info_title_width: Int by remember { mutableStateOf(0) }
- var box_width: Int by remember { mutableStateOf(-1) }
+ // Info header
+ Row(Modifier.requiredHeight(1.dp).fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(20.dp)) {
+ var info_title_width: Int by remember { mutableStateOf(0) }
+
+ Box(
+ Modifier.fillMaxWidth().weight(1f),
+ contentAlignment = Alignment.CenterStart
+ ) {
+ AlignableCrossfade(show_info, Modifier.requiredHeight(40.dp), contentAlignment = Alignment.CenterStart) { info ->
+ val text = if (info) getString("lpm_long_press_actions") else data.getTitle?.invoke()
+ val current = info == show_info
+ if (text != null) {
+ Text(
+ text,
+ Modifier.onSizeChanged { if (current) info_title_width = it.width },
+ overflow = TextOverflow.Visible
+ )
+ }
+ else if (current) {
+ info_title_width = 0
+ }
+ }
Box(
Modifier
- .fillMaxWidth()
- .weight(1f)
- .onSizeChanged { box_width = it.width },
- contentAlignment = Alignment.CenterStart
- ) {
- AlignableCrossfade(show_info, Modifier.requiredHeight(40.dp), contentAlignment = Alignment.CenterStart) { info ->
- val text = if (info) getString("lpm_long_press_actions") else data.getTitle?.invoke()
- val current = info == show_info
- if (text != null) {
- Text(
- text,
- Modifier.onSizeChanged { if (current) info_title_width = it.width },
- overflow = TextOverflow.Visible
+ .run {
+ padding(
+ start = animateDpAsState(
+ with (density) {
+ if (info_title_width == 0) 0.dp else (info_title_width.toDp() + 15.dp)
+ }
+ ).value
)
}
- else if (current) {
- info_title_width = 0
- }
- }
-
- Box(
- Modifier
- .run {
- if (box_width < 0) fillMaxWidth()
- else width(animateDpAsState(
- with(LocalDensity.current) {
- if (info_title_width == 0) box_width.toDp() else (box_width - info_title_width).toDp() - 15.dp
- }
- ).value)
- }
- .requiredHeight(20.dp)
- .background(player.theme.background)
- .align(Alignment.CenterEnd),
- contentAlignment = Alignment.Center
- ) {
- Divider(
- thickness = Dp.Hairline,
- color = player.theme.on_background
- )
- }
+ .requiredHeight(20.dp)
+ .background(background_colour)
+ .align(Alignment.CenterEnd),
+ contentAlignment = Alignment.Center
+ ) {
+ Divider(
+ thickness = Dp.Hairline,
+ color = background_colour.getContrasted()
+ )
}
+ }
- data.SideButton(
- Modifier.requiredHeight(40.dp),
- player.theme.background
- )
+ data.SideButton(
+ Modifier.requiredHeight(40.dp),
+ background_colour
+ )
- PlatformClickableIconButton(
- onClick = {
- show_info = !show_info
- },
- modifier = Modifier.requiredHeight(40.dp),
- apply_minimum_size = false
- ) {
- Crossfade(show_info) { info ->
- Icon(if (info) Icons.Filled.Close else Icons.Filled.Info, null)
- }
+ PlatformClickableIconButton(
+ onClick = {
+ show_info = !show_info
+ },
+ modifier = Modifier.requiredHeight(40.dp),
+ apply_minimum_size = false
+ ) {
+ Crossfade(show_info) { info ->
+ Icon(if (info) Icons.Filled.Close else Icons.Filled.Info, null)
}
}
+ }
- // Info/action list
+ // Info/action list
Crossfade(show_info) { info ->
Column(verticalArrangement = Arrangement.spacedBy(MENU_ITEM_SPACING.dp)) {
if (info) {
@@ -295,7 +294,6 @@ internal fun LongPressMenuContent(
}
}
}
- }
}
}
}
@@ -304,11 +302,27 @@ internal fun LongPressMenuContent(
@Composable
internal fun LongPressMenuBackground(
modifier: Modifier = Modifier,
+ onScroll: () -> Unit = {},
+ enable_input: Boolean = true,
close: () -> Unit
) {
Box(
modifier
.background(Color.Black.copy(alpha = 0.5f))
- .clickable(remember { MutableInteractionSource() }, null, onClick = close)
+ .thenIf(enable_input) {
+ pointerInput(Unit) {
+ while (currentCoroutineContext().isActive) {
+ awaitPointerEventScope {
+ val event: PointerEvent = awaitPointerEvent()
+ if (event.type == PointerEventType.Release) {
+ close()
+ }
+ else if (event.type == PointerEventType.Scroll) {
+ onScroll()
+ }
+ }
+ }
+ }
+ }
)
}
diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/component/longpressmenu/LongPressMenuData.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/component/longpressmenu/LongPressMenuData.kt
index c330d64ba..95ea35caf 100644
--- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/component/longpressmenu/LongPressMenuData.kt
+++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/component/longpressmenu/LongPressMenuData.kt
@@ -6,9 +6,13 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.layout.onGloballyPositioned
+import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.IntSize
import com.toasterofbread.composekit.utils.common.getContrasted
import com.toasterofbread.spmp.model.mediaitem.MediaItem
import com.toasterofbread.spmp.model.mediaitem.MediaItemPreviewInteractionPressStage
@@ -24,6 +28,7 @@ import com.toasterofbread.spmp.ui.layout.artistpage.ArtistSubscribeButton
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
+import kotlin.properties.Delegates
data class LongPressMenuData(
val item: MediaItem,
@@ -33,6 +38,10 @@ data class LongPressMenuData(
val multiselect_key: Int? = null,
val playlist_as_song: Boolean = false
) {
+ var layout_size: IntSize by Delegates.notNull()
+ var layout_offset: Offset by Delegates.notNull()
+ var click_offset: Offset by Delegates.notNull()
+
var current_interaction_stage: MediaItemPreviewInteractionPressStage? by mutableStateOf(null)
private val coroutine_scope = CoroutineScope(Dispatchers.Main)
private val HINT_MIN_STAGE = MediaItemPreviewInteractionPressStage.LONG_1
@@ -97,3 +106,9 @@ data class LongPressMenuData(
}
}
}
+
+fun Modifier.longPressItem(long_press_menu_data: LongPressMenuData): Modifier =
+ onGloballyPositioned {
+ long_press_menu_data.layout_size = it.size
+ long_press_menu_data.layout_offset = it.positionInRoot()
+ }
diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/component/longpressmenu/song/SongLongPressMenuActions.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/component/longpressmenu/song/SongLongPressMenuActions.kt
index 3da519321..aa3fae642 100644
--- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/component/longpressmenu/song/SongLongPressMenuActions.kt
+++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/component/longpressmenu/song/SongLongPressMenuActions.kt
@@ -52,7 +52,7 @@ import com.toasterofbread.spmp.model.mediaitem.playlist.Playlist
import com.toasterofbread.spmp.model.mediaitem.playlist.PlaylistEditor.Companion.getEditorOrNull
import com.toasterofbread.spmp.model.mediaitem.song.Song
import com.toasterofbread.spmp.model.settings.category.BehaviourSettings
-import com.toasterofbread.spmp.platform.download.PlayerDownloadManager
+import com.toasterofbread.spmp.platform.download.DownloadStatus
import com.toasterofbread.spmp.platform.download.rememberDownloadStatus
import com.toasterofbread.spmp.resources.getString
import com.toasterofbread.spmp.ui.component.longpressmenu.LongPressMenuActionProvider
@@ -175,7 +175,7 @@ private fun LongPressMenuActionProvider.LPMActions(
openPlaylistInterface: () -> Unit
) {
val player = LocalPlayerState.current
- val download: PlayerDownloadManager.DownloadStatus? by (item as? Song)?.rememberDownloadStatus()
+ val download: DownloadStatus? by (item as? Song)?.rememberDownloadStatus()
val coroutine_scope = rememberCoroutineScope()
ActionButton(
@@ -228,32 +228,31 @@ private fun LongPressMenuActionProvider.LPMActions(
ActionButton(Icons.Default.PlaylistAdd, getString("song_add_to_playlist"), onClick = openPlaylistInterface, onAction = {})
- if (download != null) {
- if (download?.isCompleted() == true) {
- ActionButton(
- Icons.Default.Delete,
- getString("lpm_action_delete_local_song_file"),
- onClick = {
- val song = download?.song ?: return@ActionButton
- coroutine_scope.launch {
- player.context.download_manager.deleteSongLocalAudioFile(song)
- }
+ if (download?.isCompleted() == true) {
+ ActionButton(
+ Icons.Default.Delete,
+ getString("lpm_action_delete_local_song_file"),
+ onClick = {
+ val song: Song = download?.song ?: return@ActionButton
+ coroutine_scope.launch {
+ player.context.download_manager.deleteSongLocalAudioFile(song)
}
- )
- }
+ }
+ )
}
- else {
+ else if (download == null || download?.status == DownloadStatus.Status.IDLE) {
ActionButton(Icons.Default.Download, getString("lpm_action_download"), onClick = {
withSong {
- player.onSongDownloadRequested(it) { status: PlayerDownloadManager.DownloadStatus? ->
+ player.onSongDownloadRequested(it) { status: DownloadStatus? ->
when (status?.status) {
null -> {}
- PlayerDownloadManager.DownloadStatus.Status.FINISHED -> player.context.sendToast(getString("notif_download_finished"))
- PlayerDownloadManager.DownloadStatus.Status.ALREADY_FINISHED -> player.context.sendToast(getString("notif_download_already_finished"))
- PlayerDownloadManager.DownloadStatus.Status.CANCELLED -> player.context.sendToast(getString("notif_download_cancelled"))
+ DownloadStatus.Status.FINISHED -> player.context.sendToast(getString("notif_download_finished"))
+ DownloadStatus.Status.ALREADY_FINISHED -> player.context.sendToast(getString("notif_download_already_finished"))
+ DownloadStatus.Status.CANCELLED -> player.context.sendToast(getString("notif_download_cancelled"))
// IDLE, DOWNLOADING, PAUSED
else -> {
+ TODO(status.toString())
player.context.sendToast(getString("notif_download_already_downloading"))
}
}
diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/component/mediaitemlayout/MediaItemCard.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/component/mediaitemlayout/MediaItemCard.kt
index 9cc590f17..537f1d324 100644
--- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/component/mediaitemlayout/MediaItemCard.kt
+++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/component/mediaitemlayout/MediaItemCard.kt
@@ -35,7 +35,11 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.layout.onGloballyPositioned
+import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -58,6 +62,7 @@ import com.toasterofbread.spmp.ui.component.longpressmenu.longPressMenuIcon
import com.toasterofbread.spmp.ui.component.mediaitempreview.MediaItemPreviewLong
import com.toasterofbread.spmp.ui.component.mediaitempreview.getThumbShape
import com.toasterofbread.spmp.ui.component.multiselect.MediaItemMultiSelectContext
+import com.toasterofbread.spmp.ui.layout.apppage.mainpage.PlayerState
@OptIn(ExperimentalFoundationApi::class)
@Composable
@@ -67,7 +72,7 @@ fun MediaItemCard(
multiselect_context: MediaItemMultiSelectContext? = null,
apply_filter: Boolean = false
) {
- val player = LocalPlayerState.current
+ val player: PlayerState = LocalPlayerState.current
val item: MediaItem = layout.items.first()
if (apply_filter && isMediaItemHidden(item, player.database)) {
@@ -76,8 +81,8 @@ fun MediaItemCard(
val accent_colour: Color? = item.rememberThemeColour()
- val shape = item.getType().getThumbShape()
- val long_press_menu_data = remember(item, shape) {
+ val shape: Shape = item.getType().getThumbShape()
+ val long_press_menu_data: LongPressMenuData = remember(item, shape) {
LongPressMenuData(item, shape, multiselect_context = multiselect_context)
}
diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/component/mediaitemlayout/MediaItemGrid.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/component/mediaitemlayout/MediaItemGrid.kt
index bc5b47b53..2ae7bde4a 100644
--- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/component/mediaitemlayout/MediaItemGrid.kt
+++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/component/mediaitemlayout/MediaItemGrid.kt
@@ -160,7 +160,7 @@ fun MediaItemGrid(
title_modifier,
view_more = view_more,
multiselect_context = multiselect_context,
- scroll_state =
+ scrollable_state =
if (Platform.DESKTOP.isCurrent()) grid_state
else null
)
@@ -206,13 +206,6 @@ fun MediaItemGrid(
}
}
}
-
- Platform.DESKTOP.only {
-// GridHorizontalScrollbar(
-// grid_state,
-// Modifier.fillMaxWidth()
-// )
- }
}
}
}
diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/component/mediaitemlayout/MediaItemLayoutTitleBar.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/component/mediaitemlayout/MediaItemLayoutTitleBar.kt
index ed2807baa..32d612684 100644
--- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/component/mediaitemlayout/MediaItemLayoutTitleBar.kt
+++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/component/mediaitemlayout/MediaItemLayoutTitleBar.kt
@@ -2,8 +2,6 @@ package com.toasterofbread.spmp.ui.component.mediaitemlayout
import LocalPlayerState
import androidx.compose.animation.AnimatedVisibility
-import androidx.compose.animation.core.AnimationSpec
-import androidx.compose.animation.core.spring
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.ScrollableState
import androidx.compose.foundation.gestures.animateScrollBy
@@ -25,11 +23,9 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
-import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
-import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
@@ -58,11 +54,11 @@ fun TitleBar(
view_more: ViewMore? = null,
font_size: TextUnit? = null,
multiselect_context: MediaItemMultiSelectContext? = null,
- scroll_state: ScrollableState? = null
+ scrollable_state: ScrollableState? = null
) {
val player: PlayerState = LocalPlayerState.current
- AnimatedVisibility(shouldShowTitleBar(title, subtitle, view_more), modifier) {
+ AnimatedVisibility(shouldShowTitleBar(title, subtitle, view_more, scrollable_state), modifier) {
val title_string: String? = remember { title?.getString(player.context) }
val subtitle_string: String? = remember { subtitle?.getString(player.context) }
@@ -99,7 +95,7 @@ fun TitleBar(
}
}
- scroll_state?.ScrollButtons()
+ scrollable_state?.ScrollButtons()
multiselect_context?.CollectionToggleButton(items)
}
diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/component/mediaitempreview/MediaItemPreview.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/component/mediaitempreview/MediaItemPreview.kt
index f25725e5b..81fba2ba0 100644
--- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/component/mediaitempreview/MediaItemPreview.kt
+++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/component/mediaitempreview/MediaItemPreview.kt
@@ -49,6 +49,7 @@ import com.toasterofbread.spmp.model.mediaitem.playlist.Playlist
import com.toasterofbread.spmp.model.mediaitem.playlist.PlaylistFileConverter
import com.toasterofbread.spmp.model.mediaitem.song.Song
import com.toasterofbread.spmp.platform.AppContext
+import com.toasterofbread.spmp.platform.download.DownloadStatus
import com.toasterofbread.spmp.platform.download.PlayerDownloadManager
import com.toasterofbread.spmp.platform.isLargeFormFactor
import com.toasterofbread.spmp.platform.download.rememberDownloadStatus
@@ -183,7 +184,7 @@ fun MediaItemPreviewSquare(
textAlign = if (player.isLargeFormFactor()) TextAlign.Start else TextAlign.Center
)
- val download_status: PlayerDownloadManager.DownloadStatus? by (loaded_item as? Song)?.rememberDownloadStatus()
+ val download_status: DownloadStatus? by (loaded_item as? Song)?.rememberDownloadStatus()
if (show_download_indicator && download_status != null) {
Icon(
@@ -233,7 +234,6 @@ fun MediaItemPreviewLong(
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier
- .fillMaxWidth()
.mediaItemPreviewInteraction(loaded_item, long_press_menu_data)
.height(MEDIA_ITEM_PREVIEW_LONG_HEIGHT_DP.dp)
) {
@@ -269,7 +269,7 @@ fun MediaItemPreviewLong(
val artist_title: String? = if (show_artist) (loaded_item as? MediaItem.WithArtist)?.Artist?.observePropertyActiveTitle()?.value else null
val extra_info: List = getExtraInfo?.invoke() ?: emptyList()
- val download_status: PlayerDownloadManager.DownloadStatus? by (loaded_item as? Song)?.rememberDownloadStatus()
+ val download_status: DownloadStatus? by (loaded_item as? Song)?.rememberDownloadStatus()
val is_explicit: Boolean? by (loaded_item as? Song)?.Explicit?.observe(player.database)
if ((show_download_indicator && download_status != null) || show_play_count || show_type || extra_info.isNotEmpty() || artist_title != null || is_explicit == true) {
diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/component/multiselect/MediaItemMultiSelectContext.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/component/multiselect/MediaItemMultiSelectContext.kt
index 7880a5c05..28c0bffd2 100644
--- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/component/multiselect/MediaItemMultiSelectContext.kt
+++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/component/multiselect/MediaItemMultiSelectContext.kt
@@ -74,6 +74,7 @@ import com.toasterofbread.spmp.model.mediaitem.playlist.PlaylistEditor.Companion
import com.toasterofbread.spmp.model.mediaitem.playlist.PlaylistEditor.Companion.isPlaylistEditable
import com.toasterofbread.spmp.model.mediaitem.song.Song
import com.toasterofbread.spmp.model.settings.category.BehaviourSettings
+import com.toasterofbread.spmp.platform.download.DownloadStatus
import com.toasterofbread.spmp.platform.download.PlayerDownloadManager
import com.toasterofbread.spmp.platform.download.rememberSongDownloads
import com.toasterofbread.spmp.resources.getString
@@ -333,7 +334,7 @@ class MediaItemMultiSelectContext(
private fun RowScope.GeneralSelectedItemActions() {
val player = LocalPlayerState.current
val coroutine_scope = rememberCoroutineScope()
- val downloads: List by rememberSongDownloads()
+ val downloads: List by rememberSongDownloads()
val all_are_pinned: Boolean =
if (selected_items.isEmpty()) false
@@ -376,7 +377,7 @@ class MediaItemMultiSelectContext(
return@any false
}
- val download: PlayerDownloadManager.DownloadStatus? = downloads.firstOrNull { it.song.id == item.first.id }
+ val download: DownloadStatus? = downloads.firstOrNull { it.song.id == item.first.id }
return@any download?.isCompleted() != true
}
} }
diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/apppage/library/LibraryArtistsPage.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/apppage/library/LibraryArtistsPage.kt
index fa257f7d6..ccafb69d1 100644
--- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/apppage/library/LibraryArtistsPage.kt
+++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/apppage/library/LibraryArtistsPage.kt
@@ -30,6 +30,7 @@ import com.toasterofbread.spmp.model.mediaitem.MediaItemHolder
import com.toasterofbread.spmp.model.mediaitem.artist.Artist
import com.toasterofbread.spmp.model.mediaitem.artist.ArtistRef
import com.toasterofbread.spmp.platform.AppContext
+import com.toasterofbread.spmp.platform.download.DownloadStatus
import com.toasterofbread.spmp.platform.download.PlayerDownloadManager
import com.toasterofbread.spmp.platform.download.rememberSongDownloads
import com.toasterofbread.spmp.resources.getString
@@ -54,7 +55,7 @@ class LibraryArtistsPage(context: AppContext): LibrarySubPage(context) {
) {
val player = LocalPlayerState.current
- val downloads: List by rememberSongDownloads()
+ val downloads: List by rememberSongDownloads()
var sorted_artists: List> by remember { mutableStateOf(emptyList()) }
with(library_page) {
diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/apppage/library/LibrarySongsPage.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/apppage/library/LibrarySongsPage.kt
index 5993320b1..c9d6cb874 100644
--- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/apppage/library/LibrarySongsPage.kt
+++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/apppage/library/LibrarySongsPage.kt
@@ -26,6 +26,7 @@ import com.toasterofbread.composekit.utils.composable.EmptyListCrossfade
import com.toasterofbread.spmp.model.mediaitem.enums.MediaItemType
import com.toasterofbread.spmp.model.mediaitem.song.Song
import com.toasterofbread.spmp.platform.AppContext
+import com.toasterofbread.spmp.platform.download.DownloadStatus
import com.toasterofbread.spmp.platform.download.PlayerDownloadManager
import com.toasterofbread.spmp.platform.getUiLanguage
import com.toasterofbread.spmp.platform.download.rememberSongDownloads
@@ -47,9 +48,9 @@ class LibrarySongsPage(context: AppContext): LibrarySubPage(context) {
modifier: Modifier
) {
val player = LocalPlayerState.current
- val downloads: List by rememberSongDownloads()
+ val downloads: List by rememberSongDownloads()
- var sorted_downloads: List by remember { mutableStateOf(emptyList()) }
+ var sorted_downloads: List by remember { mutableStateOf(emptyList()) }
with(library_page) {
LaunchedEffect(downloads, search_filter, sort_type, reverse_sort) {
@@ -121,7 +122,7 @@ class LibrarySongsPage(context: AppContext): LibrarySubPage(context) {
}
@Composable
-private fun InfoRow(downloads: List, modifier: Modifier = Modifier) {
+private fun InfoRow(downloads: List, modifier: Modifier = Modifier) {
if (downloads.isEmpty()) {
return
}
@@ -153,7 +154,7 @@ private fun InfoRow(downloads: List, modif
}
}
-private fun onSongClicked(downloads: List, player: PlayerState, song: Song, index: Int) {
+private fun onSongClicked(downloads: List, player: PlayerState, song: Song, index: Int) {
player.withPlayer {
val ADD_BEFORE = 0
val ADD_AFTER = 9
diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/apppage/mainpage/PlayerState.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/apppage/mainpage/PlayerState.kt
index 3419a26ef..15d8017d2 100644
--- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/apppage/mainpage/PlayerState.kt
+++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/apppage/mainpage/PlayerState.kt
@@ -22,6 +22,7 @@ import com.toasterofbread.spmp.model.mediaitem.song.Song
import com.toasterofbread.spmp.model.mediaitem.song.SongRef
import com.toasterofbread.spmp.platform.AppContext
import com.toasterofbread.spmp.platform.PlayerListener
+import com.toasterofbread.spmp.platform.download.DownloadStatus
import com.toasterofbread.spmp.platform.download.PlayerDownloadManager
import com.toasterofbread.spmp.platform.playerservice.MediaPlayerRepeatMode
import com.toasterofbread.spmp.platform.playerservice.PlatformPlayerService
@@ -37,7 +38,7 @@ import com.toasterofbread.spmp.ui.layout.nowplaying.overlay.PlayerOverlayMenu
import java.net.URI
import java.net.URISyntaxException
-typealias DownloadRequestCallback = (PlayerDownloadManager.DownloadStatus?) -> Unit
+typealias DownloadRequestCallback = (DownloadStatus?) -> Unit
class PlayerStatus internal constructor() {
private var player: PlatformPlayerService? = null
diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/apppage/mainpage/RootView.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/apppage/mainpage/RootView.kt
index 731581d91..9e19412a6 100644
--- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/apppage/mainpage/RootView.kt
+++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/apppage/mainpage/RootView.kt
@@ -1,16 +1,30 @@
package com.toasterofbread.spmp.ui.layout.apppage.mainpage
import LocalPlayerState
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.input.pointer.PointerEventPass
+import androidx.compose.ui.input.pointer.PointerEventType
+import androidx.compose.ui.input.pointer.isPrimaryPressed
+import androidx.compose.ui.input.pointer.isSecondaryPressed
+import androidx.compose.ui.input.pointer.isTertiaryPressed
+import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.DpSize
import com.toasterofbread.composekit.utils.modifier.background
import com.toasterofbread.spmp.ui.layout.nowplaying.maintab.NowPlayingMainTabPage
+import kotlinx.coroutines.currentCoroutineContext
+import kotlinx.coroutines.isActive
val MINIMISED_NOW_PLAYING_HEIGHT_DP: Float
@Composable get() = NowPlayingMainTabPage.Mode.getCurrent(LocalPlayerState.current).getMinimisedPlayerHeight().value
@@ -21,20 +35,38 @@ const val MEDIAITEM_PREVIEW_SQUARE_SIZE_LARGE: Float = 200f
@Composable
fun RootView(player: PlayerStateImpl) {
- val density = LocalDensity.current
- Box(Modifier.fillMaxSize().onSizeChanged { size ->
- with(density) {
- player.screen_size = DpSize(
- size.width.toDp(),
- size.height.toDp()
- )
- }
- })
+ val density: Density = LocalDensity.current
+ Box(
+ Modifier
+ .fillMaxSize()
+ .onSizeChanged { size ->
+ with(density) {
+ player.screen_size = DpSize(
+ size.width.toDp(),
+ size.height.toDp()
+ )
+ }
+ }
+ )
+
+ val focus_requester: FocusRequester = remember { FocusRequester() }
Column(
Modifier
.fillMaxSize()
.background(player.theme.background_provider)
+// .pointerInput(Unit) {
+// while (currentCoroutineContext().isActive) {
+// awaitPointerEventScope {
+// val event = awaitPointerEvent(PointerEventPass.Final)
+// if (event.type == PointerEventType.Press && (event.buttons.isPrimaryPressed || event.buttons.isSecondaryPressed || event.buttons.isTertiaryPressed)) {
+// println(event.changes.any { it.isConsumed })
+// focus_requester.requestFocus()
+// }
+// }
+// }
+// }
+// .focusRequester(focus_requester)
) {
player.HomePage()
player.NowPlaying()
diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/apppage/settingspage/category/BehaviourCategory.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/apppage/settingspage/category/BehaviourCategory.kt
index ddb0acc6e..bfe99a357 100644
--- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/apppage/settingspage/category/BehaviourCategory.kt
+++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/apppage/settingspage/category/BehaviourCategory.kt
@@ -65,6 +65,11 @@ internal fun getBehaviourCategoryItems(): List {
ToggleSettingsItem(
SettingsValueState(BehaviourSettings.Key.LPM_INCREMENT_PLAY_AFTER.getName()),
getString("s_key_lpm_increment_play_after"), null
+ ),
+
+ ToggleSettingsItem(
+ SettingsValueState(BehaviourSettings.Key.DESKTOP_LPM_KEEP_ON_BACKGROUND_SCROLL.getName()),
+ getString("s_key_desktop_lpm_keep_on_background_scroll"), getString("s_sub_desktop_lpm_keep_on_background_scroll")
)
)
}
diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/apppage/settingspage/category/DesktopCategory.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/apppage/settingspage/category/DesktopCategory.kt
index 17fc9e26a..845888e42 100644
--- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/apppage/settingspage/category/DesktopCategory.kt
+++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/apppage/settingspage/category/DesktopCategory.kt
@@ -1,10 +1,11 @@
package com.toasterofbread.spmp.ui.layout.apppage.settingspage.category
-import com.toasterofbread.composekit.settings.ui.item.SettingsItem
+import com.toasterofbread.composekit.settings.ui.item.GroupSettingsItem
import com.toasterofbread.composekit.settings.ui.item.InfoTextSettingsItem
+import com.toasterofbread.composekit.settings.ui.item.SettingsItem
+import com.toasterofbread.composekit.settings.ui.item.SettingsValueState
import com.toasterofbread.composekit.settings.ui.item.TextFieldSettingsItem
import com.toasterofbread.composekit.settings.ui.item.ToggleSettingsItem
-import com.toasterofbread.composekit.settings.ui.item.SettingsValueState
import com.toasterofbread.spmp.model.settings.category.DesktopSettings
import com.toasterofbread.spmp.resources.getString
@@ -19,6 +20,10 @@ internal fun getDesktopCategoryItems(): List {
check(port_regex.matches("1111"))
return listOf(
+ GroupSettingsItem(
+ getString("s_group_desktop_system")
+ ),
+
TextFieldSettingsItem(
SettingsValueState(DesktopSettings.Key.STARTUP_COMMAND.getName()),
getString("s_key_startup_command"), getString("s_sub_startup_command")
diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/apppage/songfeedpage/LFFSongFeedAppPage.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/apppage/songfeedpage/LFFSongFeedAppPage.kt
index 60cb1ec63..b65dc9084 100644
--- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/apppage/songfeedpage/LFFSongFeedAppPage.kt
+++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/apppage/songfeedpage/LFFSongFeedAppPage.kt
@@ -21,6 +21,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
@@ -28,6 +29,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
+import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.unit.dp
@@ -36,6 +38,8 @@ import com.toasterofbread.composekit.platform.composable.SwipeRefresh
import com.toasterofbread.composekit.platform.composable.platformClickable
import com.toasterofbread.composekit.utils.common.launchSingle
import com.toasterofbread.composekit.utils.composable.SubtleLoadingIndicator
+import com.toasterofbread.composekit.utils.modifier.horizontal
+import com.toasterofbread.composekit.utils.modifier.vertical
import com.toasterofbread.spmp.model.mediaitem.MediaItem
import com.toasterofbread.spmp.model.mediaitem.layout.MediaItemLayout
import com.toasterofbread.spmp.model.settings.category.FeedSettings
@@ -46,6 +50,7 @@ import com.toasterofbread.spmp.ui.layout.PinnedItemsRow
import com.toasterofbread.spmp.ui.layout.apppage.mainpage.FeedLoadState
import com.toasterofbread.spmp.youtubeapi.NotImplementedMessage
+@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun SongFeedAppPage.LFFSongFeedAppPage(
multiselect_context: MediaItemMultiSelectContext,
@@ -130,8 +135,8 @@ fun SongFeedAppPage.LFFSongFeedAppPage(
}
@Composable
- fun TopContent() {
- PinnedItemsRow(Modifier.padding(bottom = 10.dp))
+ fun TopContent(modifier: Modifier = Modifier) {
+ PinnedItemsRow(modifier.padding(bottom = 10.dp))
}
var hiding_layout: MediaItemLayout? by remember { mutableStateOf(null) }
@@ -169,7 +174,8 @@ fun SongFeedAppPage.LFFSongFeedAppPage(
)
}
- val state = current_state
+ val state: Any? = current_state
+ val horizontal_padding = content_padding.horizontal
when (state) {
// Loaded
@@ -182,16 +188,20 @@ fun SongFeedAppPage.LFFSongFeedAppPage(
LazyColumn(
Modifier.graphicsLayer { alpha = state_alpha.value },
state = scroll_state,
- contentPadding = content_padding,
+ contentPadding = content_padding.vertical,
userScrollEnabled = !state_alpha.isRunning
) {
item {
- TopContent()
+ TopContent(Modifier.padding(horizontal_padding))
}
item {
if (artists_layout.items.isNotEmpty()) {
- artists_layout.Layout(multiselect_context = player.main_multiselect_context, apply_filter = true)
+ artists_layout.Layout(
+ multiselect_context = player.main_multiselect_context,
+ apply_filter = true,
+ content_padding = horizontal_padding
+ )
}
}
@@ -211,7 +221,7 @@ fun SongFeedAppPage.LFFSongFeedAppPage(
}
}
- val type = layout.type ?: MediaItemLayout.Type.GRID
+ val type: MediaItemLayout.Type = layout.type ?: MediaItemLayout.Type.GRID
val rows: Int = if (type == MediaItemLayout.Type.GRID_ALT) alt_grid_rows else grid_rows
val expanded_rows: Int = if (type == MediaItemLayout.Type.GRID_ALT) alt_grid_rows_expanded else grid_rows_expanded
@@ -230,12 +240,13 @@ fun SongFeedAppPage.LFFSongFeedAppPage(
apply_filter = true,
square_item_max_text_rows = square_item_max_text_rows,
show_download_indicators = show_download_indicators,
- grid_rows = Pair(rows, expanded_rows)
+ grid_rows = Pair(rows, expanded_rows),
+ content_padding = horizontal_padding
)
}
item {
- Crossfade(Pair(onContinuationRequested, loading_continuation)) { data ->
+ Crossfade(Pair(onContinuationRequested, loading_continuation), Modifier.padding(horizontal_padding)) { data ->
val (requestContinuation, loading) = data
if (loading || requestContinuation != null) {
diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/apppage/songfeedpage/SongFeedAppPage.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/apppage/songfeedpage/SongFeedAppPage.kt
index db60eba54..b491dc509 100644
--- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/apppage/songfeedpage/SongFeedAppPage.kt
+++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/apppage/songfeedpage/SongFeedAppPage.kt
@@ -39,6 +39,7 @@ import com.toasterofbread.spmp.ui.component.multiselect.MediaItemMultiSelectCont
import com.toasterofbread.spmp.ui.layout.apppage.AppPage
import com.toasterofbread.spmp.ui.layout.apppage.AppPageState
import com.toasterofbread.spmp.ui.layout.apppage.mainpage.FeedLoadState
+import com.toasterofbread.spmp.ui.layout.apppage.mainpage.PlayerState
import com.toasterofbread.spmp.youtubeapi.endpoint.HomeFeedEndpoint
import com.toasterofbread.spmp.youtubeapi.endpoint.HomeFeedLoadResult
import com.toasterofbread.spmp.youtubeapi.impl.youtubemusic.cast
@@ -83,7 +84,7 @@ class SongFeedAppPage(override val state: AppPageState): AppPage() {
@Composable
override fun TopBarContent(modifier: Modifier, close: () -> Unit) {
- val player = LocalPlayerState.current
+ val player: PlayerState = LocalPlayerState.current
val show: Boolean by mutableSettingsState(FeedSettings.Key.SHOW_FILTER_BAR)
AnimatedVisibility(show) {
diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/nowplaying/overlay/MainOverlayMenu.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/nowplaying/overlay/MainOverlayMenu.kt
index 4ecabd0f4..37e64f0df 100644
--- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/nowplaying/overlay/MainOverlayMenu.kt
+++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/nowplaying/overlay/MainOverlayMenu.kt
@@ -27,7 +27,7 @@ import com.toasterofbread.spmp.model.mediaitem.artist.Artist
import com.toasterofbread.spmp.model.mediaitem.enums.MediaItemType
import com.toasterofbread.spmp.model.mediaitem.song.Song
import com.toasterofbread.spmp.platform.download.PlayerDownloadManager
-import com.toasterofbread.spmp.platform.download.PlayerDownloadManager.DownloadStatus
+import com.toasterofbread.spmp.platform.download.DownloadStatus
import com.toasterofbread.spmp.resources.getString
import com.toasterofbread.spmp.ui.component.mediaitempreview.MediaItemPreviewLong
import com.toasterofbread.spmp.youtubeapi.implementedOrNull
@@ -71,7 +71,7 @@ class MainPlayerOverlayMenu(
}
DisposableEffect(Unit) {
- val status_listener = object : PlayerDownloadManager.DownloadStatusListener() {
+ val status_listener: PlayerDownloadManager.DownloadStatusListener = object : PlayerDownloadManager.DownloadStatusListener() {
override fun onDownloadChanged(status: DownloadStatus) {
if (status.song.id == getSong().id) {
download_status = status
diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/youtubeapi/impl/youtubemusic/LoadMediaitem.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/youtubeapi/impl/youtubemusic/LoadMediaitem.kt
index 38705f615..0c786890f 100644
--- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/youtubeapi/impl/youtubemusic/LoadMediaitem.kt
+++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/youtubeapi/impl/youtubemusic/LoadMediaitem.kt
@@ -49,7 +49,7 @@ suspend fun processSong(song: SongData, response_body: Reader, api: YoutubeApi):
song.related_browse_id = tabs.getOrNull(2)?.tabRenderer?.endpoint?.browseEndpoint?.browseId
val video: YoutubeiNextResponse.PlaylistPanelVideoRenderer = try {
- tabs[0].tabRenderer.content!!.musicQueueRenderer.content.playlistPanelRenderer.contents.first().playlistPanelVideoRenderer!!
+ tabs[0].tabRenderer.content!!.musicQueueRenderer.content!!.playlistPanelRenderer.contents.first().playlistPanelVideoRenderer!!
}
catch (e: Throwable) {
return Result.failure(e)
diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/youtubeapi/impl/youtubemusic/endpoint/YTMSongRadioEndpoint.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/youtubeapi/impl/youtubemusic/endpoint/YTMSongRadioEndpoint.kt
index 168d4131e..03fecbd8e 100644
--- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/youtubeapi/impl/youtubemusic/endpoint/YTMSongRadioEndpoint.kt
+++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/youtubeapi/impl/youtubemusic/endpoint/YTMSongRadioEndpoint.kt
@@ -84,7 +84,7 @@ class YTMSongRadioEndpoint(override val api: YoutubeMusicApi): SongRadioEndpoint
val result: Result = api.performRequest(request)
- val radio: YoutubeiNextResponse.PlaylistPanelRenderer
+ val radio: YoutubeiNextResponse.PlaylistPanelRenderer?
val out_filters: List>?
if (continuation == null) {
@@ -109,7 +109,7 @@ class YTMSongRadioEndpoint(override val api: YoutubeMusicApi): SongRadioEndpoint
.content!!
.musicQueueRenderer
- radio = renderer.content.playlistPanelRenderer
+ radio = renderer.content?.playlistPanelRenderer
out_filters = renderer.subHeaderChipCloud?.chipCloudRenderer?.chips?.mapNotNull { chip ->
radioToFilters(chip.getPlaylistId(), video_id)
}
@@ -133,7 +133,7 @@ class YTMSongRadioEndpoint(override val api: YoutubeMusicApi): SongRadioEndpoint
return@withContext Result.success(
RadioData(
- radio.contents.map { item ->
+ radio?.contents?.map { item ->
val renderer = item.getRenderer()
val song = SongData(renderer.videoId)
@@ -149,8 +149,8 @@ class YTMSongRadioEndpoint(override val api: YoutubeMusicApi): SongRadioEndpoint
)
return@map song
- },
- radio.continuations?.firstOrNull()?.data?.continuation,
+ } ?: emptyList(),
+ radio?.continuations?.firstOrNull()?.data?.continuation,
out_filters
)
)
diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/youtubeapi/radio/YoutubeiNextResponse.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/youtubeapi/radio/YoutubeiNextResponse.kt
index a3c093af8..97f0a45b5 100644
--- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/youtubeapi/radio/YoutubeiNextResponse.kt
+++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/youtubeapi/radio/YoutubeiNextResponse.kt
@@ -27,7 +27,7 @@ data class YoutubeiNextResponse(
class TabRenderer(val content: Content?, val endpoint: TabRendererEndpoint?)
class TabRendererEndpoint(val browseEndpoint: BrowseEndpoint)
class Content(val musicQueueRenderer: MusicQueueRenderer)
- class MusicQueueRenderer(val content: MusicQueueRendererContent, val subHeaderChipCloud: SubHeaderChipCloud?)
+ class MusicQueueRenderer(val content: MusicQueueRendererContent?, val subHeaderChipCloud: SubHeaderChipCloud?)
class SubHeaderChipCloud(val chipCloudRenderer: ChipCloudRenderer)
class ChipCloudRenderer(val chips: List)
diff --git a/shared/src/commonMain/resources/assets/values-ja-JP/strings.xml b/shared/src/commonMain/resources/assets/values-ja-JP/strings.xml
index 57ad79a0a..542429e95 100644
--- a/shared/src/commonMain/resources/assets/values-ja-JP/strings.xml
+++ b/shared/src/commonMain/resources/assets/values-ja-JP/strings.xml
@@ -303,6 +303,8 @@
長押しメニュー
アクションを行ったら閉じる
「○曲後に再生」使用時に曲数を増加
+ スクロール中にメニューを表示する
+ メニューの裏にあるの画面をスクロールしても、長押しメニューは隠されません
アイテムフィルターを使用
題名フィルターのキーワード
@@ -518,6 +520,8 @@
+ システム
+
開始コマンド
プログラム開始時に一回実行されるコマンド
diff --git a/shared/src/commonMain/resources/assets/values/strings.xml b/shared/src/commonMain/resources/assets/values/strings.xml
index 3e548c917..eb5e0e66b 100644
--- a/shared/src/commonMain/resources/assets/values/strings.xml
+++ b/shared/src/commonMain/resources/assets/values/strings.xml
@@ -317,6 +317,8 @@
Long-press menu
Close menu on action
Increment 'Play after' action on use
+ Keep menu while scrolling
+ When scrolling the view behind the menu, the menu will be moved to a corner instead of being hidden
Enable item filter
Title filter keywords
@@ -532,6 +534,8 @@
Enable
Disable
+ System
+
Startup command
Command to be executed at program start
diff --git a/shared/src/desktopMain/kotlin/com/toasterofbread/spmp/platform/download/PlayerDownloadManager.desktop.kt b/shared/src/desktopMain/kotlin/com/toasterofbread/spmp/platform/download/PlayerDownloadManager.desktop.kt
index 778d1de66..5cfe44758 100644
--- a/shared/src/desktopMain/kotlin/com/toasterofbread/spmp/platform/download/PlayerDownloadManager.desktop.kt
+++ b/shared/src/desktopMain/kotlin/com/toasterofbread/spmp/platform/download/PlayerDownloadManager.desktop.kt
@@ -1,44 +1,52 @@
package com.toasterofbread.spmp.platform.download
import com.toasterofbread.composekit.platform.PlatformFile
+import com.toasterofbread.spmp.model.mediaitem.library.MediaItemLibrary
import com.toasterofbread.spmp.model.mediaitem.song.Song
-import com.toasterofbread.spmp.model.mediaitem.song.SongAudioQuality
import com.toasterofbread.spmp.platform.AppContext
import com.toasterofbread.spmp.ui.layout.apppage.mainpage.DownloadRequestCallback
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import java.util.concurrent.Executors
-actual class PlayerDownloadManager actual constructor(context: AppContext) {
- actual class DownloadStatus {
- actual val song: Song
- get() = TODO("Not yet implemented")
- actual val status: Status
- get() = TODO("Not yet implemented")
- actual val quality: SongAudioQuality?
- get() = TODO("Not yet implemented")
- actual val progress: Float
- get() = TODO("Not yet implemented")
- actual val id: String
- get() = TODO("Not yet implemented")
- actual enum class Status { IDLE, PAUSED, DOWNLOADING, CANCELLED, ALREADY_FINISHED, FINISHED }
-
- actual fun isCompleted(): Boolean = TODO()
- }
-
- actual open class DownloadStatusListener actual constructor() {
- actual open fun onDownloadAdded(status: DownloadStatus) {
+actual class PlayerDownloadManager actual constructor(private val context: AppContext) {
+ private val downloader: SongDownloader = object : SongDownloader(
+ context,
+ Executors.newFixedThreadPool(3)
+ ) {
+ override fun getAudioFileDurationMs(file: PlatformFile): Long? {
+ // TODO
+ return null
}
- actual open fun onDownloadRemoved(id: String) {
+ override fun onDownloadStatusChanged(download: Download, started: Boolean) {
+ synchronized(listeners) {
+ for (listener in listeners) {
+ listener.onDownloadChanged(download.getStatusObject())
+ }
+ }
}
+ }
- actual open fun onDownloadChanged(status: DownloadStatus) {
- }
+ private val listeners: MutableList = mutableListOf()
+
+ actual open class DownloadStatusListener actual constructor() {
+ actual open fun onDownloadAdded(status: DownloadStatus) {}
+ actual open fun onDownloadRemoved(id: String) {}
+ actual open fun onDownloadChanged(status: DownloadStatus) {}
}
actual fun addDownloadStatusListener(listener: DownloadStatusListener) {
+ synchronized(listeners) {
+ listeners.add(listener)
+ }
}
actual fun removeDownloadStatusListener(listener: DownloadStatusListener) {
+ synchronized(listeners) {
+ listeners.remove(listener)
+ }
}
@Synchronized
@@ -48,34 +56,44 @@ actual class PlayerDownloadManager actual constructor(context: AppContext) {
file_uri: String?,
callback: DownloadRequestCallback?,
) {
- TODO()
+ downloader.startDownload(song, silent, file_uri) { download, result ->
+ callback?.invoke(download.getStatusObject())
+ }
}
actual fun release() {
+ synchronized(listeners) {
+ listeners.clear()
+ }
}
- actual suspend fun getDownload(song: Song): DownloadStatus? {
- return null // TODO
+ actual suspend fun getDownload(song: Song): DownloadStatus? = withContext(Dispatchers.IO) {
+ val service_status: DownloadStatus? = downloader.getDownloadStatus(song)
+ if (service_status != null) {
+ return@withContext service_status
+ }
+
+ return@withContext MediaItemLibrary.getLocalSongDownload(song, context)
}
- actual suspend fun getDownloads(): List {
- return emptyList() // TODO
+ actual suspend fun getDownloads(): List = withContext(Dispatchers.IO) {
+ val current_downloads: List = downloader.getAllDownloadsStatus()
+ val local_downloads: List = MediaItemLibrary.getLocalSongDownloads(context)
+
+ return@withContext current_downloads + local_downloads.filter { local ->
+ current_downloads.none { current ->
+ current.file?.matches(local.file!!) == true
+ }
+ }
}
actual suspend fun deleteSongLocalAudioFile(song: Song) {
+ val download: DownloadStatus = getDownload(song) ?: return
+ download.file?.delete()
+ synchronized(listeners) {
+ for (listener in listeners) {
+ listener.onDownloadRemoved(download.id)
+ }
+ }
}
}
-
-actual suspend fun Song.getLocalSongFile(
- context: AppContext,
- allow_partial: Boolean,
-): PlatformFile? {
- return null // TODO
-}
-
-actual fun Song.getLocalLyricsFile(
- context: AppContext,
- allow_partial: Boolean,
-): PlatformFile? {
- return null // TODO
-}
diff --git a/shared/src/desktopMain/kotlin/com/toasterofbread/spmp/platform/playerservice/ZmqSpMsPlayerService.kt b/shared/src/desktopMain/kotlin/com/toasterofbread/spmp/platform/playerservice/ZmqSpMsPlayerService.kt
index 973808c17..ecae536f6 100644
--- a/shared/src/desktopMain/kotlin/com/toasterofbread/spmp/platform/playerservice/ZmqSpMsPlayerService.kt
+++ b/shared/src/desktopMain/kotlin/com/toasterofbread/spmp/platform/playerservice/ZmqSpMsPlayerService.kt
@@ -151,6 +151,20 @@ abstract class ZmqSpMsPlayerService: PlatformServiceImpl(), PlayerService {
val volume: Float
)
+ private inline fun tryTransaction(transaction: () -> Unit) {
+ while (true) {
+ try {
+ transaction()
+ break
+ }
+ catch (e: Throwable) {
+ if (e.javaClass.name != "org.sqlite.SQLiteException") {
+ throw e
+ }
+ }
+ }
+ }
+
private suspend fun ZMQ.Socket.connectToServer(url: String): Boolean = withContext(Dispatchers.IO) {
val handshake_message = ZMsg()
handshake_message.add(getClientName())
@@ -195,13 +209,17 @@ abstract class ZmqSpMsPlayerService: PlatformServiceImpl(), PlayerService {
state.queue.mapIndexed { i, id ->
launch {
val song: Song = SongRef(id)
- song.loadData(context).onSuccess { data ->
- data.saveToDatabase(context.database)
-
- data.artist?.also { artist ->
- this@withContext.launch {
- artist.loadData(context).onSuccess { artist_data ->
- artist_data.saveToDatabase(context.database)
+ tryTransaction {
+ song.loadData(context).onSuccess { data ->
+ data.saveToDatabase(context.database)
+
+ data.artist?.also { artist ->
+ this@withContext.launch {
+ tryTransaction {
+ artist.loadData(context).onSuccess { artist_data ->
+ artist_data.saveToDatabase(context.database)
+ }
+ }
}
}
}
@@ -290,7 +308,6 @@ abstract class ZmqSpMsPlayerService: PlatformServiceImpl(), PlayerService {
catch (e: Throwable) {
throw RuntimeException("Parsing event failed '$event_str'", e)
}
-// println("Processing event: $event")
try {
val type = (event["type"] as String?) ?: continue