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