ultrasonic-app-subsonic-and.../ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/JukeboxMediaPlayer.kt

342 lines
10 KiB
Kotlin

/*
* JukeboxMediaPlayer.kt
* Copyright (C) 2009-2022 Ultrasonic developers
*
* Distributed under terms of the GNU GPLv3 license.
*/
package org.moire.ultrasonic.service
import android.content.Context
import android.os.Handler
import android.os.Looper
import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
import android.widget.ProgressBar
import android.widget.Toast
import java.util.concurrent.Executors
import java.util.concurrent.LinkedBlockingQueue
import java.util.concurrent.ScheduledFuture
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicLong
import kotlin.math.roundToInt
import org.koin.java.KoinJavaComponent.inject
import org.moire.ultrasonic.R
import org.moire.ultrasonic.api.subsonic.ApiNotSupportedException
import org.moire.ultrasonic.api.subsonic.SubsonicRESTException
import org.moire.ultrasonic.app.UApp.Companion.applicationContext
import org.moire.ultrasonic.data.ActiveServerProvider.Companion.isOffline
import org.moire.ultrasonic.domain.JukeboxStatus
import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService
import org.moire.ultrasonic.util.Util.sleepQuietly
import org.moire.ultrasonic.util.Util.toast
import timber.log.Timber
/**
* Provides an asynchronous interface to the remote jukebox on the Subsonic server.
*
* TODO: Report warning if queue fills up.
* TODO: Create shutdown method?
* TODO: Disable repeat.
* TODO: Persist RC state?
* TODO: Minimize status updates.
*/
class JukeboxMediaPlayer(private val downloader: Downloader) {
private val tasks = TaskQueue()
private val executorService = Executors.newSingleThreadScheduledExecutor()
private var statusUpdateFuture: ScheduledFuture<*>? = null
private val timeOfLastUpdate = AtomicLong()
private var jukeboxStatus: JukeboxStatus? = null
private var gain = 0.5f
private var volumeToast: VolumeToast? = null
private val running = AtomicBoolean()
private var serviceThread: Thread? = null
private var enabled = false
// TODO: These create circular references, try to refactor
private val mediaPlayerControllerLazy = inject<MediaPlayerController>(
MediaPlayerController::class.java
)
fun startJukeboxService() {
if (running.get()) {
return
}
running.set(true)
startProcessTasks()
Timber.d("Started Jukebox Service")
}
fun stopJukeboxService() {
running.set(false)
sleepQuietly(1000)
if (serviceThread != null) {
serviceThread!!.interrupt()
}
Timber.d("Stopped Jukebox Service")
}
private fun startProcessTasks() {
serviceThread = object : Thread() {
override fun run() {
processTasks()
}
}
(serviceThread as Thread).start()
}
@Synchronized
private fun startStatusUpdate() {
stopStatusUpdate()
val updateTask = Runnable {
tasks.remove(GetStatus::class.java)
tasks.add(GetStatus())
}
statusUpdateFuture = executorService.scheduleWithFixedDelay(
updateTask,
STATUS_UPDATE_INTERVAL_SECONDS,
STATUS_UPDATE_INTERVAL_SECONDS,
TimeUnit.SECONDS
)
}
@Synchronized
private fun stopStatusUpdate() {
if (statusUpdateFuture != null) {
statusUpdateFuture!!.cancel(false)
statusUpdateFuture = null
}
}
private fun processTasks() {
while (running.get()) {
var task: JukeboxTask? = null
try {
if (!isOffline()) {
task = tasks.take()
val status = task.execute()
onStatusUpdate(status)
}
} catch (ignored: InterruptedException) {
} catch (x: Throwable) {
onError(task, x)
}
sleepQuietly(1)
}
}
private fun onStatusUpdate(jukeboxStatus: JukeboxStatus) {
timeOfLastUpdate.set(System.currentTimeMillis())
this.jukeboxStatus = jukeboxStatus
}
private fun onError(task: JukeboxTask?, x: Throwable) {
if (x is ApiNotSupportedException && task !is Stop) {
disableJukeboxOnError(x, R.string.download_jukebox_server_too_old)
} else if (x is OfflineException && task !is Stop) {
disableJukeboxOnError(x, R.string.download_jukebox_offline)
} else if (x is SubsonicRESTException && x.code == 50 && task !is Stop) {
disableJukeboxOnError(x, R.string.download_jukebox_not_authorized)
} else {
Timber.e(x, "Failed to process jukebox task")
}
}
private fun disableJukeboxOnError(x: Throwable, resourceId: Int) {
Timber.w(x.toString())
val context = applicationContext()
Handler(Looper.getMainLooper()).post { toast(context, resourceId, false) }
mediaPlayerControllerLazy.value.isJukeboxEnabled = false
}
fun updatePlaylist() {
if (!enabled) return
tasks.remove(Skip::class.java)
tasks.remove(Stop::class.java)
tasks.remove(Start::class.java)
val ids: MutableList<String> = ArrayList()
for (file in downloader.all) {
ids.add(file.track.id)
}
tasks.add(SetPlaylist(ids))
}
fun skip(index: Int, offsetSeconds: Int) {
tasks.remove(Skip::class.java)
tasks.remove(Stop::class.java)
tasks.remove(Start::class.java)
startStatusUpdate()
if (jukeboxStatus != null) {
jukeboxStatus!!.positionSeconds = offsetSeconds
}
tasks.add(Skip(index, offsetSeconds))
}
fun stop() {
tasks.remove(Stop::class.java)
tasks.remove(Start::class.java)
stopStatusUpdate()
tasks.add(Stop())
}
fun start() {
tasks.remove(Stop::class.java)
tasks.remove(Start::class.java)
startStatusUpdate()
tasks.add(Start())
}
@Synchronized
fun adjustVolume(up: Boolean) {
val delta = if (up) 0.05f else -0.05f
gain += delta
gain = gain.coerceAtLeast(0.0f)
gain = gain.coerceAtMost(1.0f)
tasks.remove(SetGain::class.java)
tasks.add(SetGain(gain))
val context = applicationContext()
if (volumeToast == null) volumeToast = VolumeToast(context)
volumeToast!!.setVolume(gain)
}
private val musicService: MusicService
get() = getMusicService()
val positionSeconds: Int
get() {
if (jukeboxStatus == null ||
jukeboxStatus!!.positionSeconds == null ||
timeOfLastUpdate.get() == 0L
) {
return 0
}
if (jukeboxStatus!!.isPlaying) {
val secondsSinceLastUpdate =
((System.currentTimeMillis() - timeOfLastUpdate.get()) / 1000L).toInt()
return jukeboxStatus!!.positionSeconds!! + secondsSinceLastUpdate
}
return jukeboxStatus!!.positionSeconds!!
}
var isEnabled: Boolean
set(enabled) {
Timber.d("Jukebox Service setting enabled to %b", enabled)
this.enabled = enabled
tasks.clear()
if (enabled) {
updatePlaylist()
}
stop()
}
get() {
return enabled
}
private class TaskQueue {
private val queue = LinkedBlockingQueue<JukeboxTask>()
fun add(jukeboxTask: JukeboxTask) {
queue.add(jukeboxTask)
}
@Throws(InterruptedException::class)
fun take(): JukeboxTask {
return queue.take()
}
fun remove(taskClass: Class<out JukeboxTask?>) {
try {
val iterator = queue.iterator()
while (iterator.hasNext()) {
val task = iterator.next()
if (taskClass == task.javaClass) {
iterator.remove()
}
}
} catch (x: Throwable) {
Timber.w(x, "Failed to clean-up task queue.")
}
}
fun clear() {
queue.clear()
}
}
private abstract class JukeboxTask {
@Throws(Exception::class)
abstract fun execute(): JukeboxStatus
override fun toString(): String {
return javaClass.simpleName
}
}
private inner class GetStatus : JukeboxTask() {
@Throws(Exception::class)
override fun execute(): JukeboxStatus {
return musicService.getJukeboxStatus()
}
}
private inner class SetPlaylist(private val ids: List<String>) :
JukeboxTask() {
@Throws(Exception::class)
override fun execute(): JukeboxStatus {
return musicService.updateJukeboxPlaylist(ids)
}
}
private inner class Skip(
private val index: Int,
private val offsetSeconds: Int
) : JukeboxTask() {
@Throws(Exception::class)
override fun execute(): JukeboxStatus {
return musicService.skipJukebox(index, offsetSeconds)
}
}
private inner class Stop : JukeboxTask() {
@Throws(Exception::class)
override fun execute(): JukeboxStatus {
return musicService.stopJukebox()
}
}
private inner class Start : JukeboxTask() {
@Throws(Exception::class)
override fun execute(): JukeboxStatus {
return musicService.startJukebox()
}
}
private inner class SetGain(private val gain: Float) : JukeboxTask() {
@Throws(Exception::class)
override fun execute(): JukeboxStatus {
return musicService.setJukeboxGain(gain)
}
}
private class VolumeToast(context: Context) : Toast(context) {
private val progressBar: ProgressBar
fun setVolume(volume: Float) {
progressBar.progress = (100 * volume).roundToInt()
show()
}
init {
duration = LENGTH_SHORT
val inflater =
context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater
val view = inflater.inflate(R.layout.jukebox_volume, null)
progressBar = view.findViewById<View>(R.id.jukebox_volume_progress_bar) as ProgressBar
setView(view)
setGravity(Gravity.TOP, 0, 0)
}
}
companion object {
private const val STATUS_UPDATE_INTERVAL_SECONDS = 5L
}
}